Skip to content

MoonLedDriver

Source: MoonLedDriver.h

MoonI80Peripheral

class MoonI80Peripheral
src/light/drivers/MoonLedDriver.h:186

Inherits: LedPeripheral

A LedPeripheral backend: parallel WS2812B on the LCD_CAM peripheral (ESP32-S3 / -P4 / -S31), driven by our own DMA code instead of ESP-IDF's esp_lcd.

Same peripheral, pins and wire contract as the i80 backend (I80Peripheral); the difference is underneath, and it buys two things esp_lcd cannot give — a frame streamed rather than held whole, and a 74HCT595 pin expander (one GPIO driving 8 strands).

Streamed, not held: the DMA refills a small pool of internal buffers behind the read head, so RAM stops scaling with strand length (useRing picks this path; ringRows/ringBufs size it). The i80 backend is the memory-capped reference, this is the streaming challenger, and switching the peripheral control A/Bs them on the same board with no reflash. LCD_CAM only — the classic ESP32's i80 is the I2S peripheral, a separate backend. Everything above the DMA (slicing, the fused 3-slot encode, the async double-buffer, loopback, the frameTime KPI, the dead-frame guard) lives in ParallelLedDriver, so this backend is nearly all one-liners.

The deep dives are under More info, below the attribute/method lists: why our own DMA driver, the 74HCT595 pin expander, and [the ringDbg instrument legend].

Public Attributes

int8_t clockPin = 10 : WR — the pixel clock — needed only by a 74HCT595 expander (in direct mode nothing reads it, so the platform leaves it unrouted and the pin stays free for a strand); why it doubles as the shift clock, and why there is no dcPin, is under More info → the expander.

bool useRing = true : Stream the frame as a RING of small internal DMA buffers (on, the default), or send it WHOLE from one contiguous buffer (off).

bool ringAuto = true : Derive ringRows/ringBufs from the live config (ON, the default) and write them INTO those controls so the user sees the real numbers — the DHCP pattern; switch OFF to tune by hand, and see More info → tuning the ring for the derivation.

uint8_t ringRows = platform::kRingRowsDefault : Lights per DMA buffer — the ring's grain, the main RAM-vs-interrupt-rate lever, and a prepare trigger (pin-expander mode only); how to pick it is under More info → tuning the ring.

uint8_t ringBufs = platform::kRingBufsDefault : How many buffers the DMA circulates.

uint8_t ringPadUs = 0 : Inter-buffer zero-pad, µs (LAPPING only; 0 = off) — a strand-level pause that buys refill deadline at a linear frame-time cost, whose ceiling is a HARDWARE fact of the strip measured under More info → tuning the ring.

bool shiftOverclock = false : Overclock the '595 shift clock: OFF (default) = 20 MHz — the reliability point, wall-verified on two rigs and the rate hpwit ships (~19.2 MHz).

char ringDbgStr_ = "—" : Backing store for the read-only ringDbg control — the ring's raw instrument (a one-line health + timing dump), legend under [More info → the ringDbg instrument].

Public Methods

virtual inline uint8_t lanesAvailable() const override : LCD_CAM lanes on this chip (0 = none, and then the orchestrator's guards make the driver inert).

virtual inline bool powerOfTwoBus() const override : The i80 bus width is 8 or 16 — a hardware fact (lcd_ll_set_data_wire_width takes nothing else).

virtual inline bool loopbackFullWidth() const override : The loopback cannot build a 1-lane private bus, so it rebuilds the full-width bus and carries the pattern on lane 0 — the test frame must therefore be encoded at the operational bus width.

virtual inline LedHwBlock hwBlock() const override : MoonI80 programs LCD_CAM directly (its own GDMA below esp_lcd), so it claims the LcdCam block — the same block the esp_lcd-i80 backend uses on the LCD_CAM chips (S3/P4/S31), hence the two can't run together.

virtual inline const char * initFailMsg() const override : Status text when the bus will not come up, so the cause is on screen rather than in a serial log.

virtual inline bool supportsPinExpander() const override : The expander needs a backend that can stream its ×8 frame; LCD_CAM is it, and this backend is LCD_CAM-only, so the answer is simply "wherever this backend runs at all".

virtual inline bool supportsDoubleBuffer() const override : No async double-buffer on this backend — the own-GDMA two-buffer completion handshake races and wedges the bus (see busInit).

virtual inline uint16_t clockPinForBus() const override : The orchestrator pads spare bus lanes with this GPIO.

virtual inline bool spareLanesNeedPad() const override : This backend routes its own GPIOs, so a lane past the data-pin count is left unconnected and costs nothing (see configureGpio: it routes only what it is handed).

virtual inline void addBusControls(ControlList & controls) override : WR is a '595 pin here, so the control follows the expander toggle: bound always (a saved value survives a round-trip through direct mode) but shown only when a shift register can read it.

virtual inline void addRingControls(ControlList & controls) override : The output path + the ring's geometry and instrument.

virtual inline void refreshBusKpi() override : Refresh the ringDbg diagnostic string once a second (the orchestrator's tick1s chains here via refreshBusKpi).

virtual inline bool busControlTriggersBuild(const char * name) const override : Which of this backend's controls need the BUS rebuilt (not just a re-encode) when they change: the WR pin, the output path, and the ring's geometry — buffers are sized and the DMA chain mounted at build time, so each of these is a rebuild.

virtual inline const char * validateBusFatal() const override : WR only reaches a pad in shift mode, so it can only COLLIDE in shift mode.

virtual inline const char * validateBusPins(const uint16_t * lanes, uint8_t n) const override : A data lane sharing WR's GPIO is silent corruption — the matrix routes both signals to the one pad and that strand emits the shift clock instead of pixel data.

virtual inline bool busInit(size_t frameBytes, bool) override : Create the bus + its DMA buffer(s) for frameBytes.

virtual inline void busDeinit() override : The rest of the bus surface: one-line forwards to the platform seam, each carrying the contract its LedPeripheral base already states (busBuffer is "DMA buffer i the encoder writes into", and so on).

virtual inline uint8_t * busBuffer(uint8_t i) override : DMA buffer i (0/1) the encoder writes into; buffer 1 is null in single-buffer mode.

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

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

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

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

virtual inline platform::RmtLoopbackResult busLoopback(const uint8_t * frame, size_t frameBytes, size_t dataBytes, uint8_t rowBits) override : Drive frame on a private bus and capture the wire back on loopbackRxPin (jumpered), so the self-test bit-verifies what the peripheral ACTUALLY emitted — the one instrument that does not take the driver's word for it.

virtual inline bool wantsRing() const override : Should reinit build a RING for this config instead of the whole-frame path? Only in pin-expander mode — direct mode never rings, it drives PSRAM fine at the 10x-slower clock — and then it is the user's choice via useRing, defaulting to on.

virtual inline bool busInitRing(size_t rowBytes, uint32_t totalRows) override : Bring the bus up as a streaming RING (the phase-2 path): the platform loops a few small internal buffers and calls back per drained buffer to refill it, so a frame too big for internal RAM never materialises (see platform.h).

virtual inline bool busTransmitRing() override : Send one frame on the ring: prime the pool, fire the DMA, and let the EOF ISR refill behind it.

virtual inline const char * busRingMode() const override : The ring's regime for the driving-status suffix: "primed" (whole frame encoded before arming — no deadline, pixel-perfect) vs "lapping" (the ISR refills behind the DMA — the deadline regime).

virtual inline bool busIsRing() const override : Did the bus actually come up as a ring? The orchestrator routes tick() on this, so it reports what the platform BUILT, not what was asked for — a ring that would not fit falls back to whole-frame.

virtual inline void recordBusPins() override : WR is part of the bus identity, so a change to it rebuilds the bus — not just a data-pin edit.

virtual inline bool extraBusPinsCurrent() const override : Do this backend's extra bus pins still match the live bus? WR is bus identity here, so the orchestrator rebuilds when this goes false rather than routing a stale clock.

virtual inline bool snapHelperReady() const override : True when the fork-join should engage: the helper task is up AND this caller is running on core 1 — i.e.

inline void helperKick() : Publish this frame's prime job and wake the helper.

inline void helperJoin() : Block until the helper's half is primed.

inline void runHelperJob() : The job itself: prime the buffer range the kick published.

inline void ensureSnapHelper() : Bring the helper task up (idempotent).

inline void stopSnapHelper() : Tear the helper down: signal the stop flag, wake it, and wait for it to leave its loop.

Public Static Methods

static inline void MM_RAMFUNC ringEncodeTrampoline(void * user, uint8_t * dst, uint32_t firstRow, uint32_t rowCount, bool closeFrame, bool needsPrefill) : The platform's MoonI80EncodeFn seam: a plain function pointer (there is no virtual hook for it), so this static trampoline recovers this from user and encodes one slice into the ring buffer the platform hands it.

static inline void snapHelperTramp(void * user) : The worker's entry point — a plain function, because the task API takes no member pointer.

More info

Why our own DMA driver (below the read head)

esp_lcd re-arms the peripheral on every transaction: lcd_start_transaction() does lcd_ll_reset()

  • lcd_ll_fifo_reset() + a hard-coded 4 µs busy-wait before each one. An LCD panel does not care — it is addressed, not clocked continuously. WS2812 is one unbroken self-clocked bit stream, so a reset mid-frame corrupts everything after it: a frame cannot be split across transactions at ANY chunk size, which forces the whole frame into ONE transaction from ONE contiguous DMA-reachable block. That is the cap this driver exists to lift.

The hardware never demanded that cap. The LCD peripheral has no data-length register (lcd_ll_set_phase_cycles() sets lcd_dout as a boolean enable; IDF's own comment reads "Number of data phase cycles are controlled by DMA buffer length"). It clocks exactly what the DMA feeds it and stops when the chain ends. So one gdma_start() over an arbitrarily long descriptor chain plus one lcd_ll_start() is a single gapless stream across as many buffers as we like — built on IDF's HAL + GDMA link-list APIs, one level below esp_lcd (not raw registers; IDF's own drivers use these APIs). Rationale + what we give up: ADR-0014.

What streaming costs: the whole-frame path has no CPU deadline once armed; the ring does. Its refill runs from the DMA's end-of-buffer interrupt and must beat the wire — 576 B per light is 28.8 µs/light at the default 20 MHz shift clock (21.6 at the div-3 overclock) — or the strands see a gap. That trade is why useRing is a switch, not a constant.

The 74HCT595 pin expander (skip unless one is fitted)

1 GPIO drives 8 strands, so 6 data pins → 48 strands. Off by default (pinExpander); with it off, everything above is the whole story and WR never reaches a pad.

bus lane 0 ──────► SER    ┌── 74HCT595 ──┐ QA ──► strand 0
                          │              │ QB ──► strand 1
WR (pixel clock) ──┬────► SRCLK (shift)  │ ..        ..
                   │      │              │ QH ──► strand 7
bus lane N ──┬─────┼────► RCLK (latch)   └──────────────┘
             │     │
             │     └────► SRCLK of every other '595  (all shift in lockstep)
             └──────────► RCLK  of every other '595  (all latch together)

How one lane becomes 8 strands. A '595 is a serial-in / parallel-out shift register: 8 SRCLK edges clock 8 bits down its chain, then one RCLK edge presents all 8 at once on QA..QH. So a lane's byte becomes 8 strands' worth of one WS2812 bit, and the whole '595 bank presents that bit simultaneously.

Why WR is free, and why the latch is not. The peripheral toggles WR once per bus word in hardware — exactly a shift clock — so SRCLK costs zero DMA bytes. But the peripheral has only ONE clock output, and it is already spent on SRCLK; the latch has to come from somewhere else, and the only thing left is a data lane, driven per word like pixel data — one lane that carries no strand.

What bounds the strand count is the driver, not the bus: every data pin fans out to 8, and ParallelLedDriver refuses more than kMaxStrands (64), so 8 data pins is the ceiling — 64 strands. hpwit's board populates 6 → 48 strands.

The '595 is a one-slot pipeline. Its outputs only change on the latch, so during slot N the strand sees the byte latched at the START of slot N — i.e. what was shifted in during slot N−1. The encoder therefore writes every value ONE SLOT EARLY (the rotation in [ParallelSlots.h]). Get that wrong and the first LED survives while everything after it is noise.

Why WR is the shift clock, and why there is no DC pin. WR toggles once per bus word in hardware, exactly what a '595's SRCLK needs — so the expander costs zero DMA bytes for its clock, and the LATCH must ride a data lane instead (the peripheral gives only one clock output). DC exists so an LCD panel can separate command bytes from data; a WS2812 strand has no such concept, so the peripheral holds DC at a constant level and this backend never routes it to a pad. Unlike esp_lcd, which mandates a valid DC GPIO, spending a pin on it would buy nothing.

Why the expander needs the ring. The fan-out costs 8 bus words per WS2812 bit, so a light is 576 B and a 48 x 256 frame is ~144 KB — more contiguous internal RAM than an S3 has, and from PSRAM the DMA cannot sustain the expander's shift clock (measured at 26.67 MHz). Streaming is the only route to that target, which is why these two features are one story.

Tuning the three ring controls

A config at or under the prime-only boundary (ceil(lights/strand ÷ ringRows) ≤ ringBufs, ~224 lights/strand at the defaults) needs none of them — the whole frame is encoded before arming, the pad is not even mounted, and the defaults just work. They exist for the LAPPING frontier (256+/strand), where the ISR races the wire. ringRows/ringBufs are DEVICE tuning (RAM vs interrupt rate vs jitter pool); ringPadUs is a HARDWARE fact of the strip. The ringDbg counters — lt above all — are the meter every sweep reads.

ringRows — lights per DMA buffer. A control rather than a constant because the optimum is a measurement, not a derivation. RAM is the ONLY axis that wants it small: at 1 the ring is ~18 KB flat at ANY strand length (128, 256, 1024 all cost the same), which is what puts 48x256 in reach. Three axes want it big:

axis why big wins
per-call overhead the encode seam's fixed cost amortises over the rows in a buffer; at 1 it is paid per light, inside the ISR
interrupt rate one EOF per buffer: 256 lights at 100 fps is 25.6k int/s at 1 row vs 1.6k at 16 — and a busy ISR starves the network stack
refill deadline the drain of one buffer (ringRows × 21.6 µs + the pad) is the slice deadline the ISR's AVERAGE encode must beat

The platform additionally clamps a buffer to ONE DMA descriptor node (4095 bytes — 7 rows at the 16-strand row size), so values above the clamp behave as the clamp: 7 is the effective maximum there, and the sweet spot measured on the bench.

ringPadUs — and why its ceiling must be measured on the wall. Sweep it up only until ringDbg's lt counter stays 0 (hpwit's _DMA_EXTENSTION, studied then written fresh). The ceiling is the STRIP's latch threshold and it is per-silicon: a WS2812 that reads a LOW gap as a reset LATCHES AND RESETS ITS ADDRESS, and then every slice repaints LEDs 0..ringRows-1 — the unmistakable signature (each panel shows only its first few LEDs flickering through the whole frame's colors, while every ring counter stays clean). The bench wall latches at ≤60 µs (30 is safe there); other strips tolerate up to ~150. That hardware dependence is why it is a user control, not a constant.

ringAuto — what it derives. Per the wall-validated viability rule ((nSlices − ringBufs) × refill ≤ frame wire time): rows = the one-descriptor-node maximum (fewest slices), bufs = as deep as fits (min of nSlices+1 — which IS prime-only when it fits — the platform max, and the internal-RAM budget after its reserve). Deeper is strictly better: more prime coverage, less ISR load. ringPadUs stays manual — the strip's latch threshold is a hardware fact no derivation can know. The toggle is explicit, not edit-triggered: persistence restore fires the same control-change path as a user edit, so an auto-flip-on-edit would trip on every boot.

The ringDbg instrument (expert-mode read-only)

A one-line raw readout of the ring's health + timing, refreshed each second — the field it backs is expert-only in the UI. A whole line reads, e.g.:

sl37/bf31 co671 cr5 ab0 dn4403 ld13 lt0 tx7459 ipb1 ci31 tn-1 de0 enc536 ea369 sg5815 se11239 tw23 ts7740 tp9425 gap1469854
Each token is <abbr><value> (µs = microseconds, cy = CPU cycles):

Field Name What it measures
sn snapshot residency where the ring's snapshot buffer lives: I internal, P PSRAM (the internal-first alloc fell back), - absent. The ISR reads it per byte, so P under lapping means the measured 4-8x encode cost plus cache-contention exposure.
lv live-source residency same probe for the live source buffer (what the encode reads when ringSnapshot is off).
sl slices frame length in DMA slices (nSlices). Geometry.
bf buffers ring pool depth (ringBufs). bf < sl ⇒ the lapping regime (the ISR refills behind the DMA); bf ≥ slprime-only (whole frame encoded before arm).
co cache-off defers lifetime EOF firings that skipped their refill because the flash cache was off (a flash/WiFi write). ~10/s at idle is normal and harmless.
cr cache-off run worst run of consecutive cache-off defers ≈ buffers the DMA drained un-refilled in one window. Healthy while < bf.
ab stall abandons lifetime frames the DMA self-terminated at the frontier because a cache-off window outlasted the pool — each is one held (not corrupted) frame. 0 ideal; climbing = held frames the eye may catch.
dn done lifetime completed frames. Should climb steadily; frozen = the ring stalled.
ld last drain oracle drain position (slice index) observed at the last EOF. Diagnostic.
lt late lifetime slices refilled after their drain began (stale on the wire) — the scatter meter. A clean run holds lt at its arm-time value; any climb is a deadline miss.
tx transmit µs last frame's measured wire time.
ipb items per buffer DMA descriptor nodes per ring buffer (1 by design — a buffer that spans nodes breaks the terminator).
ci consumed items descriptor nodes the mount actually used. Diagnostic.
tn terminator node the fixed NULL-terminator node index (prime-only); -1 = the lapping chain (no fixed terminator).
de descriptor errors GDMA fetched a bad descriptor = memory corruption. 0 = clean; > 0 is a real fault.
enc encode max µs worst single ISR refill-encode (the jitter number). Compare to gap.
ea encode avg µs average refill-encode (the pace number — the one that must beat the slice deadline).
sg seg gather cy avg CPU cycles/row spent gathering source pixels (encode profiling).
se seg emit cy avg CPU cycles/row spent emitting bus words — the '595 transpose (encode profiling).
tw tick wait µs the ring tick's wire-wait segment (waiting the previous frame out).
ts tick snapshot µs the per-frame source-snapshot copy.
tp tick prime µs the pool-prime segment (encode the first bf slices + arm).
gap max EOF gap µs worst inter-interrupt gap = the per-buffer drain deadline the encode must beat.

Reading it: a healthy lapping run has lt/de/ab flat at zero, cr < bf, dn climbing, and ea < gap (the average refill beats the deadline). co climbing is normal (WiFi). enc ≥ gap means the worst refill can't keep pace (a pace problem); enc ≪ gap but lt climbing means a cursor/logic miss (a timing problem, not throughput).

Prior art. The '595 expander and the per-light streaming ring are hpwit's ideas, from I2SClocklessVirtualLedDriver and his expander board — studied hard, credited, and then written fresh against our own architecture and layout (his putdefaultones() prefill has our own counterpart; the transpose is our own SWAR). He runs the S3 shift clock at ~19.2 MHz; we default to the same reliability point (20 MHz, a 28.8 µs/light budget) with the shiftOverclock switch (26.67 MHz, 21.6 µs/light) for short-wired rigs (see the control).