src/fl/channels/README.md
The Channels API provides a modern, hardware-accelerated interface for driving multiple LED strips in parallel with DMA-based timing. It abstracts platform-specific hardware (PARLIO, RMT, SPI, I2S) into a unified interface that works across ESP32 variants and other microcontrollers.
Key benefits:
The system consists of two layers:
Users create Channel objects using the Channel API (recommended) or the template-based FastLED.addLeds<>() API (backwards compatible). The driver layer is managed automatically based on platform capabilities and priorities.
The Channel API provides a clean, explicit interface for creating and configuring LED strips:
#include "FastLED.h"
#include "fl/channels/channel.h"
#include "fl/channels/config.h"
#define NUM_LEDS 60
#define PIN1 16
#define PIN2 17
CRGB leds1[NUM_LEDS];
CRGB leds2[NUM_LEDS];
void setup() {
// Create channel configurations with names
auto timing = fl::makeTimingConfig<fl::TIMING_WS2812_800KHZ>();
fl::ChannelConfig config1("left_strip", fl::ClocklessChipset(PIN1, timing),
fl::span<CRGB>(leds1, NUM_LEDS), RGB);
fl::ChannelConfig config2("right_strip", fl::ClocklessChipset(PIN2, timing),
fl::span<CRGB>(leds2, NUM_LEDS), RGB);
// Register channels with FastLED
auto ch1 = FastLED.add(config1);
auto ch2 = FastLED.add(config2);
Serial.printf("Created: %s and %s\n", ch1->name().c_str(), ch2->name().c_str());
}
void loop() {
fill_solid(leds1, NUM_LEDS, CRGB::Red);
fill_solid(leds2, NUM_LEDS, CRGB::Blue);
FastLED.show();
}
Benefits:
For existing code, the template-based FastLED.addLeds<>() API is still supported:
#include "FastLED.h"
CRGB leds1[60];
CRGB leds2[60];
void setup() {
// Template-based API (creates channels internally)
FastLED.addLeds<WS2812, 16>(leds1, 60);
FastLED.addLeds<WS2812, 17>(leds2, 60);
}
void loop() {
fill_solid(leds1, 60, CRGB::Red);
FastLED.show();
}
This API is simpler for basic use cases but offers less flexibility than the Channel API.
The system automatically selects the best hardware driver based on platform capabilities:
Engines are tried in priority order until one accepts the channel:
| Engine | Priority | Platforms | Status |
|---|---|---|---|
| PARLIO | 4 (Highest) | ESP32-P4, C6, H2, C5 | Parallel I/O with hardware timing |
| RMT | 2 (Recommended) | All ESP32 variants | Recommended default, reliable |
| I2S | 1 | ESP32-S3 | LCD_CAM via I80 bus (experimental) |
| SPI | 0 | ESP32, S2, S3 | DMA-based, deprioritized due to reliability |
| UART | -1 (Lowest) | All ESP32 variants | Wave8 encoding (broken, not recommended) |
For testing or performance tuning, you can control driver selection:
void setup() {
auto timing = fl::makeTimingConfig<fl::TIMING_WS2812_800KHZ>();
fl::ChannelConfig config(16, timing, leds, RGB);
FastLED.add(config);
// Method 1: Force a specific driver exclusively (disables all others)
FastLED.setExclusiveDriver("RMT");
// Method 2: Enable/disable specific drivers
FastLED.setDriverEnabled("PARLIO", true); // Enable
FastLED.setDriverEnabled("SPI", false); // Disable
// Method 3: Adjust driver priority (higher = preferred)
// Engines are sorted by priority - changing priority triggers re-sort
FastLED.setDriverPriority("RMT", 9000); // Increase priority
FastLED.setDriverPriority("PARLIO", 8000); // Set below RMT
// Query available drivers (sorted by priority, high to low)
for (size_t i = 0; i < FastLED.getDriverCount(); i++) {
auto info = FastLED.getDriverInfos()[i];
Serial.printf("%s: priority=%d, enabled=%s\n",
info.name.c_str(), info.priority,
info.enabled ? "yes" : "no");
}
}
Control methods:
setExclusiveDriver(name) - Disable all drivers except the named onesetDriverEnabled(name, enabled) - Enable/disable specific driversetDriverPriority(name, priority) - Change priority (triggers automatic re-sort)When to override:
Default behavior is recommended - automatic selection provides optimal performance and reliability.
Register callbacks for channel lifecycle events:
#include "FastLED.h"
#include "fl/channels/channel.h"
#include "fl/channels/config.h"
CRGB leds[60];
void setup() {
// Register event listeners via FastLED
auto& events = FastLED.channelEvents();
// Called when channel is created
events.onChannelCreated.add([](const fl::Channel& ch) {
Serial.printf("Channel created: %s\n", ch.name().c_str());
});
// Called when channel data is enqueued to driver
events.onChannelEnqueued.add([](const fl::Channel& ch, const fl::string& driver) {
Serial.printf("%s → %s\n", ch.name().c_str(), driver.c_str());
});
// Create channel (triggers onChannelCreated)
auto timing = fl::makeTimingConfig<fl::TIMING_WS2812_800KHZ>();
fl::ChannelConfig config("my_strip", fl::ClocklessChipset(5, timing),
fl::span<CRGB>(leds, 60), RGB);
FastLED.add(config);
}
void loop() {
fill_rainbow(leds, 60, 0, 255 / 60);
FastLED.show(); // Triggers onChannelEnqueued for "my_strip"
}
Available events:
onChannelCreated - After channel constructiononChannelAdded - After adding to FastLED controller listonChannelEnqueued - After data enqueued to driveronChannelConfigured - After applyConfig() calledonChannelRemoved - After removing from controller listonChannelBeginDestroy - Before channel destructionUse cases:
The UCS7604 chipset supports 16-bit color depth, which benefits from gamma correction to produce perceptually smooth brightness gradients. The Channels API provides per-channel gamma control:
#include <FastLED.h>
#include "fl/channels/config.h"
#include "fl/chipsets/led_timing.h"
#define NUM_LEDS 60
CRGB leds[NUM_LEDS];
void setup() {
auto timing = fl::makeTimingConfig<fl::TIMING_UCS7604_800KHZ>();
fl::ChannelConfig config(fl::ClocklessChipset(2, timing), leds, RGB);
auto channel = FastLED.add(config);
channel->setGamma(3.2f); // Override gamma (default is 2.8)
FastLED.setBrightness(128);
}
void loop() {
static uint8_t hue = 0;
fill_rainbow(leds, NUM_LEDS, hue++, 7);
FastLED.show();
delay(20);
}
How gamma resolution works:
| Method | Scope | Precedence |
|---|---|---|
channel->setGamma(3.2f) | Per-channel | Highest - overrides built-in default |
| (no call) | Built-in default | 2.8 (matches UCS7604 datasheet recommendation) |
Common gamma values:
Per-channel example (two strips, different gamma):
CRGB warm_leds[60];
CRGB cool_leds[60];
void setup() {
auto timing = fl::makeTimingConfig<fl::TIMING_UCS7604_800KHZ>();
auto warm = FastLED.add(fl::ChannelConfig(
fl::ClocklessChipset(2, timing), warm_leds, RGB));
warm->setGamma(2.2f); // Gentle curve for warm ambiance
auto cool = FastLED.add(fl::ChannelConfig(
fl::ClocklessChipset(4, timing), cool_leds, RGB));
cool->setGamma(3.2f); // Steep curve for high contrast
}
Note: Gamma correction only affects 16-bit UCS7604 modes (TIMING_UCS7604_800KHZ with 16-bit, TIMING_UCS7604_1600KHZ). 8-bit mode passes values through unchanged.
Change LED settings at runtime without recreating channels using applyConfig():
fl::ChannelPtr channel;
void setup() {
auto timing = fl::makeTimingConfig<fl::TIMING_WS2812_800KHZ>();
fl::ChannelConfig config(16, timing, leds, GRB);
channel = fl::Channel::create(config);
FastLED.add(channel);
}
// Called from UI/network handler
void updateSettings(CRGB* newLeds, int count, EOrder order) {
auto timing = fl::makeTimingConfig<fl::TIMING_WS2812_800KHZ>();
fl::ChannelOptions opts;
opts.mCorrection = TypicalSMD5050;
opts.mTemperature = Tungsten100W;
opts.mDitherMode = DISABLE_DITHER;
fl::ChannelConfig newConfig(16, timing, fl::span<CRGB>(newLeds, count), order, opts);
channel->applyConfig(newConfig);
}
What changes:
What stays the same:
Use cases:
Bind specific channels to specific drivers (useful for mixing chipset timings in parallel):
#include "FastLED.h"
#include "fl/channels/channel.h"
#include "fl/channels/config.h"
#define NUM_LEDS 100
// Two strips with different chipset timings
CRGB ws2812_strip[NUM_LEDS];
CRGB ws2816_strip[NUM_LEDS];
void setup() {
// WS2812 strips bound to RMT driver
fl::ChannelOptions ws2812_opts;
ws2812_opts.mAffinity = "RMT";
auto timing_ws2812 = fl::makeTimingConfig<fl::TIMING_WS2812_800KHZ>();
FastLED.add(fl::ChannelConfig(16, timing_ws2812,
fl::span<CRGB>(ws2812_strip, NUM_LEDS), RGB, ws2812_opts));
// WS2816 strips bound to SPI driver (transmits in parallel with RMT)
fl::ChannelOptions ws2816_opts;
ws2816_opts.mAffinity = "SPI";
auto timing_ws2816 = fl::makeTimingConfig<fl::TIMING_WS2816>();
FastLED.add(fl::ChannelConfig(18, timing_ws2816,
fl::span<CRGB>(ws2816_strip, NUM_LEDS), RGB, ws2816_opts));
}
void loop() {
// Different effects on each strip
fill_rainbow(ws2812_strip, NUM_LEDS, 0, 255 / NUM_LEDS);
fill_solid(ws2816_strip, NUM_LEDS, CRGB::Blue);
FastLED.show(); // Both drivers transmit simultaneously
delay(20);
}
Use cases:
Channel API: Recommended - Modern, flexible interface
The Channel API is the modern interface for FastLED. It provides explicit control over LED strip configuration and is recommended for new projects.
Backwards Compatible API (FastLED.addLeds<>()): Stable - Template-based convenience wrapper
The template-based API is maintained for backwards compatibility with existing code. It internally uses the Channel API but provides a simpler interface for basic use cases.
Prefer Channel API when:
⚠️ Advanced users only - Most users don't need direct driver access. FastLED.show() handles everything automatically.
Engines handle different chipset timings in two modes:
Sequential (Default) - Single driver transmits different timings one after another:
// Automatic - no configuration needed
FastLED.addLeds<WS2812, 16>(leds1, 60); // 800kHz timing
FastLED.addLeds<WS2816, 17>(leds2, 60); // Different timing
FastLED.show(); // Sequential transmission through same driver
Parallel (Explicit) - Multiple drivers transmit different timings simultaneously (see Engine Affinity example above).
Hardware drivers use a 4-state machine for non-blocking DMA transmission:
| State | Description | poll() return value meaning |
|---|---|---|
| READY | Idle, ready to accept new data | Hardware is idle, safe to call show() |
| BUSY | Actively transmitting or queuing channels | Transmission in progress, driver is working |
| DRAINING | All channels enqueued, DMA still transmitting | Transmission finishing, no more data needed |
| ERROR | Hardware error occurred | Error state, check error message |
State flow: READY → show() → BUSY → DRAINING → poll() → READY
For advanced CPU/DMA parallelism (e.g., computing next frame while DMA transmits):
#include "FastLED.h"
#include "fl/channels/manager.h"
CRGB leds[300];
void setup() {
auto timing = fl::makeTimingConfig<fl::TIMING_WS2812_800KHZ>();
fl::ChannelConfig config(16, timing, fl::span<CRGB>(leds, 300), RGB);
FastLED.add(config);
}
void computeNextFrame() {
// Do CPU-intensive work while DMA transmits
static uint8_t hue = 0;
fill_rainbow(leds, 300, hue++, 255 / 300);
}
void loop() {
// Get driver from ChannelManager
auto& manager = fl::ChannelManager::instance();
// Check if driver is ready for new data
fl::IChannelDriver::DriverState state = manager.poll();
if (state == fl::IChannelDriver::DriverState::READY) {
// Hardware is idle - safe to show next frame
FastLED.show();
} else if (state == fl::IChannelDriver::DriverState::DRAINING) {
// DMA transmission finishing - no more poll() needed this frame
// Do useful work while waiting
computeNextFrame();
} else if (state == fl::IChannelDriver::DriverState::ERROR) {
Serial.println(state.error.c_str());
}
// BUSY state: Keep polling until DRAINING or READY
delay(20);
}
When to use:
Key insight: DRAINING state signals that the driver doesn't need more poll() calls - all channels are enqueued and DMA is finishing transmission. This is the optimal time to compute the next frame.
Third-party developers can create custom channel drivers to support new hardware peripherals or transmission protocols. This section covers the requirements and best practices.
A channel driver bridges the gap between high-level Channel objects and low-level hardware. Channels pass their encoded data to drivers via an ephemeral enqueue - drivers manage transmission, not channel registration.
Key responsibilities:
enqueue() (temporary, per-frame)isInUse flag during transmissionInherit from fl::IChannelDriver and implement these methods:
#include "fl/channels/driver.h"
#include "fl/channels/data.h"
class MyCustomEngine : public fl::IChannelDriver {
public:
/// Check if driver can handle this channel type
bool canHandle(const ChannelDataPtr& data) const override {
// Example: Only accept clockless chipsets
return data && data->isClockless();
}
/// Enqueue channel data for transmission (ephemeral, per-frame)
void enqueue(ChannelDataPtr channelData) override {
if (channelData) {
mEnqueuedChannels.push_back(channelData);
}
}
/// Trigger transmission of enqueued channels
void show() override {
if (mEnqueuedChannels.empty()) {
return;
}
// CRITICAL: Mark all channels as in-use BEFORE transmission
for (auto& channel : mEnqueuedChannels) {
channel->setInUse(true);
}
// Move pending queue to in-flight queue
mTransmittingChannels = fl::move(mEnqueuedChannels);
mEnqueuedChannels.clear();
// Start hardware transmission
beginTransmission(fl::span<const ChannelDataPtr>(
mTransmittingChannels.data(),
mTransmittingChannels.size()));
}
/// Query driver state and perform maintenance
DriverState poll() override {
// Check hardware status
if (isHardwareBusy()) {
return DriverState::BUSY;
}
if (isTransmitting()) {
return DriverState::DRAINING;
}
// Transmission complete - CRITICAL: Clear isInUse flags
if (!mTransmittingChannels.empty()) {
for (auto& channel : mTransmittingChannels) {
channel->setInUse(false);
}
mTransmittingChannels.clear();
}
return DriverState::READY;
}
/// Get driver name for affinity binding
fl::string getName() const override {
return fl::string::from_literal("MY_ENGINE");
}
/// Declare capabilities (clockless, SPI, or both)
Capabilities getCapabilities() const override {
return Capabilities(true, false); // Clockless only
}
private:
void beginTransmission(fl::span<const ChannelDataPtr> channels);
bool isHardwareBusy() const;
bool isTransmitting() const;
// Two-queue architecture (required)
fl::vector<ChannelDataPtr> mEnqueuedChannels; // Pending queue
fl::vector<ChannelDataPtr> mTransmittingChannels; // In-flight queue
};
The isInUse flag prevents channels from modifying their data while the driver is transmitting. All drivers MUST manage this flag correctly.
Rules:
isInUse(true) in show() - Before starting transmissionisInUse(false) in poll() - When transmission completes (READY state)isInUse(false) on errors - When returning ERROR stateWhy it matters:
Channel::showPixels() prevents this:
if (mChannelData->isInUse()) {
FL_ASSERT(false, "Skipping update - buffer in use by driver");
return;
}
Example (correct pattern):
void show() override {
// Mark in-use BEFORE transmission
for (auto& channel : mEnqueuedChannels) {
channel->setInUse(true); // ✅ Prevent modification
}
mTransmittingChannels = fl::move(mEnqueuedChannels);
mEnqueuedChannels.clear();
startHardware();
}
DriverState poll() override {
if (hardwareComplete()) {
// Clear in-use AFTER transmission
for (auto& channel : mTransmittingChannels) {
channel->setInUse(false); // ✅ Allow modification
}
mTransmittingChannels.clear();
return DriverState::READY;
}
return DriverState::DRAINING;
}
Engines use a dual-queue system to separate pending data from in-flight data:
Pending Queue (mEnqueuedChannels):
enqueue() callsshow() is calledshow() after moving to in-flight queueIn-Flight Queue (mTransmittingChannels):
show(), cleared by poll() when READYisInUse flagLifecycle flow:
Channel::showPixels()
↓
driver->enqueue(data) → mEnqueuedChannels.push_back(data)
↓
driver->show() → Move to mTransmittingChannels, clear mEnqueuedChannels
↓
driver->poll() → Check hardware status
↓
DriverState::READY → Clear mTransmittingChannels, ready for next frame
Engines implement a 4-state machine for non-blocking transmission:
| State | Description | When poll() returns this |
|---|---|---|
| READY | Idle, ready for new data | Hardware idle, no transmissions in progress |
| BUSY | Actively transmitting channels | Hardware actively working, still accepting data |
| DRAINING | All channels enqueued, DMA finishing | All data submitted, no more poll() needed |
| ERROR | Hardware error occurred | Error state, check error message |
State flow:
READY → show() → BUSY → (all queued) → DRAINING → (hardware complete) → READY
↓
(error) → ERROR
Implementation notes:
show())Register your driver with the bus manager to make it available:
#include "fl/channels/manager.h"
// In your platform initialization code
void setupCustomEngine() {
auto driver = fl::make_shared<MyCustomEngine>();
// Register with priority (higher = preferred)
// Built-in ESP32 drivers use priorities 4 (PARLIO), 2 (RMT), 1 (I2S), 0 (SPI), -1 (UART)
// Custom drivers can use any integer priority value
fl::ChannelManager::instance().addDriver(
10, // Priority (higher than built-in drivers)
driver, // Shared pointer to driver
"MY_ENGINE" // Unique name for affinity binding
);
}
Priority guidelines for custom drivers:
Engine selection:
canHandle() on eachtrue winsChannelOptions.mAffinity or FastLED.setExclusiveDriver()Priority modification:
addDriver())setDriverPriority(name, priority)show() must wait for READY before starting a new frame. The correct pattern is a simple spin on poll():
void show() override {
// Wait for previous frame to finish.
while (poll() != EngineState::READY) {
// poll() drives the state machine and clears in-use flags.
}
// Now safe to start new frame...
}
Do NOT branch on DRAINING or other intermediate states inside show()'s wait loop. The poll() method is responsible for driving the state machine to READY — show() just needs to wait for it. Branching on intermediate states (e.g., breaking early on DRAINING) splits the "wait for previous frame" logic across multiple places and makes the code harder to reason about.
Memory Management:
fl::vector for dynamic arrays (not std::vector)fl::shared_ptr<ChannelData> (not raw pointers)Thread Safety:
enqueue(), show(), poll() are called from main threadFL_IRAM attributesrc/platforms/esp/32/drivers/parlio/parlio_engine.h for ISR patternsError Handling:
DriverState::ERROR on hardware failuresisInUse flags before returning ERRORFL_WARN() or FL_DBG()Performance:
show() and poll() (hot paths)Compatibility:
canHandle() conservatively (reject unsupported chipsets)canHandle() if hardware has limitsReference implementations in the codebase:
Simple (good starting point):
src/platforms/esp/32/drivers/uart/channel_engine_uart.cpp.hpp - UART Wave8 encodingsrc/platforms/stub/clockless_channel_stub.h - Stub driver for testingAdvanced (full-featured):
src/platforms/esp/32/drivers/rmt/rmt_5/channel_engine_rmt.cpp.hpp - RMT with ISR callbackssrc/platforms/esp/32/drivers/parlio/channel_engine_parlio.cpp.hpp - PARLIO with chipset groupingKey differences:
Create unit tests following the existing patterns:
#include "test.h"
#include "fl/channels/driver.h"
#include "fl/channels/data.h"
FL_TEST_CASE("MyEngine: Basic enqueue and transmission") {
auto driver = fl::make_shared<MyCustomEngine>();
// Create test data
auto data = fl::ChannelData::create(5, timing, fl::move(encodedData));
// Enqueue
driver->enqueue(data);
// Verify isInUse flag lifecycle
FL_CHECK_FALSE(data->isInUse()); // Not in use before show()
driver->show();
FL_CHECK(data->isInUse()); // In use during transmission
// Poll until complete
while (driver->poll() != fl::IChannelDriver::DriverState::READY) {
fl::delayMicroseconds(100);
}
FL_CHECK_FALSE(data->isInUse()); // Not in use after transmission
}
See tests/fl/channels/driver.cpp for more test examples.
Headers:
fl/channels/channel.h - Channel class and factory methodsfl/channels/config.h - ChannelConfig, ClocklessChipset, SpiChipsetConfigfl/channels/options.h - ChannelOptions (correction, temperature, dither, affinity, gamma)fl/channels/channel_events.h - Lifecycle event callbacksfl/channels/driver.h - Engine interface and state machineExamples:
examples/BlinkParallel.ino - Parallel LED strip example