-
Notifications
You must be signed in to change notification settings - Fork 3
MoonI80 streaming ring: 48×256 (12,288 lights) via '595 expander, dual-core prime, WDT self-heal, expert mode #51
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 9 commits
Commits
Show all changes
22 commits
Select commit
Hold shift + click to select a range
7c1ce57
Add 74HCT595 shift-register expander to the parallel LED drivers (dor…
ewowi 930fee1
Fix WiFi self-healing, a shift-encoder bug, and baseline shift mode a…
ewowi 8639888
Add MoonI80: our own gapless i80 DMA driver, and find the real shift-…
ewowi a0f6bf2
Hoist the shift encoder's frame constants; fix the AP-retry dropping …
ewowi 6040a7c
Hoist the shift encoder's transpose; free MoonI80's DC/WR pins
ewowi 2873ec9
Loopback self-test verified on a spare '595 strand; panel-dots mapping
ewowi 2f5f531
Add MoonI80 streaming ring: 128 lights/strand via '595 expander (from…
ewowi db67b0d
Fix shift-ring wedge + windowed snapshot; loopback toggles live now
ewowi 616d34e
Add MoonI80 shift-ring reuse diagnosis + no-reuse stopgap (clean to 2…
ewowi 0d75fdb
Rename the LED driver surface so the UI reads in plain language
ewowi da67edf
Drive the shift ring one light per DMA buffer (constant RAM at any le…
ewowi 8d11d5e
Revert "Drive the shift ring one light per DMA buffer (constant RAM a…
ewowi 270862a
Make the pin-expander ring's geometry a live control, not a constant
ewowi 36f5563
Ring reset-tail + prime-only gate; flash last_port; MoonLive guard
ewowi 5b0e6bc
Ring prime-only self-termination (192/strand clean); rabbit fixes
ewowi 2d60024
Run the S3 at 240 MHz; IRAM ring-encode chain; correcting snapshot
ewowi d263c27
Stream 256 lights/strand: the MoonI80 clock-oracle lapping ring
ewowi 5ab8d81
48x256 achieved: auto ring geometry, validated viability rule, instru…
ewowi a425a84
Dual-core ring: fork-join prime + memcpy snapshot, heap accounting, f…
ewowi 2ca3138
Fix per-strand '595 corruption: shiftOverclock switch; pre-merge fixes
ewowi 87b941c
Ring completion + prime barrier + WDT self-heal; expert mode; doc reorg
ewowi 8cdb9ff
Process CodeRabbit review; record the 48x256 flash investigation
ewowi File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,72 @@ | ||
| # 14. Our own i80 DMA driver, one level below esp_lcd | ||
|
|
||
| Date: 2026-07-14 | ||
|
|
||
| ## Status | ||
|
|
||
| Accepted | ||
|
|
||
| ## Context | ||
|
|
||
| `I80LedDriver` drives parallel WS2812 output through ESP-IDF's `esp_lcd` i80 bus. It pre-encodes a whole frame and sends it as **one** `esp_lcd_panel_io_tx_color` transaction, which is gapless and correct — the peripheral streams the buffer and stops. | ||
|
|
||
| That single-transaction design is also the driver's ceiling. The DMA must read the whole frame in one unbroken stream, so the frame has to be somewhere the DMA can sustain, in one contiguous allocation. The measured consequences: | ||
|
|
||
| - The **74HCT595 expander** renders correctly only while its ×8 frame fits internal DMA RAM — about **96 lights per strand** on the ESP32-S3. Above that the frame lands in PSRAM and the strands garble. | ||
| - **Parlio** caps at ~**4,096 lights**: a hardware 65,535-byte single-transfer limit, and a contiguous-block limit below that. | ||
| - The **classic ESP32** caps at ~**2,048 lights**: its i80 backend is the I2S peripheral, whose DMA cannot address PSRAM at all. | ||
|
|
||
| The obvious fix is to split the frame into several transactions. **We built that, and it does not work.** On a dense frame the strands flash full-brightness white; on a sparse frame the fault hides. The cause is in IDF, not in our code — every transaction starts with `lcd_start_transaction` (`esp_lcd/i80/esp_lcd_panel_io_i80.c`): | ||
|
|
||
| ```c | ||
| lcd_ll_reset(bus->hal.dev); // reset the LCD peripheral | ||
| lcd_ll_fifo_reset(bus->hal.dev); // flush the FIFO | ||
| gdma_start(...); | ||
| esp_rom_delay_us(4); // hard-coded busy-wait | ||
| lcd_ll_start(bus->hal.dev); | ||
| ``` | ||
|
|
||
| **`esp_lcd` resets the peripheral between transactions.** For an LCD panel that is harmless — a panel is addressed, not clocked continuously. For WS2812 it is fatal: the protocol is one unbroken self-clocked bit stream, and a mid-frame reset corrupts everything after it. IDF's Parlio driver resets its FIFO in the same place (`esp_driver_parlio/src/parlio_tx.c`). So **no chunking strategy inside IDF's LED-adjacent drivers can be gapless**, at any chunk size or boundary. This is why hpwit's driver — the reference implementation for this class of LED output — hand-rolls its DMA rather than using IDF's. | ||
|
|
||
| There is, however, an opening. The LCD peripheral has **no data-length register**: `lcd_ll_set_phase_cycles()` sets `lcd_dout` as a *boolean enable*, and IDF's own comment reads *"Number of data phase cycles are controlled by DMA buffer length"*. The peripheral clocks out exactly what the DMA feeds it and stops when the chain ends. Therefore **one `gdma_start()` over an arbitrarily long descriptor chain, plus one `lcd_ll_start()`, is a single continuous gapless stream spanning as many buffers as we like.** `esp_lcd` discards that capability by re-arming per transaction; the hardware never required it. | ||
|
|
||
| The descriptors are almost free: a 144 KB frame (16 lanes × 1,024 lights) needs 37 descriptors — 444 bytes. | ||
|
|
||
| ## Decision | ||
|
|
||
| **Build a second i80 implementation, `MoonI80`, on IDF's HAL and GDMA link-list APIs (`lcd_ll_*`, `gdma_link_*`) — one level below `esp_lcd` — and ship it alongside the existing driver rather than replacing it.** | ||
|
|
||
| Three parts to the decision: | ||
|
|
||
| 1. **One level below `esp_lcd`, not down to the registers.** `gdma_link_*` and the LCD HAL are the APIs IDF's own drivers are built on. We are declining `esp_lcd`'s transaction *policy*, not its abstractions. No raw register pokes. | ||
|
|
||
| 2. **The whole frame in one descriptor chain (phase 1).** We already pre-encode the frame, so the DMA can simply read it — no ISR refill, no ring, no real-time deadline, and therefore no underrun for WiFi to cause. This is strictly simpler than hpwit's design, which needs a CPU refill only because it transposes per-LED. | ||
|
|
||
| 3. **Both drivers ship.** `I80LedDriver` remains the default and the **reference implementation**: correct, memory-capped, and the thing MoonI80 is measured against. MoonI80 is the challenger. It replaces the reference only when it demonstrably beats it on the same bench. Both are registered module types, so the A/B is a swap in the UI with no reflash. | ||
|
|
||
| An **internal-RAM ring with CPU refill** (hpwit's shape, and the only thing that can ever work on the classic ESP32) is deferred to a phase 2, and **gated on phase 1 measuring that the silicon — not `esp_lcd` — is the wall.** The ring is a superset of the same descriptor machinery (`GDMA_FINAL_LINK_TO_HEAD` closes the chain), so it is an extension, not a rewrite. | ||
|
|
||
| ## Consequences | ||
|
|
||
| **What we gain in phase 1, precisely.** We own the descriptor chain and fire it with a single `gdma_start` + `lcd_ll_start`, so `esp_lcd`'s per-transaction re-arming (the peripheral reset that corrupts a WS2812 stream) is gone, and with it the whole `lli full` mount-failure class — the chain is mounted once, owner-checking off. **That is all phase 1 gains.** It does *not* yet lift a memory ceiling: it still mounts one contiguous frame buffer per transfer, exactly as `esp_lcd` did. And it does not touch Parlio, whose 65,535-byte and contiguous-block caps are its own peripheral's, not `esp_lcd`'s. | ||
|
|
||
| What phase 1 *buys* is the **capability** the ceilings need: a chain we control, which phase 2 closes into a ring over small internal buffers. Only then does the frame stop needing to be one contiguous DMA-reachable block. | ||
|
|
||
| **What we give up, and it is real.** This diverges from *[Industry standards, our own code](../../CLAUDE.md#principles)* — we are leaving a maintained IDF driver for code we own. The justification is that the maintained driver **cannot express the behaviour the hardware supports and WS2812 requires**, and that is demonstrated from IDF's source, not assumed. But we now carry: the GPIO/clock/bus setup `esp_lcd` was doing for us, the interrupt plumbing, and the risk of drifting against future IDF versions. Keeping `I80LedDriver` as the reference is the mitigation — if MoonI80 rots, the working path is still there and still default. | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
| **What phase 1 measured (2026-07-14, board B — the question this ADR was written to settle).** | ||
|
|
||
| MoonI80 renders correctly on real hardware: the SE16 at 4,096 lights (direct, 16 lanes) and board B through the 74HCT595 expander, both confirmed by eye, with **zero GDMA mount failures** and a wire time matching the `esp_lcd` reference to within 0.15% (19,646 µs vs 19,674 µs — so our own peripheral configuration produces the same waveform). | ||
|
|
||
| And it answered the open question, by removing the suspect rather than reasoning about it: | ||
|
|
||
| | pixel clock | frame in PSRAM | result | | ||
| |---|---|---| | ||
| | **2.67 MHz** (direct) | 2,048 lights | **drives** — 7,712 µs on the wire | | ||
| | **26.67 MHz** (expander) | *any* size — 54 KB or 144 KB | **never completes** | | ||
|
|
||
| Same board, same PSRAM, same descriptor chain, same driver. The only variable is the clock. **The S3's GDMA cannot sustain a PSRAM read at the expander's 10× rate** — a '595 is serial-in, so each WS2812 slot is shifted out over 8 bus words, and the bus must run ten times faster to keep the slot's duration. | ||
|
|
||
| This **kills the hypothesis that motivated the build**: the `esp_lcd` path failed with thousands of `lli full` descriptor-mount errors, which pointed hard at its descriptor handling. MoonI80 removes that mechanism entirely (own chain, mounted once, owner-checking off) — the mount errors are gone, and the transfer still never completes. The `lli full` storm was a **symptom, not the cause**. Six earlier hypotheses about this bug were proposed and refuted (see [lessons.md](../history/lessons.md)); this is the first one killed by a controlled experiment with a working control condition rather than by a story that stopped fitting. | ||
|
|
||
| **Consequence: phase 2 is now justified by measurement, and this driver is its foundation.** The fix is the internal-RAM ring — close the chain into a ring (`GDMA_FINAL_LINK_TO_HEAD`) over small *internal* buffers and refill them from the PSRAM frame in our own EOF callback, a bulk sequential CPU read, so the DMA never reads PSRAM at the expander's clock at all. Every piece of that (our own link list, our own EOF hook, one continuous `lcd_ll_start` that is never re-armed) exists *only* because we own the DMA; `esp_lcd` can express none of it. The ring extends this machinery rather than replacing it, exactly as the decision above anticipated. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win
Move the historical rationale out of
CLAUDE.md.Keep the current workflow rule here, but move the backward-looking explanation about prior agent behavior to
docs/history/,docs/adr/, orlessons.md, then link to it if needed. As per coding guidelines, historical contrasts belong in those history-oriented documents rather than general documentation.🤖 Prompt for AI Agents
Source: Coding guidelines