MoonLedDriver
Source:
MoonLedDriver.h
MoonLedDriver¶
src/light/drivers/MoonLedDriver.h:139Inherits:
ParallelLedDriver< MoonLedDriver >
Output driver: parallel WS2812B on the LCD_CAM peripheral (ESP32-S3 / -P4), driven by our own DMA code instead of ESP-IDF's esp_lcd.
Same peripheral, pins and wire contract as MultiPinLedDriver; 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). Both paths ship:MultiPinLedDriver is the memory-capped reference, this is the streaming challenger, and useRing A/Bs them on the same board with no reflash. LCD_CAM only — the classic ESP32's i80 is the I2S peripheral, a different backend. Everything above the DMA (slicing, the fused 3-slot encode, the async double-buffer, loopback, the frameTime KPI, the dead-frame guard) is inherited from ParallelLedDriver, so this class 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 — and it is needed only by a 74HCT595 expander, which is why it is the one bus control this driver keeps.
bool useRing =
: 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 =
: Compute ringRows/ringBufs automatically from the live config (ON, the default), writing the chosen values INTO those controls so the user always sees the real numbers — the DHCP pattern (an "automatic" toggle whose fields fill with the derived values).
uint8_t ringRows = platform::kRingRowsDefault
: Lights per DMA buffer — the ring's grain.
uint8_t ringBufs = platform::kRingBufsDefault
: How many buffers the DMA circulates.
uint8_t ringPadUs = 0
: Inter-buffer zero-pad, µs (LAPPING only; 0 = off).
bool shiftOverclock =
: 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¶
inline uint16_t clockPinForBus() const
: The base pads spare bus lanes with this GPIO.
inline void addBusControls()
: 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.
inline void addRingControls()
: The output path + the ring's geometry and instrument.
inline void refreshBusKpi()
: Refresh the ringDbg diagnostic string once a second (base tick1s chains here via refreshBusKpi).
inline bool busControlTriggersBuild(constchar * name) const
: Which of this driver'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.
inline constchar * validateBusFatal() const
: WR only reaches a pad in shift mode, so it can only COLLIDE in shift mode.
inline constchar * validateBusPins(constuint16_t * lanes, uint8_t n) const
: 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.
inline bool busInit(size_t frameBytes, bool wantSecondBuffer)
: Create the bus + its DMA buffer(s) for frameBytes.
inline bool wantsRing() const
: 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.
inline bool busInitRing(size_t rowBytes, uint32_t totalRows)
: 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).
inline bool busTransmitRing()
: Send one frame on the ring: prime the pool, fire the DMA, and let the EOF ISR refill behind it.
inline constchar * busRingMode() const
: 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).
inline bool busIsRing() const
: Did the bus actually come up as a ring? The base 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.
inline uint8_t * busBuffer(uint8_t i)
: The whole-frame path's DMA buffer i (0, or 1 with doubleBuffer on) — where the base encodes a frame.
inline size_t busCapacity() const
: Bytes that buffer holds — the base's guard against encoding past the end after a live resize.
inline bool busTransmit(uint8_t i, size_t bytes)
: Clock buffer i out: one gapless DMA transfer of bytes, returning as soon as it is armed.
inline bool busWait(uint8_t i, uint32_t ms)
: Block until buffer i has finished clocking (or ms elapses) — how the async double-buffer defers its wait to the NEXT frame instead of stalling this one.
inline uint32_t busLastTransmitUs() const
: Measured wire time of the last frame, in µs — the frameTime KPI's source, and the output floor the encode is compared against.
inline void busDeinit()
: Tear the bus down: stop the DMA before freeing anything it could still read.
inline platform::RmtLoopbackResult busLoopback(constuint8_t * frame, size_t frameBytes, size_t dataBytes, uint8_t rowBits)
: 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.
inline void recordBusPins()
: WR is part of the bus identity, so a change to it rebuilds the bus — not just a data-pin edit.
inline bool extraBusPinsCurrent() const
: Do this driver's extra bus pins still match the live bus? WR is bus identity here, so the base rebuilds when this goes false rather than routing a stale clock.
inline bool snapHelperReady() const
: 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()
inline void helperJoin()
inline void runHelperJob()
inline void ensureSnapHelper()
: Bring the helper task up (idempotent).
inline void stopSnapHelper()
Public Static Attributes¶
constexprbool kPowerOfTwoBus =
: The i80 bus width is 8 or 16 — a hardware fact (lcd_ll_set_data_wire_width takes nothing else).
constexprbool kLoopbackFullWidth =
: 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.
constexprconstchar * kInitFailMsg = "MoonI80 init /
: Status text when the bus will not come up, so the cause is on screen rather than in a serial log.
constexprbool kSupportsPinExpander = platform::lcdLanes > 0
: The expander needs a backend that can stream its ×8 frame; LCD_CAM is it, and this driver is LCD_CAM-only, so the answer is simply "wherever this driver runs at all".
Public Static Methods¶
constexpr static inline constexpruint8_t lanesAvailable()
: LCD_CAM lanes on this chip (0 = none, and then the base's guards make the driver inert).
static inline voidMM_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 CRTP 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)
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 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.
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
<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 ≥ sl ⇒ prime-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).