Multi-channel dimmer — how to control 4 channels independently?

I’m building a 4-channel lighting controller for my living room — four separate dimmable ceiling lights on one ESP32 with rbdimmer 4CH module.

Basic on/off and set-to-level works fine for all four channels. The problem is I want each channel to run independent fade animations simultaneously. For example:

  • Channel 1: slow fade up over 5 seconds
  • Channel 2: slow fade down over 3 seconds
  • Channel 3: hold at 80%
  • Channel 4: pulse effect (fade up then down in a loop)

All running at the same time without blocking each other.

My setup:

  • ESP32 DevKit V1
  • rbdimmer 4CH 4A module
  • rbdimmerESP32 library
  • Arduino IDE 2.3

I know delay() is out — it blocks the entire loop. What’s the best approach for independent multi-channel fades?

I can think of three options:

  1. millis()-based timer with a state machine per channel
  2. FreeRTOS tasks — one task per channel
  3. DimmerLink with sequential I2C commands

Anyone tried any of these? I’d like to hear what works in practice before I start coding.

Hello, I have been working on exactly this problem for a 2-channel setup. I used the millis() approach and it works well for two channels.

Here is my working code:

#include <rbdimmerESP32.h>

rbdimmerESP32 ch1(18, 19);  // gate, ZC
rbdimmerESP32 ch2(21, 19);  // gate, same ZC pin

unsigned long lastUpdate1 = 0;
unsigned long lastUpdate2 = 0;
int power1 = 0;
int power2 = 100;
int dir1 = 1;   // 1 = up, -1 = down
int dir2 = -1;

void setup() {
  ch1.begin(NORMAL_MODE, ON);
  ch2.begin(NORMAL_MODE, ON);
}

void loop() {
  unsigned long now = millis();
  
  // Channel 1: fade up/down every 30ms
  if (now - lastUpdate1 > 30) {
    lastUpdate1 = now;
    power1 += dir1;
    if (power1 >= 100) dir1 = -1;
    if (power1 <= 0) dir1 = 1;
    ch1.setPower(power1);
  }
  
  // Channel 2: fade up/down every 50ms
  if (now - lastUpdate2 > 50) {
    lastUpdate2 = now;
    power2 += dir2;
    if (power2 >= 100) dir2 = -1;
    if (power2 <= 0) dir2 = 1;
    ch2.setPower(power2);
  }
}

Both channels fade independently with different speeds. The important thing is that all channels share the same ZC pin — only one zero-cross interrupt for the whole module.

My question: does this scale to 4 channels the same way? Just add more if blocks with their own timers?

Yes, the millis() approach scales to 4 channels — you add a timer variable, power variable, and direction variable per channel. Functionally it works.

But there is a practical issue with state management. With 2 channels you have 6 variables (lastUpdate, power, direction per channel). With 4 channels that becomes 12 variables floating around in global scope. If you add more features — like different fade curves, min/max limits, or pause/resume — it gets unwieldy quickly.

A cleaner millis() approach for 4 channels would use a struct:

struct FadeChannel {
  rbdimmerESP32* dimmer;
  unsigned long lastUpdate;
  int power;
  int target;
  int stepMs;
  int direction;
};

FadeChannel channels[4];

void updateFade(FadeChannel& ch) {
  if (millis() - ch.lastUpdate > ch.stepMs) {
    ch.lastUpdate = millis();
    ch.power += ch.direction;
    if (ch.power >= ch.target) ch.direction = -1;
    if (ch.power <= 0) ch.direction = 1;
    ch.dimmer->setPower(ch.power);
  }
}

void loop() {
  for (int i = 0; i < 4; i++) {
    updateFade(channels[i]);
  }
}

This is fine for simple linear fades. However, if you want very smooth concurrent fades with different timing requirements, the millis() approach has a limitation: all updates happen sequentially in one loop iteration. If one channel’s fade calculation takes time, it delays the others.

Good point about the struct approach — that’s much cleaner than 12 individual variables.

But I’m curious about the FreeRTOS option since I’m on ESP32 anyway. Each channel gets its own task running on its own timeline — no sequential bottleneck. What would that look like?

For ESP32 with FreeRTOS, you create one task per channel. Each task runs independently with its own timing via vTaskDelay() — no interference between channels.

Here’s a skeleton:

#include <rbdimmerESP32.h>

rbdimmerESP32 ch1(18, 19);
rbdimmerESP32 ch2(21, 19);
rbdimmerESP32 ch3(22, 19);
rbdimmerESP32 ch4(23, 19);

struct FadeParams {
  rbdimmerESP32* dimmer;
  int target;
  int stepMs;
};

void fadeTask(void* param) {
  FadeParams* fp = (FadeParams*)param;
  int power = 0;
  int dir = 1;
  
  for (;;) {
    power += dir;
    if (power >= fp->target) dir = -1;
    if (power <= 0) dir = 1;
    fp->dimmer->setPower(power);
    vTaskDelay(pdMS_TO_TICKS(fp->stepMs));
  }
}

FadeParams params1 = {&ch1, 100, 30};
FadeParams params2 = {&ch2, 80, 50};
FadeParams params3 = {&ch3, 100, 20};
FadeParams params4 = {&ch4, 60, 40};

void setup() {
  ch1.begin(NORMAL_MODE, ON);
  ch2.begin(NORMAL_MODE, ON);
  ch3.begin(NORMAL_MODE, ON);
  ch4.begin(NORMAL_MODE, ON);
  
  xTaskCreate(fadeTask, "fade1", 2048, &params1, 1, NULL);
  xTaskCreate(fadeTask, "fade2", 2048, &params2, 1, NULL);
  xTaskCreate(fadeTask, "fade3", 2048, &params3, 1, NULL);
  xTaskCreate(fadeTask, "fade4", 2048, &params4, 1, NULL);
}

void loop() {
  // Main loop free for other work
}

Each task gets 2048 bytes of stack — sufficient for simple fade logic. The FreeRTOS scheduler handles time-slicing so all four fades run concurrently without blocking each other.

Important: setPower() in rbdimmerESP32 is thread-safe as long as each task only touches its own channel object. Don’t share a dimmer object between tasks without a mutex.

That is very clean. I like how one task function handles all channels via the parameter struct — no code duplication.

Question: what happens to the fade tasks during an OTA update? If the ESP32 reboots mid-fade, do the tasks terminate cleanly or could there be a brief TRIAC firing issue?

During OTA the ESP32 reboots — all FreeRTOS tasks terminate immediately. The rbdimmerESP32 library disables the TRIAC timer interrupt during shutdown, so there’s no stray firing. The lamp just goes dark for 2-3 seconds during the reboot.

If OTA-safe dimming is important (no blackout during updates), that’s where DimmerLink comes in. The DimmerLink MCU keeps the last brightness value regardless of what the ESP32 does. But for a 4-channel FreeRTOS setup without DimmerLink, the brief OTA blackout is the only downside.

I went with the FreeRTOS approach. Four channels running simultaneously with independent fade speeds — works perfectly.

Here’s my final implementation with a few additions:

void fadeTask(void* param) {
  FadeParams* fp = (FadeParams*)param;
  int power = 0;
  int dir = 1;
  
  for (;;) {
    power += dir;
    if (power >= fp->target) {
      dir = -1;
      vTaskDelay(pdMS_TO_TICKS(500));  // pause at peak
    }
    if (power <= fp->minPower) {
      dir = 1;
      vTaskDelay(pdMS_TO_TICKS(200));  // pause at bottom
    }
    fp->dimmer->setPower(power);
    vTaskDelay(pdMS_TO_TICKS(fp->stepMs));
  }
}

I added minPower to the params struct so each channel can have its own minimum brightness (some of my LED fixtures look bad below 15%). Also added pause delays at peak and bottom for a more natural breathing effect.

All four fades running simultaneously, each at different speeds and ranges. The ESP32’s dual core handles it easily — I can even run WiFi and MQTT alongside without any timing issues.

UPDATE: I converted my 2-channel setup to the FreeRTOS approach as well. Works great — the code is much cleaner than my original millis() version.

One thing I noticed: if you set the task stack size below 1024 bytes, you get a stack overflow crash. 2048 is safe for the fade logic plus any Serial.print debugging. I learned this the hard way — it makes no sense to save 1KB of RAM and lose stability.

For anyone finding this later, the multi-channel guide from rbdimmer covers both the direct approach and DimmerLink:
https://rbdimmer.com/faq/multi-channel-ac-dimmer-control-2ch-and-4ch-guide-26

For anyone finding this later, here’s the summary:

Three approaches for multi-channel independent fades:

  1. millis() + struct — works on any Arduino/ESP board. Good for 2-3 channels with simple linear fades. Gets complex with advanced effects
  2. FreeRTOS tasks — ESP32 only. Cleanest solution for 4+ channels. Each channel runs independently. Needs 2048+ bytes stack per task
  3. DimmerLink 4CH — send I2C commands to registers 0x01-0x04 sequentially. Offloads timing to DimmerLink MCU. OTA-safe. Best for production installs

I went with FreeRTOS (#2) for development flexibility. If I ever move this to a permanent install I’ll switch to DimmerLink (#3) for the OTA safety.

All three approaches share one key detail: all channels use the same ZC pin. Only one zero-cross interrupt for the entire module regardless of channel count.

[SOLVED]