Skip to content

MultiPinLedDriver

Source: MultiPinLedDriver.h

MultiPinLedDriver

class MultiPinLedDriver
src/light/drivers/MultiPinLedDriver.h:40

Inherits: ParallelLedDriver< MultiPinLedDriver >

Output driver: parallel 8-or-16-lane WS2812B over the ESP-IDF esp_lcd i80 bus — the parallel scale path on all three i80-capable ESP32 families.

RMT gives a chip 4-8 channels; this gives 8-16 lanes for the wall time of one. The magic is that ESP-IDF exposes ONE public i80 API (esp_i80_new_i80_bus / esp_i80_panel_io_tx_color) and routes it to whichever peripheral the silicon has — so this single driver serves every chip:

  • ESP32-S3 / -P4: backed by the dedicated LCD_CAM peripheral.

  • classic ESP32: backed by the I2S peripheral in i80/LCD mode (the classic has no LCD_CAM; I2S-i80 is its only >8-lane route). IDF's own CMake picks the backend by chip; the two are mutually exclusive per silicon, so [lanesAvailable()] reads whichever lane-count constant is non-zero. Named for the i80 bus (the shared API), not a peripheral, since it isn't one peripheral — same reason its siblings are named Rmt/Parlio (their APIs).

The shared body (slicing, the whole-frame async double-buffer DMA, the fused encode, the loopback self-test, the frameTime KPI) lives in ParallelLedDriver; this class adds only the i80-specific pieces:

  • The sacrificial WR (pixel clock) + DC GPIOs the i80 bus mandates even though WS2812 ignores both, and the "exactly 8 or 16 pins" rule (the i80 layer rejects a partial bus). A sub-16 board parks unused lanes + WR/DC on one spare GPIO (the ghost-pin trick).

  • The 3-slot-per-bit wire contract: each WS2812 bit becomes three bus slots at 2.67 MHz (slot = 375 ns): all-active-lanes HIGH, the data bits, then all LOW — so a 1 is HIGH 750 ns and a 0 375 ns, approximating RMT's 700/350. The slot is deliberately NOT the lineage's ~416 ns: newer WS2812B revisions spec T0H max ≈ 380 ns, and a longer 0 on a direct 3.3 V line gets misread as 1 (the strip washes white). One bus word per slot (bus bit L = the L-th pin); unequal strands idle LOW once exhausted. Slot layout: [ParallelSlots.h].

  • Both backends do whole-frame chained DMA (autonomous, CPU out of the timing loop), so the classic I2S path is WiFi-underrun-immune by construction — it does NOT need the ISR-refilled ring / large nbDmaBuffer cushion the raw-register I2S-clockless lineage requires.

  • The platform::i80Ws2812* calls (ESP-IDF's esp_lcd i80 bus + GDMA).

Prior art: Adafruit's LCD_CAM discovery, hpwit's I2SClockless lineage (classic-ESP32 I2S parallel), FastLED's S3 driver — architecture studied, never copied. We build on IDF's maintained esp_lcd i80 abstraction rather than tracing the raw-register I2S driver (Industry standards, our own code).

Public Attributes

int8_t clockPin = 10 : WR (pixel clock) and DC: the IDF i80 bus requires both on real GPIOs (esp_i80_panel_io_i80.c: wr_gpio_num >= 0 && dc_gpio_num >= 0), yet the WS2812 strands ignore both — they are peripheral-fixed, not user-strand wiring, so a sensible overridable default cannot do harm (same class as the chip-fixed Ethernet pins).

int8_t dcPin = 11

Public Methods

inline uint16_t clockPinForBus() const : Spare bus lanes (shift mode, when the board has fewer data pins than the bus is wide) park on WR: the peripheral already drives it and the board already wires it, so the lane is inert.

inline void addBusControls() : Bind the i80-specific bus controls: the sacrificial WR (clockPin) and DC pins the peripheral mandates.

inline bool busControlTriggersBuild(constchar * name) const : A clockPin or dcPin change triggers a bus rebuild via the prepare sweep.

inline constchar * validateBusFatal() const : Reject a data lane that collides with the WR (clockPin) or DC pin.

inline constchar * validateBusPins(constuint16_t * lanes, uint8_t n) const

inline bool busInit(size_t frameBytes, bool wantSecondBuffer) : The bus pin list comes from the base: in shift mode it appends the latch to the data pins (the latch is a bus lane), so the peripheral drives it.

inline uint8_t * busBuffer(uint8_t i) : DMA buffer i (0/1) the base encodes into; buffer 1 is null when the second buffer didn't fit (single-buffer mode).

inline size_t busCapacity() const : The per-buffer byte capacity (fixed at bus creation; both buffers equal).

inline bool busTransmit(uint8_t i, size_t bytes) : Kick off the autonomous transfer of the first bytes of DMA buffer i; returns whether it started.

inline bool busWait(uint8_t i, uint32_t ms) : Block up to ms for buffer i's in-flight transfer to complete.

inline uint32_t busLastTransmitUs() const : The most recent DMA transfer's wire time (µs) — the WS2812 output floor.

inline void busDeinit() : Tear down the i80 bus and its DMA buffer.

inline platform::RmtLoopbackResult busLoopback(constuint8_t * frame, size_t frameBytes, size_t dataBytes, uint8_t rowBits) : Run the loopback self-test.

inline void recordBusPins() : Store WR/DC alongside the data pins, so a clockPin/dcPin edit rebuilds the bus too (not just a data-pin change).

inline bool extraBusPinsCurrent() const : Whether the live bus's WR/DC pins still match the current clockPin/dcPin (so the base can skip a rebuild).

Public Static Attributes

constexprbool kPowerOfTwoBus =

constexprbool kLoopbackFullWidth =

constexprconstchar * kInitFailMsg = "i80 init /

constexprbool kSupportsPinExpander = platform::lcdLanes > 0 : Create the i80 bus + its DMA buffer(s) sized for frameBytes on the current data lanes plus the WR/DC pins; wantSecondBuffer requests the async double-buffer's second frame buffer (allocated only if it fits — else single-buffer).

Public Static Methods

constexpr static inline constexpruint8_t lanesAvailable() : The number of i80 lanes this chip provides (0 = no i80 bus on this chip); the base's inert-on-wrong-chip guards key off it.