Skip to content

fix(esp32-s3): verify and finish #2420 FastLED.show() timing regression #2991

Description

@zackees

Context

#2421 mixed two separate regressions from #2420: ESP32-S3 FastLED.show() timing drift and binary-size growth. The binary-size work has its own history and follow-ups; this issue intentionally tracks only the timing regression.

Original timing report from #2420:

  • Board: ESP32-S3
  • LEDs: 35 WS2812B / NeoPixel LEDs on pin 4
  • Pacing: FastLED.show() every 5 ms via millisDelay from powerbroker2/SafeString
  • FastLED settings: FastLED.setMaxRefreshRate(0), setCpuFrequencyMhz(160)
  • Expected sequence time: ~2500 ms
  • Observed on master#ea5d8ec: commonly ~2600 ms, with random spikes above 3000 ms
  • Later retest on master#787de450d: 2600-2800 ms remained, although the long-running drift toward 3000 ms was no longer present

Since then, #2815, #2817, #2827, and #2828 landed wait-loop improvements. There is not yet a focused hardware confirmation that the #2420 ESP32-S3 timing case is fixed on current master, and current source still has one suspicious gap: IChannelDriver::waitDone() exists, but no ESP32 driver overrides it yet.

Binary-size numbers, PlatformIO exception-frame behavior, and lean-build work are out of scope for this issue.

Reproduction Case

Use the original #2420 sketch and measure one full fade sequence. Prefer serial micros()/millis() instrumentation over stopwatch timing so the result is machine-readable.

#include <Arduino.h>
#include <millisDelay.h>
#include <FastLED.h>

uint8_t i_post_fade = 255;
#define LED_PIN 4
#define FAST_LED_UPDATE_MS 5
#define NUM_LEDS 35 // 35-LED NeoPixel ring
CRGB leds[NUM_LEDS];
uint8_t i_fast_led_delay = FAST_LED_UPDATE_MS;
millisDelay ms_fast_led;
millisDelay ms_delay_post;
uint32_t cycle_start_ms = 0;

void setup() {
  FastLED.addLeds<NEOPIXEL, LED_PIN>(leds, NUM_LEDS).setCorrection(TypicalLEDStrip);
  FastLED.setMaxRefreshRate(0);
  FastLED.show();
  setCpuFrequencyMhz(160);
  Serial.begin(115200);
  ms_delay_post.start(0);
  ms_fast_led.start(i_fast_led_delay);
  cycle_start_ms = millis();
}

void updateLEDs() {
  if (ms_fast_led.justFinished()) {
    FastLED.show();
    ms_fast_led.start(i_fast_led_delay);
  }
}

void loop() {
  if (i_post_fade > 0 && ms_delay_post.justFinished()) {
    const uint8_t i_ring_start = 0;
    const uint8_t i_ring_end = 34;
    const uint8_t i_ring_divisor = 7;
    uint8_t i_ring_counter = ((255 - i_post_fade) / i_ring_divisor) + i_ring_start;

    if (i_ring_counter <= i_ring_end) {
      leds[i_ring_counter] = CRGB::Red;
    }

    if (i_ring_counter - 1 >= i_ring_start && i_ring_counter - 1 <= i_ring_end) {
      leds[i_ring_counter - 1] = CRGB::Black;
    }

    i_post_fade--;

    if (i_post_fade == 0) {
      for (uint8_t i = i_ring_start; i <= i_ring_end; i++) {
        leds[i] = CRGB::Black;
      }

      updateLEDs();
      const uint32_t elapsed = millis() - cycle_start_ms;
      Serial.printf("cycle_ms=%lu\n", (unsigned long)elapsed);
      delay(1000);
      i_post_fade = 255;
      cycle_start_ms = millis();
      ms_delay_post.start(225);
      return;
    } else {
      ms_delay_post.start(5);
    }
  }

  updateLEDs();
}

Original PlatformIO setup from #2420:

[env:esp32s3]
platform = https://github.com/pioarduino/platform-espressif32/releases/download/55.03.38-1/platform-espressif32.zip
board = esp32s3custom
framework = arduino
lib_deps =
  https://github.com/FastLED/FastLED.git#<commit-under-test>
  powerbroker2/SafeString@^4.1.42
build_flags =
  -D "CORE_DEBUG_LEVEL=3"
  -D "LOG_LOCAL_LEVEL=ESP_LOG_INFO"
  -D "FASTLED_WS2812_T1=375"
  -D "FASTLED_WS2812_T2=375"
  -D "FASTLED_WS2812_T3=500"

For repository automation, an equivalent bash autoresearch esp32s3 ... / examples/AutoResearch timing mode would be better than manual PlatformIO testing.

Likely Code Areas

Current master has the #2815 wait-loop scaffolding, but the driver-specific completion wait still appears incomplete:

  • src/fl/channels/manager.cpp.hpp:471-520 has the single-driver fast path. It delegates to onlyBusy->waitDone(timeoutMs) when exactly one driver is busy.
  • src/fl/channels/driver.h:151-152 defaults waitDone() back to waitForReady(timeoutMs).
  • A current tree search only finds the default and the manager call site for waitDone(, so ESP32-S3 RMT5 still falls back to the generic wait path instead of an ISR-exact wait.
  • src/fl/channels/driver.cpp.hpp:12-60 and src/fl/channels/manager.cpp.hpp:375-435 are the generic tiered wait loops: instant check, bounded spin, then NetworkDetector-gated yield.
  • If fl::NetworkDetector::isAnyNetworkActive() returns true unexpectedly, the wait still calls task::run(250, task::ExecFlags::SYSTEM), which reaches pumpCoroutines(1000) in src/fl/task/executor.cpp.hpp:135-140 and vTaskDelay(ticks) in src/platforms/esp/32/coroutine_esp32.impl.hpp:62-87.
  • RMT5 already has an underlying completion primitive: IRMT5Peripheral::waitAllDone() / Rmt5PeripheralESPImpl::waitAllDone() wraps rmt_tx_wait_all_done() (src/platforms/esp/32/drivers/rmt/rmt_5/rmt5_peripheral_esp.cpp.hpp). That is the likely primitive for an RMT5 waitDone() override.
  • src/platforms/esp/32/drivers/rmt/rmt_5/channel_driver_rmt.cpp.hpp:214-231 currently calls waitForReady() before beginning a new transmission. That path should be checked alongside the manager's onBeginFrame() / onEndFrame() waits.

Proposal

  1. Re-run the [Bug] Compounded timing inaccuracy on master compared to 3.10.3 #2420 reproduction on current master with serial timing output.
  2. If current master still drifts beyond tolerance, implement the ESP32-S3/RMT5 waitDone() override around the existing RMT5 waitAllDone() completion primitive.
  3. Confirm the no-WiFi/no-BT case avoids the vTaskDelay(>=1 tick) path entirely.
  4. Keep the ESP32-S3: I2S driver starves websockets, SPI output incorrect, Audio driver FPS impact #2254 invariant: when WiFi/lwIP/BT is active, FastLED must still yield enough for network tasks.
  5. Add a regression test or hardware-harness mode so this does not regress silently.

Acceptance Criteria

  • The [Bug] Compounded timing inaccuracy on master compared to 3.10.3 #2420 35-LED ESP32-S3 reproduction reports a 2500 ms cycle within a tight tolerance on current master after the fix. Target: +/-10 ms if the hardware harness is stable; otherwise document the measured jitter floor and use that as the threshold.
  • FastLED.show() for a single RMT5 strip with no WiFi/BT active has no visible >=1 FreeRTOS tick floor. For 35 WS2812B LEDs, wall time should be close to the ~1.05 ms bitstream duration plus small overhead.
  • RMT5 either overrides IChannelDriver::waitDone() with an ISR/IDF completion wait, or the issue documents why the generic tiered wait is sufficient with current measurements.
  • WiFi/lwIP behavior from ESP32-S3: I2S driver starves websockets, SPI output incorrect, Audio driver FPS impact #2254 is not regressed when a network is active.
  • Binary-size work remains out of scope.

Open Questions

Related Issues

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    Status
    Done

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions