MoonI80 streaming ring: 48×256 (12,288 lights) via '595 expander, dual-core prime, WDT self-heal, expert mode#51
Conversation
…mant) Adds support for hpwit's 74HCT595 expander board, which fans each i80 data pin out to 8 WS2812 strands — so a 2-pin board drives 16 strands and a 6-pin board 48, without spending 48 GPIOs. The encoder, the wire timing and the config are proven correct in simulation and on hardware, but a GDMA descriptor bug in the transport means frames mostly fail to reach the wire; the feature therefore ships OFF by default (`shiftRegister` unchecked) as dormant capability, and direct mode is proven byte-for-byte unchanged on three boards (S3 8-lane, S3 16-lane, P4 i80: zero GDMA errors, all driving). KPI: 16384lights | Desktop:718KB | tick:124/101/10/125/20/3/273/70/17/22/165/122/22/10/42us(FPS:8064/9900/100000/8000/50000/333333/3663/14285/58823/45454/6060/8196/45454/100000/23809) | tick:25715us(FPS:38) | heap:8154KB | src:195(40532) | test:134(22343) | lizard:144w Light domain: - ParallelSlots: new shift encoder — each WS2812 slot becomes 8 bus words, with the '595 latch riding a data lane (the peripheral gives only one clock output, and the i80 WR pixel-clock IS the register's shift clock, so the latch cannot have one of its own). The latch fires on word 0 of each slot: since WR is SRCLK, latching on the last word would fire RCLK while the 8th bit is still clocking in and present a byte shifted one short. - ParallelSlots: a latch-only word now heads the zeroed reset pad, so the final clocked byte is actually presented and every strand idles LOW into the WS2812 reset. Without it, a strand whose last wire byte is odd idled HIGH through the entire pad and never reset — garbling content-dependently. - ParallelSlots: documented why ×16 (two cascaded '595s) is not offered — an in-spec ×16 slot needs a 42-55 MHz pixel clock and no exact divide of the 80 MHz LCD_CAM bus resolution lands in that band, so it cannot emit a valid WS2812 waveform at all. More pins beats deeper cascades on every axis anyway. - ParallelLedDriver: `shiftRegister` checkbox + `latchPin`. A bool, not a fan-out number: the '595's width is the chip's, not a setting, so exactly two wirings exist and a checkbox says which. The driver pads spare bus lanes onto WR itself, so a board configures only the pins that actually drive a register. - I80LedDriver: `kSupportsShiftRegister` now keys on `platform::lcdLanes`, so the classic ESP32 (whose i80 is the I2S peripheral, and whose DMA cannot read PSRAM at all) refuses shift mode as a config error instead of dying at bus init with a misleading "check pins / memory". Core: - platform_esp32_i80: shift mode clocks the bus at 26.67 MHz (prescale 3 of 80 MHz), giving 300 ns slots — T0H 300 ns, T1H 600 ns, both inside spec. The obvious 20 MHz "because it divides exactly" gives 400 ns slots, over the WS2812B T0H max, and washed the strands white on the bench. esp_lcd silently rounds an inexact pclk down into a wrong waveform rather than erroring, so the rate must be an exact divide AND land in the in-spec band; 26.67 MHz is the only one that does. - platform_esp32_i80: fixed the PSRAM capacity query — `heap_caps_get_free_size(MALLOC_CAP_DMA | MALLOC_CAP_SPIRAM)` always returns 0 (no heap carries both tags), which silently forced every i80 frame into internal RAM and capped it at ~80 KB. A standalone bug, independent of the expander: the P4 now reaches 138 fps. Tests: - unit_ParallelSlots: a 74HCT595 simulator (shift register + storage register + latch edge) replays the emitted bus words and asserts what each strand PHYSICALLY receives, rather than asserting bus-word indices. This is the point: the original latch test asserted an index, encoded the bug as correct, and stayed green for two days while the hardware was broken. - unit_ParallelLedDriver_shiftregister: driver-level config, lane mapping, frame sizing (15x256 and 48x256 cost the same frame — strand count is not in the cost formula), and regression guards that a transfer which never completes neither wedges the driver nor re-encodes a timed-out buffer. Docs / CI: - shift-register-driver-analysis: the research record, including §7.5 — the GDMA blocker, the six hypotheses ruled out by measurement, and the key new datum: across 67 mount attempts `avail` takes every value 0..37 and never 38, stopping exactly one short of what the frame needs. That refutes the earlier "descriptors are never handed back" reading and points at an off-by-one in the frame's descriptor count. Next iteration starts from the loopback, not the eye — the visual bench cannot judge encoder correctness while most frames never land. - CLAUDE.md: new rule — when a change produces something the product owner can see or judge, invite them to test and then STOP and wait, rather than drawing the conclusion for them. - deviceModels: hpwit's board in both fitted configurations (6 registers / 48 strands, 2 registers / 15 panels). - check_devices: validates the expander's wiring invariants (latch present, latch not colliding with clock/DC, 1..15 data pins). Reviews: - 👾 Latch pad never latched the final byte, so a strand ending on an odd byte idled HIGH and never saw the WS2812 reset — FIXED, with a red-first simulator test that failed exactly the odd half (56/112) before the fix. - 👾 ×16 cascade could not emit an in-spec waveform at any available clock, had no hardware, and was speculative generality — CUT entirely. - 👾 Duplicated and mutually contradictory GDMA comments, one of which framed `trans_queue_depth = 1` as the fix the bench disproves — collapsed to one honest statement pointing at the backlog. - 👾 Stale "20 MHz" claims across four files contradicted the shipped 26.67 MHz, including advice to lower the clock that the code itself calls exactly backwards — reconciled. - 👾 Classic-ESP32 shift mode failed with a misleading bus-init message — now a compile-time refusal keyed on the silicon. - 👾 Self-contradictory and stale comments (a control described as both "a select" and "not a select", a member that no longer exists) — removed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 7
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
src/light/drivers/ParallelLedDriver.h (2)
256-281: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winToggling
shiftRegister/latchPinduring an active loopback test doesn't refresh the verdict.
isPinControlonly matchespins/loopbackTxPin/loopbackRxPin. FlippingshiftRegisterorlatchPinwhileloopbackTestis on rebuilds the bus (viaaffectsPrepare) but skips theelse if (loopbackTest && (isTestControl || isPinControl))branch, sorunLoopbackSelfTest()isn't re-run — the displayed PASS/FAIL verdict is now stale relative to the just-changed hardware mode, which is exactly the control an operator validating this feature would toggle.🐛 Proposed fix: include shiftRegister/latchPin in the retest trigger
const bool isPinControl = std::strcmp(name, "pins") == 0 || std::strcmp(name, "loopbackTxPin") == 0 - || std::strcmp(name, "loopbackRxPin") == 0; + || std::strcmp(name, "loopbackRxPin") == 0 + || std::strcmp(name, "shiftRegister") == 0 + || std::strcmp(name, "latchPin") == 0;🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/light/drivers/ParallelLedDriver.h` around lines 256 - 281, Update the isPinControl condition in onControlChanged to also recognize shiftRegister and latchPin. Ensure changes to either control while loopbackTest is active refresh the configuration and invoke runLoopbackSelfTest(), keeping the existing handling for pins, loopbackTxPin, and loopbackRxPin unchanged.
97-102: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
ledsPerPindoc doesn't mention its shift-mode reinterpretation.The field comment says "matched to
pinsby position", but onceshiftRegisteris on,parseConfigdistributes this same string overlanes = n * outputsPerPin()— i.e., one entry per STRAND (up to 64), not one per physical pin — per the comment at thelanescomputation (line ~727: "This is the count ledsPerPin distributes over ... 'lane' means a strand"). A user typing per-pin counts for a 6-pin board would get their 6 values applied to the first 6 strands, not the 6 physical pins.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/light/drivers/ParallelLedDriver.h` around lines 97 - 102, The ledsPerPin field documentation must describe its shift-register interpretation: when shiftRegister is enabled, parseConfig distributes entries across lanes/strands (n × outputsPerPin()), not physical pins. Update the comment near ledsPerPin to distinguish normal pin-position mapping from shift-mode strand mapping, while preserving the existing behavior and limits.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In @.gitignore:
- Around line 128-130: Update the .snapshots ignore pattern to / .snapshots/
scoped at the repository root, preserving the existing hardware snapshot
exclusion while allowing nested paths with that name to remain visible.
In `@CLAUDE.md`:
- Line 82: Keep the current product-owner testing workflow in CLAUDE.md, but
remove its historical rationale about repeated agent behavior and
decision-taking. Move that rationale to an appropriate history-oriented document
under docs/history, docs/adr, or lessons.md, and add a link from the workflow
rule only if the context remains necessary.
In `@docs/backlog/shift-register-driver-analysis.md`:
- Around line 212-275: Remove the shipped-dormant/GDMA investigation section
§7.5 from this forward-looking backlog document and relocate its historical
status, diagnosis, measurements, superseded hypotheses, and bench findings to
the established historical destination, preferably docs/history/lessons.md or
the existing Plan file. Keep only genuinely forward-looking backlog material
here, preserving the relevant content during the move.
In `@src/light/drivers/ParallelSlots.h`:
- Around line 237-303: Fix encodeWs2812ShiftSlots in
src/light/drivers/ParallelSlots.h:237-303 by tracking activePins per shift cycle
instead of aggregating across all cycles, and use each cycle’s mask when
emitting the pulse-start words so inactive shared-pin strands remain LOW. Extend
the “inactive strands idle LOW on every cycle” test in
test/unit/light/unit_ParallelSlots.cpp:364-382 with two strands sharing one
physical pin, only one active, and assert the inactive strand’s START slot is
zero.
In `@src/platform/esp32/platform_esp32_i80.cpp`:
- Around line 346-347: Update the fitsPsram calculation in the I2S-i80
allocation pre-check to only consider PSRAM when SOC_LCDCAM_I80_LCD_SUPPORTED is
enabled, matching the capability guard used by createState. On unsupported
targets, ensure the pre-check requires sufficient internal RAM so it fails
before bus creation.
In `@test/unit/light/unit_ParallelLedDriver_shiftregister.cpp`:
- Around line 26-70: Add the missing standard library <vector> include to the
test file containing MockShiftDriver so std::vector and its buf_ member compile
correctly. Do not change the driver behavior or test logic.
In `@test/unit/light/unit_ParallelSlots.cpp`:
- Around line 364-382: Add coverage in the test case “shift encoder: inactive
strands idle LOW on every cycle” for the excluded strand’s pulse-start slot when
it shares a pin with the active strand. Assert that the corresponding pin bit
remains low at the pulse-start slot on strand 1’s cycle, in addition to the
existing cycle-wide check, following the consolidated guidance for
encodeWs2812ShiftSlots.
---
Outside diff comments:
In `@src/light/drivers/ParallelLedDriver.h`:
- Around line 256-281: Update the isPinControl condition in onControlChanged to
also recognize shiftRegister and latchPin. Ensure changes to either control
while loopbackTest is active refresh the configuration and invoke
runLoopbackSelfTest(), keeping the existing handling for pins, loopbackTxPin,
and loopbackRxPin unchanged.
- Around line 97-102: The ledsPerPin field documentation must describe its
shift-register interpretation: when shiftRegister is enabled, parseConfig
distributes entries across lanes/strands (n × outputsPerPin()), not physical
pins. Update the comment near ledsPerPin to distinguish normal pin-position
mapping from shift-mode strand mapping, while preserving the existing behavior
and limits.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 26066d13-ce46-4324-9a8c-12f09bed8443
⛔ Files ignored due to path filters (1)
docs/assets/deviceModels/hpwit-shift-register-board.jpgis excluded by!**/*.jpg
📒 Files selected for processing (21)
.gitignoreCLAUDE.mddocs/backlog/backlog-light.mddocs/backlog/led-driver-psram-ring-analysis.mddocs/backlog/leddriver-analysis-top-down.mddocs/backlog/multicore-analysis-top-down.mddocs/backlog/shift-register-driver-analysis.mddocs/history/plans/Plan-20260714 - Shift-register LED driver (shipped dormant, blocked on GDMA).mdmoondeck/check/check_devices.pysrc/light/drivers/I80LedDriver.hsrc/light/drivers/ParallelLedDriver.hsrc/light/drivers/ParallelSlots.hsrc/light/drivers/ParlioLedDriver.hsrc/platform/desktop/platform_desktop.cppsrc/platform/esp32/platform_esp32_i80.cppsrc/platform/platform.htest/CMakeLists.txttest/unit/light/unit_ParallelLedDriver_doublebuffer.cpptest/unit/light/unit_ParallelLedDriver_shiftregister.cpptest/unit/light/unit_ParallelSlots.cppweb-installer/deviceModels.json
|
|
||
| **The one nuance: a *rigorous* change gets a heads-up first.** "Flash freely" is the default for ordinary iteration (a code fix, a UI tweak, a normal reflash to see it run). But when the change is *rigorous* — it could leave a board in a bad state or is disruptive to recover from: erasing/repartitioning flash, a bootloader / partition-table / sdkconfig change, changing the flash-size variant, a first-flash of an untested board, a long full-erase, or anything that risks bricking or a bootloop — **say what you're about to do and why, and wait for the go-ahead.** The test is *reversibility*: a routine reflash is a keystroke to redo, so just do it; a change that could cost real recovery effort (or hardware) is worth one sentence of confirmation first. When unsure which side a change falls on, ask. | ||
|
|
||
| **Invite the product owner to test — then STOP and wait.** Whenever a change produces something the product owner can *see or judge* (LEDs on a bench board, a UI screen, a rendered effect, a live device behaviour), the agent's job ends at "it's running on <board/URL>, here's what to look at" — **not** at the agent's own verdict. Say what changed, where to look, and what would count as good or bad, then **stop and wait for their observation before drawing conclusions, writing them into docs, or moving to the next step.** The product owner's eyes are the measurement; serial logs and API reads are supporting evidence, not a substitute. This is the *[Agent Roles](#agent-roles)* division made concrete: the product owner tests on hardware before approving, and an agent that races ahead — reaching a conclusion, updating a doc, starting the next task — has quietly taken that decision away from them and is often *wrong* (this rule exists because it happened repeatedly: conclusions written up from a bench the product owner never got to look at). The trigger is simply *"could the product owner see this?"* — if yes, hand it over and wait. Doubly so before anything irreversible-ish (a revert, a reconfigure, a reflash) that would destroy the very state they were about to look at: leave it running. |
There was a problem hiding this comment.
📐 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/, or lessons.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
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@CLAUDE.md` at line 82, Keep the current product-owner testing workflow in
CLAUDE.md, but remove its historical rationale about repeated agent behavior and
decision-taking. Move that rationale to an appropriate history-oriented document
under docs/history, docs/adr, or lessons.md, and add a link from the workflow
rule only if the context remains necessary.
Source: Coding guidelines
…t its real limit
Three device-reliability fixes and an honest re-baselining of the 74HCT595 shift-register expander. The WiFi fixes matter most: a device that lost its network could never get back on it, and a misconfigured LED driver could starve the device off the air entirely — both left bench boards rendering happily as unreachable bricks. The shift-register expander stays OFF by default and is now documented at the size it actually works (≤96 lights/strand, asyncTransmit OFF); its transport bug is NOT solved, and the record of what has been ruled out is rewritten so the next attempt doesn't re-walk six dead hypotheses.
KPI: 16384lights | Desktop:718KB | tick:397/333/28/508/137/36/926/333/59/105/626/413/406/135/129us(FPS:2518/3003/35714/1968/7299/27777/1079/3003/16949/9523/1597/2421/2463/7407/7751) | tick:9503us(FPS:105) | heap:8292KB | src:195(40794) | test:134(22429) | lizard:146w
Core:
- platform_esp32: WiFi STA now reconnects on disconnect. ESP-IDF does not do this for us, so a single dropout (an AP reboot, a roam, a few lost beacons) orphaned the device permanently — it kept rendering at full frame rate with no IP and no retry until someone power-cycled it. The retry does not sleep in the event handler (that task also carries the Ethernet and IP events); the driver's own association timeout paces it.
- NetworkModule: a dropped STA link gets a 10 s grace to self-heal before falling back to AP, instead of being torn down on the first failed poll — one lost beacon used to cost a working network. Reuses WaitingSta's existing constant, now named (kStaGraceMs), so both paths answer the same question the same way.
- NetworkModule: AP mode is no longer a dead end. Falling back stopped the STA radio, so wifiStaConnected() could never turn true again — and that was State::AP's only route back. It now retries STA every 60 s while parked in AP, so a device that lost its network climbs back on its own.
Light domain:
- ParallelSlots: fixed a real encoder bug (found by CodeRabbit) — the pulse-start activity mask was aggregated across shift cycles instead of tracked per cycle, so a short strand sharing a '595 with a longer one kept getting driven HIGH after it was exhausted and flashed white at full brightness.
- ParallelLedDriver: a stalled bus can no longer take the device down with it. A dead transfer used to burn a 1 s blocking wait on the render thread every frame — 20x a whole tick budget — which starved WiFi/HTTP off the air; a merely misconfigured LED setting therefore cost the user the device. The wait is now derived from the frame's own wire time (4x, clamped 20-100 ms), and after 8 consecutive dead frames the driver gives up and reports it: LEDs dark, device alive and reachable. It self-heals on a config change, no reboot.
- ParallelLedDriver: shiftRegister/latchPin now refresh the loopback self-test like the other pin controls, so a test left running across such an edit can't report a verdict for a configuration that is gone.
- platform_esp32_i80: shift mode allocates its frame from internal DMA RAM first (direct mode keeps PSRAM-first, unchanged). This is a labelled STOPGAP, not the answer — it caps a shift display at ~1,500 lights, fewer than direct mode already drives, and PSRAM is what the feature needs to get back to.
Scripts / MoonDeck:
- improv_provision: fixed a silent lookup miss that meant NO board ever got its TX-power cap applied. It read entry["controls"]["Network"], but every catalog entry stores per-module settings in a `modules` list — so the chained .get() resolved to None for every board, forever. The hpwit shift-register board was transmitting at 20 dBm instead of its required 8, associating and being dropped within a second; deviceModels.json had the right value all along.
- deviceModels: both hpwit shift-register entries now provision the measured-good baseline (ledsPerPin 96, asyncTransmit off) instead of a config that flickers.
Tests:
- unit_ParallelSlots: added the shared-pin case the encoder bug lived in (two strands on one '595, one exhausted) — and fixed an older test that had encoded the bug as correct, the third time a bus-word-index assertion has done that. The strand-level '595 simulator remains the authority.
- unit_ParallelLedDriver_shiftregister: a persistently dead bus is given up on (bounded, reported) and recovers when the config is fixed.
Docs / CI:
- lessons.md: three entries. The important one is the method failure — six confident hypotheses died on the shift-register bug in one session, and the transferable rules are: a theory that explains everything is not evidence; absence of evidence (an S3 that *cannot* report underruns) is not evidence; check the datasheet number before theorising about it; and a proxy metric you never validated is not a meter (the GDMA error count was the success signal all session, then the PO reported visually clean strands in a state still logging 121 failures). Plus: a fallback state must carry a path back to the state it fell from; and a chain of dict.get(k, {}) cannot fail loudly.
- shift-register-driver-analysis § 7.5: rewritten as what is TRUE (measured) vs what is FALSE (guessed, then refuted — do not re-derive). Records the PO's blind ledsPerPin sweep, which landed exactly on the internal-RAM/PSRAM allocator boundary (96 good, 128 bad, against a ~38 KB largest free internal block) — the strongest evidence in the whole investigation, and found without knowing which memory the frame was in.
- The shiftRegister control's own doc now carries an EXPERIMENTAL warning with the real limit, and states plainly that the feature does not yet deliver the large displays it exists for.
Reviews:
- 🐇 activePins aggregated across shift cycles — CONFIRMED a real bug (an exhausted strand sharing a '595 flashes white). Fixed, with the shared-pin test they asked for.
- 🐇 missing <vector> include in the shift-register test — fixed.
- 🐇 fitsPsram pre-check ignored the SOC_LCDCAM capability guard, so a classic-ESP32 frame could pass it and then die at bus init with a misleading message — fixed.
- 🐇 onControlChanged didn't treat shiftRegister/latchPin as pin controls — fixed.
- 🐇 ledsPerPin doc didn't describe its shift-mode meaning (entries map to strands, not pins) — fixed.
- 🐇 .gitignore .snapshots pattern was unanchored — fixed (/.snapshots/).
- 🐇 move CLAUDE.md's rationale and § 7.5 to history — declined. The rationale is what makes the rule stick (CLAUDE.md carries rationale throughout), and § 7.5 is the forward-looking plan the next iteration executes from, which is what backlog/ is for.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/core/NetworkModule.h`:
- Around line 479-498: Remove the automatic STA retry branch in the AP fallback
state around the ssid_ and kApRetryStaMs check, including the
platform::wifiStaInit() call and related WaitingSta transition. Preserve AP mode
until an explicit/manual reconnect, avoiding any background operation that tears
down or restarts the AP.
- Around line 439-447: The comments at src/core/NetworkModule.h#L439-L447 and
src/platform/esp32/platform_esp32.cpp#L787-L791 require present-tense
documentation only. Rewrite the NetworkModule comment to describe the
grace-window and automatic STA reconnect behavior without historical narrative
or dates; rewrite the platform_esp32.cpp comment to describe why explicit
reconnect is required, also removing bench history and past-tense explanations.
In `@src/platform/esp32/platform_esp32.cpp`:
- Line 779: Change the declaration of wifiStaStopping_ from volatile bool to
std::atomic<bool>, preserving its existing initialization and usage in
wifiEventHandler and wifiStaStop.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 4c90c4ce-e4b3-49b3-ab18-c0089eacd56d
⛔ Files ignored due to path filters (1)
moondeck/build/improv_provision.pyis excluded by!**/build/**
📒 Files selected for processing (16)
.gitignoredocs/backlog/backlog-core.mddocs/backlog/backlog-light.mddocs/backlog/shift-register-driver-analysis.mddocs/history/lessons.mdsrc/core/NetworkModule.hsrc/light/drivers/DriverBase.hsrc/light/drivers/ParallelLedDriver.hsrc/light/drivers/ParallelSlots.hsrc/platform/esp32/platform_esp32.cppsrc/platform/esp32/platform_esp32_i80.cppsrc/platform/esp32/platform_esp32_rmt.cppsrc/platform/platform.htest/unit/light/unit_ParallelLedDriver_shiftregister.cpptest/unit/light/unit_ParallelSlots.cppweb-installer/deviceModels.json
| } else if (ssid_[0] != 0 && now - stateChangeTime_ > kApRetryStaMs) { | ||
| // **AP is a fallback, not a destination.** Falling back stopped the STA radio, | ||
| // so wifiStaConnected() can never turn true again on its own — the promote | ||
| // check above would wait forever, and a device that lost WiFi once would sit | ||
| // on its own AP until someone power-cycled it. With credentials configured, | ||
| // the network is *expected* to come back (the AP was rebooting, the device was | ||
| // briefly out of range), so periodically go and look: re-init STA and let | ||
| // WaitingSta run its normal grace. If it fails, WaitingSta drops us right back | ||
| // here and we try again later — an idle retry loop, not a dead end. | ||
| // | ||
| // AP stays up across the attempt (it is torn down only once STA actually | ||
| // connects, in onConnected), so a user mid-setup on 4.3.2.1 is not cut off. | ||
| std::printf("NetworkModule: AP — retrying WiFi STA (%s)\n", ssid_); | ||
| if (platform::wifiStaInit(ssid_, password_)) { | ||
| state_ = State::WaitingSta; | ||
| stateChangeTime_ = now; | ||
| syncTxPower(); // see setWifiCredentials's syncTxPower comment | ||
| } else { | ||
| stateChangeTime_ = now; // init refused; wait out another interval | ||
| } |
There was a problem hiding this comment.
🩺 Stability & Availability | 🔴 Critical | 🏗️ Heavy lift
Concurrent AP and STA are not supported by the platform layer, causing the AP to drop during retries.
The AP fallback retry loop calls platform::wifiStaInit() while in AP mode, expecting the AP to stay up ("AP stays up across the attempt..."). However, platform_esp32.cpp does not use WIFI_MODE_APSTA. Instead, wifiStaInit() sets WIFI_MODE_STA, which immediately kills the AP radio. Furthermore, when the WaitingSta grace period expires, wifiStaStop() tears down the entire WiFi driver before startAP() rebuilds it.
This causes the AP to drop and restart every 70 seconds, breaking the captive portal and disconnecting the user mid-setup.
Consider either removing this background retry loop so the AP remains stable until a manual reconnect, or thoroughly refactoring platform_esp32.cpp to correctly reference-count and support concurrent WIFI_MODE_APSTA operation.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/core/NetworkModule.h` around lines 479 - 498, Remove the automatic STA
retry branch in the AP fallback state around the ssid_ and kApRetryStaMs check,
including the platform::wifiStaInit() call and related WaitingSta transition.
Preserve AP mode until an explicit/manual reconnect, avoiding any background
operation that tears down or restarts the AP.
…register wall Adds a second LCD_CAM LED driver that programs the DMA itself instead of going through ESP-IDF's esp_lcd. It ships ALONGSIDE the existing i80 driver, not instead of it: I80LedDriver stays the reference and the default, MoonI80LedDriver is the challenger, and because both are registered types the comparison is a swap in the UI with no reflash. Built to settle a question six hypotheses had failed to answer — and it did, in one controlled experiment: the ESP32-S3's GDMA cannot sustain a PSRAM read at the 74HCT595 expander's 10x pixel clock. That is the wall, and it is silicon, not software. KPI: 16384lights | Desktop:718KB | tick:129/105/11/129/21/3/289/72/18/23/172/126/23/10/45us(FPS:7751/9523/90909/7751/47619/333333/3460/13888/55555/43478/5813/7936/43478/100000/22222) | ESP32:1428KB | tick:24172us(FPS:41) | heap:8221KB | src:197(41750) | test:135(22541) | lizard:149w Core: - platform_esp32_moon_i80 (new): the LCD_CAM peripheral + GDMA descriptor chain, on IDF's HAL and gdma_link APIs one level below esp_lcd — not raw registers; IDF's own drivers use the same APIs. The whole frame is mounted as ONE descriptor chain and fired with one gdma_start + one lcd_ll_start, so it clocks out as a single unbroken stream. The peripheral has no data-length register (lcd_ll_set_phase_cycles takes a boolean enable; IDF's own comment: "Number of data phase cycles are controlled by DMA buffer length"), so it stops when the chain ends — which is the whole opening this driver exploits. Owner-checking is off and the chain is ours, so the `lli full` mount-failure class that plagued the esp_lcd path simply does not exist here. - platform.h: the moonI80Ws2812* seam — the same eight functions as the esp_lcd family, so the domain driver is the same CRTP sibling with its forwards re-pointed. Desktop stubs keep it inert on the host. - platform_esp32: WiFi reconnect comment rewritten present-tense, and wifiStaStopping_ is now std::atomic (it is written from a task and read on IDF's event-loop task; volatile guarantees neither atomicity nor ordering). Light domain: - MoonI80LedDriver (new): a CRTP sibling of ParallelLedDriver, ~140 lines of one-line forwards. Everything above the DMA — slicing, the fused encode, the async double-buffer, the shift-register expander, the loopback, the wireUs KPI, the dead-frame guard — is inherited unchanged. LCD_CAM only: unlike its sibling it does NOT claim the classic ESP32's I2S-backed i80, which is a different peripheral entirely. - NetworkModule: the AP-fallback and grace-window comments rewritten present-tense (the behaviour is unchanged). Tests: - unit_MoonI80LedDriver (new): six cases pinning what is SPECIFIC to this driver — that it is LCD_CAM-only, that it keeps the i80 bus rules, and its own WR/DC/latch pin validation. The base's behaviour is already covered through the Mock suites and unit_I80LedDriver, so re-testing it through a second concrete driver would assert the same base twice. Docs / CI: - ADR-0014 (new): why we left a maintained IDF driver, what we give up, and what phase 1 measured. esp_lcd re-arms the peripheral on every transaction (lcd_ll_reset + lcd_ll_fifo_reset + a hard-coded 4 us wait). Harmless for an LCD panel; fatal for WS2812's unbroken bit stream — which is why a frame split across several esp_lcd transactions can never be gapless at any chunk size, and therefore why the whole frame must go in one transaction, and therefore why the driver is memory-capped. That is the chain this driver breaks. - The experiment, and the result: same board, same PSRAM, same chain, same driver — only the clock differs. At 2.67 MHz (direct) a PSRAM frame drives fine; at 26.67 MHz (the expander) it never completes, at ANY size, 54 KB or 144 KB. So it was never PSRAM as such and never the frame size: the S3's GDMA cannot feed the wire from PSRAM at the expander's rate. The thousands of `lli full` errors on the esp_lcd path were a SYMPTOM, not the cause — this driver removes that mechanism entirely, the errors vanish, and the transfer still does not happen. First hypothesis in this investigation killed by a controlled experiment with a working control condition rather than by a story that stopped fitting. - shift-register-driver-analysis: the phase-2 design, with the arithmetic. The cap is exactly 96 lights/strand because the encoder emits 1,152 bytes per light in shift mode, so 96 lights = 108 KB = the internal-DMA-RAM edge. The fix is to never materialise the encoded frame at all: the DMA loops a small ring of internal buffers, and the CPU encodes each slice straight into it from the Layer buffer (internal, and 24x smaller than the encoded output). The DMA drains a light in 21.6 us while the CPU encodes one in ~3 us — 7x headroom, because the expander's 8x output inflation buys more DMA time than it costs CPU. The refill must run in the EOF ISR in IRAM (the alternative needs a ~72 KB ring to survive a WiFi preemption, which just rebuilds the frame buffer). hpwit arrived at the same design independently. - drivers.md: MoonI80 shares the existing LED-output card rather than getting its own — same feature, different DMA underneath. Reviews: - 🐇 wifiStaStopping_ should be std::atomic, not volatile — fixed. - 🐇 The WiFi comments carried bench-diary narration against the present-tense rule — rewritten. - 🐇 Remove the AP→STA auto-retry — DECLINED. Without it State::AP is a dead end: the fallback stops the STA radio, so wifiStaConnected() can never become true again and the promote check waits forever. A device that loses WiFi once would sit on its own AP until power-cycled. The canonical case is a router rebooting, and the device must find its way home unattended. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 5
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
docs/moonmodules/light/drivers.md (1)
110-120: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick winAdd MoonI80 to the detailed comparison table.
The page now lists MoonI80 as a fourth LED-output driver, but this section still says “three” and contains no MoonI80 row. Update the count and add its LCD_CAM/own-DMA characteristics so the comparison is complete.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@docs/moonmodules/light/drivers.md` around lines 110 - 120, Update the “LED output — details” comparison in the documentation to state four peripherals and add a MoonI80 row. Describe its LCD_CAM backend and own-DMA characteristics, matching the table’s existing chip, strand, and notes format; leave the RMT, i80, and Parlio entries unchanged.
♻️ Duplicate comments (1)
src/core/NetworkModule.h (1)
474-491: 🩺 Stability & Availability | 🔴 Critical | 🏗️ Heavy liftConcurrent AP and STA are not supported by the platform layer, causing the AP to drop during retries.
The AP fallback retry loop calls
platform::wifiStaInit()while in AP mode, expecting the AP to stay up ("AP stays up across the attempt..."). However,platform_esp32.cppdoes not useWIFI_MODE_APSTA. Instead,wifiStaInit()setsWIFI_MODE_STA, which immediately kills the AP radio. Furthermore, when theWaitingStagrace period expires,wifiStaStop()tears down the entire WiFi driver beforestartAP()rebuilds it.This causes the AP to drop and restart every 70 seconds, breaking the captive portal and disconnecting the user mid-setup.
Consider removing this background retry loop so the AP remains stable until a manual reconnect, or thoroughly refactor
platform_esp32.cppto correctly reference-count and support concurrentWIFI_MODE_APSTAoperation.♻️ Proposed fix
- } else if (ssid_[0] != 0 && now - stateChangeTime_ > kApRetryStaMs) { - // **AP is a fallback, not a destination.** Falling back stops the STA radio, so - // wifiStaConnected() cannot become true on its own and the promote check above - // would wait forever — a device that lost its network would stay on its own AP - // until power-cycled. With credentials configured the network is expected back - // (the router finishes rebooting; the device comes back into range), so go and - // look periodically: re-init STA and let WaitingSta run its normal grace. A - // failure drops straight back here and retries later — an idle loop, not a - // dead end. AP stays up across the attempt (onConnected tears it down only once - // STA is actually up), so a user mid-setup on 4.3.2.1 keeps their connection. - std::printf("NetworkModule: AP — retrying WiFi STA (%s)\n", ssid_); - if (platform::wifiStaInit(ssid_, password_)) { - state_ = State::WaitingSta; - stateChangeTime_ = now; - syncTxPower(); // see setWifiCredentials's syncTxPower comment - } else { - stateChangeTime_ = now; // init refused; wait out another interval - } }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/core/NetworkModule.h` around lines 474 - 491, Remove the background STA retry branch guarded by ssid_[0] and kApRetryStaMs in the AP fallback state machine, including its wifiStaInit, WaitingSta transition, and retry timing logic. Keep the AP active and stable until the existing manual reconnect path is invoked; do not assume concurrent AP/STA support from platform::wifiStaInit.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/adr/0014-own-i80-dma-driver-below-esp-lcd.md`:
- Around line 49-53: Correct the phase-1 capability claims in
docs/adr/0014-own-i80-dma-driver-below-esp-lcd.md lines 49-53 to state that the
actual benefit is removing esp_lcd transaction re-arming; do not claim removal
of contiguous source-allocation requirements or Parlio limits. Also update
docs/history/plans/Plan-20260714 - MoonI80 - our own gapless i80 driver.md lines
35-37 to remove the Parlio-cap claim and reserve non-contiguous/internal-RAM
buffering for phase 2.
In `@docs/history/plans/Plan-20260714` - MoonI80 - our own gapless i80 driver.md:
- Around line 51-77: Fix the Markdown lint issues in the plan: add a language
tag such as text or cpp to the fenced block near the i80 function list, and
insert blank lines after the headings at the sections corresponding to lines 59,
70, and 77. Preserve the existing content and structure otherwise.
In `@docs/moonmodules/light/drivers.md`:
- Around line 28-30: Shorten the MoonI80 catalog entry to identify it as the
custom-DMA alternative to i80 and retain the comparison/default context. Remove
the detailed reset, transaction, and descriptor-chain explanation from this
page, relying on the existing ADR-0014 and relevant technical documentation
links for rationale.
In `@src/platform/esp32/platform_esp32_moon_i80.cpp`:
- Around line 451-478: Update createState’s buf[0] and buf[1] allocation order
so shiftMode attempts PSRAM first and only attempts internal DMA memory when the
HEAP_RESERVE threshold permits it. Apply the reserve guard to the internal
allocation attempt rather than the PSRAM retry, while preserving the existing
non-shift allocation behavior and single-buffer fallback.
In `@test/unit/light/unit_MoonI80LedDriver.cpp`:
- Around line 93-101: Extend the “MoonI80LedDriver rejects a latchPin on WR or
DC” test to also assign latchPin to d.dcPin and assert Severity::Error, covering
the symmetric branch in MoonI80LedDriver::validateBusFatal while preserving the
existing clock-pin case.
---
Outside diff comments:
In `@docs/moonmodules/light/drivers.md`:
- Around line 110-120: Update the “LED output — details” comparison in the
documentation to state four peripherals and add a MoonI80 row. Describe its
LCD_CAM backend and own-DMA characteristics, matching the table’s existing chip,
strand, and notes format; leave the RMT, i80, and Parlio entries unchanged.
---
Duplicate comments:
In `@src/core/NetworkModule.h`:
- Around line 474-491: Remove the background STA retry branch guarded by
ssid_[0] and kApRetryStaMs in the AP fallback state machine, including its
wifiStaInit, WaitingSta transition, and retry timing logic. Keep the AP active
and stable until the existing manual reconnect path is invoked; do not assume
concurrent AP/STA support from platform::wifiStaInit.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: b3c33b81-dbdb-4786-9e29-85a062a01aef
📒 Files selected for processing (16)
docs/adr/0014-own-i80-dma-driver-below-esp-lcd.mddocs/adr/README.mddocs/backlog/backlog-light.mddocs/backlog/shift-register-driver-analysis.mddocs/history/plans/Plan-20260714 - MoonI80 - our own gapless i80 driver.mddocs/moonmodules/light/drivers.mdesp32/main/CMakeLists.txtsrc/core/NetworkModule.hsrc/light/drivers/MoonI80LedDriver.hsrc/main.cppsrc/platform/desktop/platform_desktop.cppsrc/platform/esp32/platform_esp32.cppsrc/platform/esp32/platform_esp32_moon_i80.cppsrc/platform/platform.htest/CMakeLists.txttest/unit/light/unit_MoonI80LedDriver.cpp
| const bool shiftMode = clockMultiplier > 1; | ||
| st->buf[0] = allocFrame(st, bufferBytes, /*psram=*/!shiftMode); | ||
| if (!st->buf[0]) st->buf[0] = allocFrame(st, bufferBytes, /*psram=*/shiftMode); | ||
| if (!st->buf[0]) { | ||
| destroyState(st); | ||
| return nullptr; | ||
| } | ||
| st->cap = bufferBytes; | ||
|
|
||
| // Second buffer for the async double-buffer — ONLY when asked. Allocate-and-degrade (ADR 0002): | ||
| // if it fits, arm double-buffer mode (buf[1] + its semaphore); if it doesn't, leave buf[1] null | ||
| // and the driver runs single-buffer. The internal fallback additionally must leave HEAP_RESERVE | ||
| // intact — the second buffer is a nice-to-have and must never eat the WiFi/HTTP reserve. | ||
| if (wantSecond) { | ||
| st->done[1] = xSemaphoreCreateBinary(); | ||
| if (st->done[1]) { | ||
| st->buf[1] = allocFrame(st, bufferBytes, /*psram=*/!shiftMode); | ||
| if (!st->buf[1] | ||
| && heap_caps_get_free_size(MALLOC_CAP_DMA | MALLOC_CAP_INTERNAL) | ||
| >= bufferBytes + HEAP_RESERVE) { | ||
| st->buf[1] = allocFrame(st, bufferBytes, /*psram=*/shiftMode); | ||
| } | ||
| if (!st->buf[1]) { | ||
| vSemaphoreDelete(st->done[1]); | ||
| st->done[1] = nullptr; | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Locate the relevant file and inspect the surrounding code.
git ls-files 'src/platform/esp32/platform_esp32_moon_i80.cpp' 'src/platform/platform.h'
wc -l src/platform/esp32/platform_esp32_moon_i80.cpp
sed -n '400,540p' src/platform/esp32/platform_esp32_moon_i80.cpp
sed -n '620,710p' src/platform/esp32/platform_esp32_moon_i80.cppRepository: MoonModules/projectMM
Length of output: 13758
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Search for HEAP_RESERVE and the init pre-check to compare with createState.
rg -n "HEAP_RESERVE|fitsInternal|fitsPsram|shiftMode|wantSecond|allocFrame" src/platform/esp32/platform_esp32_moon_i80.cppRepository: MoonModules/projectMM
Length of output: 1807
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Inspect the allocation helper to understand what allocFrame actually does.
rg -n "allocFrame\\s*\\(" src/platform/esp32/platform_esp32_moon_i80.cpp
sed -n '1,140p' src/platform/esp32/platform_esp32_moon_i80.cppRepository: MoonModules/projectMM
Length of output: 9260
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Read the init function that performs the coarse fitsInternal/fitsPsram pre-check.
ast-grep outline src/platform/esp32/platform_esp32_moon_i80.cpp --view expandedRepository: MoonModules/projectMM
Length of output: 2428
🏁 Script executed:
#!/bin/bash
set -euo pipefail
sed -n '360,480p' src/platform/esp32/platform_esp32_moon_i80.cpp
sed -n '527,555p' src/platform/esp32/platform_esp32_moon_i80.cppRepository: MoonModules/projectMM
Length of output: 9136
🏁 Script executed:
#!/bin/bash
set -euo pipefail
python3 - <<'PY'
from pathlib import Path
p = Path('src/platform/esp32/platform_esp32_moon_i80.cpp')
lines = p.read_text().splitlines()
for start,end in [(360,480),(527,555)]:
print(f'লines {start}-{end}')
for i in range(start-1,end):
print(f"{i+1:4d}: {lines[i]}")
print()
PYRepository: MoonModules/projectMM
Length of output: 10066
Keep shift-mode allocations off the internal reserve. In src/platform/esp32/platform_esp32_moon_i80.cpp:451-471, createState() still tries buf[0] from internal DMA RAM first when clockMultiplier > 1, so a PSRAM-only init can still burn into the WiFi/HTTP headroom before falling back. buf[1] has the same mismatch: the reserve check guards the PSRAM retry instead of the internal attempt, so a valid second buffer can be skipped unnecessarily.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/platform/esp32/platform_esp32_moon_i80.cpp` around lines 451 - 478,
Update createState’s buf[0] and buf[1] allocation order so shiftMode attempts
PSRAM first and only attempts internal DMA memory when the HEAP_RESERVE
threshold permits it. Apply the reserve guard to the internal allocation attempt
rather than the PSRAM retry, while preserving the existing non-shift allocation
behavior and single-buffer fallback.
…the portal Two independent fixes plus the CodeRabbit findings on MoonI80. The encoder change benefits ALL THREE parallel drivers (I80, MoonI80, Parlio) — they share ParallelLedDriver's encode, so none of them pays for it twice. KPI: 16384lights | Desktop:718KB | tick:591/471/35/589/84/16/1422/312/90/112/821/592/98/29/1/186us(FPS:1692/2123/28571/1697/11904/62500/703/3205/11111/8928/1218/1689/10204/34482/1000000/5376) | tick:21163us(FPS:47) | heap:8237KB | src:197(41991) | test:135(22681) | lizard:151w Light domain: - ParallelSlots / ParallelLedDriver: the shift encoder no longer rewrites the frame's CONSTANT words for every light. Of the three words a WS2812 slot emits per shift cycle, only the middle one carries pixel data — the pulse-start (which strands are live) and the pulse-tail (all-LOW) are the same for every light in the frame. They are now pre-filled once in reinit() (prefillWs2812ShiftConstants) and the per-light encoder writes only the data word (encodeWs2812ShiftData). Two thirds of the encoder's stores, gone from the hot path. Short strands still work: the prefill runs in RUNS of rows sharing an active mask, so a strand that ends mid-frame stops asserting from that row on and stays dark — the alternative is the exhausted strand flashing white at full brightness. hpwit does the same with putdefaultones() at buffer init; studied, then written fresh against our own layout. - Measured on board B (S3, 16 strands through a 74HCT595, 1536 lights): 9.76 -> 8.85 us/light, 41 -> 47 fps. Honest note: a 9% gain, not the 3x the store count suggested — which is itself the useful result. It proves the stores are only ~14% of the encode and the real cost is elsewhere (24 SWAR transposes per light, each on a mostly-empty lane array). That is now measured rather than assumed, and it is the next piece of work. Core: - NetworkModule: the AP-fallback's periodic STA retry now only fires when nobody is connected to the AP. The retry itself is NOT removed — a router rebooting is exactly the case it exists for, and without it State::AP is a dead end (the fallback stops the STA radio, so wifiStaConnected() can never turn true again). But the platform has no concurrent AP+STA mode: wifiStaInit() puts the radio in WIFI_MODE_STA, which drops the SoftAP. So an unconditional retry kicked anyone off the captive portal every ~70 s, mid-setup. Gating on an empty AP keeps both properties: an unattended device (the router-reboot case — nobody is on the AP) still heals itself, and a user mid-setup is never interrupted. - platform: new wifiApClientCount(), fed by the AP_STACONNECTED / AP_STADISCONNECTED events. Atomic — written from IDF's event-loop task, read from the render task. - platform_esp32_moon_i80: the HEAP_RESERVE guard now follows the INTERNAL allocation rather than a fixed branch. MoonI80 flips its preference by mode (shift wants internal first, direct wants PSRAM first), so a fixed branch put the guard on the wrong attempt half the time — in shift mode the internal allocation ran unguarded, free to eat the WiFi/HTTP reserve, while the PSRAM retry was pointlessly gated on internal free space. Tests: - unit_ParallelSlots: two cases pinning that prefill + data-only is BYTE-IDENTICAL to the whole-slot encoder, including the exhausted-strand case (where a mismatch is the white-flash bug). The '595 simulator suite continues to assert what the strand physically receives, so the waveform is provably unchanged. - unit_ParallelLedDriver_shiftregister: a sliced encode (a row range) is byte-identical to the whole-frame encode — the invariant the streaming ring will rest on, pinned with a dense pattern (a sparse buffer would hide a slicing bug). - unit_MoonI80LedDriver: the missing latchPin == dcPin case (the symmetric branch of validateBusFatal was never asserted). Docs / CI: - ADR-0014: corrected an overclaim. It said phase 1 "lifts the memory ceilings: the frame no longer has to be one contiguous DMA-reachable block" and "removes the per-transaction size caps that bound Parlio". Both false — the backend still mounts one contiguous frame buffer, and it does not touch Parlio at all. What phase 1 actually buys is the CAPABILITY the ceilings need: a descriptor chain we own, mounted once, with esp_lcd's per-transaction re-arming (and the whole lli-full class) gone. The ceiling lift is phase 2's, when that chain closes into a ring over small internal buffers. - drivers.md: MoonI80 added to the LED-output comparison table (which still said "three peripherals"), and its card shortened to link the ADR rather than restate it. Reviews: - 🐇 ADR-0014 phase-1 capability claims were wrong — corrected (see above). - 🐇 MoonI80's buf[1] reserve guard was on the wrong branch — fixed. - 🐇 Missing latchPin == dcPin coverage — added. - 🐇 MoonI80 missing from the drivers.md comparison table — added. - 🐇 Concurrent AP+STA is unsupported, so the STA retry drops the AP — CONFIRMED, and the comment claiming "AP stays up across the attempt" was simply false. Fixed by gating the retry on an empty AP rather than removing it: the router-reboot recovery it exists for is precisely the case where nobody is on the AP. - 🐇 Markdown lint on the plan file — fixed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/light/drivers/ParallelLedDriver.h (1)
1046-1051: 🩺 Stability & Availability | 🔴 Critical | ⚡ Quick winHeap buffer overflow: the shift-mode loopback test frame is undersized by one bus word.
encodeLoopbackFrameShift(lines 629-646) writeslightsrows and then unconditionally callsencodeWs2812ShiftLatchPad<Slot>(latchBit_, out), which writes one moreSlotelement after those rows — exactly like the operational path'sencodeRows/frameBytesFor, which reserves a large pad for this. ButtestFrameByteshere (used toplatform::alloc(testFrameBytes)theframebuffer at line ~1057) only countslights * perLightBytes, with no room for that trailing latch word. The result is a 1-2 byte out-of-bounds heap write on every shift-mode loopback self-test run — i.e. whenever a user enablesshiftRegister+loopbackTesttogether, which is exactly the validation workflow this PR's shift-register work relies on. The direct-mode siblingencodeLoopbackFramehas no such call, so it's unaffected.🐛 Proposed fix
const uint8_t opp = outputsPerPin(); const size_t perLightBytes = static_cast<size_t>(outCh) * 8 * 3 * sb * opp; - const size_t testFrameBytes = static_cast<size_t>(lights) * perLightBytes; + // Shift mode's encodeLoopbackFrameShift writes one extra latch-pad word + // (encodeWs2812ShiftLatchPad) after the last row — reserve room for it or + // that write lands past this allocation. + const size_t testFrameBytes = static_cast<size_t>(lights) * perLightBytes + + (shiftMode() ? sb : 0);A regression test on
MockShiftDriver(loopbackTest=true,shiftRegister=true, run under ASan) would have caught this and is worth adding alongside the fix.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/light/drivers/ParallelLedDriver.h` around lines 1046 - 1051, Increase the shift-mode loopback allocation size calculated in the test-frame setup by reserving one additional trailing Slot-sized latch word for encodeLoopbackFrameShift, while leaving the direct-mode calculation unchanged. Update the testFrameBytes computation near encodeLoopbackFrameShift so the allocated buffer covers both all lights rows and the unconditional encodeWs2812ShiftLatchPad<Slot> write.
♻️ Duplicate comments (1)
src/platform/esp32/platform_esp32_moon_i80.cpp (1)
464-482: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick winGuard
buf[0]'s internal allocation against consuming the heap reserve.The
tryAlloclambda correctly prevents the second buffer from eating into theHEAP_RESERVE. However, the primary bufferbuf[0](allocated earlier around line 452) still unconditionally attempts an unguarded internal allocation whenshiftModeis true.If
moonI80Ws2812Init's pre-check evaluatesfitsInternalas false (meaning allocating internally would eat the reserve) butfitsPsramas true, the initialization proceeds. Becausebuf[0]prefers internal memory inshiftModewithout checking the reserve, its allocation will succeed and consume the WiFi/HTTP reserve anyway, potentially leading to instability or a crash.Extract the
tryAlloclambda earlier and use it forbuf[0]as well to ensure both buffers respect the reserve constraint.🛠️ Proposed fix
- const bool shiftMode = clockMultiplier > 1; - st->buf[0] = allocFrame(st, bufferBytes, /*psram=*/!shiftMode); - if (!st->buf[0]) st->buf[0] = allocFrame(st, bufferBytes, /*psram=*/shiftMode); - if (!st->buf[0]) { - destroyState(st); - return nullptr; - } - st->cap = bufferBytes; - - // Second buffer for the async double-buffer — ONLY when asked. Allocate-and-degrade (ADR 0002): - // if it fits, arm double-buffer mode (buf[1] + its semaphore); if it doesn't, leave buf[1] null - // and the driver runs single-buffer. The internal fallback additionally must leave HEAP_RESERVE - // intact — the second buffer is a nice-to-have and must never eat the WiFi/HTTP reserve. - // - // **The reserve guards the INTERNAL attempt, whichever attempt that is.** The preference order - // flips with the mode (shift wants internal first, direct wants PSRAM first), so binding the guard - // to a fixed branch would put it on the wrong one half the time — in shift mode it would leave the - // internal allocation unguarded (free to eat the WiFi/HTTP reserve) while pointlessly gating the - // PSRAM retry on internal free space. A small lambda keeps the rule with the thing it guards. - if (wantSecond) { - st->done[1] = xSemaphoreCreateBinary(); - if (st->done[1]) { - auto tryAlloc = [&](bool psram) -> uint8_t* { - if (!psram - && heap_caps_get_free_size(MALLOC_CAP_DMA | MALLOC_CAP_INTERNAL) - < bufferBytes + HEAP_RESERVE) { - return nullptr; // internal, and it would eat the reserve — refuse - } - return allocFrame(st, bufferBytes, psram); - }; - st->buf[1] = tryAlloc(/*psram=*/!shiftMode); - if (!st->buf[1]) st->buf[1] = tryAlloc(/*psram=*/shiftMode); + const bool shiftMode = clockMultiplier > 1; + + auto tryAlloc = [&](bool psram) -> uint8_t* { + if (!psram + && heap_caps_get_free_size(MALLOC_CAP_DMA | MALLOC_CAP_INTERNAL) + < bufferBytes + HEAP_RESERVE) { + return nullptr; // internal, and it would eat the reserve — refuse + } + return allocFrame(st, bufferBytes, psram); + }; + + st->buf[0] = tryAlloc(/*psram=*/!shiftMode); + if (!st->buf[0]) st->buf[0] = tryAlloc(/*psram=*/shiftMode); + if (!st->buf[0]) { + destroyState(st); + return nullptr; + } + st->cap = bufferBytes; + + // Second buffer for the async double-buffer — ONLY when asked. Allocate-and-degrade (ADR 0002): + // if it fits, arm double-buffer mode (buf[1] + its semaphore); if it doesn't, leave buf[1] null + // and the driver runs single-buffer. The internal fallback additionally must leave HEAP_RESERVE + // intact — the second buffer is a nice-to-have and must never eat the WiFi/HTTP reserve. + // + // **The reserve guards the INTERNAL attempt, whichever attempt that is.** The preference order + // flips with the mode (shift wants internal first, direct wants PSRAM first), so binding the guard + // to a fixed branch would put it on the wrong one half the time — in shift mode it would leave the + // internal allocation unguarded (free to eat the WiFi/HTTP reserve) while pointlessly gating the + // PSRAM retry on internal free space. A small lambda keeps the rule with the thing it guards. + if (wantSecond) { + st->done[1] = xSemaphoreCreateBinary(); + if (st->done[1]) { + st->buf[1] = tryAlloc(/*psram=*/!shiftMode); + if (!st->buf[1]) st->buf[1] = tryAlloc(/*psram=*/shiftMode);🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/platform/esp32/platform_esp32_moon_i80.cpp` around lines 464 - 482, Move the reserve-aware tryAlloc lambda in moonI80Ws2812Init before the primary buf[0] allocation, then use it for both the initial allocation and fallback of buf[0]. Preserve its existing internal-memory HEAP_RESERVE check and reuse the same lambda for buf[1], removing the later duplicate definition.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/moonmodules/light/drivers.md`:
- Around line 112-118: Condense the MoonI80 row in the LED-output comparison
table by removing the detailed esp_lcd reset, GDMA descriptor-chain, comparison,
and rationale explanation. Keep only the concise identifying behavior, platform
availability, and reference link to ADR-0014; leave the i80 row unchanged.
In `@src/light/drivers/ParallelSlots.h`:
- Around line 334-405: Extract the shared per-cycle active-pin calculation from
prefillWs2812ShiftConstants into a helper that accepts activeMask, physPins, and
outPerPin and returns activePins. Update prefillWs2812ShiftConstants to use it,
and reuse the helper in encodeWs2812ShiftSlots for its non-gathering active-mask
computation while preserving its inline lane collection and existing output
behavior.
---
Outside diff comments:
In `@src/light/drivers/ParallelLedDriver.h`:
- Around line 1046-1051: Increase the shift-mode loopback allocation size
calculated in the test-frame setup by reserving one additional trailing
Slot-sized latch word for encodeLoopbackFrameShift, while leaving the
direct-mode calculation unchanged. Update the testFrameBytes computation near
encodeLoopbackFrameShift so the allocated buffer covers both all lights rows and
the unconditional encodeWs2812ShiftLatchPad<Slot> write.
---
Duplicate comments:
In `@src/platform/esp32/platform_esp32_moon_i80.cpp`:
- Around line 464-482: Move the reserve-aware tryAlloc lambda in
moonI80Ws2812Init before the primary buf[0] allocation, then use it for both the
initial allocation and fallback of buf[0]. Preserve its existing internal-memory
HEAP_RESERVE check and reuse the same lambda for buf[1], removing the later
duplicate definition.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 7323712f-f5eb-45d3-9b5b-a8c613dd0698
📒 Files selected for processing (13)
docs/adr/0014-own-i80-dma-driver-below-esp-lcd.mddocs/history/plans/Plan-20260714 - MoonI80 - our own gapless i80 driver.mddocs/moonmodules/light/drivers.mdsrc/core/NetworkModule.hsrc/light/drivers/ParallelLedDriver.hsrc/light/drivers/ParallelSlots.hsrc/platform/desktop/platform_desktop.cppsrc/platform/esp32/platform_esp32.cppsrc/platform/esp32/platform_esp32_moon_i80.cppsrc/platform/platform.htest/unit/light/unit_MoonI80LedDriver.cpptest/unit/light/unit_ParallelLedDriver_shiftregister.cpptest/unit/light/unit_ParallelSlots.cpp
The 74HCT595 encoder now transposes straight in registers instead of through staging arrays (9.76 -> 5.98 us/light, board B 41 -> 54 fps), and MoonI80 stops spending GPIOs on bus lines the LEDs never read: DC is gone entirely and WR is routed only when a shift register uses it as SRCLK, so a direct-mode board spends its pins on strands alone. The i80 drivers also accept any pin count now (1..16) rather than exactly 8 or 16 -- the bus rounds up around the board instead of the board padding out to the bus. tick:18257us(FPS:54) Light domain: - ParallelSlots: transposeBits8x8 takes and returns a PACKED uint64 word, so the per-light encode keeps the bit-planes in registers rather than filling a lanes[8] array the transpose immediately packs back and then spilling eight bytes the emit loop reloads one at a time. Measured 9.76 -> 5.98 us/light on an S3; all three parallel drivers share the encoder, so all three gain. A batched-transpose variant was also built and measured -- zero gain -- and reverted rather than kept. - ParallelSlots: extracted shiftActivePins() for the per-cycle active-pin computation the prefill needs. encodeWs2812ShiftSlots deliberately does NOT use it: there the same mask test is fused with the lane gather, so routing it through the helper would walk the pins twice. - MoonI80LedDriver: dcPin removed. DC exists so an LCD panel can separate command from data bytes; WS2812 has no such concept, the peripheral holds it at a constant level, and we were routing it to a GPIO for nothing. clockPin (WR) is kept but reaches a pad only under the expander -- WS2812 is self-clocked, so in direct mode nothing reads WR. Owning the GPIO matrix is what buys this: an unrouted peripheral signal simply stays inside the peripheral. esp_lcd cannot (it hard-requires both and rejects an NC data pin), which is why I80LedDriver keeps them. - MoonI80LedDriver: clockPin now renders below the shiftRegister toggle and only when it is on -- it is a '595 pin here, not an i80 tax. The bus-pin hook moved under the toggle in the base to allow it. - ParallelLedDriver: the i80 drivers accept 1..16 pins instead of exactly 8 or 16. The BUS is a peripheral fact (8 or 16 bits); the PIN COUNT is a board fact, and the driver reconciles them by parking the lanes the board does not drive. kExactLaneCount renamed kPowerOfTwoBus, since that is what it now means. - ParallelLedDriver: busPinList() pads in BOTH modes. It used to shortcut `if (!shiftMode()) return laneList_` -- the raw, unpadded list -- while busPinCount() reported the rounded width, so a sub-width pin list would read past the end of the array. Direct mode only escaped it because the old validation rejected any count but 8 or 16; allowing any count is what exposes it. - ParallelLedDriver: the shift-mode loopback frame reserves the trailing latch word it unconditionally writes. encodeLoopbackFrameShift emits `lights` rows and then always closes the frame with one more Slot, but the buffer was sized for the rows alone -- a heap overrun on every shift-mode loopback. Core: - platform_esp32_moon_i80: configureGpio routes only what a strand reads. Spare data lanes are left unrouted rather than parked on a "ghost" GPIO, DC is never routed, and WR is routed only in shift mode. The moonI80Ws2812* seam drops its dcGpio parameter throughout. - platform_esp32_i80: fixed an unused-variable warning that failed the classic-ESP32 build under -Werror (shiftMode is only read inside SOC_LCDCAM_I80_LCD_SUPPORTED blocks, so it was dead on a chip without LCD_CAM). Tests: - unit_ParallelLedDriver_shiftregister: the bus pin list is padded to the full bus width in both modes (pins the read-past-the-end bug above), and the shift-mode loopback runs to completion. That path had NO coverage at all, which is how the latch-pad overrun survived. - unit_I80LedDriver: replaced "requires exactly 8 or 16 pins" with the new contract -- 3, 10 and 16 pins all drive, and the bus width follows. - unit_MoonI80LedDriver: direct-mode clockPin cannot collide (it is unrouted), and shift mode still catches a data pin on WR. Docs: - backlog-light: MoonI80's shift-mode loopback stalls the bus (dead-frame guard fires, reboot recovers). The white panel seen alongside it is NOT part of the bug -- those lights are past ledsPerPin, so nothing drives them and they hold their power-on latch. Also records that macOS ASan hangs, so CI Linux is the only sanitizer available. - backlog-light / drivers.md: the WR/DC item is shipped for MoonI80 and unfixable for I80 (esp_lcd mandates both); rewritten to say so. Reviews: - 🐇 encodeLoopbackFrameShift's latch pad overran the loopback buffer -- fixed (see Light domain). - 🐇 extract the shared per-cycle active-pin calculation -- done for the prefill; declined for encodeWs2812ShiftSlots, where the computation is fused with the lane gather and splitting it would double the work. - 🐇 condense the MoonI80 row in drivers.md -- done, it links ADR-0014 rather than restating it. - 🐇 move the reserve-aware tryAlloc before buf[0] -- declined. buf[0] is the frame itself; guarding it would refuse to drive the LEDs to protect the WiFi/HTTP reserve, inverting allocate-and-degrade (ADR-0002). The reserve guards the OPTIONAL second buffer, which is what it does. Comment added at the site. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 4
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (4)
src/platform/esp32/platform_esp32_moon_i80.cpp (1)
610-619: 🩺 Stability & Availability | 🔴 Critical | ⚡ Quick winDrain the stale token to fix the backpressure block.
The
wireFreebinary semaphore accumulates a stale token when a transfer completes (given by the EOF ISR). If the render loop transmits again on the next tick,st->busyis false, so it skips takingwireFreeand the semaphore remains full. If a subsequent transmit arrives while a transfer is still in flight (st->busyis true), the blockingxSemaphoreTakewill succeed instantly using the stale token. This reprograms the active DMA chain while the hardware is still running, garbling the output.Drain the stale token before checking the busy state.
🐛 Proposed fix
- if (st->busy) { + xSemaphoreTake(st->wireFree, 0); // drain any stale token from a prior completion + if (st->busy) { // Block on the dedicated wire-free signal, not on the in-flight buffer's done[] — that one // belongs to the DRIVER (it waits on the buffer it means to reuse), and consuming it here // would make that wait miss. The EOF gives both. // // The bound is a backstop against a wedged peripheral, not a policy: the driver's own // frame-derived timeout (ParallelLedDriver::waitBudgetMs) is what actually governs a stalled // bus. This exists only so a broken DMA cannot hang the render thread indefinitely. if (xSemaphoreTake(st->wireFree, pdMS_TO_TICKS(kWireFreeTimeoutMs)) != pdTRUE) return false; }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/platform/esp32/platform_esp32_moon_i80.cpp` around lines 610 - 619, Drain any pending token from st->wireFree before the if (st->busy) check, then retain the existing blocking take for an actually busy transfer. Ensure the drain is non-blocking so a stale completion signal is consumed without delaying the render loop, while the busy path still waits for the current transfer’s EOF or timeout.test/unit/light/unit_ParallelSlots.cpp (1)
299-307: 🩺 Stability & Availability | 🔴 Critical | ⚡ Quick winFix stack buffer overflow in the shift mode loopback test.
The
outarray is drastically undersized for shift mode, leading to a stack buffer overflow.encodeWs2812ShiftSlotswrites1 * 8 * 3 * kShslots (192 bytes ifkShis 8), but theoutarray is allocated for only8 * kSlotsPerBit(24 bytes).
Additionally, the verification loop incorrectly uses the direct-modebitBase(bit)index, which fails to account for thekShmultiplier in shift mode and reads from incorrect offsets for bits> 0.🐛 Proposed fix for the buffer size and array indexing
- uint8_t out[8 * kSlotsPerBit] = {}; + uint8_t out[8 * kSlotsPerBit * kSh] = {}; const uint8_t latchBit = 4; // 4 data pins → latch on bus bit 4 mm::encodeWs2812ShiftSlots<uint8_t>(wire, /*activeMask=*/1u, /*physPins=*/4, latchBit, kSh, 1, out); const uint8_t latch = static_cast<uint8_t>(1u << latchBit); for (int bit = 0; bit < 8; bit++) { + const int shiftBitBase = bit * kSlotsPerBit * kSh; for (int slot = 0; slot < 3; slot++) { // start, data, tail for (int c = 0; c < kSh; c++) { - const uint8_t w = out[bitBase(bit) + slot * kSh + c]; + const uint8_t w = out[shiftBitBase + slot * kSh + c]; const bool isFirst = (c == 0); CHECK(((w & latch) != 0) == isFirst); // latch iff FIRST cycle of the slot🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@test/unit/light/unit_ParallelSlots.cpp` around lines 299 - 307, Resize the out buffer in the shift-mode loopback test to hold all encoded shift slots: 8 bits × 3 slots per bit × kSh bytes. Update the verification loop’s indexing to use the shift-mode layout with the per-bit stride of 3 * kSh rather than direct-mode bitBase(bit), while preserving the existing start/data/tail and channel checks.src/platform/esp32/platform_esp32_i80.cpp (2)
351-361: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick winReject multipliers other than 1 and 8.
Any value greater than 1 selects the fixed 26.67 MHz clock, but that clock only produces valid WS2812 timing for ×8. For example, multiplier 2 yields a 75 ns slot. Validate the two supported modes in both init and loopback.
Proposed fix
- if (!dataPins || laneCount == 0 || bufferBytes == 0 || clockMultiplier == 0) return false; + if (!dataPins || laneCount == 0 || bufferBytes == 0 + || (clockMultiplier != 1 && clockMultiplier != 8)) return false;Apply the same condition to
i80Ws2812Loopback.Also applies to: 480-491
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/platform/esp32/platform_esp32_i80.cpp` around lines 351 - 361, Validate clockMultiplier in both i80Ws2812Init and i80Ws2812Loopback so only 1 and 8 are accepted; reject all other values, including intermediate multipliers greater than 1. Preserve the existing zero-value validation and ensure both initialization paths enforce the same supported modes.
283-336: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick winKeep the optional shift-mode buffer out of PSRAM.
Buffer 0 correctly prefers internal RAM, but buffer 1 still allocates PSRAM first. With default async mode, frames therefore alternate onto the memory path documented here as stalling. In shift mode, try the reserve-safe internal allocation and otherwise degrade to one buffer; reserve PSRAM-first behavior for direct mode.
Proposed fix
`#if` SOC_LCDCAM_I80_LCD_SUPPORTED - st->buf[1] = static_cast<uint8_t*>(esp_lcd_i80_alloc_draw_buffer( - st->io, bufferBytes, MALLOC_CAP_DMA | MALLOC_CAP_SPIRAM)); + if (!shiftMode) { + st->buf[1] = static_cast<uint8_t*>(esp_lcd_i80_alloc_draw_buffer( + st->io, bufferBytes, MALLOC_CAP_DMA | MALLOC_CAP_SPIRAM)); + } `#endif`🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/platform/esp32/platform_esp32_i80.cpp` around lines 283 - 336, Update the buffer-1 allocation logic under wantSecond so shiftMode attempts the reserve-safe internal DMA allocation first and degrades to single-buffer when it cannot fit, without falling back to PSRAM. Preserve PSRAM-first allocation for direct mode only, using st->buf[1] and the existing HEAP_RESERVE check.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/light/drivers/MoonI80LedDriver.h`:
- Around line 119-123: Update busInit and busLoopback in MoonI80LedDriver to
pass the real laneCount() to the ESP32 backend instead of the padded
busPinCount(). Preserve the existing padded bus list for routing while ensuring
both initialization and loopback leave unused data lines unrouted.
- Around line 101-105: Update validateBusFatal() to reject shift mode when
clockPin is unassigned (less than zero), returning a clear validation error
before busInit can route the invalid pin. Preserve the existing
latchPin-versus-clockPin validation and valid shift-mode behavior.
In `@src/light/drivers/ParallelSlots.h`:
- Around line 58-63: Remove historical implementation narratives, dated
measurements, rejected hypotheses, and future-work discussion from the
documented sites. In src/light/drivers/ParallelSlots.h lines 58-63 and 421-427,
and src/platform/esp32/platform_esp32_i80.cpp lines 270-282, retain only concise
present-tense contracts and rationale; move historical details to an appropriate
history/ADR/lessons document. In
test/unit/light/unit_ParallelLedDriver_shiftregister.cpp lines 450-453, document
the current pin-list invariant, and at lines 486-496 document the current
buffer-sizing invariant instead of prior bug and overflow history.
In `@test/unit/light/unit_ParallelLedDriver_shiftregister.cpp`:
- Around line 498-508: Set the mock driver's loopbackRxPin to a valid receive
pin in the test before calling applyState, so runLoopbackSelfTest() passes its
RX-pin guard and reaches frame allocation and closing-latch encoding. Keep the
existing loopback strand and frame assertions unchanged.
---
Outside diff comments:
In `@src/platform/esp32/platform_esp32_i80.cpp`:
- Around line 351-361: Validate clockMultiplier in both i80Ws2812Init and
i80Ws2812Loopback so only 1 and 8 are accepted; reject all other values,
including intermediate multipliers greater than 1. Preserve the existing
zero-value validation and ensure both initialization paths enforce the same
supported modes.
- Around line 283-336: Update the buffer-1 allocation logic under wantSecond so
shiftMode attempts the reserve-safe internal DMA allocation first and degrades
to single-buffer when it cannot fit, without falling back to PSRAM. Preserve
PSRAM-first allocation for direct mode only, using st->buf[1] and the existing
HEAP_RESERVE check.
In `@src/platform/esp32/platform_esp32_moon_i80.cpp`:
- Around line 610-619: Drain any pending token from st->wireFree before the if
(st->busy) check, then retain the existing blocking take for an actually busy
transfer. Ensure the drain is non-blocking so a stale completion signal is
consumed without delaying the render loop, while the busy path still waits for
the current transfer’s EOF or timeout.
In `@test/unit/light/unit_ParallelSlots.cpp`:
- Around line 299-307: Resize the out buffer in the shift-mode loopback test to
hold all encoded shift slots: 8 bits × 3 slots per bit × kSh bytes. Update the
verification loop’s indexing to use the shift-mode layout with the per-bit
stride of 3 * kSh rather than direct-mode bitBase(bit), while preserving the
existing start/data/tail and channel checks.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: e0d0235b-34c5-4434-b539-547d5fb70003
📒 Files selected for processing (18)
docs/backlog/backlog-light.mddocs/moonmodules/light/drivers.mdsrc/light/drivers/I80LedDriver.hsrc/light/drivers/MoonI80LedDriver.hsrc/light/drivers/ParallelLedDriver.hsrc/light/drivers/ParallelSlots.hsrc/light/drivers/ParlioLedDriver.hsrc/platform/desktop/platform_desktop.cppsrc/platform/esp32/platform_esp32_i80.cppsrc/platform/esp32/platform_esp32_moon_i80.cppsrc/platform/platform.htest/scenarios/light/scenario_Audio_mutation.jsontest/scenarios/light/scenario_MoonLiveEffect_livescript.jsontest/unit/light/unit_I80LedDriver.cpptest/unit/light/unit_MoonI80LedDriver.cpptest/unit/light/unit_ParallelLedDriver_doublebuffer.cpptest/unit/light/unit_ParallelLedDriver_shiftregister.cpptest/unit/light/unit_ParallelSlots.cpp
| TEST_CASE("shift register: the loopback frame has room for its closing latch word") { | ||
| MockShiftDriver d; | ||
| mm::Buffer src; | ||
| mm::Correction corr; | ||
| wire(d, src, corr, 16 * 8, "1,2", /*shiftOn=*/true, /*latch=*/3); | ||
| REQUIRE(d.laneCount() == 16); // 2 pins x 8 outputs | ||
|
|
||
| d.loopbackTest = true; | ||
| d.loopbackStrand = 0; | ||
| d.applyState(); // arms the self-test | ||
| d.tick(); // builds + "transmits" the test frame (mock bus) |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Set loopbackRxPin so this regression reaches frame encoding.
The default is -1, so runLoopbackSelfTest() returns at its RX-pin guard before allocating the frame or writing the closing latch word. The test therefore cannot catch the claimed heap overflow.
Proposed fix
d.loopbackTest = true;
d.loopbackStrand = 0;
+ d.loopbackRxPin = 4;
d.applyState();📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| TEST_CASE("shift register: the loopback frame has room for its closing latch word") { | |
| MockShiftDriver d; | |
| mm::Buffer src; | |
| mm::Correction corr; | |
| wire(d, src, corr, 16 * 8, "1,2", /*shiftOn=*/true, /*latch=*/3); | |
| REQUIRE(d.laneCount() == 16); // 2 pins x 8 outputs | |
| d.loopbackTest = true; | |
| d.loopbackStrand = 0; | |
| d.applyState(); // arms the self-test | |
| d.tick(); // builds + "transmits" the test frame (mock bus) | |
| TEST_CASE("shift register: the loopback frame has room for its closing latch word") { | |
| MockShiftDriver d; | |
| mm::Buffer src; | |
| mm::Correction corr; | |
| wire(d, src, corr, 16 * 8, "1,2", /*shiftOn=*/true, /*latch=*/3); | |
| REQUIRE(d.laneCount() == 16); // 2 pins x 8 outputs | |
| d.loopbackTest = true; | |
| d.loopbackStrand = 0; | |
| d.loopbackRxPin = 4; | |
| d.applyState(); // arms the self-test | |
| d.tick(); // builds + "transmits" the test frame (mock bus) |
🧰 Tools
🪛 Cppcheck (2.21.0)
[style] 500-500: The function 'forEachListElement' is never used.
(unusedFunction)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@test/unit/light/unit_ParallelLedDriver_shiftregister.cpp` around lines 498 -
508, Set the mock driver's loopbackRxPin to a valid receive pin in the test
before calling applyState, so runLoopbackSelfTest() passes its RX-pin guard and
reaches frame allocation and closing-latch encoding. Keep the existing loopback
strand and frame assertions unchanged.
The shift-mode loopback self-test now runs end-to-end on a spare 74HCT595 output and reports a clean PASS: it drives a known pattern, captures it back off the strand, and bit-verifies it (2304/2304 bits, textbook 300/600 ns pulse widths). Along the way it tolerates a benign '595 first-latch artifact, adds a LinesEffect "panel dots" mode for mapping which strand drives which panel, and picks up two CodeRabbit fixes.
tick:7130us(FPS:140)
Light domain:
- ParallelLedDriver: the loopback verdict tolerates a lone short-clipped bit 0. With a '595 expander the register outputs are still settling as the first RCLK latch fires, so the frame's very first pulse comes back one slot short (a "0" where a "1" was sent) — measured as EXACTLY 1 mismatch in 2304, always bit 0, always short-clipped, costing only the first pixel's red MSB (invisible). Any second mismatch, or a bit-0 miss that is not short-clipped, still fails; direct mode drives the pin straight so its bit 0 stays clean.
- MoonI80LedDriver: validateBusFatal rejects shift mode with an unassigned clockPin (< 0). The '595 needs WR on a real GPIO as its SRCLK; unset would route the peripheral's WR signal to GPIO 65535, so catch it as a config error before busInit reaches the pad.
- LinesEffect: added a "panel dots" mode (mode control) — each panelW×panelH block lights (panelIndex+1) LEDs, so the wall reads back which physical panel carries which grid block (and thus which strand). Width and height are separate controls (non-square panels like 16×6 need both, or a square step skips whole panel rows). The mode's controls are conditionally hidden so each mode shows only its own.
Core:
- platform_esp32_moon_i80: moonI80Ws2812Transmit drains a stale wireFree token before the busy-wait, so the wait blocks on THIS frame's EOF rather than a past one (the previous frame's EOF gives wireFree unconditionally, leaving a token when no transmit was waiting).
Tests:
- unit_MoonI80LedDriver: shift mode requires a clockPin; direct mode does not (pins the validateBusFatal fix).
Docs:
- backlog-light: the shift-mode loopback OFF wedges the bus until a reboot — a no-reboot-principle violation. Recorded with the key diagnostic (DIRECT mode does NOT wedge, only shift does, so the cause is shift-specific peripheral state, not the generic teardown) and the three attempts that did not fix it, so the next attempt starts from the narrowed lead rather than repeating them.
Reviews:
- 🐇 shift mode + unassigned clockPin — fixed (see MoonI80LedDriver).
- 🐇 stale wireFree token before the busy-wait — fixed (see platform_esp32_moon_i80).
- 🐇 pass laneCount() not busPinCount() to the backend — declined: busPinCount() IS the bus width (8/16) the peripheral configures; laneCount() is the strand count (up to 64 in shift) and would mis-size the bus.
- 🐇 validate clockMultiplier ∈ {1,8} — declined: the driver only ever passes 1 or kShiftOutputs via busClockMultiplier(), and >1 already means shift uniformly; a strict check guards an unreachable input.
- 🐇 i80 buf[1] shift-mode PSRAM fallback — declined: that is the esp_lcd reference driver, memory-capped by design; out of scope for this change.
- 🐇 strip historical narrative from ParallelSlots.h / platform_esp32_i80.cpp comments — deferred: style-only cleanup on committed code, its own change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 2
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (5)
src/platform/esp32/platform_esp32_moon_i80.cpp (1)
531-535: 🩺 Stability & Availability | 🔴 Critical | ⚡ Quick winEnforce
extAlignacross the GDMA lifecycle. BecauseinitDmasetstransfer.access_ext_mem = true, ESP-IDF'sgdma_link_mount_bufferswill unconditionally enforceextAlign(cache-line alignment) on ALL buffer addresses and lengths mounted to this channel.startTransfermounts the exact unaligned payload size (bytes), causinggdma_link_mount_buffersto reject the transfer withESP_ERR_INVALID_ARG.
src/platform/esp32/platform_esp32_moon_i80.cpp#L531-L535: CalculatealignedBytespadded up to the GDMA alignment constraint and pass it tomount.length, preventing a short tail from failing the mount.src/platform/esp32/platform_esp32_moon_i80.cpp#L388-L396: UpdateallocFrameto pad the allocation size and address againstextAligneven for internal memory allocations, as the channel demands it.src/platform/esp32/platform_esp32_moon_i80.cpp#L521-L525: Update theesp_cache_msynccall instartTransferto usealignedBytes.🐛 Proposed fixes
For
allocFrame:uint8_t* allocFrame(MoonI80State* st, size_t bufferBytes, bool psram) { size_t intAlign = 0, extAlign = 0; gdma_get_alignment_constraints(st->dma, &intAlign, &extAlign); - const size_t align = psram ? extAlign : intAlign; + // access_ext_mem = true forces the DMA to always expect extAlign, even for internal memory + const size_t align = intAlign > extAlign ? intAlign : extAlign; const uint32_t caps = MALLOC_CAP_8BIT | MALLOC_CAP_DMA | (psram ? MALLOC_CAP_SPIRAM : MALLOC_CAP_INTERNAL);For
startTransfer:bool startTransfer(MoonI80State* st, uint8_t buffer, size_t bytes) { lcd_cam_dev_t* dev = st->hal.dev; + size_t intAlign = 0, extAlign = 0; + gdma_get_alignment_constraints(st->dma, &intAlign, &extAlign); + const size_t align = intAlign > extAlign ? intAlign : extAlign; + const size_t alignedBytes = align > 1 ? ((bytes + align - 1) / align) * align : bytes; + // Write the encoded frame back from cache to physical memory — the GDMA reads DRAM/PSRAM, not // the CPU's cache. (esp_lcd does this inside tx_color, esp_lcd_panel_io_i80.c:576.) if (esp_cache_get_line_size_by_addr(st->buf[buffer]) > 0) { - esp_cache_msync(st->buf[buffer], bytes, + esp_cache_msync(st->buf[buffer], alignedBytes, ESP_CACHE_MSYNC_FLAG_DIR_C2M | ESP_CACHE_MSYNC_FLAG_UNALIGNED); } // Mount the WHOLE frame in ONE call: the link list splits it across as many 4095-byte nodes as // it needs and chains them, so the DMA walks the frame end to end without CPU involvement. // mark_eof fires our completion callback on the last node; mark_final NULLs its next-pointer so // the DMA stops there instead of wrapping to the head. gdma_buffer_mount_config_t mount = {}; mount.buffer = st->buf[buffer]; - mount.length = bytes; + mount.length = alignedBytes; mount.flags.mark_eof = true; mount.flags.mark_final = GDMA_FINAL_LINK_TO_NULL; if (gdma_link_mount_buffers(st->link, 0, &mount, 1, nullptr) != ESP_OK) return false;🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/platform/esp32/platform_esp32_moon_i80.cpp` around lines 531 - 535, Enforce extAlign throughout the GDMA buffer lifecycle: in src/platform/esp32/platform_esp32_moon_i80.cpp lines 388-396, update allocFrame to pad both allocation size and address for extAlign, including internal-memory allocations; in startTransfer at lines 521-525, compute alignedBytes from bytes and use it for esp_cache_msync; at lines 531-535, use the same alignedBytes for mount.length before calling gdma_link_mount_buffers.src/platform/esp32/platform_esp32_i80.cpp (2)
287-303: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick winApply the internal-first policy to both shift buffers.
Only
buf[0]uses shift mode’s internal-first allocation. With default async transmission,buf[1]still allocates PSRAM first, so alternating frames can hit the exact stalled-transfer path this policy avoids. Use the same mode-dependent allocation order for both buffers.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/platform/esp32/platform_esp32_i80.cpp` around lines 287 - 303, Update the buffer allocation logic around shiftMode and both st->buf[0] and st->buf[1] so shift mode attempts MALLOC_CAP_INTERNAL before MALLOC_CAP_SPIRAM for each buffer. Preserve the existing fallback behavior, including the shift-mode PSRAM fallback and normal-mode allocation order, while applying the same mode-dependent policy to the async second buffer.
351-361: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick winReject shift multipliers other than eight.
Every value greater than one currently selects
kShiftPclkHz, but that clock is valid specifically for ×8. For example, ×2 produces a 75 ns strand slot. Accept only1and8, or derive and validate an exact clock for each multiplier.As per path instructions, “clockMultiplier scales pixel clock” and backends must choose an “EXACTLY divisible clock.”
Also applies to: 480-491, 536-550
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/platform/esp32/platform_esp32_i80.cpp` around lines 351 - 361, Update i80Ws2812Init and the associated clock-selection paths to accept only clockMultiplier values 1 or 8, rejecting other values before allocation or timing setup. Ensure every backend uses the ×8-specific kShiftPclkHz only for multiplier 8, while multiplier 1 retains the existing base-clock behavior; apply the same validation and selection consistently in the code paths around the referenced initialization and frame-building logic.Source: Path instructions
src/light/drivers/ParallelLedDriver.h (2)
372-375: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy liftKeep polling a suspended transfer for recovery.
Once
deadFrames_reaches the threshold,busGaveUp()returns beforebusWaitIfBusy(), so a later DMA completion is never consumed and the documented self-healing cannot occur. Poll in-flight buffers non-blockingly while suspended and clear the error when completion arrives.Suggested recovery shape
bool busGaveUp() { if (deadFrames_ < kDeadFramesBeforeGiveUp) return false; + + for (uint8_t i = 0; i < 2; ++i) { + if (inFlight_[i] && derived()->busWait(i, 0)) { + inFlight_[i] = false; + deadFrames_ = 0; + gaveUpReported_ = false; + clearStatus(); + return false; + } + } + if (!gaveUpReported_) {Also applies to: 396-403, 471-478
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/light/drivers/ParallelLedDriver.h` around lines 372 - 375, Update the transfer guards around busGaveUp() in the affected buffer-submit paths so suspended transfers still poll in-flight DMA buffers non-blockingly via busWaitIfBusy(). When a pending transfer completes, consume it and clear the suspended/dead-frame state so recovery can resume; retain the existing early return when no completion is available.
97-106: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick winResize
ledsPerPinfor the 64-strand contract.parseConfig()expands this control ton * outputsPerPin()strands and caps that atkMaxStrands = 64, so a full shift-register list can’t fit inchar ledsPerPin[96](even 64 single-digit entries need 128 bytes with commas and NUL). Size it fromkMaxStrandsand the worst-case decimal width, or move to a structured control.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/light/drivers/ParallelLedDriver.h` around lines 97 - 106, Resize the ledsPerPin control used by parseConfig() to support the full kMaxStrands = 64 contract, including commas, the terminating NUL, and the maximum decimal width for each per-strand value. Replace the current fixed 16-entry sizing with a capacity derived from kMaxStrands and the worst-case value width, while preserving the existing comma-separated configuration format.
♻️ Duplicate comments (1)
src/light/drivers/MoonI80LedDriver.h (1)
124-128: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick winPass the real
laneCount()to avoid routing padding pins.
ParallelLedDriverpads the bus list up to the full bus width usingclockPinForBus(). Passingthis->busPinCount()sends this padded length to the ESP32 backend. The backend explicitly expects the real lane count so it can leave spare data lines unrouted; passing the padded count causesconfigureGpioto route the dummy padding pins, mapping the unused data lanes directly ontoclockPin(WR) and destroying the direct-mode protection.
src/light/drivers/MoonI80LedDriver.h#L124-L128: passthis->laneCount()tobusInitinstead ofthis->busPinCount().src/light/drivers/MoonI80LedDriver.h#L138-L142: passthis->laneCount()tobusLoopbackinstead ofthis->busPinCount().🐛 Proposed fixes
For
busInit:bool busInit(size_t frameBytes, bool wantSecondBuffer) { - return platform::moonI80Ws2812Init(bus_, this->busPinList(), this->busPinCount(), + return platform::moonI80Ws2812Init(bus_, this->busPinList(), this->laneCount(), static_cast<uint16_t>(clockPin), frameBytes, wantSecondBuffer, this->busClockMultiplier()); }For
busLoopback:platform::RmtLoopbackResult busLoopback(const uint8_t* frame, size_t frameBytes, size_t dataBytes, uint8_t rowBits) { - return platform::moonI80Ws2812Loopback(this->busPinList(), this->busPinCount(), + return platform::moonI80Ws2812Loopback(this->busPinList(), this->laneCount(), static_cast<uint16_t>(clockPin), static_cast<uint16_t>(loopbackRxPin), frame, frameBytes, dataBytes, rowBits, this->busClockMultiplier()); }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/light/drivers/MoonI80LedDriver.h` around lines 124 - 128, Use the real lane count in both MoonI80LedDriver backend calls: update busInit to pass this->laneCount() instead of this->busPinCount(), and make the same substitution in busLoopback. This prevents padded pins from being routed as data lanes.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/backlog/backlog-light.md`:
- Line 193: Convert the identified British spellings to American English: in
docs/backlog/backlog-light.md lines 193-193 change “behaviour” to “behavior”; in
src/platform/esp32/platform_esp32_rmt.cpp lines 375-375 change “colour” to
“color”; and in test/unit/light/unit_ParallelSlots.cpp lines 615-615 change
“unoptimised” to “unoptimized”.
In `@src/light/effects/LinesEffect.h`:
- Around line 54-79: Guard the panel-dots block in the mode == 1 path so it
returns without writing when the grid depth d is zero or the buffer has no
storage. Keep the existing panel mapping and dot generation unchanged for valid
grids, including 0x0x0, and ensure no loop can access buf when cpl or the
allocated buffer size is empty.
---
Outside diff comments:
In `@src/light/drivers/ParallelLedDriver.h`:
- Around line 372-375: Update the transfer guards around busGaveUp() in the
affected buffer-submit paths so suspended transfers still poll in-flight DMA
buffers non-blockingly via busWaitIfBusy(). When a pending transfer completes,
consume it and clear the suspended/dead-frame state so recovery can resume;
retain the existing early return when no completion is available.
- Around line 97-106: Resize the ledsPerPin control used by parseConfig() to
support the full kMaxStrands = 64 contract, including commas, the terminating
NUL, and the maximum decimal width for each per-strand value. Replace the
current fixed 16-entry sizing with a capacity derived from kMaxStrands and the
worst-case value width, while preserving the existing comma-separated
configuration format.
In `@src/platform/esp32/platform_esp32_i80.cpp`:
- Around line 287-303: Update the buffer allocation logic around shiftMode and
both st->buf[0] and st->buf[1] so shift mode attempts MALLOC_CAP_INTERNAL before
MALLOC_CAP_SPIRAM for each buffer. Preserve the existing fallback behavior,
including the shift-mode PSRAM fallback and normal-mode allocation order, while
applying the same mode-dependent policy to the async second buffer.
- Around line 351-361: Update i80Ws2812Init and the associated clock-selection
paths to accept only clockMultiplier values 1 or 8, rejecting other values
before allocation or timing setup. Ensure every backend uses the ×8-specific
kShiftPclkHz only for multiplier 8, while multiplier 1 retains the existing
base-clock behavior; apply the same validation and selection consistently in the
code paths around the referenced initialization and frame-building logic.
In `@src/platform/esp32/platform_esp32_moon_i80.cpp`:
- Around line 531-535: Enforce extAlign throughout the GDMA buffer lifecycle: in
src/platform/esp32/platform_esp32_moon_i80.cpp lines 388-396, update allocFrame
to pad both allocation size and address for extAlign, including internal-memory
allocations; in startTransfer at lines 521-525, compute alignedBytes from bytes
and use it for esp_cache_msync; at lines 531-535, use the same alignedBytes for
mount.length before calling gdma_link_mount_buffers.
---
Duplicate comments:
In `@src/light/drivers/MoonI80LedDriver.h`:
- Around line 124-128: Use the real lane count in both MoonI80LedDriver backend
calls: update busInit to pass this->laneCount() instead of this->busPinCount(),
and make the same substitution in busLoopback. This prevents padded pins from
being routed as data lanes.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 01f04f40-684f-46c5-a1e6-4bbac0eb5f66
📒 Files selected for processing (20)
docs/backlog/backlog-light.mddocs/moonmodules/light/drivers.mdsrc/light/drivers/I80LedDriver.hsrc/light/drivers/MoonI80LedDriver.hsrc/light/drivers/ParallelLedDriver.hsrc/light/drivers/ParallelSlots.hsrc/light/drivers/ParlioLedDriver.hsrc/light/effects/LinesEffect.hsrc/platform/desktop/platform_desktop.cppsrc/platform/esp32/platform_esp32_i80.cppsrc/platform/esp32/platform_esp32_moon_i80.cppsrc/platform/esp32/platform_esp32_rmt.cppsrc/platform/platform.htest/scenarios/light/scenario_Audio_mutation.jsontest/scenarios/light/scenario_MoonLiveEffect_livescript.jsontest/unit/light/unit_I80LedDriver.cpptest/unit/light/unit_MoonI80LedDriver.cpptest/unit/light/unit_ParallelLedDriver_doublebuffer.cpptest/unit/light/unit_ParallelLedDriver_shiftregister.cpptest/unit/light/unit_ParallelSlots.cpp
… 96)
Lifts the shift-register cap from ~96 to 128 lights/strand by streaming the frame through a small pool of internal DMA buffers refilled by a task as the DMA drains them, so the frame never lands in PSRAM (which the S3's GDMA can't read at the expander's 26.67MHz clock). 128 runs reliably; ≥192 (buffer reuse) still halts and is backlogged.
KPI: 16384lights | Desktop:718KB | tick:562/445/38/563/77/18/1356/311/91/115/812/621/88/33/1/186us(FPS:1779/2247/26315/1776/12987/55555/737/3215/10989/8695/1231/1610/11363/30303/1000000/5376) | ESP32:1457KB | tick:24156us(FPS:41) | heap:8155KB | src:197(42789) | test:136(23205) | lizard:151w
Core
- platform_esp32_moon_i80: streaming ring backend — a linear, self-terminating GDMA chain over N internal buffers (node i → ring[i%N]), refilled by a pinned task off the render thread; drain-count termination (no ISR stop, no prefetcher race); loopback routed through the ring so its bit-verify PASSES at 256; zero the last slice's pad window so a non-×16 strand length can't clock stale ghost rows; drain stale refill tokens per frame.
- platform.h: MoonI80 ring seam (InitRing/TransmitRing/IsRing/InternalFits + MoonI80EncodeFn) — task refill, documented.
Light domain
- ParallelLedDriver: tickRing (async kick-and-return, doesn't block the render thread on the wire); busGaveUp now self-recovers on a periodic retry instead of latching until reinit; drainInFlight() before parseConfig so a resize/RGBW switch can't free the scratch the ring's refill task reads; rowBytesFor/padBytesFor extracted (ring and whole-frame geometry share one source); ledsPerPin sized for the full kMaxStrands contract.
- MoonI80LedDriver: wantsRing (ring only when a shift frame won't fit internal), busInitRing + the encode trampoline (prefill+encode per slice).
- LinesEffect: guard the panel-dots path at zero depth/channels (0×0×0).
Tests
- unit_ParallelLedDriver_ring: tiling, last-slice-closes, recycled==fresh, and a short-last-slice clean-pad test (proven to fail without the pad-zero fix).
- unit_ParallelLedDriver_doublebuffer: give-up self-recovery test.
Docs
- backlog-light: the ≥192 reuse race (halt + 1-pixel shift), the refill-task source snapshot, and the toggle-off/on stall — all backlogged with root cause.
- Plan-20260715 - MoonI80 streaming ring (clean-room): the approved design record.
Reviews
- 👾 Stale ghost rows in the pad at non-×16 strand lengths — FIXED (pad-zero) + a test proven to fail without the fix.
- 👾 Refill task reads live state → heap corruption on resize/RGBW mid-wire — FIXED (drainInFlight before parseConfig); the UAF-read/tear half (source snapshot) backlogged.
- 👾 Stale refill tokens crossing a frame boundary — FIXED (drain per frame).
- 👾 Dead code + contradictory comments from the abandoned looping design — FIXED.
- 👾 Geometry math duplicated across three sites — FIXED (rowBytesFor/padBytesFor).
- 🐇 British spellings (behaviour/colour/unoptimised) — FIXED.
- 🐇 LinesEffect 0×0×0 guard — FIXED.
- 🐇 ledsPerPin sizing to kMaxStrands — FIXED.
- 🐇 MoonI80 busInit laneCount vs busPinCount — ACCEPTED (busPinCount is correct: the full bus width incl. parked lanes; laneCount would under-size the pin list).
- 🐇 i80 shift internal-first alloc / clockMultiplier∈{1,8} — ACCEPTED (shift already tries internal first; multiplier>1 is already rejected on the esp_lcd path).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 6
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (4)
src/platform/esp32/platform_esp32_rmt.cpp (1)
379-381: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick winGate the bit-zero exception to shift-register loopbacks. The shared verifier is used by i80, moonI80, and Parlio; this exception should only apply when
clockMultiplier > 1, so direct i80 and Parlio do not hide a real first-bit error.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/platform/esp32/platform_esp32_rmt.cpp` around lines 379 - 381, Update the onlyBit0Clip exception in the shared verifier so it is additionally enabled only when clockMultiplier > 1. Preserve the existing mismatch and threshold conditions, ensuring direct i80 and Parlio paths cannot accept a first-bit mismatch through this exception.Source: Path instructions
src/light/drivers/ParallelLedDriver.h (2)
163-166: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winMake loopback documentation follow
loopbackStrand. The implementation supports any selected strand, but both comments still prescribe strand 0/Q7.
src/light/drivers/ParallelLedDriver.h#L163-L166: explain how the configured strand maps to the selected 74HCT595 output.src/light/drivers/ParallelLedDriver.h#L715-L719: remove the fixed active-mask-bit-0/Q7 claim.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/light/drivers/ParallelLedDriver.h` around lines 163 - 166, The loopback documentation must reflect the configured loopbackStrand instead of always describing strand 0/Q7. In src/light/drivers/ParallelLedDriver.h lines 163-166, explain how loopbackStrand maps to the corresponding 74HCT595 output under MSB-first shifting; in lines 715-719, remove the fixed active-mask-bit-0/Q7 assertion and describe the selected strand generically.
654-682: 🩺 Stability & Availability | 🔴 Critical | 🏗️ Heavy liftSnapshot the source before the refill task reads it.
encodeRows()dereferences the live Layer buffer from the concurrent ring-refill task. A Layer resize can free that storage, and a subsequent render can overwrite it mid-frame, causing a use-after-free or torn output. Copy the selected source window into driver-owned staging storage before starting ring DMA.As per coding guidelines, running devices must tolerate arbitrary valid module and grid changes without crashing.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/light/drivers/ParallelLedDriver.h` around lines 654 - 682, Update encodeRows to snapshot the selected source window into driver-owned staging storage before any ring DMA or refill task can read it, then use that stable snapshot instead of sourceBuffer_->data() during row encoding. Ensure the snapshot is correctly sized and covers the winStart_, laneStart_, and requested row range, preserving channel layout while preventing resize-related use-after-free and mid-frame tearing.Source: Coding guidelines
docs/backlog/backlog-light.md (1)
63-73: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick winReplace the obsolete pre-ring expander limitation. Both locations still describe approximately 96 lights/strand and PSRAM as the required next step, contradicting the newly documented internal-DMA ring support at 128 lights/strand.
docs/backlog/backlog-light.md#L63-L73: describe the whole-frame 96-light boundary separately from the reliable 128-light ring boundary.src/light/drivers/ParallelLedDriver.h#L192-L202: update user-facing guidance to match the ring path and its current reuse limit.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@docs/backlog/backlog-light.md` around lines 63 - 73, Update docs/backlog/backlog-light.md lines 63-73 to distinguish the approximately 96-lights-per-strand whole-frame limit from the reliable 128-lights-per-strand internal-DMA ring boundary, removing obsolete guidance that treats PSRAM support as the required next step. Update src/light/drivers/ParallelLedDriver.h lines 192-202 user-facing guidance to describe the ring path and its current reuse limit consistently with the backlog documentation.Source: Coding guidelines
♻️ Duplicate comments (1)
src/light/effects/LinesEffect.h (1)
62-65: 🩺 Stability & Availability | 🔴 Critical | ⚡ Quick winMove the empty-volume guard before
memset.The buffer is cleared before this guard runs. If an empty allocation returns
nullptr,memset(nullptr, 0, 0)is undefined. Guard the dimensions, channels, and pointer before any access, covering both modes.Proposed fix
const lengthType d = depth(); const uint8_t cpl = channelsPerLight(); + if (w == 0 || h == 0 || d == 0 || cpl == 0 || buf == nullptr) return; memset(buf, 0, static_cast<size_t>(w) * h * d * cpl); ... - if (w == 0 || h == 0 || d == 0 || cpl == 0) return;As per path instructions, light effects must handle every grid size, including 0×0×0, without crashes.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/light/effects/LinesEffect.h` around lines 62 - 65, Move the empty-volume check in the surrounding effect method before the memset call, and include w, h, d, cpl, and the buffer pointer in the guard so no null or zero-length allocation is accessed in either mode. Preserve the existing early-return behavior for any zero dimension or channel count.Source: Path instructions
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/history/plans/Plan-20260715` - MoonI80 streaming ring (clean-room).md:
- Line 9: In the “The mechanism” paragraph, replace the British spelling
“materialise” with the American spelling “materialize,” leaving the rest of the
documentation unchanged.
In `@src/light/drivers/ParallelLedDriver.h`:
- Around line 529-543: The retry branch in busGaveUp() must clear or reset the
stale in-flight bus transfer before returning false, so the caller reaches
busTransmit*() with a clean bus state and starts a replacement transfer. Reuse
the existing transfer-abort/reset or bus-rebuild mechanism, while preserving the
retry counter and give-up status behavior.
In `@src/platform/esp32/platform_esp32_moon_i80.cpp`:
- Around line 239-262: The streaming ring must retain explicit frame ownership
until refill work is quiescent. In
src/platform/esp32/platform_esp32_moon_i80.cpp lines 239-262, have the refill
task process exactly the required slice count, signal only necessary refill
operations, terminate by count rather than NULL termination, and acknowledge
task quiescence before publishing frame completion. In
src/light/drivers/MoonI80LedDriver.h lines 166-178, encode from an immutable
per-frame source snapshot or block upstream source mutation until that
acknowledgement.
In `@test/unit/light/unit_ParallelLedDriver_doublebuffer.cpp`:
- Around line 387-393: Update the recovery assertion in the test loop around
d.tick() so recovered becomes true only after a successful wait clears
Severity::Error, not merely when any Call::Transmit retry occurs. Continue
ticking until the driver leaves the error/give-up state, then verify that
subsequent normal transmission continues.
In `@test/unit/light/unit_ParallelLedDriver_ring.cpp`:
- Around line 203-209: Update wireShift to allocate the full configured
shift-register geometry by deriving the light count from the number of
configured pins multiplied by outputsPerPin(), rather than using lights * 8.
Preserve the existing allocation depth and correction setup while ensuring every
configured strand is represented.
- Around line 120-132: Update onlyLastSliceClosedFrame() to inspect the pad
region starting at the final slice’s actual row count (lastRows * ringRowBytes_)
in lastSlot_, rather than always after kMockRingRows. Require at least one
non-zero latch byte in that final-slice pad, while continuing to reject non-zero
pad bytes in other ring buffers.
---
Outside diff comments:
In `@docs/backlog/backlog-light.md`:
- Around line 63-73: Update docs/backlog/backlog-light.md lines 63-73 to
distinguish the approximately 96-lights-per-strand whole-frame limit from the
reliable 128-lights-per-strand internal-DMA ring boundary, removing obsolete
guidance that treats PSRAM support as the required next step. Update
src/light/drivers/ParallelLedDriver.h lines 192-202 user-facing guidance to
describe the ring path and its current reuse limit consistently with the backlog
documentation.
In `@src/light/drivers/ParallelLedDriver.h`:
- Around line 163-166: The loopback documentation must reflect the configured
loopbackStrand instead of always describing strand 0/Q7. In
src/light/drivers/ParallelLedDriver.h lines 163-166, explain how loopbackStrand
maps to the corresponding 74HCT595 output under MSB-first shifting; in lines
715-719, remove the fixed active-mask-bit-0/Q7 assertion and describe the
selected strand generically.
- Around line 654-682: Update encodeRows to snapshot the selected source window
into driver-owned staging storage before any ring DMA or refill task can read
it, then use that stable snapshot instead of sourceBuffer_->data() during row
encoding. Ensure the snapshot is correctly sized and covers the winStart_,
laneStart_, and requested row range, preserving channel layout while preventing
resize-related use-after-free and mid-frame tearing.
In `@src/platform/esp32/platform_esp32_rmt.cpp`:
- Around line 379-381: Update the onlyBit0Clip exception in the shared verifier
so it is additionally enabled only when clockMultiplier > 1. Preserve the
existing mismatch and threshold conditions, ensuring direct i80 and Parlio paths
cannot accept a first-bit mismatch through this exception.
---
Duplicate comments:
In `@src/light/effects/LinesEffect.h`:
- Around line 62-65: Move the empty-volume check in the surrounding effect
method before the memset call, and include w, h, d, cpl, and the buffer pointer
in the guard so no null or zero-length allocation is accessed in either mode.
Preserve the existing early-return behavior for any zero dimension or channel
count.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 6ccc5f8d-d901-4693-ae50-8db4f41a8d9f
📒 Files selected for processing (19)
docs/backlog/backlog-light.mddocs/history/plans/Plan-20260715 - MoonI80 streaming ring (clean-room).mdsrc/light/drivers/MoonI80LedDriver.hsrc/light/drivers/ParallelLedDriver.hsrc/light/effects/LinesEffect.hsrc/platform/desktop/platform_desktop.cppsrc/platform/esp32/platform_esp32_moon_i80.cppsrc/platform/esp32/platform_esp32_rmt.cppsrc/platform/platform.htest/CMakeLists.txttest/scenarios/light/scenario_Audio_mutation.jsontest/scenarios/light/scenario_Layouts_mutation.jsontest/scenarios/light/scenario_MoonLiveEffect_livescript.jsontest/scenarios/light/scenario_modifier_chain.jsontest/scenarios/light/scenario_modifier_swap.jsontest/scenarios/light/scenario_perf_full.jsontest/unit/light/unit_ParallelLedDriver_doublebuffer.cpptest/unit/light/unit_ParallelLedDriver_ring.cpptest/unit/light/unit_ParallelSlots.cpp
The MoonI80 74HCT595 shift ring now survives a loopback on/off, a module toggle, and a ledsPerPin/asyncTransmit edit without a reboot — the no-reboot-to-apply-config guarantee, PO-confirmed on the bench strip. Two root causes fixed: an oversize shift frame was routed to the whole-frame PSRAM path (which stalls at the expander clock) when the heap had room in total but not in one contiguous block, and a single control edit rebuilt the bus twice in quick succession, wedging the second build's DMA interrupt. Also windows the per-frame source snapshot so a large display copies only each driver's slice, not the whole frame.
tick:21336us(FPS:46)
Core:
- HttpServerModule: @status leaf no longer double-quotes writeJsonString's output (the value self-quotes), so a one-shot status patch is valid JSON and reaches the UI instead of being dropped.
Light domain:
- ParallelLedDriver: onCorrectionChanged reinits the bus ONLY on a real frameBytes_ change — a light-count/window edit no longer triggers a second redundant reinit alongside prepare()'s, which was the double-rebuild that wedged the shift ring. Source snapshot is now windowed (winLen_ × srcCh from winStart_, bias-corrected so encodeRows' index is unchanged) instead of a whole-buffer copy. Added the ringSnapshot A/B control (ring-only source-snapshot on/off; both it and asyncTransmit shown unconditionally for now — the wantsRing() visibility gate can't resolve at schema-build time, backlogged). 0×0×0 guard hoisted before the memset in LinesEffect.
- platform_esp32_moon_i80: moonI80Ws2812InternalFits tests heap_caps_get_largest_free_block (the contiguous block a whole-frame alloc actually faces), not total free — so an oversize shift frame correctly rings instead of falling to PSRAM and stalling. Ring init/transmit doc clarified (task refill, not ISR).
- platform_esp32_{i80,parlio,rmt}: captureAndVerifyFrame carries the shiftMode flag through the shared loopback path.
UI:
- app.js: sendControl optimistically updates local state so a control edit doesn't visually revert before the WS echo.
Tests:
- unit_ParallelLedDriver_ring: snapshot-vs-live-source invariant, windowed-snapshot bias, and clean-pad-on-recycled-buffer cases.
- unit_ParallelLedDriver_doublebuffer: give-up self-recovery tightened to require the Error state clears and transmission resumes.
- unit_HttpServerModule_apply: status patch asserts valid single-quoted JSON, no doubled quotes.
Docs/CI:
- backlog-light: removed the now-fixed loopback/toggle wedge entries; added the residuals (boot-transient give-up status, ring-inert control visibility timing, graceful blank-on-stall) and two WANTED items (classic raw-I2S ring, P4 Parlio ring); recorded that today's fixes leave the ≥192 reuse race as the sole remaining ring blocker.
- lessons.md: the shift-ring wedge was two mundane bugs (total-free vs largest-block, and a double-rebuild) wearing a peripheral-level mask.
- coding-standards: a conditional control is registered below the control it depends on.
- Saved the MoonI80 ring ISR-refill + source-snapshot plan (source-snapshot half shipped; ISR-refill deferred behind a neutral MM_HOT macro).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 5
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
docs/backlog/backlog-light.md (1)
87-89: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick winQualify the shift-mode ceiling by backend.
Earlier in this file, MoonI80 is documented as reliable at 128 lights/strand, while this section broadly says 128+ flickers on S3. Identify this as the
esp_lcd/I80 whole-frame limitation and explicitly exempt MoonI80, otherwise users may reject a supported configuration.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@docs/backlog/backlog-light.md` around lines 87 - 89, Update the shift-mode limitation paragraph to scope the 128+ flicker ceiling specifically to the esp_lcd/I80 whole-frame backend, while explicitly excluding MoonI80 because it remains reliable at 128 lights per strand. Preserve the existing measurements, hypotheses, and asyncTransmit guidance.src/platform/esp32/platform_esp32_i80.cpp (1)
312-343: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick winKeep the shift-mode second buffer out of PSRAM.
Buffer 0 is internal-first because shifted PSRAM transfers are known to stall, but buffer 1 remains PSRAM-first. With async mode enabled, every alternate frame can therefore use the unsupported memory path. In shift mode, allocate buffer 1 internally or degrade to single-buffer mode.
Proposed adjustment
`#if` SOC_LCDCAM_I80_LCD_SUPPORTED - st->buf[1] = static_cast<uint8_t*>(esp_lcd_i80_alloc_draw_buffer( - st->io, bufferBytes, MALLOC_CAP_DMA | MALLOC_CAP_SPIRAM)); + if (!shiftMode) + st->buf[1] = static_cast<uint8_t*>(esp_lcd_i80_alloc_draw_buffer( + st->io, bufferBytes, MALLOC_CAP_DMA | MALLOC_CAP_SPIRAM)); `#endif`As per path instructions, platform implementations must match the exported memory-policy contract.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/platform/esp32/platform_esp32_i80.cpp` around lines 312 - 343, Update the wantSecond allocation logic for st->buf[1] so shift mode never attempts PSRAM allocation; use the same internal-first policy as buffer 0 and retain the HEAP_RESERVE guard for the internal fallback. Preserve PSRAM-first allocation for non-shift modes, and continue degrading to single-buffer mode by deleting done[1] when the second buffer cannot be allocated.Source: Path instructions
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/backlog/backlog-light.md`:
- Line 11: Update the July 16, 2026 entries in the backlog to avoid presenting
those fixes and observations as settled history before that date. Use present or
future wording, or revise the dates once the events have occurred, including the
related entries in sections 23-23 and 27-29.
- Around line 11-21: Consolidate the wedge diagnosis into
docs/history/lessons.md: retain the generalized reuse-race lesson there and link
to docs/backlog/backlog-light.md for current measurements and implementation
status. In docs/backlog/backlog-light.md, replace the repeated root-cause and
implementation narrative with concise current status, measurements, and a link
to the lesson; update both affected sections without duplicating facts or module
explanations.
In `@docs/coding-standards.md`:
- Line 17: Update the new guideline in the documentation to remove all em dashes
from its prose, replacing them with commas, colons, parentheses, or sentence
breaks while preserving the registration-order guidance and examples.
In `@src/core/HttpServerModule.cpp`:
- Around line 892-897: Update the full-state serialization path in writeStatus
to replace the raw status formatting with writeJsonString, preserving the
existing "status" property structure while ensuring all status values are
escaped as valid JSON. Keep the patch serialization using its existing
writeJsonString call.
In `@src/light/drivers/ParallelLedDriver.h`:
- Around line 719-726: Move snapshotBuf_ capacity allocation/growth out of
tickRing() and the render-loop path into the prepare/reinitialization flow,
ensuring it can hold every window of length winLen_. In snapshotSourceForRing(),
reject or reparse configurations whose source data cannot fully populate the
requested window before entering the ring path. Update encodeRows() to index the
complete snapshot relative to laneStart_ without manufacturing a pre-window base
pointer, while preserving safe behavior across arbitrary reconfiguration
sequences.
---
Outside diff comments:
In `@docs/backlog/backlog-light.md`:
- Around line 87-89: Update the shift-mode limitation paragraph to scope the
128+ flicker ceiling specifically to the esp_lcd/I80 whole-frame backend, while
explicitly excluding MoonI80 because it remains reliable at 128 lights per
strand. Preserve the existing measurements, hypotheses, and asyncTransmit
guidance.
In `@src/platform/esp32/platform_esp32_i80.cpp`:
- Around line 312-343: Update the wantSecond allocation logic for st->buf[1] so
shift mode never attempts PSRAM allocation; use the same internal-first policy
as buffer 0 and retain the HEAP_RESERVE guard for the internal fallback.
Preserve PSRAM-first allocation for non-shift modes, and continue degrading to
single-buffer mode by deleting done[1] when the second buffer cannot be
allocated.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 8cb259d4-97a6-4295-b789-95bbf9a3b7e4
📒 Files selected for processing (17)
docs/backlog/backlog-light.mddocs/coding-standards.mddocs/history/lessons.mddocs/history/plans/Plan-20260715 - MoonI80 ring race-free ISR refill + source snapshot.mddocs/history/plans/Plan-20260715 - MoonI80 streaming ring (clean-room).mdsrc/core/HttpServerModule.cppsrc/light/drivers/ParallelLedDriver.hsrc/light/effects/LinesEffect.hsrc/platform/esp32/platform_esp32_i80.cppsrc/platform/esp32/platform_esp32_moon_i80.cppsrc/platform/esp32/platform_esp32_parlio.cppsrc/platform/esp32/platform_esp32_rmt.cppsrc/platform/platform.hsrc/ui/app.jstest/unit/core/unit_HttpServerModule_apply.cpptest/unit/light/unit_ParallelLedDriver_doublebuffer.cpptest/unit/light/unit_ParallelLedDriver_ring.cpp
| **Two SEPARATE wedge classes were fixed 2026-07-16, leaving the reuse race as the SOLE remaining ≥192 blocker** (so a bench run at ≥192 no longer conflates them): (1) a **fragmentation-routing bug** — `moonI80Ws2812InternalFits` tested *total* internal free instead of the *largest contiguous block*, so a 144 KB shift frame (16 strands × 128) reported "fits" and took the whole-frame path, whose contiguous alloc then failed → PSRAM → stall; it now tests `heap_caps_get_largest_free_block`, so an oversize shift frame correctly rings. (2) a **double-rebuild wedge** — one control edit rebuilt the bus TWICE (the Drivers container's `passBufferToDrivers`→`rebuildCorrection`→`onCorrectionChanged` reinit, then the child's own `prepare()` reinit in the same `prepareTree` sweep), and the rapid second rebuild came up with its GDMA EOF dead; `onCorrectionChanged` now reinits only on a real `frameBytes_` change, so a light-count/window edit rebuilds once. These two fixes are what made **loopback ON→OFF, module OFF→ON, and `asyncTransmit`/`ledsPerPin` edits all resume live with NO reboot** (PO-confirmed on the strip). The remaining ≥192 halt is the buffer-reuse race, addressed by the ISR-refill below. | ||
|
|
||
| Two symptoms of the same reuse hazard, seen at ≥192: | ||
| - **Halt** (the blocker): the frame stalls, the dead-frame guard trips → "output stalled". The give-up now self-recovers on a ~1 s retry, but ≥192 re-stalls, so it does not stay up. Earlier bench runs caught brief working windows (loopback PASSED at 256, a coherent image appeared) — but it is NOT stable at ≥192. | ||
| - **1-pixel row shift**: when it does render at ≥192, one row occasionally shows a 1-pixel horizontal offset. | ||
|
|
||
| **Root cause (diagnosed, not a cache issue):** the refill task races the DMA re-reading a reused buffer. The linear chain's node `K + kRingBufs` points back at `ring[K]`; if the DMA reaches that node before/while the refill for it lands, it clocks a stale or half-written buffer → a stalled/underrun frame (the halt) or a shifted row. It is NOT cache coherency — the S3 does not cache internal SRAM (`esp_cache_get_line_size_by_addr` returns 0 for internal, correctly skipping the msync). A deeper ring (raised `kRingBufs` 4→8) lowered the frequency enough that 128 fits reuse-free and is solid, but did NOT close the race for ≥192. | ||
|
|
||
| **The fix is the ISR-refill (IDF's bounce-buffer pattern), and it is designed but DEFERRED — see the plan `docs/history/plans/Plan-20260715 - MoonI80 ring race-free ISR refill + source snapshot.md`.** The producer/consumer guarantee IDF's own continuous-gapless driver uses (RGB-LCD bounce buffers, `esp_lcd_panel_rgb.c:1062-1084`) is to do the refill SYNCHRONOUSLY inside the GDMA EOF ISR (IRAM), so the ISR-priority refill always beats the DMA lapping into the reused buffer — the same shape hpwit uses. The GDMA owner bit is NOT a stall primitive on the S3/P4 (enabling `owner_check` only converts the silent race into an unrecoverable `DSCR_ERR`; no IDF driver uses it for flow control), so that path is closed. **Why deferred:** moving the refill into the ISR requires the domain encode path (`encodeRows`/`prefillShiftRows`/`ParallelSlots`/`Correction`, all in `src/light/`) to be IRAM-resident, which raw `IRAM_ATTR` in domain code would do by putting a platform macro across the platform boundary — a hard-rule violation, and it contradicts platform.h's stated reason the refill is a task. The agreed resolution (PO, 2026-07-15) is to introduce a platform-NEUTRAL `MM_HOT` macro (expands to `IRAM_ATTR` on ESP32, nothing elsewhere — the same `if constexpr`-on-a-flag shape the boundary rule permits) and adopt it GRADUALLY, rather than a single big ISR-refill leap. Re-open a focused `/plan` for it, with the reuse-boundary measurements from bench bring-up feeding it. Pin the fix with a test that drives a >8-slice frame and asserts every row is byte-exact AND the frame completes (the host ring test already tiles slices — extend it to the reuse boundary). | ||
|
|
||
| The **source-snapshot half of that plan SHIPPED** (2026-07-15): the ring encodes off the render thread concurrently with rendering, so it now reads a per-frame immutable snapshot (`snapshotSourceForRing()` copies the live source into a driver-owned grow-only buffer at transmit time; the trampoline's `encodeRows` reads it via `encodeSrc_`). That closes the use-after-free-on-resize and frame-tearing read hazards regardless of the ring's reliability. The sync/async whole-frame paths keep reading the live source (they encode inline on the render thread, no concurrency). **Also confirmed live (PO, 2026-07-15):** `asyncTransmit` ON with a '595 expander fitted now renders cleanly on the ring path — the "async+shift caused chaos" observation was the OLD whole-frame path (async's second DMA buffer against esp_lcd's one-frame descriptor pool); the ring never materialises a whole frame, so that contention is gone. |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win
Keep the wedge diagnosis in one documentation home.
The backlog should retain current status and measurements, while the lessons file should retain the generalized debugging takeaway and link to the backlog for incident details.
docs/backlog/backlog-light.md#L11-L21: reduce the repeated root-cause narrative to status plus a link to the lesson.docs/history/lessons.md#L342-L350: retain the generalized lesson and link to the backlog for current measurements and implementation status.
As per coding guidelines, avoid duplicating facts and module explanations across Markdown files.
📍 Affects 2 files
docs/backlog/backlog-light.md#L11-L21(this comment)docs/history/lessons.md#L342-L350
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/backlog/backlog-light.md` around lines 11 - 21, Consolidate the wedge
diagnosis into docs/history/lessons.md: retain the generalized reuse-race lesson
there and link to docs/backlog/backlog-light.md for current measurements and
implementation status. In docs/backlog/backlog-light.md, replace the repeated
root-cause and implementation narrative with concise current status,
measurements, and a link to the lesson; update both affected sections without
duplicating facts or module explanations.
Source: Coding guidelines
| - **Semantic variable names.** Name variables for what they represent, not just their type. `availableHeap` not `available`, `internalHeap` not `internal`, `lutBytes` not `bytes`. A reader should understand the variable without looking at its assignment. | ||
| - **No hard line wraps in markdown.** Let the editor soft-wrap. Hard wraps make diffs noisier than they need to be. | ||
| - **No em-dashes (` — `) in prose.** Use a comma, semicolon, colon, parentheses, or a full stop instead, whichever the clause actually calls for. Applies to docs, comments, and commit messages. (Code is exempt: a literal `—` in a UI string or test fixture stays.) Existing em-dashes get replaced as files are touched, not in a single sweep. | ||
| - **A conditional control is registered BELOW the control it depends on.** When a control's visibility (or range, or presence) is gated on another control's value, `addX()` it *after* that control in `defineDriverControls()`/`defineControls()`, so registration order matches the dependency: the driving control first, the dependent one under it. This makes the UI read top-down as cause-then-effect (toggle `shiftRegister` on, the `latchPin` that depends on it appears just below; the ring-vs-whole-frame mode gate `wantsRing()` sits above the `ringSnapshot`/`asyncTransmit` it hides), and it keeps the source order self-documenting: a reader sees the gate, then the thing it gates. Mutually-exclusive controls (never visible together) still follow the rule by their shared gate, grouped after it. |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Remove the em dashes from the new guideline.
This line introduces multiple — separators even though the preceding rule prohibits em dashes in prose. Replace them with commas, colons, parentheses, or full stops.
As per coding guidelines, “No em-dashes (—) in prose” applies to documentation.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/coding-standards.md` at line 17, Update the new guideline in the
documentation to remove all em dashes from its prose, replacing them with
commas, colons, parentheses, or sentence breaks while preserving the
registration-order guidance and examples.
Source: Coding guidelines
…40/strand) The 74HCT595 shift-ring now drives cleanly up to 240 lights/strand. Above that it still corrupts (a known buffer-reuse race, fully diagnosed and backlogged); this lands the working stopgap, the root-cause analysis, and the tests + diagnostics that pin it, so the wall runs today while the proper fix is scoped. tick:30519us(FPS:32) Core: - platform_esp32_moon_i80: kRingBufs=16 no-reuse stopgap (clean ≤240/strand); the looping-chain wrap re-clocks a reused buffer mid-refill above that (green dot per panel). ISR refill, looping chain, on_descr_err + timing instrumentation. Depth 17 was tried and reverted — it exceeds the ~160KB internal DMA heap and regressed 192. - platform.h: MoonI80RingStats struct + moonI80Ws2812RingStats() accessor for the read-only ring diagnostics. - platform_desktop: moonI80Ws2812RingStats stub. - HttpServerModule: writeStatus full-state path uses writeJsonString (🐇 CodeRabbit). Light domain: - MoonI80LedDriver: forceRing (auto/ring/wholeFrame A/B) + ringDbg read-only counters to diagnose the reuse stall; ring encode trampoline (prefill+encode per slice). - ParallelLedDriver: windowed source snapshot for the ring (no tear/UAF on resize); double-rebuild gate on correction change; shift-data encode reads the snapshot. - platform_esp32_i80: async buf[1] internal-first in shift mode (🐇 CodeRabbit). - platform_esp32_rmt: enriched loopback-verify log; accepted single-bit-0 '595 clip. Tests: - unit_ParallelLedDriver_ring: +2 termination tests (clean LOW tail + deterministic stop; correct no-reuse stopgap sizing/drain count) with a DMA-termination mock model. Pins the termination contract; the wrap race itself needs a multi-strand hardware loopback (noted in backlog). Docs: - backlog-light: corrected ring root cause (looping-chain wrap race, not the encode); 240/strand RAM ceiling (256 unreachable by more buffers); verified PSRAM-at-shift-clock research (hybrid lever blocked by WS2812 waveform); proper self-terminating-chain fix scoped. coding-standards: conditional-control-registration rule. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 3
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (5)
src/platform/esp32/platform_esp32_i80.cpp (1)
375-377: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winUse
heap_caps_get_largest_free_blockto verify contiguous memory availability.
heap_caps_get_free_sizereturns the total free memory across all blocks. Since the GDMA draw buffer strictly requires contiguous memory, a fragmented heap will pass this pre-check but fail insideesp_lcd_i80_alloc_draw_buffer. To fulfill the PR's objective of fixing contiguous-block detection, check the largest free block instead.
src/platform/esp32/platform_esp32_i80.cpp#L375-L377: replaceheap_caps_get_free_sizewithheap_caps_get_largest_free_blockto check the contiguous internal DMA heap.src/platform/esp32/platform_esp32_i80.cpp#L336-L338: replaceheap_caps_get_free_sizewithheap_caps_get_largest_free_blockfor the async back buffer fallback.src/platform/esp32/platform_esp32_i80.cpp#L388-L390: replaceheap_caps_get_free_sizewithheap_caps_get_largest_free_blockto correctly measure contiguous PSRAM.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/platform/esp32/platform_esp32_i80.cpp` around lines 375 - 377, Replace heap_caps_get_free_size with heap_caps_get_largest_free_block in the contiguous-memory checks at src/platform/esp32/platform_esp32_i80.cpp#L375-L377, `#L336-L338`, and `#L388-L390`. Apply this to the internal DMA buffer check, async back-buffer fallback, and PSRAM capacity check respectively, preserving their existing capability flags and reserve calculations.src/light/drivers/ParallelLedDriver.h (1)
1212-1221: 🩺 Stability & Availability | 🔴 Critical | ⚡ Quick winTear down the bus if the ring snapshot fails to allocate.
ensureSnapshotCap()returnsfalseif it cannot allocate the required memory for the snapshot buffer. Currently,reinit()ignores this failure and proceeds to mark the ring asinited_. When the DMA starts,snapshotSourceForRing()will safely clamp thememcpyto the smaller capacity, butencodeRows()will still iterate up to the fullwinLen_geometry, leading to an out-of-bounds heap read fromsnapshotBuf_in the concurrent refill task.Tear down the bus if the snapshot allocation fails so it correctly falls back to the whole-frame degradation path.
💚 Proposed fix
if (derived()->busInitRing(rowBytes, static_cast<uint32_t>(maxLaneLights_), padBytes)) { - ensureSnapshotCap(); // size the per-frame snapshot here, OFF the hot path (tickRing only memcpys) - inited_ = true; - dmaBuf_ = derived()->busBuffer(0); // ring[0] — a real pointer, the "inited" sentinel - for (uint8_t i = 0; i < kMaxLanes; i++) busPins_[i] = laneList_[i]; - busLaneCount_ = laneCount_; - derived()->recordBusPins(); - if (status() == Derived::kInitFailMsg) clearStatus(); - return; + if (!ensureSnapshotCap()) { + deinit(); + } else { + inited_ = true; + dmaBuf_ = derived()->busBuffer(0); // ring[0] — a real pointer, the "inited" sentinel + for (uint8_t i = 0; i < kMaxLanes; i++) busPins_[i] = laneList_[i]; + busLaneCount_ = laneCount_; + derived()->recordBusPins(); + if (status() == Derived::kInitFailMsg) clearStatus(); + return; + } }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/light/drivers/ParallelLedDriver.h` around lines 1212 - 1221, Handle the boolean result of ensureSnapshotCap() in reinit() before setting inited_. If snapshot allocation fails, tear down the successfully initialized bus/ring and return through the existing failure or whole-frame degradation path; only mark the driver initialized and record bus state after allocation succeeds.src/light/drivers/MoonI80LedDriver.h (1)
164-174: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick winReject shift geometries beyond the ring’s supported 240-row boundary.
AUTO routes every oversized shift frame into the ring, but the backend requires
nSlices < kRingBufs, limiting this stopgap to 240 rows. At 256 or more, the known buffer-reuse/equal-depth failure remains selectable and can stall output. Return an explicit configuration error above the supported limit, reserving any unsafe run for an intentional diagnostic override, and add 241/256-row regression coverage.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/light/drivers/MoonI80LedDriver.h` around lines 164 - 174, Update wantsRing() and the surrounding configuration validation to reject shift-mode geometries above the ring backend’s 240-row limit, returning an explicit configuration error instead of selecting the ring automatically. Preserve an intentional diagnostic override for unsafe runs, and add regression coverage for 241-row and 256-row configurations.src/platform/esp32/platform_esp32_moon_i80.cpp (2)
1204-1210: 🩺 Stability & Availability | 🔴 Critical | ⚡ Quick winDerive
loopRowsfromloopRowBytes.The current denominator omits
slotBytes × clockMultiplier. In shift mode it inflates the row count eightfold, causing the ring copy callback to read beyond the supplied encoded frame.Proposed fix
- const uint32_t loopRows = loopRowBytes ? static_cast<uint32_t>(dataBytes / (static_cast<size_t>(rowBits) * 3u)) : 0; + const uint32_t loopRows = + loopRowBytes && dataBytes % loopRowBytes == 0 + ? static_cast<uint32_t>(dataBytes / loopRowBytes) + : 0;🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/platform/esp32/platform_esp32_moon_i80.cpp` around lines 1204 - 1210, Update the loopRows calculation near useRing to derive the row count using loopRowBytes, while preserving the zero-length guard. Ensure the denominator includes slotBytes and clockMultiplier so shift-mode ring copying cannot exceed the encoded frame.
914-929: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick winDo not allocate an unused latch pad in every ring buffer.
The looping descriptors mount only
rowsPerBuf * rowBytes, and every encode call useslast=false; therefore the per-buffer pad is never DMA-visible. Including it in all 16 allocations and the preflight wastes roughly 110 KB for the current shift geometry and can make an otherwise viable ring fail initialization. Size the pool and capacity from rows-only bytes.Also applies to: 996-1002
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/platform/esp32/platform_esp32_moon_i80.cpp` around lines 914 - 929, Update the ring-buffer sizing in the initialization path around st->cap, bufBytes, and the allocFrame loop to use rows-only bytes (kRingRows * rowBytes), removing padBytes from each allocation and reported capacity. Keep the latch pad allocated or accounted for only where the final DMA-visible slice requires it, including the corresponding logic around the additionally referenced lines.
♻️ Duplicate comments (2)
src/light/drivers/MoonI80LedDriver.h (1)
158-161: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick winPass the real lane count to every platform entry point.
busPinCount()includes padded bus entries, while the backend routes every entry up tolaneCount. This can route padding GPIOs as data and overwrite theclockPinmatrix route. Usethis->laneCount()for whole-frame init, ring init, and loopback.Proposed fix
- return platform::moonI80Ws2812Init(bus_, this->busPinList(), this->busPinCount(), + return platform::moonI80Ws2812Init(bus_, this->busPinList(), this->laneCount(), ... - return platform::moonI80Ws2812InitRing(bus_, this->busPinList(), this->busPinCount(), + return platform::moonI80Ws2812InitRing(bus_, this->busPinList(), this->laneCount(), ... - return platform::moonI80Ws2812Loopback(this->busPinList(), this->busPinCount(), + return platform::moonI80Ws2812Loopback(this->busPinList(), this->laneCount(),Also applies to: 182-186, 224-230
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/light/drivers/MoonI80LedDriver.h` around lines 158 - 161, Update the platform initialization calls in busInit, ring initialization, and loopback to pass this->laneCount() instead of this->busPinCount() wherever the backend receives the lane count. Preserve the existing pin list and other arguments while ensuring every platform entry point routes only real lanes, excluding padded bus entries.src/platform/esp32/platform_esp32_moon_i80.cpp (1)
652-658: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick winKeep the primary shift buffer out of the internal heap reserve.
A PSRAM-only preflight can succeed, after which shift mode still attempts internal allocation first without the
HEAP_RESERVEguard. This can consume WiFi/HTTP headroom before falling back. Guard the internal attempt just like buffer 1; if it cannot preserve the reserve, fail or use only an explicit diagnostic fallback.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/platform/esp32/platform_esp32_moon_i80.cpp` around lines 652 - 658, The primary shift-buffer allocation in the buf[0] initialization must not consume the internal heap reserve. Update the allocFrame path for shiftMode so internal-heap allocation is reserve-guarded like buf[1], allowing only PSRAM allocation during normal operation; if an internal allocation remains, restrict it to an explicit diagnostic fallback.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/backlog/backlog-light.md`:
- Around line 242-247: The spacer-layout design must explicitly ensure gap LEDs
are driven with zero values each frame rather than left unmapped, since WS2812
pixels retain their previous colors. Update the layout specification in the
“Spacer / gapped-strand layout” entry to define how spacer positions are encoded
and transmitted as black, or identify an existing supported clearing mechanism
that guarantees the same behavior.
- Around line 11-15: Update the LED DMA EOF callback path around the sliced
refill logic to be safe during flash-cache-disabled windows, or explicitly
quiesce and prevent in-flight LED DMA transfers before http_fetch_to_ota()
begins OTA/NVS flash writes. Ensure the chosen approach covers the separate OTA
task and cannot execute flash-resident callback code while flash access is
disabled.
In `@src/platform/esp32/platform_esp32_moon_i80.cpp`:
- Around line 772-776: Update startRingTransfer to reset dbgLastEofUs when
beginning each frame, before the ring is armed and DMA activity can produce EOF
events. Keep the existing drainCount, refillSlot, and busy initialization
unchanged.
---
Outside diff comments:
In `@src/light/drivers/MoonI80LedDriver.h`:
- Around line 164-174: Update wantsRing() and the surrounding configuration
validation to reject shift-mode geometries above the ring backend’s 240-row
limit, returning an explicit configuration error instead of selecting the ring
automatically. Preserve an intentional diagnostic override for unsafe runs, and
add regression coverage for 241-row and 256-row configurations.
In `@src/light/drivers/ParallelLedDriver.h`:
- Around line 1212-1221: Handle the boolean result of ensureSnapshotCap() in
reinit() before setting inited_. If snapshot allocation fails, tear down the
successfully initialized bus/ring and return through the existing failure or
whole-frame degradation path; only mark the driver initialized and record bus
state after allocation succeeds.
In `@src/platform/esp32/platform_esp32_i80.cpp`:
- Around line 375-377: Replace heap_caps_get_free_size with
heap_caps_get_largest_free_block in the contiguous-memory checks at
src/platform/esp32/platform_esp32_i80.cpp#L375-L377, `#L336-L338`, and `#L388-L390`.
Apply this to the internal DMA buffer check, async back-buffer fallback, and
PSRAM capacity check respectively, preserving their existing capability flags
and reserve calculations.
In `@src/platform/esp32/platform_esp32_moon_i80.cpp`:
- Around line 1204-1210: Update the loopRows calculation near useRing to derive
the row count using loopRowBytes, while preserving the zero-length guard. Ensure
the denominator includes slotBytes and clockMultiplier so shift-mode ring
copying cannot exceed the encoded frame.
- Around line 914-929: Update the ring-buffer sizing in the initialization path
around st->cap, bufBytes, and the allocFrame loop to use rows-only bytes
(kRingRows * rowBytes), removing padBytes from each allocation and reported
capacity. Keep the latch pad allocated or accounted for only where the final
DMA-visible slice requires it, including the corresponding logic around the
additionally referenced lines.
---
Duplicate comments:
In `@src/light/drivers/MoonI80LedDriver.h`:
- Around line 158-161: Update the platform initialization calls in busInit, ring
initialization, and loopback to pass this->laneCount() instead of
this->busPinCount() wherever the backend receives the lane count. Preserve the
existing pin list and other arguments while ensuring every platform entry point
routes only real lanes, excluding padded bus entries.
In `@src/platform/esp32/platform_esp32_moon_i80.cpp`:
- Around line 652-658: The primary shift-buffer allocation in the buf[0]
initialization must not consume the internal heap reserve. Update the allocFrame
path for shiftMode so internal-heap allocation is reserve-guarded like buf[1],
allowing only PSRAM allocation during normal operation; if an internal
allocation remains, restrict it to an explicit diagnostic fallback.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: abc44b64-c91e-4c50-9f14-dc8964e76a12
📒 Files selected for processing (14)
docs/backlog/backlog-light.mddocs/coding-standards.mdsrc/core/HttpServerModule.cppsrc/light/drivers/MoonI80LedDriver.hsrc/light/drivers/ParallelLedDriver.hsrc/platform/desktop/platform_desktop.cppsrc/platform/esp32/platform_esp32_i80.cppsrc/platform/esp32/platform_esp32_moon_i80.cppsrc/platform/esp32/platform_esp32_rmt.cppsrc/platform/platform.htest/scenarios/light/scenario_MoonLiveEffect_livescript.jsontest/scenarios/light/scenario_perf_full.jsontest/scenarios/light/scenario_perf_light.jsontest/unit/light/unit_ParallelLedDriver_ring.cpp
The UI showed a control's raw name as its label, so it read like a datasheet: `I80LedDriver`, `wireUs`, `shiftRegister`, and a `stall` KPI that implied a fault when a large value actually means spare capacity. Driver types and controls are renamed to say what they are and do, consistently across code, UI, docs, the board catalog, and tests. Hardware names stay at the platform boundary, where they belong. KPI: 16384lights | Desktop:718KB | ESP32:tick:25657us(FPS:38) | src:197(43116) | test:136(23491) | lizard:151w Light domain: - MultiPinLedDriver (was I80LedDriver) and MoonLedDriver (was MoonI80LedDriver): "i80" is IDF's bus name, meaningless to a user picking a driver. RmtLedDriver / ParlioLedDriver keep their names — those peripherals are what users see in chip docs. - ParallelLedDriver: control `shiftRegister` → `pinExpander` (says what it does: one pin fans out to 8 strands), `asyncTransmit` → `doubleBuffer` (the textbook name), read-only `wireUs` → `frameTime` (unambiguous; `refresh` was rejected — it collides with ~30 existing refresh* identifiers and reads as "re-fetch"). The give-up status now reads "no LED output — the driver is not sending frames; check pins and LED count". - ParallelSlots / ParallelLedDriver: the pin-expander MODE identifiers follow the control — shiftMode() → pinExpanderMode(), kShiftOutputs → kPinExpanderOutputs, kSupportsShiftRegister → kSupportsPinExpander. The WS2812 shift-out ENCODER keeps its "shift" naming (encodeWs2812Shift*, prefillWs2812ShiftConstants, shiftActivePins): those genuinely are bit-shift operations, and the textbook algorithm name beats the product term. - Drivers: read-only `stall` → `renderWait`. It is the render core's worst wait on the output core — one-way, always ≥ 0, and a LARGE value means recoverable headroom, not a fault. "stall" implied both the wrong direction and a defect. Core: - platform layer keeps `i80` (i80Ws2812*, MoonI80State, platform_esp32_i80.cpp) and kShiftPclkHz: these name IDF's real esp_lcd_new_i80_bus / SOC_LCD_I80_SUPPORTED and the WS2812 slot timing. The platform boundary is exactly where hardware names belong — renaming would hide which IDF API is wrapped. Only comment references to the renamed driver classes changed. Scripts / MoonDeck: - check_devices.py: the 74HCT595 rule reads `pinExpander`, not `shiftRegister`. This one fails SILENTLY — a stale key would leave all four wiring invariants (latchPin presence, 1..15 data pins, latch/clock/dc collisions) quietly not validating. Verified it still fires by temporarily breaking a latchPin. Docs / CI: - MIGRATING.md (new): the breaking-change log — newest first, each entry stating what changed and the action it costs (nothing / re-set a control / re-add a module / update a file / erase flash). ADR-0013's "Known breaking changes" list MOVED here: it was an append-only log living inside an immutable ADR, which is a contradiction; the ADR keeps its decision and links to the log. Listed in the mkdocs nav and CLAUDE.md's doc tree. - drivers.md: the <a id> anchors are now #multipinled / #moonled, in lockstep with the registerType docPaths (check_specs validates this). performance.md, coding-standards.md, gpio-usage.md follow the new control names. docs/history/**, docs/backlog/*-analysis.md and docs/adr/** are left alone — rewriting a dated bench log or a verbatim quote would falsify the record. - deviceModels.json: 4 boards (LightCrafter 16, SE 16 V1, both hpwit shift-register boards) move to the new type + control keys. Tests: - unit_MultiPinLedDriver / unit_MoonLedDriver / unit_ParallelLedDriver_pinexpander renamed with their subjects; scenario_perf_full.json follows the type strings. 822 cases / 80,515 assertions pass — the rename is behavior-neutral. Verified on hardware: shiffy (MoonLedDriver, pin-expander path) drives at frameTime 4457 µs / 224 fps; SE16 (MultiPinLedDriver, direct 16-lane, 8192 lights) at 15,056 µs / 66 fps. The live ESP32 tick line now reads `renderWait: 17162us` and `MultiPinLed:24950us`. Both boards demonstrated the documented migration exactly as MIGRATING.md predicts: the driver was absent on first boot (its persisted config named the old type — the unknown type is ignored, no crash), and came back on re-add. Note: the KPI's ESP32 tick was captured manually from SE16 — collect_kpi.py polls shiffy's serial port, which has stopped responding at the USB level (the board itself is healthy: it serves over LAN and was flashed OTA). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (4)
docs/moonmodules/light/drivers.md (1)
79-84: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueUse American English spelling for "colour".
As per path instructions, Markdown files must use American English spelling everywhere. Please convert the existing British spelling "colour" to "color" opportunistically since this file is being touched.
♻️ Proposed fix
-Drives **Philips Hue bulbs as pixels**: each colour bulb in the driver's window becomes one pixel, pushed to the bridge over its HTTP API. Paced to the bridge's ~10 cmd/s limit — smooth ambient colour, not strobing. +Drives **Philips Hue bulbs as pixels**: each color bulb in the driver's window becomes one pixel, pushed to the bridge over its HTTP API. Paced to the bridge's ~10 cmd/s limit — smooth ambient color, not strobing. - `bridgeIp` — the bridge's LAN IPv4. - `appKey` — the Hue app key; filled by `pair`, persisted. - `pair` — button: press it, then the bridge's physical link button within ~30 s to claim a key. -- `room` / `light` — dropdowns narrowing which colour lights are driven (both default `All`). +- `room` / `light` — dropdowns narrowing which color lights are driven (both default `All`).🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@docs/moonmodules/light/drivers.md` around lines 79 - 84, Update the touched documentation text to use American English spelling by replacing every occurrence of “colour” with “color” in the Philips Hue driver description and related option wording.Source: Path instructions
src/platform/esp32/platform_esp32_moon_i80.cpp (1)
939-948: 🎯 Functional Correctness | 🔴 Critical | 🏗️ Heavy liftRing mode truncates the latch pad because looping GDMA descriptors have fixed lengths.
This is the root cause behind the documented "GDMA descriptor-count issue" that prevents shift-mode frames from reaching the wire. The continuous looping GDMA chain mounts every node at a fixed
rowsOnlylength to avoid mid-frame gaps. However, the '595 latch pad is written by the encoder at the end of the last slice's rows. Because the DMA descriptor length never increases, any latch pad that extends beyond therowsOnlyboundary (which is guaranteed to occur for a full slice) is never clocked to the wire, preventing the 74HCT595 from latching the final update and causing the RMT loopback to time out. The test mock actively hides this hardware reality by dynamically shrinking its simulated read length, which fixed-length descriptors cannot do.
src/platform/esp32/platform_esp32_moon_i80.cpp#L939-L948: Redesign the latch pad delivery. Since fixed-length looping descriptors cannot dynamically expand to clock the pad, explicitly update the descriptorlengthof the final slice in themoonI80EofCbISR before it is clocked, or move the latch word generation into thekTailBufszero-buffer so it is clocked reliably as part of the standardrowsOnlyDMA flow.test/unit/light/unit_ParallelLedDriver_ring.cpp#L156-L161: Update the test comment that inaccurately claims "the last node is mounted at lastRows*rowBytes + padBytes", reflecting the hardware's fixedrowsOnlyreality.test/unit/light/unit_ParallelLedDriver_ring.cpp#L262-L265: UpdatedriveRingFrameWithTerminationto consistently clock exactlykMockRingRows * ringRowBytes_(the truerowsOnlysize) for every node, and add an assertion to verify the latch pulse is fully present in the simulated wire output.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/platform/esp32/platform_esp32_moon_i80.cpp` around lines 939 - 948, Fix ring-mode latch delivery across src/platform/esp32/platform_esp32_moon_i80.cpp#L939-L948, test/unit/light/unit_ParallelLedDriver_ring.cpp#L156-L161, and test/unit/light/unit_ParallelLedDriver_ring.cpp#L262-L265: update the GDMA flow around the ring descriptor mounting and moonI80EofCb so the final slice’s latch pad is clocked despite fixed rowsOnly lengths, either by extending the final descriptor before transmission or generating the latch word in kTailBufs; revise the test comment to reflect fixed rowsOnly descriptors; and update driveRingFrameWithTermination to clock exactly kMockRingRows * ringRowBytes_ per node while asserting the simulated wire output contains the complete latch pulse.moondeck/check/check_devices.py (1)
204-218: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick winMirror the runtime’s complete pin-expander constraints.
This accepts 9–15 pins despite the runtime’s 64-strand ceiling (
pins × 8), and it does not rejectlatchPincollisions with data pins. Such catalog entries pass validation but fail on-device.Parse the pin list using the same rules as
parsePinList, cap the current configuration at 8 data pins, and checklatchPinagainst every parsed data pin.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@moondeck/check/check_devices.py` around lines 204 - 218, Update the pin-expander validation block in the device-checking flow to parse controls["pins"] with the existing parsePinList rules, reject configurations exceeding the runtime’s 8-data-pin/64-strand limit, and check latchPin against every parsed data pin in addition to clockPin and dcPin. Preserve the existing missing-latch and invalid-list validation behavior.src/light/drivers/ParallelLedDriver.h (1)
1212-1214: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick winHandle snapshot allocation failure before accepting the ring.
ensureSnapshotCap()can fail, but its result is ignored. The ring is then marked initialized whiletickRing()skips every frame becausesnapshotBuf_is null, leaving output silently dark.On failure, tear down the ring and fall back to the whole-frame path or report initialization failure.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/light/drivers/ParallelLedDriver.h` around lines 1212 - 1214, Handle the return value of ensureSnapshotCap() in the ring initialization path before setting inited_ in the surrounding driver method. If snapshot allocation fails, tear down the successfully initialized bus ring, avoid marking the ring initialized, and fall back to the whole-frame path or propagate initialization failure according to the existing driver behavior.
♻️ Duplicate comments (1)
src/light/drivers/ParallelLedDriver.h (1)
595-600: 🩺 Stability & Availability | 🔴 Critical | 🏗️ Heavy liftA timeout does not release ownership of the DMA buffer. The recovery implementation and test both assume clearing software state makes the stale transfer safe.
src/light/drivers/ParallelLedDriver.h#L595-L600: abort/reset the transport or wait for confirmed completion before allowing buffer reuse.test/unit/light/unit_ParallelLedDriver_doublebuffer.cpp#L383-L401: keep the stale mock transfer active until that explicit recovery operation and assert no premature encode/transmit occurs.As per path instructions, a timed-out i80 transfer may still read its buffer.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/light/drivers/ParallelLedDriver.h` around lines 595 - 600, The timeout recovery in ParallelLedDriver must not clear inFlight_ and reuse the DMA buffer while the stale transfer may still be reading it. In src/light/drivers/ParallelLedDriver.h:595-600, abort/reset the transport or wait for confirmed completion before permitting a replacement encode/transmit; in test/unit/light/unit_ParallelLedDriver_doublebuffer.cpp:383-401, keep the stale mock transfer active until that explicit recovery operation and assert no premature encode/transmit occurs.Source: Path instructions
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@docs/moonmodules/light/drivers.md`:
- Around line 79-84: Update the touched documentation text to use American
English spelling by replacing every occurrence of “colour” with “color” in the
Philips Hue driver description and related option wording.
In `@moondeck/check/check_devices.py`:
- Around line 204-218: Update the pin-expander validation block in the
device-checking flow to parse controls["pins"] with the existing parsePinList
rules, reject configurations exceeding the runtime’s 8-data-pin/64-strand limit,
and check latchPin against every parsed data pin in addition to clockPin and
dcPin. Preserve the existing missing-latch and invalid-list validation behavior.
In `@src/light/drivers/ParallelLedDriver.h`:
- Around line 1212-1214: Handle the return value of ensureSnapshotCap() in the
ring initialization path before setting inited_ in the surrounding driver
method. If snapshot allocation fails, tear down the successfully initialized bus
ring, avoid marking the ring initialized, and fall back to the whole-frame path
or propagate initialization failure according to the existing driver behavior.
In `@src/platform/esp32/platform_esp32_moon_i80.cpp`:
- Around line 939-948: Fix ring-mode latch delivery across
src/platform/esp32/platform_esp32_moon_i80.cpp#L939-L948,
test/unit/light/unit_ParallelLedDriver_ring.cpp#L156-L161, and
test/unit/light/unit_ParallelLedDriver_ring.cpp#L262-L265: update the GDMA flow
around the ring descriptor mounting and moonI80EofCb so the final slice’s latch
pad is clocked despite fixed rowsOnly lengths, either by extending the final
descriptor before transmission or generating the latch word in kTailBufs; revise
the test comment to reflect fixed rowsOnly descriptors; and update
driveRingFrameWithTermination to clock exactly kMockRingRows * ringRowBytes_ per
node while asserting the simulated wire output contains the complete latch
pulse.
---
Duplicate comments:
In `@src/light/drivers/ParallelLedDriver.h`:
- Around line 595-600: The timeout recovery in ParallelLedDriver must not clear
inFlight_ and reuse the DMA buffer while the stale transfer may still be reading
it. In src/light/drivers/ParallelLedDriver.h:595-600, abort/reset the transport
or wait for confirmed completion before permitting a replacement
encode/transmit; in
test/unit/light/unit_ParallelLedDriver_doublebuffer.cpp:383-401, keep the stale
mock transfer active until that explicit recovery operation and assert no
premature encode/transmit occurs.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 5a91ffde-92d8-40ce-ac0e-d091e5e98873
📒 Files selected for processing (35)
CLAUDE.mddocs/MIGRATING.mddocs/adr/0013-no-migration-code-robust-persistence-plus-documented-breaks.mddocs/coding-standards.mddocs/history/plans/Plan-20260716 - LED driver rename for a human-readable UI.mddocs/moonmodules/light/drivers.mddocs/performance.mddocs/reference/gpio-usage.mdmkdocs.ymlmoondeck/check/check_devices.pysrc/light/drivers/Drivers.hsrc/light/drivers/MoonLedDriver.hsrc/light/drivers/MultiPinLedDriver.hsrc/light/drivers/ParallelLedDriver.hsrc/light/drivers/ParallelSlots.hsrc/light/drivers/ParlioLedDriver.hsrc/light/drivers/PinList.hsrc/light/drivers/RmtLedDriver.hsrc/main.cppsrc/platform/desktop/platform_config.hsrc/platform/esp32/platform_config.hsrc/platform/esp32/platform_esp32_i80.cppsrc/platform/esp32/platform_esp32_moon_i80.cppsrc/platform/esp32/platform_esp32_parlio.cppsrc/platform/platform.htest/CMakeLists.txttest/scenarios/light/scenario_perf_full.jsontest/unit/light/unit_MoonLedDriver.cpptest/unit/light/unit_MultiPinLedDriver.cpptest/unit/light/unit_ParallelLedDriver_doublebuffer.cpptest/unit/light/unit_ParallelLedDriver_pinexpander.cpptest/unit/light/unit_ParallelLedDriver_ring.cpptest/unit/light/unit_ParallelSlots.cpptest/unit/light/unit_RmtLedDriver_pins.cppweb-installer/deviceModels.json
…ngth) The 74HCT595 shift ring now holds ONE light per DMA buffer instead of sixteen, so its memory stops scaling with strand length: 18 KB whether a strand is 128 lights or 1024, where the old geometry needed 147 KB and capped the driver near 240. The ring streams with real buffer reuse for the first time — the refill ISR encodes 160 slices per frame with no descriptor errors — and the per-light encode is 2.7x faster than this morning. It is not shipping yet: at 128 lights the encode still misses its 21.6 us/light deadline, so the wire shows a one-slot offset the bench is still chasing. tick:125/101/11/125/20/3/275/69/17/26/167/125/22/10/45us(FPS:8000/9900/90909/8000/50000/333333/3636/14492/58823/38461/5988/8000/45454/100000/22222) | ESP32:1485KB | tick:16996us(FPS:58) Core: - platform_esp32_moon_i80: kRingRows 16 -> 1, kRingBufs 16 -> 32. One light per buffer is what makes the pool a real sliding window; depth now buys lap time (~690 us) rather than covering the frame. Removes the short-last-slice logic and rowsPerBuf entirely — a one-row buffer is either encoded or it is a tail, never partially filled. - platform_esp32_moon_i80: fix the frame close. The EOF ISR guarded the tail branch on `drained >= nSlices`, which skipped the FIRST tail EOF — the one buffer the DMA provably clocks last. The closing latch landed in the wrong buffer and never reached the wire (traced: at 128 lights over 32 buffers it went to buffer 31 while the DMA stopped on buffer 0), so every frame ended by re-clocking a stale slice. That is the "fixed dot per panel, survives brightness=0" fault the latch exists to prevent; the dots are gone from the wall. - platform_esp32_moon_i80: buffers are sized rows-only. The latch pad was allocated but never mounted, so 43% of the ring's RAM was written, cache-synced and never clocked — and it silently pushed the pool past the internal-DMA heap, which is why the ring had been falling back to whole-frame instead of running. - platform_esp32_moon_i80: closeFrameInto() folds the identical zero+latch block the ISR and the priming loop each carried. - platform.h: MoonI80PrefillFn — the encode seam's other half. Two of the three words in a WS2812 slot are frame constants, so the ring lays them once per buffer when a frame arms and the per-light refill writes only data. Doing it per refill cost 384 constant stores against 192 data words, which is more work than writing every slot whole. - platform.h/platform_esp32/platform_desktop: cycleCount() (the CPU's cycle register — a region too short for micros() to resolve) and allocIsr() (internal RAM for memory an interrupt reads; alloc() prefers PSRAM, which is wrong behind an ISR). - platform_esp32_i80: size the internal-RAM check on the largest free BLOCK, not total free. A fragmented heap reports megabytes free with no contiguous frame. Light domain: - ParallelSlots: transposeBits8x8Pair — the same Hacker's Delight 8x8 butterfly on a 32-bit register pair. The uint64 form makes a 32-bit Xtensa synthesise every shift from register pairs: 1790 -> 1052 bytes of code, 77 -> 66 us/light, bit-identical over 300k random inputs and against the whole-slot encoder the tests pin. - ParallelLedDriver: encodeRows drops the dead prefillConstants parameter (the ring prefills through the platform seam now) and gains a stage profile — cycles/light for the memset, the correction and the encode, reported through the driver's status line. - ParallelLedDriver: the ring snapshot allocates via allocIsr and tears the half-built bus down when it fails, rather than marking the driver inited with no snapshot. - MoonLedDriver: ringPrefillTrampoline; the ring encode trampoline writes data words only. Both carry what is true — including that the prefill uses row 0's mask, which is exact only while strands are the same length. Tests: - unit_ParallelLedDriver_ring: pin the frame-close rule as arithmetic — for any (nSlices, kRingBufs), the buffer holding the closing latch is the buffer the DMA clocks last. The existing mock reimplements the ISR's bookkeeping rather than calling it, so it could not see this bug and did not; the new test fails on the old code across 7 assertions. - unit_ParallelLedDriver_ring: the mock prefills once per buffer, mirroring the platform's frame-arm order. Scripts / MoonDeck: - generate_build_info: MM_BUILD_ID carries the git hash, so `build` answers "which code is on this board?". __DATE__ expands when the including TU compiles and freezes while the firmware moves on, which reads as a failed flash. Docs / CI: - sdkconfig.defaults: CONFIG_COMPILER_OPTIMIZATION_PERF. IDF defaults to -Og and we inherited it; the shift encoder is SWAR whose performance is register allocation across inlined calls. Measured 1.5x on the encode, and it moves every ESP32 KPI. - backlog/moon-i80-encode-decomposition: the per-light decomposition against hpwit's driver, what is measured, and the six hypotheses the bench ruled out. - backlog-light: the ring's flash-resident EOF ISR versus a flash-cache-disabled window, with the two candidate fixes. Reviews: - 👾 Critical, fixed: the EOF ISR's tail guard put the closing latch in a buffer the DMA never clocks (see Core, above). Verified by trace and by a new test that fails on the old code. - 👾 High, accepted for now: the ring's bookkeeping assumes one EOF callback per drained buffer. If IDF coalesces callbacks under latency the slice-to-buffer binding desynchronises and depth buys nothing. Unverified either way; the bench shows no descriptor errors at 128 lights. Backlogged with the ISR work. - 👾 Medium, fixed: encodeRows' prefillConstants parameter was dead and its doc described a call path that did not exist. Removed. - 👾 Medium, fixed: the stage profile timed the memset and never accumulated it, printing the stage as free rather than unmeasured. - 👾 Medium, fixed: the ring mock's comment claimed to mirror kRingRows; it keeps a multi-row geometry deliberately, because a 1-row slice cannot express a tiling bug. Says so now, and the per-light rule is pinned separately. - 👾 Low, fixed: MM_ISR_CODE landed with no consumer. Removed — it belongs with the IRAM step that uses it. - 👾 Low, fixed: dated incident narration in config and code comments. The rules stay; the war stories go. - 👾 Low, fixed: the duplicated zero+latch block folded into closeFrameInto(). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 4
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (8)
src/platform/esp32/platform_esp32.cpp (2)
833-835: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick winMake AP-client decrement saturating and atomic.
wifiApStop()andwifiApInit()resetapClients_from another task. If that reset occurs after the load but beforefetch_sub(), the decrement runs on zero and wraps the counter toUINT32_MAX, violating the required “0 when AP is down” contract and blocking NetworkModule’s STA retry indefinitely. Use a CAS loop or another atomic saturating-decrement helper.Proposed fix
- uint32_t n = apClients_.load(std::memory_order_relaxed); - if (n > 0) apClients_.fetch_sub(1, std::memory_order_relaxed); + uint32_t n = apClients_.load(std::memory_order_relaxed); + while (n != 0 && + !apClients_.compare_exchange_weak( + n, n - 1, + std::memory_order_relaxed, + std::memory_order_relaxed)) { + }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/platform/esp32/platform_esp32.cpp` around lines 833 - 835, Update the WIFI_EVENT_AP_STADISCONNECTED handling in the platform event handler to perform an atomic saturating decrement of apClients_, so concurrent resets cannot cause underflow or wraparound. Replace the separate load/check/fetch_sub sequence with a CAS loop or shared atomic saturating-decrement helper that leaves the counter at zero when already reset.
812-823: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick winSerialize Wi-Fi teardown against reconnect callbacks.
wifiStaStopping_blocks the normal retry path, but there’s still a check-to-use window where the disconnect handler can enter beforewifiStaStop()beginsesp_wifi_stop()/esp_wifi_deinit()and then callesp_wifi_connect()on a driver that’s already stopping. Use the same synchronization for the callback and teardown.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/platform/esp32/platform_esp32.cpp` around lines 812 - 823, Synchronize the disconnect callback’s reconnect path around wifiStaStopping_ with the teardown performed by wifiStaStop(), eliminating the check-to-use window before esp_wifi_stop() and esp_wifi_deinit(). Ensure the callback cannot call esp_wifi_connect() once teardown has begun, while preserving normal reconnect behavior when teardown is not in progress.src/light/drivers/ParallelLedDriver.h (2)
1250-1262: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick winDeinitialize a successfully-created ring when snapshot allocation fails.
After
busInitRing()succeeds,inited_is still false. Therefore the fallbackdeinit()skipsbusDeinit(), leaving the ring resources active before whole-frame initialization runs. Mark the ring initialized before snapshot allocation or explicitly callbusDeinit()on this partial-success path.As per path instructions,
allocIsr()exhaustion must degrade exactly like a failed allocation and never crash.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/light/drivers/ParallelLedDriver.h` around lines 1250 - 1262, Update the ring-initialization flow around busInitRing() and ensureSnapshotCap() so a successful ring allocation is marked initialized before snapshot allocation can fail, or explicitly invokes busDeinit() on that partial-success path before deinit(). Ensure allocIsr() exhaustion is treated as allocation failure, degrading to whole-frame initialization without crashing.Source: Path instructions
745-781: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winRecord profiling timestamps per row.
p1is captured once, butp2 - p1is accumulated for every row, producing triangular correction totals. The single memset duration is also added once per row. Record memset once and capture a fresh correction-start timestamp inside the row loop.Proposed fix
const uint32_t p0 = platform::cycleCount(); std::memset(wire_, 0, wireCap_); -const uint32_t p1 = platform::cycleCount(); +dbgCycMemset_ += platform::cycleCount() - p0; for (nrOfLightsType row = firstRow; row < lastRow; row++) { + const uint32_t p1 = platform::cycleCount(); uint64_t mask = 0; // correction loop... const uint32_t p2 = platform::cycleCount(); // encode... - dbgCycMemset_ += (p1 - p0); dbgCycCorrect_ += (p2 - p1);🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/light/drivers/ParallelLedDriver.h` around lines 745 - 781, Update the profiling timestamps around the row loop in the encoding method: accumulate the single memset duration once before iterating rows, then capture a fresh correction-start timestamp at the beginning of each row before correction_.apply. Use that per-row timestamp for dbgCycCorrect_, while keeping dbgCycEncode_ measured from the existing post-correction timestamp.src/platform/esp32/platform_esp32_i80.cpp (1)
287-303: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick winEnforce
HEAP_RESERVEon the shift-mode internal allocation.
fitsPsramcan let init continue whenfitsInternalis false, but this branch still tries internal DMA first in shift mode. That can consume the WiFi/HTTP reserve even though a PSRAM buffer is acceptable. Gate this allocation with the same reserve check used by the second buffer.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/platform/esp32/platform_esp32_i80.cpp` around lines 287 - 303, The shift-mode allocation in the buffer initialization path should not attempt internal DMA memory when the internal-fit check fails. Update the allocation guarded by shiftMode to require fitsInternal, matching the HEAP_RESERVE-aware condition used for the second buffer, while preserving the existing PSRAM fallback when internal allocation is skipped or unavailable.src/platform/esp32/platform_esp32_moon_i80.cpp (2)
1367-1381: 🩺 Stability & Availability | 🔴 Critical | ⚡ Quick winKeep the closing-tail copy inside the one-row ring buffer.
closeFrameInto()invokes this callback withfirstRow == totalRows,count == 1, andlast=true. The callback then copies one row from the pad and appends the entire pad after it, but ring buffers are allocated as exactlyrowBytes. This overruns the heap and can corrupt descriptors or stall loopback.Proposed fix
auto copySlice = [](void* user, uint8_t* dst, uint32_t firstRow, uint32_t count, bool last) { auto* c = static_cast<LoopCopyCtx*>(user); + if (last) { + std::memset(dst, 0, c->rowBytes); + const size_t tailBytes = c->pad < c->rowBytes ? c->pad : c->rowBytes; + if (tailBytes) { + std::memcpy(dst, + c->frame + static_cast<size_t>(c->totalRows) * c->rowBytes, + tailBytes); + } + return; + } std::memcpy(dst, c->frame + static_cast<size_t>(firstRow) * c->rowBytes, static_cast<size_t>(count) * c->rowBytes); - if (last && c->pad) - std::memcpy(dst + static_cast<size_t>(count) * c->rowBytes, - c->frame + static_cast<size_t>(c->totalRows) * c->rowBytes, c->pad); };🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/platform/esp32/platform_esp32_moon_i80.cpp` around lines 1367 - 1381, Update the copySlice lambda used by MoonI80Ws2812InitRing so the firstRow == totalRows closing-tail invocation copies only the trailing pad into the single row-sized ring buffer, without appending the pad after a row copy. Preserve the existing row-and-pad copy behavior for normal final slices, while ensuring every callback write stays within the allocated rowBytes buffer.
1441-1461: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick winAlign the unsupported-target stubs with
platform.h
moonI80Ws2812Initstill has an extrauint16_t, andmoonI80Ws2812Loopbackhas an extrauint16_ttoo. These fallback definitions need to match the declared signatures exactly so non-LCD_CAM builds expose the expected APIs.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/platform/esp32/platform_esp32_moon_i80.cpp` around lines 1441 - 1461, Update the unsupported-target definitions of moonI80Ws2812Init and moonI80Ws2812Loopback to remove the extra uint16_t parameter from each signature, matching the declarations in platform.h exactly while preserving their existing stub return behavior.Source: Path instructions
src/platform/platform.h (1)
808-818: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick winDocument the EOF ISR as the ring refiller.
The implementation no longer wakes a pinned refill task:
MoonI80EncodeFnruns inline from the GDMA EOF ISR. Update both comments so callers understand the callback’s ISR-safety and latency requirements.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/platform/platform.h` around lines 808 - 818, Update the comments for moonI80Ws2812InitRing and moonI80Ws2812StartFrame so they state that MoonI80EncodeFn runs inline from the GDMA EOF ISR rather than a pinned refill task. Document that the callback must be ISR-safe and complete within the ISR latency budget, and remove references to waking or refilling via a task.
♻️ Duplicate comments (1)
src/light/drivers/ParallelLedDriver.h (1)
592-600: 🩺 Stability & Availability | 🔴 Critical | 🏗️ Heavy liftAbort or rebuild the wedged bus before retrying.
Clearing
inFlight_only discards software ownership; the timed-out DMA may still read that buffer. The retry can then overwrite it and enqueue another transfer on the wedged bus. Reset/deinitialize the transfer before permitting a replacement frame.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/light/drivers/ParallelLedDriver.h` around lines 592 - 600, Update the give-up handling in the retry logic around giveUpRetry_ and inFlight_ so it aborts or resets/deinitializes the wedged DMA/bus transfer before clearing ownership and retrying. Ensure the timed-out transfer can no longer access the buffer before the next tick encodes and transmits a replacement frame.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/history/plans/Plan-20260716` - MoonI80 per-LED ring (constant RAM).md:
- Line 1: Mark this completed MoonI80 plan as shipped by renaming the document
with the “(shipped)” suffix and updating every reference to the old filename
throughout the repository, while preserving the plan contents.
In `@src/light/drivers/MoonLedDriver.h`:
- Around line 235-248: The ring buffer currently reuses row-0 pulse constants,
leaving stale HIGH values when strands have unequal lengths. Update the ring
refill logic described near the per-slice prefill path, including the
corresponding 262-276 section, to recompute pulse constants whenever the active
mask changes, or reject unequal ledsPerPin configurations for ring mode; add a
production one-row regression test covering a shorter strand ending mid-slice.
In `@src/platform/esp32/platform_esp32_moon_i80.cpp`:
- Around line 842-843: Remove the unused last parameter from encodeRingSlice’s
declaration and definition, and update every call site to stop passing it.
Preserve the existing behavior of always invoking st->encode with last=false.
In `@test/unit/light/unit_ParallelLedDriver_ring.cpp`:
- Around line 626-650: The test lambda duplicates the latch/stop state machine
instead of exercising production logic. Extract a deterministic transition
helper for the shared buffer-state behavior, reuse it from startRingTransfer and
moonI80EofCb, and update the ring test to call that helper and assert its
results for the relevant cases. Keep the helper as the single authoritative
implementation and add module coverage for each required transition.
---
Outside diff comments:
In `@src/light/drivers/ParallelLedDriver.h`:
- Around line 1250-1262: Update the ring-initialization flow around
busInitRing() and ensureSnapshotCap() so a successful ring allocation is marked
initialized before snapshot allocation can fail, or explicitly invokes
busDeinit() on that partial-success path before deinit(). Ensure allocIsr()
exhaustion is treated as allocation failure, degrading to whole-frame
initialization without crashing.
- Around line 745-781: Update the profiling timestamps around the row loop in
the encoding method: accumulate the single memset duration once before iterating
rows, then capture a fresh correction-start timestamp at the beginning of each
row before correction_.apply. Use that per-row timestamp for dbgCycCorrect_,
while keeping dbgCycEncode_ measured from the existing post-correction
timestamp.
In `@src/platform/esp32/platform_esp32_i80.cpp`:
- Around line 287-303: The shift-mode allocation in the buffer initialization
path should not attempt internal DMA memory when the internal-fit check fails.
Update the allocation guarded by shiftMode to require fitsInternal, matching the
HEAP_RESERVE-aware condition used for the second buffer, while preserving the
existing PSRAM fallback when internal allocation is skipped or unavailable.
In `@src/platform/esp32/platform_esp32_moon_i80.cpp`:
- Around line 1367-1381: Update the copySlice lambda used by
MoonI80Ws2812InitRing so the firstRow == totalRows closing-tail invocation
copies only the trailing pad into the single row-sized ring buffer, without
appending the pad after a row copy. Preserve the existing row-and-pad copy
behavior for normal final slices, while ensuring every callback write stays
within the allocated rowBytes buffer.
- Around line 1441-1461: Update the unsupported-target definitions of
moonI80Ws2812Init and moonI80Ws2812Loopback to remove the extra uint16_t
parameter from each signature, matching the declarations in platform.h exactly
while preserving their existing stub return behavior.
In `@src/platform/esp32/platform_esp32.cpp`:
- Around line 833-835: Update the WIFI_EVENT_AP_STADISCONNECTED handling in the
platform event handler to perform an atomic saturating decrement of apClients_,
so concurrent resets cannot cause underflow or wraparound. Replace the separate
load/check/fetch_sub sequence with a CAS loop or shared atomic
saturating-decrement helper that leaves the counter at zero when already reset.
- Around line 812-823: Synchronize the disconnect callback’s reconnect path
around wifiStaStopping_ with the teardown performed by wifiStaStop(),
eliminating the check-to-use window before esp_wifi_stop() and
esp_wifi_deinit(). Ensure the callback cannot call esp_wifi_connect() once
teardown has begun, while preserving normal reconnect behavior when teardown is
not in progress.
In `@src/platform/platform.h`:
- Around line 808-818: Update the comments for moonI80Ws2812InitRing and
moonI80Ws2812StartFrame so they state that MoonI80EncodeFn runs inline from the
GDMA EOF ISR rather than a pinned refill task. Document that the callback must
be ISR-safe and complete within the ISR latency budget, and remove references to
waking or refilling via a task.
---
Duplicate comments:
In `@src/light/drivers/ParallelLedDriver.h`:
- Around line 592-600: Update the give-up handling in the retry logic around
giveUpRetry_ and inFlight_ so it aborts or resets/deinitializes the wedged
DMA/bus transfer before clearing ownership and retrying. Ensure the timed-out
transfer can no longer access the buffer before the next tick encodes and
transmits a replacement frame.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 14230948-e940-43ae-a83b-d329cc689162
⛔ Files ignored due to path filters (1)
moondeck/build/generate_build_info.pyis excluded by!**/build/**
📒 Files selected for processing (16)
CMakeLists.txtdocs/backlog/backlog-light.mddocs/backlog/moon-i80-encode-decomposition.mddocs/history/plans/Plan-20260716 - MoonI80 per-LED ring (constant RAM).mdesp32/main/CMakeLists.txtesp32/sdkconfig.defaultssrc/core/FirmwareUpdateModule.hsrc/light/drivers/MoonLedDriver.hsrc/light/drivers/ParallelLedDriver.hsrc/light/drivers/ParallelSlots.hsrc/platform/desktop/platform_desktop.cppsrc/platform/esp32/platform_esp32.cppsrc/platform/esp32/platform_esp32_i80.cppsrc/platform/esp32/platform_esp32_moon_i80.cppsrc/platform/platform.htest/unit/light/unit_ParallelLedDriver_ring.cpp
| @@ -0,0 +1,47 @@ | |||
| # Plan — MoonI80 ring: measure honestly, then go per-LED (constant RAM at any strand length) | |||
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Mark this completed plan as shipped.
Rename it to Plan-20260716 - MoonI80 per-LED ring (constant RAM) (shipped).md and update references.
As per path instructions, “retain plans and mark completed outcomes with (shipped) or (attempted, abandoned).”
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/history/plans/Plan-20260716` - MoonI80 per-LED ring (constant RAM).md at
line 1, Mark this completed MoonI80 plan as shipped by renaming the document
with the “(shipped)” suffix and updating every reference to the old filename
throughout the repository, while preserving the plan contents.
Source: Path instructions
| TEST_CASE("MoonI80 ring: the closing latch lands in the buffer the DMA clocks last") { | ||
| // One light per DMA buffer (kRingRows = 1), so a slice IS a light and nSlices == totalRows. | ||
| constexpr uint32_t kTailBufs = 1; // mirrors the platform constant | ||
| auto latchBufferAndStopBuffer = [](uint32_t nSlices, uint32_t bufs) { | ||
| // --- startRingTransfer: prime every buffer, in order, with the next unencoded slice. | ||
| uint32_t refilledRow = 0; | ||
| int32_t primedLatch = -1; | ||
| for (uint32_t primed = 0; primed < bufs; primed++) { | ||
| if (refilledRow < nSlices) refilledRow++; // a real slice | ||
| else if (primedLatch < 0) primedLatch = static_cast<int32_t>(primed); // the tail (short frame) | ||
| } | ||
| // --- moonI80EofCb: each EOF refills the just-drained buffer, or writes the tail once slices run out. | ||
| uint32_t refillSlot = 0, drained = 0; | ||
| int32_t isrLatch = -1; | ||
| while (true) { | ||
| drained++; | ||
| const uint32_t slot = refillSlot; | ||
| if (refilledRow < nSlices) refilledRow++; | ||
| else if (isrLatch < 0) isrLatch = static_cast<int32_t>(slot); | ||
| refillSlot = (slot + 1u) % bufs; | ||
| if (drained >= nSlices + kTailBufs) break; | ||
| } | ||
| const int32_t latch = (primedLatch >= 0) ? primedLatch : isrLatch; | ||
| const int32_t stopBuffer = static_cast<int32_t>((nSlices + kTailBufs - 1) % bufs); | ||
| return std::pair<int32_t, int32_t>{latch, stopBuffer}; |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift
Test production state transitions instead of restating them.
This lambda duplicates the latch/stop algorithm, so production can regress while the test remains green. Extract a small deterministic transition helper used by both startRingTransfer/moonI80EofCb and this test, then assert its output across these cases.
Based on learnings, “Keep each fact or logic implementation in one authoritative location.” As per coding guidelines, every new core logic change needs a corresponding module test.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@test/unit/light/unit_ParallelLedDriver_ring.cpp` around lines 626 - 650, The
test lambda duplicates the latch/stop state machine instead of exercising
production logic. Extract a deterministic transition helper for the shared
buffer-state behavior, reuse it from startRingTransfer and moonI80EofCb, and
update the ring test to call that helper and assert its results for the relevant
cases. Keep the helper as the single authoritative implementation and add module
coverage for each required transition.
Sources: Coding guidelines, Learnings
…t any length)" This reverts commit da67edf.
The 74HCT595 ring's lights-per-DMA-buffer and pool depth are now controls you sweep on a running board, because the optimum is a measurement rather than a derivation: RAM is the only axis that wants a small buffer (at one light per buffer the ring is ~18 KB flat at ANY strand length — the only route to 48 strands x 256 lights), while encode overhead, interrupt rate and jitter tolerance all want a big one. The driver's path selector loses its auto-router and becomes a plain switch, and the LED driver docs are rewritten for someone meeting them cold.
KPI: 16384lights | Desktop:718KB | tick:127/102/11/126/20/2/280/70/17/22/169/123/22/10/43us(FPS:7874/9803/90909/7936/50000/500000/3571/14285/58823/45454/5917/8130/45454/100000/23255) | ESP32:1484KB | tick:11164us(FPS:89) | heap:8218KB | src:197(43400) | test:136(23639) | lizard:152w
Core:
- platform_esp32_moon_i80: the ring geometry arrives as parameters (rowsPerBuf, ringBufs) instead of compile-time constants, validated at the seam — a 0 would divide by zero in the slice math and an oversize depth would overrun the buffer array. The state carries the live count; kRingBufsMax is only the array bound.
- platform_esp32_moon_i80: a ring buffer holds rows and nothing else. The latch pad had been half-removed already: buffers were sized rows-only while encodeRingSlice still promised to append a pad on the last slice — a memset past the allocation, in DMA-capable RAM, for any caller that believed the docstring. No call site did, so the branch was dead; removing it took ringPadBytes, the padBytes parameter across the seam, the loopback's pad copy and loopPad with it.
- platform_esp32_moon_i80: the whole-frame fit check asks for the largest free BLOCK, not total free — one contiguous frame cannot come out of a fragmented heap, whatever the total says. The sibling i80 backend was fixed the same way; the ring's own check keeps free-size deliberately, since it makes many small allocations.
- platform_esp32_moon_i80: the initRingDma comment described a linear self-terminating chain and argued against looping, while the code mounts GDMA_FINAL_LINK_TO_HEAD. It was the first thing a reader hit. Deleted.
- platform.h: kRingRowsDefault/kRingBufsDefault name the shipped geometry once, so the driver's control defaults and the platform's own self-test cannot drift apart.
Light domain:
- MoonLedDriver: forceRing (auto/ring/wholeFrame) becomes useRing, a switch. The auto-router had exactly one right answer at the size the expander exists for, and its silent fallback hid which path was running — the driver reported "driving N lights" while quietly on a path that cannot clock at the expander's 26.67 MHz.
- MoonLedDriver: ringRows/ringBufs controls, defaulting to the geometry that already ran, so an existing config is unchanged until someone turns a knob. Shown only when the ring is the chosen path.
- ParallelLedDriver: a failed ring build now tears the bus down unconditionally. deinit() only fires busDeinit() `if (inited_)`, which is false by construction on that path — so a ring that built successfully and then failed its snapshot allocation survived as a live GDMA channel, its ISR, and ~150 KB of internal RAM, which the whole-frame build then overwrote the handle of. It leaked on the OOM path most likely to reach it, and repeated on every rebuild.
- ParallelLedDriver: addRingControls(), a second CRTP hook, so the base can place the ring's controls after latchPin — clockPin and latchPin are one wiring pair and a mode selector does not belong between them.
Tests:
- unit_ParallelLedDriver_ring: two ragged tests — a strand ending mid-slice, and an exhausted strand clocking zeros on a fresh AND a recycled buffer. Every driver-level ring test was uniform, which is why a real bug (the prefill using row 0's mask for every buffer) once shipped: with equal strands the mask never changes, so the suite could not see it. Verified by re-injecting that bug — the ragged tests fail, the uniform tiling test still passes.
- unit_ParallelLedDriver_ring: the mock sizes its buffers rows-only, as the platform does. Sized rows+pad it would let a pad-writing bug pass on the host and overrun on hardware.
- unit_ParallelLedDriver_ring: a failed ring build leaves no ring behind.
Scripts / MoonDeck:
- gen_api: free-function headers get a technical page via Doxygen groups. ParallelSlots.h (the WS2812 encoder — the render loop's hot spot) and PinList.h had no page at all: they declare no class, and the generator only read class compounds. Their file-level comments were `//`, so the encode technique, the transpose rationale and the prior-art credit reached no reader.
- gen_api: the source-link banner points at the branch being documented. It hard-coded blob/main, so every not-yet-merged module's link 404'd; and the hook repointed a generated page's own banner at itself, so the header was unreachable from the page generated from it.
- gen_api: a link moxygen puts inside a code span cannot render — it emitted as literal brackets, and mangled a half-open interval in a comment into broken markup.
- check_devices: the expander's data-pin count is 1..8, not 1..15. Eight pins x eight taps is the driver's 64-strand ceiling; the bus arithmetic that suggested 15 never binds.
Docs / CI:
- drivers.md: a decision matrix (which driver, and why), the controls restored to the card where the standard says they live, and the lane/pin/strand vocabulary written down — "lane" appeared throughout and was never defined, which is what let three different strand ceilings coexist.
- MoonLedDriver: the class comment leads with what the driver is FOR — 48 strands from 6 pins, a streamed frame — instead of esp_lcd internals. The pin expander gets a wiring diagram (SER/SRCLK/RCLK/QA-QH), the arithmetic for where the eighth lane goes, and its own section at the end, so a reader who drives strands directly can stop before it.
- ParallelLedDriver: the shared body's comment explains what the transpose actually does; eight methods that carried good `//` prose the generated page could not see now carry `///`.
- supporting.md: the Drivers card named a control that moved to the drivers (lightPreset) and one that was renamed (stall -> renderWait), and multicore was a 170-word essay in a table cell. The same drift was in architecture.md and a test comment.
- MIGRATING: forceRing -> useRing, and honest about the defaults case — the old default pool never fit internal RAM, so a config on defaults was silently falling back to whole-frame and may now actually ring.
- shift-register-driver-analysis: the measured encode decomposition (transpose + emit is 26.2 us of 33, the 19 us floor, six ruled-out hypotheses) and hpwit's _DMA_EXTENSTION finding — the encode deadline is bought with RAM, not fixed by the protocol. The section's old claim that the encode was not the ring's problem is refuted; it is now the only problem.
Reviews:
- 👾 High, fixed: a failed ring build leaked the GDMA channel, its ISR and ~150 KB of internal DMA RAM, because the teardown was gated on a flag that is false on that path (see Light domain).
- 👾 Medium-high, fixed: the half-removed latch pad left comments describing the opposite of the code and an overrun armed for the next caller who believed them (see Core).
- 👾 Medium, fixed: the expander's pin count read 1-15 in the docs, "up to 7" in a header, and 1..8 in the catalog check. One fact, three renderings; kMaxStrands settles it at 8.
- 👾 Medium, fixed: the largest-free-block fit check landed on the sibling backend but not this one, in the same commit that made the fix.
- 👾 Low, fixed: MIGRATING claimed an existing config renders identically; the old default never fit. Also a leftover "dropdown" for a control that is now a switch.
- 👾 Note, accepted: the new leak test pins the teardown contract, not the composition — ensureSnapshotCap cannot be made to fail on the host, so it would not have caught the original bug.
KPI Details:
Desktop:
Lights: 16,384
Binary: 718 KB
[doctest] test cases: 774 | 774 passed | 0 failed | 0 skipped
tick: 127us, 102us, 11us, 126us, 20us, 2us, 280us, 70us, 17us, 22us, 169us, 123us, 22us, 10us, 43us (FPS: 7874, 9803, 90909, 7936, 50000, 500000, 3571, 14285, 58823, 45454, 5917, 8130, 45454, 100000, 23255) (per scenario)
=== 20 scenario(s), 20 passed, 0 failed ===
Platform boundary: PASS
Specs: Spec check: 90 modules, 90 ok, 0 missing, 0 outdated, 0 source-link issues, 0 docPath issues
ESP32:
Image: 1,644,982 bytes (61% partition free)
Flash (code+data): 1484 KB
tick: 11164us (FPS: 89) heap free: 8415631
Code:
197 source files (43400 lines)
136 test files (23639 lines)
144 specs, 20 scenarios
Lizard: 152 warnings
42 27 436 0 47 mm::moonlive::Lexer::advance@44-90@src/core/moonlive/MoonLiveCompiler.cpp
38 21 472 1 53 mm::moonlive::Parser::parseCall@178-230@src/core/moonlive/MoonLiveCompiler.cpp
27 18 351 0 35 mm::moonlive::Parser::parseDecl@234-268@src/core/moonlive/MoonLiveCompiler.cpp
15 11 142 0 15 mm::moonlive::Parser::parseProgram@277-291@src/core/moonlive/MoonLiveCompiler.cpp
178 93 1905 1 287 mm::HttpServerModule::handleConnection@98-384@src/core/HttpServerModule.cpp
33 25 360 3 37 mm::HttpServerModule::parseFilePath@449-485@src/core/HttpServerModule.cpp
57 14 562 3 68 mm::HttpServerModule::serveFile@741-808@src/core/HttpServerModule.cpp
30 11 304 2 47 mm::HttpServerModule::writeControls@1022-1068@src/core/HttpServerModule.cpp
37 18 430 1 47 mm::HttpServerModule::applyWledState@1453-1499@src/core/HttpServerModule.cpp
24 15 214 3 45 mm::HttpServerModule::applyAddModule@1527-1571@src/core/HttpServerModule.cpp
54 13 410 3 92 mm::HttpServerModule::handleReplaceModule@1717-1808@src/core/HttpServerModule.cpp
48 19 480 4 54 mm::HttpServerModule::resolveEditableList@1908-1961@src/core/HttpServerModule.cpp
31 13 201 0 58 mm::HttpServerModule::pushStateToWebSockets@2170-2227@src/core/HttpServerModule.cpp
34 15 314 0 39 mm::HttpServerModule::pollWledStateFromWebSockets@2259-2297@src/core/HttpServerModule.cpp
37 16 317 0 44 mm::HttpServerModule::drainPreviewSend@2448-2491@src/core/HttpServerModule.cpp
19 14 188 4 19 mm::parsePinList@22-40@src/core/PinList.h
20 16 159 1 26 mm::sanitizeHostname@62-87@src/core/Control.h
44 31 535 9 58 mm::buildMqttConnect@130-187@src/core/MqttPacket.h
121 38 723 0 180 mm::NetworkModule::tick1s@362-541@src/core/NetworkModule.h
19 12 164 0 24 mm::NetworkModule::syncMdns@912-935@src/core/NetworkModule.h
40 24 405 0 97 mm::AudioService::tick@264-360@src/core/AudioService.h
29 14 242 0 40 mm::AudioService::tick1s@406-445@src/core/AudioService.h
28 14 230 0 34 mm::AudioService::syncEnsureSocket@596-629@src/core/AudioService.h
26 13 254 2 26 mm::detail::parseOneIp@32-57@src/core/IpList.h
42 20 383 3 46 mm::parseIpList@63-108@src/core/IpList.h
49 15 461 3 90 mm::FilesystemModule::applyNode@149-238@src/core/FilesystemModule.cpp
42 13 342 0 55 mm::Scheduler::tick@66-120@src/core/Scheduler.cpp
31 12 250 3 41 mm::Scheduler::setControl@211-251@src/core/Scheduler.cpp
17 11 131 1 23 mm::JsonSink::append@55-77@src/core/JsonSink.h
24 11 231 1 35 mm::JsonSink::writeJsonString@144-178@src/core/JsonSink.h
21 17 144 1 21 mm::controlTypeName@23-43@src/core/Control.cpp
58 21 430 2 72 mm::writeControlValue@73-144@src/core/Control.cpp
59 26 419 2 74 mm::writeControlMetadata@146-219@src/core/Control.cpp
90 43 863 4 137 mm::applyControlValue@221-357@src/core/Control.cpp
22 12 233 2 24 mm::DevicesModule::upsertHueBridge@196-219@src/core/DevicesModule.h
34 17 346 1 43 mm::DevicesModule::upsertDevice@425-467@src/core/DevicesModule.h
25 12 244 0 29 mm::DevicesModule::upsertSelf@473-501@src/core/DevicesModule.h
40 14 398 1 68 mm::MqttModule::publishDiscovery@86-153@src/core/MqttModule.cpp
35 13 368 1 52 mm::MqttModule::publishUpdateDiscovery@193-244@src/core/MqttModule.cpp
36 19 258 0 44 mm::MqttModule::tick1s@414-457@src/core/MqttModule.cpp
29 11 221 0 36 mm::MqttModule::serviceConnected@504-539@src/core/MqttModule.cpp
45 14 414 1 62 mm::MqttModule::handleInboundByte@541-602@src/core/MqttModule.cpp
61 25 695 3 83 mm::MqttModule::routePublish@604-686@src/core/MqttModule.cpp
39 16 408 1 53 mm::MqttModule::publishState@723-775@src/core/MqttModule.cpp
47 40 522 4 52 mm::json::parseString@48-99@src/core/JsonUtil.h
27 13 174 0 27 mm::json::detail::JsonParser::parseStringLiteral@229-255@src/core/JsonUtil.h
58 31 519 1 65 mm::json::detail::JsonParser::parseValue@257-321@src/core/JsonUtil.h
29 12 245 1 29 mm::json::detail::JsonParser::parseObject@323-351@src/core/JsonUtil.h
24 12 202 2 28 mm::ModuleFactory::displayNameFor@105-132@src/core/ModuleFactory.h
21 12 259 1 41 mm::PinsModule::PinListSource::collect@212-252@src/core/PinsModule.h
34 11 324 2 40 mm::TasksModule::TaskListSource::writeListRowDetail@111-150@src/core/TasksModule.h
59 22 400 1 65 mm::ImprovFrameParser::feed@62-126@src/core/ImprovFrame.h
36 16 401 7 53 mm::magnitudesToBands@59-111@src/core/AudioBands.h
58 17 753 3 65 mm::moonlive::lowerToBytes@25-89@src/platform/desktop/moonlive_lower_host.cpp
27 12 274 3 34 mm::platform::fsWriteStream@534-567@src/platform/desktop/platform_desktop.cpp
78 36 961 8 115 mm::platform::httpRequest@696-810@src/platform/desktop/platform_desktop.cpp
21 13 264 2 28 mm::platform::TcpConnection::connectStart@996-1023@src/platform/desktop/platform_desktop.cpp
26 11 230 3 31 mm::platform::fsWriteAtomic@122-152@src/platform/esp32/platform_esp32_fs.cpp
24 14 269 3 27 mm::platform::fsWriteStream@175-201@src/platform/esp32/platform_esp32_fs.cpp
34 12 341 1 47 mm::platform::improvHandleProvision@228-274@src/platform/esp32/platform_esp32_improv.cpp
44 18 361 1 58 mm::platform::improvDispatchFrame@392-449@src/platform/esp32/platform_esp32_improv.cpp
58 22 335 0 113 mm::platform::improvTask@477-589@src/platform/esp32/platform_esp32_improv.cpp
37 12 290 13 43 mm::platform::improvProvisioningInit@593-635@src/platform/esp32/platform_esp32_improv.cpp
84 27 692 7 181 mm::platform::createState@172-352@src/platform/esp32/platform_esp32_i80.cpp
17 11 141 8 45 mm::platform::i80Ws2812Init@356-400@src/platform/esp32/platform_esp32_i80.cpp
45 15 397 10 75 mm::platform::i80Ws2812Loopback@486-560@src/platform/esp32/platform_esp32_i80.cpp
55 17 745 3 63 mm::moonlive::lowerToBytes@21-83@src/platform/esp32/moonlive_lower_xtensa.cpp
65 18 569 5 93 mm::platform::createState@107-199@src/platform/esp32/platform_esp32_parlio.cpp
39 12 355 7 50 mm::platform::parlioWs2812Loopback@325-374@src/platform/esp32/platform_esp32_parlio.cpp
15 11 78 0 15 mm::platform::resetReason@321-335@src/platform/esp32/platform_esp32.cpp
58 16 512 0 111 mm::platform::ethInitEmac@492-602@src/platform/esp32/platform_esp32.cpp
74 18 577 0 91 mm::platform::ethInitSpi@616-706@src/platform/esp32/platform_esp32.cpp
39 12 318 2 62 mm::platform::wifiStaInit@887-948@src/platform/esp32/platform_esp32.cpp
42 11 339 2 50 mm::platform::wifiApInit@1003-1052@src/platform/esp32/platform_esp32.cpp
47 13 411 1 84 mm::platform::mdnsInit@1156-1239@src/platform/esp32/platform_esp32.cpp
74 30 827 8 102 mm::platform::httpRequest@1271-1372@src/platform/esp32/platform_esp32.cpp
38 12 295 5 67 mm::platform::rmtWs2812RxCapture@166-232@src/platform/esp32/platform_esp32_rmt.cpp
83 26 1004 9 112 mm::platform::detail::captureAndVerifyFrame@285-396@src/platform/esp32/platform_esp32_rmt.cpp
49 18 608 2 59 mm::platform::rmtWs2812Loopback@400-458@src/platform/esp32/platform_esp32_rmt.cpp
85 33 928 4 104 mm::platform::rmtWs2812LoopbackFrame@466-569@src/platform/esp32/platform_esp32_rmt.cpp
25 14 199 1 31 mm::platform::destroyState@373-403@src/platform/esp32/platform_esp32_moon_i80.cpp
47 18 384 6 122 mm::platform::createState@582-703@src/platform/esp32/platform_esp32_moon_i80.cpp
44 15 465 10 64 mm::platform::createRingState@889-952@src/platform/esp32/platform_esp32_moon_i80.cpp
16 12 167 11 35 mm::platform::moonI80Ws2812InitRing@988-1022@src/platform/esp32/platform_esp32_moon_i80.cpp
72 24 661 9 116 mm::platform::moonI80Ws2812Loopback@1181-1296@src/platform/esp32/platform_esp32_moon_i80.cpp
54 17 739 3 59 mm::moonlive::lowerToBytes@20-78@src/platform/esp32/moonlive_lower_riscv.cpp
45 15 377 6 60 mm::platform::otaWriteStream@190-249@src/platform/esp32/platform_esp32_ota.cpp
61 30 538 6 66 mm::assignCounts@61-126@src/light/drivers/PinList.h
18 11 147 1 32 mm::ParallelLedDriver::onControlChanged@310-341@src/light/drivers/ParallelLedDriver.h
13 15 143 0 29 mm::ParallelLedDriver::tick@409-437@src/light/drivers/ParallelLedDriver.h
56 29 455 0 105 mm::ParallelLedDriver::parseConfig@1105-1209@src/light/drivers/ParallelLedDriver.h
49 21 427 0 90 mm::ParallelLedDriver::reinit@1217-1306@src/light/drivers/ParallelLedDriver.h
89 26 776 0 149 mm::ParallelLedDriver::runLoopbackSelfTest@1336-1484@src/light/drivers/ParallelLedDriver.h
69 21 551 0 118 mm::NetworkSendDriver::tick@208-325@src/light/drivers/NetworkSendDriver.h
26 17 288 3 30 mm::LightPresetsModule::setListRowField@248-277@src/light/drivers/LightPresetsModule.h
31 15 501 2 32 mm::LightPresetsModule::restoreList@282-313@src/light/drivers/LightPresetsModule.h
29 12 294 0 37 mm::LightPresetsModule::rebuildPool@359-395@src/light/drivers/LightPresetsModule.h
45 21 251 0 82 mm::PreviewDriver::tick@92-173@src/light/drivers/PreviewDriver.h
83 30 1014 0 115 mm::PreviewDriver::buildAndSendCoordTable@180-294@src/light/drivers/PreviewDriver.h
52 26 700 0 69 mm::PreviewDriver::sendFrame@299-367@src/light/drivers/PreviewDriver.h
36 24 311 0 88 mm::Drivers::prepare@266-353@src/light/drivers/Drivers.h
28 16 280 0 67 mm::Drivers::tick@371-437@src/light/drivers/Drivers.h
26 20 289 2 42 mm::Correction::apply@125-166@src/light/drivers/Correction.h
18 11 154 1 23 mm::HueDriver::onControlChanged@79-101@src/light/drivers/HueDriver.h
34 15 279 1 37 mm::HueDriver::parseLights@445-481@src/light/drivers/HueDriver.h
31 13 249 1 31 mm::HueDriver::parseGroups@511-541@src/light/drivers/HueDriver.h
14 14 256 6 15 mm::HueDriver::rgbToHsv@709-723@src/light/drivers/HueDriver.h
35 21 394 0 63 mm::RmtLedDriver::tick@222-284@src/light/drivers/RmtLedDriver.h
57 14 412 0 78 mm::RmtLedDriver::runLoopbackSelfTest@436-513@src/light/drivers/RmtLedDriver.h
32 12 376 7 81 mm::encodeWs2812ShiftSlots@285-365@src/light/drivers/ParallelSlots.h
38 18 531 7 57 mm::encodeWs2812ShiftData@448-504@src/light/drivers/ParallelSlots.h
31 18 360 0 32 mm::Layer::applyLivePass@216-247@src/light/layers/Layer.h
66 24 685 3 90 mm::Layer::buildFoldedLUT@417-506@src/light/layers/Layer.h
31 12 266 1 34 mm::Layer::allocateBuffer@555-588@src/light/layers/Layer.h
71 27 788 7 90 mm::blendMap@54-143@src/light/layers/BlendMap.h
28 12 255 2 41 mm::MappingLUT::allocateDestinations@168-208@src/light/layers/MappingLUT.h
44 15 341 7 59 mm::CarLightsLayout::emitRing@93-151@src/light/layouts/CarLightsLayout.h
58 18 418 3 85 mm::RingLayout::walk@62-146@src/light/layouts/RingLayout.h
26 21 402 2 43 mm::HumanSizedCubeLayout::forEachCoord@52-94@src/light/layouts/HumanSizedCubeLayout.h
33 16 513 0 43 mm::ParticlesEffect::tick@39-81@src/light/effects/ParticlesEffect.h
36 19 415 0 48 mm::StarSkyEffect::tick@67-114@src/light/effects/StarSkyEffect.h
54 21 627 0 64 mm::RingsEffect::tick@37-100@src/light/effects/RingsEffect.h
60 36 683 0 89 mm::LinesEffect::tick@45-133@src/light/effects/LinesEffect.h
59 22 693 0 94 mm::AudioSpectrumEffect::tick@37-130@src/light/effects/AudioSpectrumEffect.h
39 12 470 0 71 mm::BouncingBallsEffect::tick@49-119@src/light/effects/BouncingBallsEffect.h
46 20 571 0 66 mm::FireEffect::tick@37-102@src/light/effects/FireEffect.h
29 12 332 0 42 mm::WaveEffect::tick@63-104@src/light/effects/WaveEffect.h
99 36 1025 0 110 mm::SolidEffect::tick@64-173@src/light/effects/SolidEffect.h
52 29 604 0 81 mm::GEQEffect::tick@64-144@src/light/effects/GEQEffect.h
37 12 492 0 50 mm::RipplesEffect::tick@37-86@src/light/effects/RipplesEffect.h
62 24 771 0 90 mm::FreqSawsEffect::tick@67-156@src/light/effects/FreqSawsEffect.h
44 19 577 0 84 mm::BlurzEffect::tick@56-139@src/light/effects/BlurzEffect.h
31 15 348 0 50 mm::FixedRectangleEffect::tick@60-109@src/light/effects/FixedRectangleEffect.h
56 23 677 0 78 mm::TetrixEffect::tick@69-146@src/light/effects/TetrixEffect.h
42 11 505 0 51 mm::LavaLampEffect::tick@31-81@src/light/effects/LavaLampEffect.h
47 18 499 0 66 mm::StarFieldEffect::tick@69-134@src/light/effects/StarFieldEffect.h
40 16 462 0 67 mm::FreqMatrixEffect::tick@62-128@src/light/effects/FreqMatrixEffect.h
25 15 256 0 40 mm::RubiksCubeEffect::tick@64-103@src/light/effects/RubiksCubeEffect.h
24 16 543 6 34 mm::RubiksCubeEffect::Cube::drawCube@220-253@src/light/effects/RubiksCubeEffect.h
41 11 587 0 65 mm::PaintBrushEffect::tick@42-106@src/light/effects/PaintBrushEffect.h
106 46 1720 0 149 mm::GEQ3DEffect::tick@49-197@src/light/effects/GEQ3DEffect.h
47 37 564 0 60 mm::GameOfLifeEffect::tick@152-211@src/light/effects/GameOfLifeEffect.h
32 21 514 5 39 mm::GameOfLifeEffect::placePentomino@326-364@src/light/effects/GameOfLifeEffect.h
93 67 1044 9 122 mm::GameOfLifeEffect::evolveAutomaton@370-491@src/light/effects/GameOfLifeEffect.h
37 14 358 0 41 mm::NetworkReceiveEffect::tick@88-128@src/light/effects/NetworkReceiveEffect.h
13 12 232 5 13 mm::Palettes::rgbToHueSat@321-333@src/light/Palette.h
17 17 223 5 17 mm::parseE131Packet@96-112@src/light/E131Packet.h
11 12 200 4 11 mm::draw::pixel@28-38@src/light/draw.h
43 24 887 6 46 mm::draw::line@50-95@src/light/draw.h
14 11 239 3 19 mm::draw::blur@191-209@src/light/draw.h
95 5 1493 0 123 registerModuleTypes@122-244@src/main.cpp
144 21 1368 2 347 mm_main@261-607@src/main.cpp
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 6
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (8)
docs/backlog/shift-register-driver-analysis.md (2)
164-171: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick winCorrect the shift-mode clock table to 26.67 MHz.
Line 169 specifies 20 MHz/prescale 4, but that produces the out-of-spec 400 ns slot described above. The valid configuration derived here is 26.67 MHz/prescale 3.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@docs/backlog/shift-register-driver-analysis.md` around lines 164 - 171, Update the pin expander mode row in the shift-mode clock table to use 26.67 MHz with prescale 3 instead of 20.000 MHz with prescale 4. Ensure the granted rate and resulting slot timing reflect the valid 300 ns configuration, while leaving the direct mode and surrounding clock derivation unchanged.
270-282: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick winDo not apply 16-bit frame sizing to the 6-pin target.
The 48-strand configuration uses six data lanes plus latch and therefore an 8-bit bus: 576 B/light, 147,456 B at 256 lights, and 21.6 µs wire time. The stated 1,152 B/light and 288 KB assume a 16-bit bus and contradict the deadline on Line 282. Scope the table explicitly or correct its arithmetic.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@docs/backlog/shift-register-driver-analysis.md` around lines 270 - 282, Correct the frame-size analysis around the encoded-frame table to scope the 1,152 B/light figures to the 16-bit pin-expander target only. Add the 6-pin target’s 8-bit calculations—576 B/light and 147,456 B for 256 lights—and ensure its 21.6 µs wire-time statement is consistent with the table and deadline discussion.src/light/drivers/ParallelLedDriver.h (1)
144-152: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick winDo not expose an unsafe live-source ring mode.
With
ringSnapshotoff, rendering writes the source while the refill task reads it on another core, creating a C++ data race—not merely visual tearing. Always snapshot in production; keep this A/B mode behind a bench-only build option.As per coding guidelines, “Running devices must tolerate any sequence of UI actions or API calls ... without crashing or wedging.”
Also applies to: 517-525
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/light/drivers/ParallelLedDriver.h` around lines 144 - 152, Restrict the ringSnapshot live-source toggle to a bench-only build configuration and keep production behavior permanently snapshot-enabled. Update the ring-path logic around ringSnapshot and its related configuration so production cannot disable source snapshots, while preserving the bench A/B control and default enabled behavior for bench builds.Source: Coding guidelines
src/platform/esp32/platform_esp32_i80.cpp (1)
296-303: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick winReject shift frames that do not fit internal DMA RAM.
The allocator knowingly falls back to PSRAM and the pre-check admits that configuration, despite expecting stalled transfers. In pin-expander mode, require
fitsInternaland fail initialization instead of constructing a bus that cannot deliver frames.As per path instructions, “Backends must reject
clockMultiplier > 1when they cannot sustain DMA reads of the resulting frame from PSRAM ... rather than attempting to output an unsustainably-timed waveform.”Also applies to: 389-394
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/platform/esp32/platform_esp32_i80.cpp` around lines 296 - 303, Remove the PSRAM fallback in the pin-expander initialization path around the `st->buf[0]` allocation, and require `fitsInternal` before accepting the shift-frame configuration. When the frame cannot fit internal DMA RAM, fail initialization instead of allocating from `MALLOC_CAP_SPIRAM` or constructing an unsustainably timed bus; update the related pre-check at the indicated alternate block consistently.Source: Path instructions
src/platform/esp32/platform_esp32_rmt.cpp (1)
371-384: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick winDo not turn first-bit corruption into a passing loopback result.
The test pattern’s first bit is
1; decoding it as0changes0xA5to0x25, so this is not an invisible success. Keep the settling diagnostic, but fail verification until the encoder, frame priming, or capture alignment produces the expected bitstream.As per path instructions, shift-mode loopback must “bit-verify the captured real waveform against the expected WS2812 bitstream.”
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/platform/esp32/platform_esp32_rmt.cpp` around lines 371 - 384, The loopback verification currently accepts a first-bit mismatch through onlyBit0Clip, but the expected WS2812 bitstream requires that bit to remain 1. Keep the settling diagnostic if useful, but remove the exception from the pass decision in this verification block so any mismatch, including the first bit, fails and firstBadBit identifies it; ensure shift-mode loopback continues verifying the captured waveform against the expected bitstream.Source: Path instructions
moondeck/check/check_devices.py (1)
204-227: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winNormalize and validate GPIO values before collision checks.
A present-but-null
latchPinpasses, and string-vs-integer comparisons can miss the same GPIO. Require an exact non-negative integer forlatchPin, parse each data-pin token as an integer, and compare normalized integers toclockPin/dcPin.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@moondeck/check/check_devices.py` around lines 204 - 227, Update the pinExpander validation around latchPin and the pins list to normalize GPIO values before collision checks. Require a present latchPin to be an exact non-negative integer, parse each comma-separated data-pin token as an integer while reporting invalid values, and compare normalized integers against normalized clockPin and dcPin values so equivalent string and integer GPIOs collide correctly.src/platform/esp32/platform_esp32_moon_i80.cpp (2)
1308-1310: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick winMatch the unsupported-target stub to the declared API.
This definition omits
clockMultiplierand declaresrowsPerBufassize_t, so it overloads rather than defines the function declared inplatform.h.Proposed fix
bool moonI80Ws2812InitRing(MoonI80Ws2812Handle&, const uint16_t*, uint8_t, uint16_t, size_t, - uint32_t, size_t, uint8_t, MoonI80EncodeFn, void*) { + uint32_t, uint32_t, uint8_t, uint8_t, MoonI80EncodeFn, void*) {🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/platform/esp32/platform_esp32_moon_i80.cpp` around lines 1308 - 1310, Update the unsupported-target stub moonI80Ws2812InitRing to exactly match the declaration in platform.h: add the missing clockMultiplier parameter and use the declared type for rowsPerBuf instead of size_t. Preserve the existing false return behavior.
373-403: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick winDetach LCD_CAM routes on teardown
destroyState()frees the state but never undoes the matrix connections installed byconfigureGpio(), so reinit or pin reassignment can leave the old pads attached to LCD_CAM and conflict with their next owner. Store the routed data pins and optional WR pin inMoonI80Stateand disconnect/reset them before freeing the handle.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/platform/esp32/platform_esp32_moon_i80.cpp` around lines 373 - 403, Update MoonI80State and configureGpio() to retain the routed data pins and optional WR pin, then have destroyState() disconnect or reset those LCD_CAM matrix routes before freeing buffers and deleting the state. Ensure teardown handles absent optional WR routing safely and clears every connection established during initialization.Source: Coding guidelines
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/architecture.md`:
- Line 472: Update the MultiPinLedDriver link in the LED drivers section of
docs/architecture.md to target moonmodules/light/drivers.md#multipinled,
replacing the nonexistent documentation path while preserving the surrounding
description.
In `@docs/history/plans/Plan-20260717` - MoonI80 runtime ring geometry.md:
- Around line 11-34: Correct the RAM trade-off discussion in the documented ring
geometry rationale: remove claims that only ringRows=1 keeps RAM flat as strand
length changes, since the stated ringRows × ringBufs × rowBytes formula is
constant for any fixed geometry. Reframe the sweep comparison around absolute
pool size, interrupt frequency, and DMA runway, updating the trade-off table and
conclusions while preserving the configurable ringRows/ringBufs design.
In `@moondeck/docs/gen_api.py`:
- Around line 121-122: Explicitly pass check=False in both subprocess.run calls
in moondeck/docs/gen_api.py at lines 121-122 and 415-420, preserving the
existing manual return-code handling.
In `@src/light/drivers/MultiPinLedDriver.h`:
- Around line 72-86: Validate clockPin and dcPin for negative values at the
driver entry points before invoking clockPinForBus() or busInit(), and fail
fatally when either is invalid. Apply the same pre-cast validation to the
corresponding paths identified around the constructor/configuration and
initialization logic, ensuring no negative pin can be converted to an unsigned
GPIO value.
In `@src/platform/esp32/platform_esp32_moon_i80.cpp`:
- Around line 1218-1222: Correct the loopRows calculation near loopRowBytes so
it divides dataBytes by the encoded row size, including sb and clockMultiplier,
rather than only rowBits * 3. Keep the zero-size guard and ensure the value
matches the number of rows produced by the copy encoder, preventing reads past
frame in shift mode.
In `@test/unit/light/unit_ParallelLedDriver_ring.cpp`:
- Around line 89-96: Update the mock ring-driver flow around busInitRing and
driveRingFrame to use the rows-only production contract: pass closeFrame=false
for every slice, remove latch-pad inspection and termination modeling, and
validate the zero-tail and peripheral-stop behavior instead. Ensure all buffer
accesses remain limited to the allocated row vectors.
---
Outside diff comments:
In `@docs/backlog/shift-register-driver-analysis.md`:
- Around line 164-171: Update the pin expander mode row in the shift-mode clock
table to use 26.67 MHz with prescale 3 instead of 20.000 MHz with prescale 4.
Ensure the granted rate and resulting slot timing reflect the valid 300 ns
configuration, while leaving the direct mode and surrounding clock derivation
unchanged.
- Around line 270-282: Correct the frame-size analysis around the encoded-frame
table to scope the 1,152 B/light figures to the 16-bit pin-expander target only.
Add the 6-pin target’s 8-bit calculations—576 B/light and 147,456 B for 256
lights—and ensure its 21.6 µs wire-time statement is consistent with the table
and deadline discussion.
In `@moondeck/check/check_devices.py`:
- Around line 204-227: Update the pinExpander validation around latchPin and the
pins list to normalize GPIO values before collision checks. Require a present
latchPin to be an exact non-negative integer, parse each comma-separated
data-pin token as an integer while reporting invalid values, and compare
normalized integers against normalized clockPin and dcPin values so equivalent
string and integer GPIOs collide correctly.
In `@src/light/drivers/ParallelLedDriver.h`:
- Around line 144-152: Restrict the ringSnapshot live-source toggle to a
bench-only build configuration and keep production behavior permanently
snapshot-enabled. Update the ring-path logic around ringSnapshot and its related
configuration so production cannot disable source snapshots, while preserving
the bench A/B control and default enabled behavior for bench builds.
In `@src/platform/esp32/platform_esp32_i80.cpp`:
- Around line 296-303: Remove the PSRAM fallback in the pin-expander
initialization path around the `st->buf[0]` allocation, and require
`fitsInternal` before accepting the shift-frame configuration. When the frame
cannot fit internal DMA RAM, fail initialization instead of allocating from
`MALLOC_CAP_SPIRAM` or constructing an unsustainably timed bus; update the
related pre-check at the indicated alternate block consistently.
In `@src/platform/esp32/platform_esp32_moon_i80.cpp`:
- Around line 1308-1310: Update the unsupported-target stub
moonI80Ws2812InitRing to exactly match the declaration in platform.h: add the
missing clockMultiplier parameter and use the declared type for rowsPerBuf
instead of size_t. Preserve the existing false return behavior.
- Around line 373-403: Update MoonI80State and configureGpio() to retain the
routed data pins and optional WR pin, then have destroyState() disconnect or
reset those LCD_CAM matrix routes before freeing buffers and deleting the state.
Ensure teardown handles absent optional WR routing safely and clears every
connection established during initialization.
In `@src/platform/esp32/platform_esp32_rmt.cpp`:
- Around line 371-384: The loopback verification currently accepts a first-bit
mismatch through onlyBit0Clip, but the expected WS2812 bitstream requires that
bit to remain 1. Keep the settling diagnostic if useful, but remove the
exception from the pass decision in this verification block so any mismatch,
including the first bit, fails and firstBadBit identifies it; ensure shift-mode
loopback continues verifying the captured waveform against the expected
bitstream.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 050e6863-0bf5-4706-8766-62049e2c2697
⛔ Files ignored due to path filters (1)
moondeck/build/generate_build_info.pyis excluded by!**/build/**
📒 Files selected for processing (30)
CMakeLists.txtdocs/MIGRATING.mddocs/architecture.mddocs/backlog/backlog-light.mddocs/backlog/shift-register-driver-analysis.mddocs/history/plans/Plan-20260717 - MoonI80 runtime ring geometry.mddocs/moonmodules/light/drivers.mddocs/moonmodules/light/supporting.mddocs/performance.mddocs/reference/esp32-s31-coreboard.mddocs/usecases/led-signal-integrity.mdesp32/main/CMakeLists.txtesp32/sdkconfig.defaultsmoondeck/check/check_devices.pymoondeck/docs/gen_api.pymoondeck/docs/mkdocs_hooks.pysrc/core/FirmwareUpdateModule.hsrc/light/drivers/MoonLedDriver.hsrc/light/drivers/MultiPinLedDriver.hsrc/light/drivers/ParallelLedDriver.hsrc/light/drivers/ParallelSlots.hsrc/light/drivers/PinList.hsrc/platform/desktop/platform_desktop.cppsrc/platform/esp32/platform_esp32_i80.cppsrc/platform/esp32/platform_esp32_moon_i80.cppsrc/platform/esp32/platform_esp32_parlio.cppsrc/platform/esp32/platform_esp32_rmt.cppsrc/platform/platform.htest/unit/light/unit_Drivers_rendersplit.cpptest/unit/light/unit_ParallelLedDriver_ring.cpp
| Each driver (a MoonModule) speaks one protocol: | ||
|
|
||
| - **LED drivers**: WS2812 via RMT (multi-pin), plus two parallel-output paths on the newer chips. The S3's LCD_CAM i80 bus ([LcdLedDriver](moonmodules/light/moxygen/LcdLedDriver.md)) drives exactly 8 data GPIOs — the i80 bus claims every data line of its width, so a partial set is rejected. The P4's Parlio peripheral ([ParlioLedDriver](moonmodules/light/moxygen/ParlioLedDriver.md)) drives 1–8 lanes — it takes the data GPIOs directly, so any count up to 8 is valid. Both are DMA-driven. Platform-specific; all behind the platform boundary. | ||
| - **LED drivers**: WS2812 via RMT (multi-pin), plus two parallel-output paths on the newer chips. The S3's LCD_CAM i80 bus ([MultiPinLedDriver](moonmodules/light/moxygen/MultiPinLedDriver.md)) drives exactly 8 data GPIOs — the i80 bus claims every data line of its width, so a partial set is rejected. The P4's Parlio peripheral ([ParlioLedDriver](moonmodules/light/moxygen/ParlioLedDriver.md)) drives 1–8 lanes — it takes the data GPIOs directly, so any count up to 8 is valid. Both are DMA-driven. Platform-specific; all behind the platform boundary. |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
target="docs/moonmodules/light/moxygen/MultiPinLedDriver.md"
if test -f "$target"; then
echo "Link target exists: $target"
else
echo "Missing link target: $target"
fd -t f -e md . docs/moonmodules | sort
exit 1
fiRepository: MoonModules/projectMM
Length of output: 578
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== architecture excerpt =="
sed -n '460,485p' docs/architecture.md
echo
echo "== references to MultiPinLedDriver =="
rg -n "MultiPinLedDriver" docs moonmodules -g '*.md' || true
echo
echo "== light docs files =="
fd -t f -e md . docs/moonmodules/light | sort
echo
echo "== ooxygen/moxygen paths =="
fd -t f -e md . docs/moonmodules | rg 'moxygen|ooxygen|MultiPin|LedDriver' || trueRepository: MoonModules/projectMM
Length of output: 11543
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== exact file search =="
fd -t f -e md 'MultiPinLedDriver.md' .
echo
echo "== paths containing moxygen =="
fd -t d 'moxygen' docs
echo
echo "== links around drivers doc =="
sed -n '1,170p' docs/moonmodules/light/drivers.mdRepository: MoonModules/projectMM
Length of output: 12894
Fix the MultiPinLedDriver link The target file does not exist; point this to moonmodules/light/drivers.md#multipinled instead.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/architecture.md` at line 472, Update the MultiPinLedDriver link in the
LED drivers section of docs/architecture.md to target
moonmodules/light/drivers.md#multipinled, replacing the nonexistent
documentation path while preserving the surrounding description.
| 1. **Geometry is not a mode, it is a number.** `rowsPerBuf` is already a runtime member (`platform_esp32_moon_i80.cpp:217`); the ISR, the prime loop and `nSlices` (`:882`) all read `st->rowsPerBuf`, never the constant. `kRingRows` has four trivial uses. **7 rows/buffer is exactly as easy as 1 or 16** — so the control is an integer, not a two-valued enum (which would be bespoke per *Common patterns first*). | ||
| 2. **The optimum is unknown and must be measured.** RAM and per-call overhead move in opposite directions: at 1 row the ring is 18 KB flat but pays the per-call fixed cost (the `slotBytes()`/`pinExpanderMode()` branches, two calls, two loop setups) **every light, inside the ISR, at 16× the EOF rate**; at 16 rows that cost amortizes but RAM scales with strand length and caps the driver near 240. Against a 21.6 µs budget the overhead is not negligible — **a middle value may beat both ends**, and only a bench sweep can say. | ||
| 3. **The ragged machinery already exists and is correct.** `prefillShiftRows` (`ParallelLedDriver.h:686-702`) already splits rows into RUNS sharing an active mask, and the *encoder-level* ragged tests are good (`unit_ParallelSlots.cpp:397/579/642`). `da67edf9`'s trampoline simply bypassed it by passing row 0's mask — exact only for uniform strands, and it would drive an exhausted strand HIGH (flash white at full brightness). **Every driver-level ring test is uniform** (`wireShift` never sets `ledsPerPin`), which is why it shipped. | ||
|
|
||
| **Outcome:** one ring whose geometry is a control, so the 1-vs-7-vs-16 question is answered on the bench instead of in a commit message — with the ragged path correct and pinned. | ||
|
|
||
| ## Design | ||
|
|
||
| **`ringRows` (1..64) and `ringBufs` (2..32) become number controls on `MoonLedDriver`**, alongside `forceRing`. RAM = `ringRows × ringBufs × rowBytes`, shown in `ringDbg`. Both are `prepare` triggers — a geometry change is already a full bus rebuild (`ParallelLedDriver.h:1207-1229` does `deinit()` then `busInitRing()`), exactly like `forceRing` today. | ||
|
|
||
| ### The four trade-offs (why the optimum can only be measured) | ||
|
|
||
| RAM is the only axis that favours a SMALL `ringRows`; every other axis favours a big one. That tension IS the design problem: | ||
|
|
||
| | axis | favours | detail | | ||
| |---|---|---| | ||
| | **RAM** | **small** | `ringRows × ringBufs × rowBytes`. Only at `ringRows=1` does it stop scaling with strand length (18 KB flat) — **the sole reason the per-light ring exists**, since 48×256 has no other route. | | ||
| | **Per-call overhead** | big | Fixed cost per `encode` call (`slotBytes()`/`pinExpanderMode()` branches, 2 calls, 2 loop setups) amortizes over `ringRows`. At 1 it is paid **every light, inside the ISR**. | | ||
| | **Interrupt rate** | big | One EOF per buffer → `lights/ringRows` interrupts per frame. At 1 row, 256 lights, 100 fps = **25,600 int/s**; at 16 rows, 1,600. Measured precedent: a ~19 ms core-0 encode starved the W5500 ethernet on the LC16 (HTTP died, render ticks fine). | | ||
| | **Lap-time runway** | big | Runway before the DMA laps a buffer the ISR is still refilling = `ringRows × ringBufs × 21.6 µs`. At 1×32 ≈ **690 µs**; at 16×12 ≈ **4.1 ms**. A per-light ring is far less forgiving of a WiFi preemption — which is why `da67edf9` needed `ringBufs=32`. | | ||
|
|
||
| **So the per-light ring is not "better" — it is the only geometry whose RAM is flat.** If the sweep shows 7 or 8 meets the 21.6 µs deadline at 256 lights *and* fits, that beats 1 on three axes out of four. | ||
|
|
||
| **Above 16 is legal and worth sweeping** (hence 1..64, not 1..16): nothing in the code caps it, and at shorter strands a big `ringRows` buys interrupt rate and runway cheaply. The real floor is the other end — `nSlices = ceil(totalRows / ringRows)` must be enough slices to be a ring at all; at 2 slices it is a whole frame in two pieces (which is what `bf16` was silently doing). |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Correct the ring-memory trade-off premise.
With the documented formula, any fixed ringRows/ringBufs geometry has constant RAM as strand length changes—not only ringRows=1. Rewrite the sweep rationale around absolute pool size, interrupt rate, and runway so it does not optimize from a false scaling assumption.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/history/plans/Plan-20260717` - MoonI80 runtime ring geometry.md around
lines 11 - 34, Correct the RAM trade-off discussion in the documented ring
geometry rationale: remove claims that only ringRows=1 keeps RAM flat as strand
length changes, since the stated ringRows × ringBufs × rowBytes formula is
constant for any fixed geometry. Reframe the sweep comparison around absolute
pool size, interrupt frequency, and DMA runway, updating the trade-off table and
conclusions while preserving the configurable ringRows/ringBufs design.
| out = subprocess.run(["git", "rev-parse", "--abbrev-ref", "HEAD"], | ||
| cwd=ROOT, capture_output=True, text=True, timeout=5) |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value
Explicitly set check=False for subprocess.run.
Since the code intentionally handles the non-zero return code manually rather than letting it raise an exception, explicitly setting check=False clarifies the intent and satisfies static analysis tools.
moondeck/docs/gen_api.py#L121-L122: Addcheck=Falseto thesubprocess.runcall.moondeck/docs/gen_api.py#L415-L420: Addcheck=Falseto thesubprocess.runcall.
🧰 Tools
🪛 Ruff (0.15.21)
[warning] 121-121: subprocess.run without explicit check argument
Add explicit check=False
(PLW1510)
[error] 121-121: Starting a process with a partial executable path
(S607)
📍 Affects 1 file
moondeck/docs/gen_api.py#L121-L122(this comment)moondeck/docs/gen_api.py#L415-L420
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@moondeck/docs/gen_api.py` around lines 121 - 122, Explicitly pass check=False
in both subprocess.run calls in moondeck/docs/gen_api.py at lines 121-122 and
415-420, preserving the existing manual return-code handling.
Source: Linters/SAST tools
| /// **Give it a real, free GPIO — do not set -1.** Bench-proven that nothing on a WS2812 strand reads | ||
| /// WR or DC (4096 lights over 16 lanes, and 1440 through a '595, both render with both pins at -1: | ||
| /// the peripheral generates the signals internally and the GPIO matrix only carries them off-chip). | ||
| /// But -1 does not MEAN "unrouted" here — it means **65535**: the value reaches the platform as | ||
| /// `uint16_t`, which slips past IDF's `wr_gpio_num >= 0 && dc_gpio_num >= 0` check | ||
| /// (esp_lcd_panel_io_i80.c), where a properly-typed `GPIO_NUM_NC` would be rejected outright. | ||
| /// `esp_lcd` then hands 65535 to `esp_rom_gpio_connect_out_signal`, and what happens next is PER-TARGET | ||
| /// ROM, not an API contract: the S3 and classic ROMs open with an unsigned bounds compare and return | ||
| /// without writing (a silent no-op), but the **ESP32-P4 ROM has no such guard** and computes a store | ||
| /// ~0x50120554 — a quarter-megabyte past the GPIO block, in another peripheral's window — plus a | ||
| /// >31-bit shift. This driver runs on the P4. IDF's own `esp_rom/patches/esp_rom_gpio.c` is unguarded | ||
| /// too, so the S3's check is an implementation detail a patch could remove, not a promise. FastLED's | ||
| /// LCD_CAM driver parks both pins on a dummy GPIO for the same reason. To spend no GPIO at all, use | ||
| /// MoonLedDriver: owning the DMA below esp_lcd, it holds DC at a constant level and routes WR only | ||
| /// when a '595 needs it as SRCLK. |
There was a problem hiding this comment.
🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win
Reject negative clockPin and dcPin before casting.
The comments document that -1 becomes GPIO 65535 and can trigger an out-of-range peripheral write on ESP32-P4, but the code still permits exactly that path. Add fatal validation for both pins before clockPinForBus() or busInit() can cast them.
Proposed fix
const char* validateBusFatal() const {
+ if (clockPin < 0)
+ return "clockPin (WR) must be a valid GPIO";
+ if (dcPin < 0)
+ return "dcPin must be a valid GPIO";
if (clockPin >= 0 && clockPin == dcPin)Also applies to: 104-107, 138-151, 182-186
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/light/drivers/MultiPinLedDriver.h` around lines 72 - 86, Validate
clockPin and dcPin for negative values at the driver entry points before
invoking clockPinForBus() or busInit(), and fail fatally when either is invalid.
Apply the same pre-cast validation to the corresponding paths identified around
the constructor/configuration and initialization logic, ensuring no negative pin
can be converted to an unsigned GPIO value.
Fixes two MoonI80 shift-ring failures at small ringRows (a frozen frame from a too-short WS2812 reset, and intermittent garbage from an ISR racing the DMA), pins the driver's clamp-to-capacity invariant with a test, and makes CLI flashing record the serial port so repeat flashes don't re-probe. Also lands a MoonLive buffer-overflow guard from the PR #29 review. KPI: tick:13424us(FPS:74) Core - platform_esp32_moon_i80: reset the WS2812 latch by TIME (>=350us idle-LOW via lastStopUs) not by tail-buffer count, so small ringRows no longer freezes; gate the ISR's zero-behind-DMA refill to lapping frames only (prime-only frames no longer race the DMA's FIFO tail). Self-terminating-chain scaffolding (bufLastNode[]/termNode/itemsPerBuf) added, inert/default-off, for the next iteration. - platform.h / platform_esp32_rmt / desktop stub: driver-agnostic ws2812LoopbackRide (intrusive loopback, parked - an RMT-RX cannot capture on a GPIO the peripheral is actively driving). Light domain - ParallelLedDriver: loopbackIntrusive mode + snapshot pattern-hold (default-off, hot-path-clean, one leading short-circuit per ring frame); busLoopbackRide; shared reportLoopbackResult extracted from the private-bus path. - MoonLiveEffect: guard tick() when channelsPerLight() < 3 - the native emitter stores R/G/B at +0/+1/+2 with cpl only as the stride, so a sub-RGB layer would overflow the layer buffer on the last light. - MoonLedDriver: thread live ringRows/ringBufs/useRing into the loopback. Scripts / MoonDeck - flash_esp32.py: write last_port into moondeck.json directly on a CLI flash (was only set by the MoonDeck GUI's discover/refresh, so a board flashed purely from the CLI never gained a last_port and every later flash re-probed every serial port). - check_devices.py: normalize GPIO to int before the latch/data-pin collision checks. Tests - unit_ParallelLedDriver_pinexpander: pin the clamp-to-capacity invariant - a driver drives exactly pins x ledsPerPin, ignores a larger layout, and every source read stays in-bounds. - test_flash_last_port (new): 5 cases for the moondeck.json last_port write (match by MAC, case-insensitive, strip a stale port off a swapped-out board, no-MAC / unknown-MAC no-ops). - unit_ParallelLedDriver_ring: ASan-safe rows-only mock (mirrors the production rows-only ring contract). Docs / CI - Backlog: MoonLive core/platform layering + JIT sdkconfig scoping (4 CodeRabbit Majors); loopback teardown-leak + heap fragmentation; parked ride-the-live-ring loopback; ring analysis clock-table fix. - Plan-20260718: the MoonI80 ring trailing-refill + loopback-instrument design arc, including the self-terminate attempts and the open GDMA link-index puzzle. Reviews - CodeRabbit #29: fixed the Critical MoonLive cpl<3 overflow guard; 3 findings verified already-resolved/moot (plan modifyLogical contract, Stage-0 (shipped) suffix, future-dated benchmark now past); 4 Majors backlogged (mm_core/mm_platform layering, src/core platform include, W^X sdkconfig default, scenario hermeticity) with reasons in backlog-core.md. Skipped gate: the scenario suite shows 4 tick-timing overruns - pre-existing and machine-load-dependent (verified identical on a clean-baseline re-run), the scenario-hermeticity issue already tracked in the backlog; not caused by this change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 4
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (3)
docs/backlog/backlog-light.md (2)
267-267: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winDo not label missing coverage as the root cause.
The absent test explains why the stall was missed, not why the bus stalls. Call this a detection/investigation gap and keep the rebuild path as the unresolved root-cause investigation.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@docs/backlog/backlog-light.md` at line 267, Revise the backlog wording to describe the missing shift-mode loopback test as a detection or investigation gap, not the root cause of the bus stall. Preserve the unresolved root-cause investigation of the rebuild path, while retaining the end-to-end mock-bus test and encodeLoopbackFrameShift context.
112-112: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winScope the “PSRAM bandwidth” conclusion to the specific symptom.
This line says PSRAM bandwidth was ruled out, while Lines 40-44 document measured shift-mode PSRAM stalls at the shift clock. Clarify that it was ruled out only as the explanation for this particular whole-frame corruption.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@docs/backlog/backlog-light.md` at line 112, Update the “PSRAM bandwidth” item in the hypothesis list to state that it was ruled out only as the cause of this specific whole-frame corruption, while preserving the documented measured shift-mode PSRAM stalls at the shift clock.src/platform/esp32/platform_esp32_rmt.cpp (1)
319-356: 🩺 Stability & Availability | 🔴 Critical | ⚡ Quick winRide-mode capture task can outlive the freed
rxSymbolsbuffer (use-after-free).In ride mode the capture task re-arms up to
attempts = 40times, eachrmtWs2812RxCapture(..., 1000), so it can run ~40 s writing intoc->buf(rxSymbols). But the outer completion wait is onlywaitTicks = 600(6 s). Whencap.donenever becomes true — a dead/mis-wired jumper, or a scattered ring that never delivers a fullkBitsframe (precisely the fault this instrument exists to catch) — the outer loop exits at 6 s andheap_caps_free(rxSymbols)(Line 410) frees the buffer while the task is still callingrmt_receiveinto it. That is a use-after-free / heap corruption reachable from theloopbackIntrusiveself-test.Bound the task's worst-case runtime to the wait window (or extend the wait to cover it), so the buffer is never freed while the task can still write to it.
🐛 One option: keep the total re-arm budget inside the wait ceiling
- const int attempts = c->ride ? 40 : 1; // ~40 × up-to-1-frame arms ≈ well inside the 4 s outer wait + // Bound the total re-arm time to stay INSIDE the outer wait ceiling below, so the buffer is never + // freed while this task can still write into it. ride: <= ~5 s of arms vs the 6 s ceiling. + const int attempts = c->ride ? 5 : 1;If more retries are genuinely needed, raise
waitTicksin lockstep (attempts × per-arm-timeout < waitTicks × 10 ms), or join the task before freeingrxSymbols.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/platform/esp32/platform_esp32_rmt.cpp` around lines 319 - 356, Update the rxTask retry budget and completion wait in loopbackIntrusive so the task always finishes before rxSymbols is freed. Ensure attempts × the per-capture timeout remains within the waitTicks ceiling, or extend waitTicks to cover all retries; preserve bounded retries and do not allow the function to proceed to heap_caps_free while cap.done is false.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/backlog/backlog-light.md`:
- Line 24: Update the ring-streaming statement in the backlog entry to
explicitly compare per-light times: state that producer encode time per light
must be less than or equal to the consumer wire-time budget, avoiding the
ambiguous producer/consumer rate inequality.
- Around line 13-24: Update the opening backlog statement to describe slice
count as the only surviving correlation, not the proven root cause, and preserve
the existing evidence and ruled-out hypotheses. Keep the explicit bisect call in
the same section as the required next step to isolate the boundary before
asserting causality.
In `@src/platform/esp32/platform_esp32_moon_i80.cpp`:
- Around line 1302-1305: Update the loopRows calculation near loopRowBytes to
derive strand rows from dataBytes divided by rowBits * 3, without including sb
or clockMultiplier. Keep loopRowBytes unchanged as the per-row slice width used
by the ring copy.
In `@test/python/test_flash_last_port.py`:
- Around line 65-76: Add a regression test covering an S31/EUI-64-truncated MAC
alias accepted by moondeck.py, verifying _set_last_port_in_catalog updates the
matching catalog entry’s last_port instead of treating it as unknown. Keep the
existing no-op tests unchanged.
---
Outside diff comments:
In `@docs/backlog/backlog-light.md`:
- Line 267: Revise the backlog wording to describe the missing shift-mode
loopback test as a detection or investigation gap, not the root cause of the bus
stall. Preserve the unresolved root-cause investigation of the rebuild path,
while retaining the end-to-end mock-bus test and encodeLoopbackFrameShift
context.
- Line 112: Update the “PSRAM bandwidth” item in the hypothesis list to state
that it was ruled out only as the cause of this specific whole-frame corruption,
while preserving the documented measured shift-mode PSRAM stalls at the shift
clock.
In `@src/platform/esp32/platform_esp32_rmt.cpp`:
- Around line 319-356: Update the rxTask retry budget and completion wait in
loopbackIntrusive so the task always finishes before rxSymbols is freed. Ensure
attempts × the per-capture timeout remains within the waitTicks ceiling, or
extend waitTicks to cover all retries; preserve bounded retries and do not allow
the function to proceed to heap_caps_free while cap.done is false.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 61331e77-9ce3-4887-b04a-ce26d90ad174
⛔ Files ignored due to path filters (1)
moondeck/build/flash_esp32.pyis excluded by!**/build/**
📒 Files selected for processing (17)
docs/backlog/backlog-core.mddocs/backlog/backlog-light.mddocs/backlog/shift-register-driver-analysis.mddocs/history/plans/Plan-20260718 - MoonI80 ring trailing-refill + loopback instrument.mdmoondeck/check/check_devices.pysrc/light/drivers/MoonLedDriver.hsrc/light/drivers/ParallelLedDriver.hsrc/light/moonlive/MoonLiveEffect.hsrc/platform/desktop/platform_desktop.cppsrc/platform/esp32/platform_esp32_i80.cppsrc/platform/esp32/platform_esp32_moon_i80.cppsrc/platform/esp32/platform_esp32_parlio.cppsrc/platform/esp32/platform_esp32_rmt.cppsrc/platform/platform.htest/python/test_flash_last_port.pytest/unit/light/unit_ParallelLedDriver_pinexpander.cpptest/unit/light/unit_ParallelLedDriver_ring.cpp
| - **The frame breaks between 8 and 16 SLICES — and nothing else explains it (bench, 2026-07-17).** Every mechanism proposed for this has been killed on the wall, in this order: **reuse** (`bufs=17` for a 16-slice frame — no lap at all — still scatters), the **refill cursor** (rewritten to target the buffer the EOF just drained; no change), the **lap**, the **encode** (`enc0` scatters; 65 µs/light at 3× over the deadline renders fine), and the **descriptor pool** (36 nodes in both a clean and a scattered config). What survives is only the slice count: | ||
|
|
||
| | lights | rows | slices | bufs | laps? | ISR | wall | | ||
| |---|---|---|---|---|---|---| | ||
| | 128 | 16 | **8** | 12 | no | `enc0` | ✅ clean | | ||
| | 128 | whole-frame | 1 | — | — | — | ✅ clean | | ||
| | 128 | 8 | **16** | 12 | yes | encodes | ❌ scattered | | ||
| | 128 | 8 | **16** | **17** | **no** | `enc0` | ❌ scattered | | ||
| | 256 | 16 | **16** | 12 | yes | encodes | ❌ scattered | | ||
|
|
||
| **The counters are blind to it**: `descErr=0` and healthy timings in every scattered case. The symptom is a **CORRECT geometry drawn in SCATTERED DOTS** — right bytes, chopped frame — which is why it reads as a wire/latch fault, not a data fault. Note the two earlier entries here (first "reuse works", then "REUSE IS THE BLOCKER") were BOTH wrong, and both were written from counters; the wall settled it each time. **Next: bisect the boundary (rows 16/13/11/10/9/8 at 128 lights = 8/10/12/13/15/16 slices) — its exact value is the evidence — or ask hpwit, who runs a per-pixel ring with hundreds of slices on this silicon.** | ||
| - **The encode misses the wire.** 576 B/light at 26.67 MHz = a **21.6 µs/light** budget; the measured encode is **~46 µs/light** after the `-O2` and prefill wins. A ring only streams while producer ≤ consumer, so this is the one thing between us and 48×256. The per-light decomposition and the six ruled-out hypotheses are in [the shift-register analysis](shift-register-driver-analysis.md#76-the-1-led-ring--what-the-first-attempt-proved). |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Do not present the slice-count correlation as the proven root cause.
The table establishes a boundary, but not causality; Line 23 still calls for a bisect. Replace “nothing else explains it” with “the only surviving correlation” until the boundary is isolated.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/backlog/backlog-light.md` around lines 13 - 24, Update the opening
backlog statement to describe slice count as the only surviving correlation, not
the proven root cause, and preserve the existing evidence and ruled-out
hypotheses. Keep the explicit bisect call in the same section as the required
next step to isolate the boundary before asserting causality.
| | 256 | 16 | **16** | 12 | yes | encodes | ❌ scattered | | ||
|
|
||
| **The counters are blind to it**: `descErr=0` and healthy timings in every scattered case. The symptom is a **CORRECT geometry drawn in SCATTERED DOTS** — right bytes, chopped frame — which is why it reads as a wire/latch fault, not a data fault. Note the two earlier entries here (first "reuse works", then "REUSE IS THE BLOCKER") were BOTH wrong, and both were written from counters; the wall settled it each time. **Next: bisect the boundary (rows 16/13/11/10/9/8 at 128 lights = 8/10/12/13/15/16 slices) — its exact value is the evidence — or ask hpwit, who runs a per-pixel ring with hundreds of slices on this silicon.** | ||
| - **The encode misses the wire.** 576 B/light at 26.67 MHz = a **21.6 µs/light** budget; the measured encode is **~46 µs/light** after the `-O2` and prefill wins. A ring only streams while producer ≤ consumer, so this is the one thing between us and 48×256. The per-light decomposition and the six ruled-out hypotheses are in [the shift-register analysis](shift-register-driver-analysis.md#76-the-1-led-ring--what-the-first-attempt-proved). |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Make the producer/consumer inequality explicit.
producer ≤ consumer is correct only when comparing per-unit times; for rates, the direction is reversed. State this as “producer encode time per light must be ≤ the consumer wire-time budget.”
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/backlog/backlog-light.md` at line 24, Update the ring-streaming
statement in the backlog entry to explicitly compare per-light times: state that
producer encode time per light must be less than or equal to the consumer
wire-time budget, avoiding the ambiguous producer/consumer rate inequality.
The MoonI80 shift-ring's prime-only regime now self-terminates: the DMA chain is mounted NULL-terminated at build time, one node per buffer, one interrupt per frame - no mid-frame stop, no EOF counting. Wall-verified clean at 60, 128, and 192 lights per strand; 256+ (the lapping regime) is the remaining work. Also processes the CodeRabbit round: a loopback row-count unit fix, a capture-buffer use-after-free guard, and S31 MAC-alias support in the flash-script port capture. KPI: tick:19866us(FPS:50) (measured at the 192-lights/strand config, 3072 lights driven) Core - platform_esp32_moon_i80: prime-only frames mount a self-terminating chain (buffer nSlices -> NULL at build time), clamp rowsPerBuf so one buffer = one DMA descriptor node (hpwit's structure - a later per-buffer mount re-links a mid-chain NULL away, and multi-node buffers broke the walk), and mark suc_eof only on the terminator: one interrupt per frame, so latch-coalesced EOFs can no longer undercount the drain and wedge the driver. The ISR skips gdma_stop for self-terminated frames. Lapping (nSlices > ringBufs) keeps the looping chain + counter stop unchanged. Loopback ring row count now derives from strand-side units (dataBytes / (rowBits*3)), not bus bytes. - platform_esp32_rmt: ride-mode capture retries fit inside the wait ceiling (100 ms arms) and the capture buffer is never freed while the rx task can still write it. - platform.h: ring stats expose itemsPerBuf + the terminator node - the lapping-phase instruments, kept until 256/strand ships. Light domain - MoonLedDriver: ringDbg carries the lapping-phase fields (ld/tx/ipb/ci/tn), buffer sized to fit. Scripts / MoonDeck - flash_esp32.py: last_port matching reuses moondeck's _mac_matches, so an S31's EUI-64-truncated MAC still links its port. Tests - test_flash_last_port: S31 EUI-64 alias regression (81 Python tests total). Docs / CI - Backlog ring entry rewritten to the two-regime state (prime-only ships / lapping open, encode re-measure note); loopback-stall entry reframed (detection gap, root cause open, teardown-leak cross-linked); PSRAM-bandwidth ruled-out item scoped to the whole-frame corruption. - Plan-20260718: the full three-bug resolution arc. Reviews - CodeRabbit: fixed the loopback loopRows unit mix, the ride-capture use-after-free window, and the S31 MAC-alias gap (+ regression test); reworded two backlog claims; 2 findings skipped - they target the ring-backlog text replaced by this branch's rewrite. Skipped gate: the same 4 pre-existing scenario tick-timing overruns (machine-load-dependent, baseline-verified previously; scenario-hermeticity tracked in the backlog). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 6
♻️ Duplicate comments (1)
docs/backlog/backlog-light.md (1)
255-260: 🎯 Functional Correctness | 🟠 MajorEncode zeroes for spacer LEDs; do not leave them unmapped.
Line [259] still says unaddressed physical LEDs “never receive data and hold LOW.” WS2812 LEDs retain their previously latched values, so skipped positions on an active strand must receive zero bytes every frame. This repeats the previously reported spacer-state issue.
Proposed fix
-... so the un-addressed LEDs simply never receive data and hold LOW. +... so spacer positions are encoded and transmitted as zeroes every frame.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@docs/backlog/backlog-light.md` around lines 255 - 260, Update the spacer/gapped-strand layout specification to require explicit zero output for every spacer LED on each frame, rather than leaving spacer coordinates unmapped or assuming they remain LOW. Ensure the design preserves the logical-to-physical mapping while clearing all gap positions before or during driver output.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/backlog/backlog-light.md`:
- Around line 34-42: Rewrite the affected backlog entries in present-tense
status or acceptance-criteria language. In the sections containing “Measure
once,” “remove once,” “next attempt,” and “first step,” replace
roadmap/action-plan phrasing with statements describing current implementation
state or the conditions that define completion, while preserving the technical
details and control names.
- Line 23: Replace the truncated plan reference in the backlog entry with the
complete existing filename, including the full “Plan-20260718 - MoonI80 ring
trailing-refill + loopback instrument.md” path, so readers can open it directly.
In `@docs/history/plans/Plan-20260718` - MoonI80 ring trailing-refill + loopback
instrument.md:
- Around line 256-257: Add a blank line immediately after the “RESOLVED:
prime-only self-termination SHIPS” Markdown heading, leaving the heading text
and following paragraph unchanged.
In `@src/platform/esp32/platform_esp32_moon_i80.cpp`:
- Around line 994-998: Update moonI80Ws2812InitRing() to clamp rowsPerBuf to the
effective descriptor-row limit before calculating the ring heap-fit value need.
Reuse that normalized value for nSlices, descriptor-pool sizing, and ring
construction, including the existing minimum of one row, so the pre-check
reflects the geometry actually initialized.
In `@src/platform/esp32/platform_esp32_rmt.cpp`:
- Around line 417-426: Move the Cap context used by captureAndVerifyFrame from
stack storage to heap storage so rxTask can safely access it after the function
returns. Update the Cap declaration and initialization to use a nothrow
allocation, clean up cap_ptr on all successful completion paths after the RX
task has finished, and retain the allocation on the existing wedged-task leak
path alongside rxSymbols.
In `@src/platform/platform.h`:
- Around line 829-831: Update the ring-diagnosis comments at
src/platform/platform.h lines 829-831, src/light/drivers/MoonLedDriver.h lines
217-220, and src/platform/esp32/platform_esp32_moon_i80.cpp lines 1035-1037 to
use present-tense descriptions of their current diagnostic and lapping behavior.
Remove future milestones, “until 256+/strand ships” wording, removal-list
narration, and later-phase roadmap references without changing the associated
implementation.
---
Duplicate comments:
In `@docs/backlog/backlog-light.md`:
- Around line 255-260: Update the spacer/gapped-strand layout specification to
require explicit zero output for every spacer LED on each frame, rather than
leaving spacer coordinates unmapped or assuming they remain LOW. Ensure the
design preserves the logical-to-physical mapping while clearing all gap
positions before or during driver output.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 15a86703-a4b7-4dbd-b9b3-1c2e0691407c
⛔ Files ignored due to path filters (1)
moondeck/build/flash_esp32.pyis excluded by!**/build/**
📒 Files selected for processing (7)
docs/backlog/backlog-light.mddocs/history/plans/Plan-20260718 - MoonI80 ring trailing-refill + loopback instrument.mdsrc/light/drivers/MoonLedDriver.hsrc/platform/esp32/platform_esp32_moon_i80.cppsrc/platform/esp32/platform_esp32_rmt.cppsrc/platform/platform.htest/python/test_flash_last_port.py
The ESP32 CPU ran at IDF's silent 160 MHz default, taxing every render and ring-encode benchmark 1.5x; it now runs at the chip's rated 240 MHz, with a new read-only System cpu control that reports the running clock and core count straight from the hardware. The MoonI80 ring's ISR encode chain moves to IRAM and its snapshot pre-applies color correction on the render thread, and the measured result is 192 lights/strand prime-only at 80 fps (wall-verified clean) with the 48x256 lapping gap measured at 1.34x worst-case — the size of the planned zero-pad lever.
KPI: 16384lights | Desktop:786KB | tick:126/100/6/126/19/3/280/71/17/27/165/127/22/6/46us(FPS:7936/10000/166666/7936/52631/333333/3571/14084/58823/37037/6060/7874/45454/166666/21739) | tick:12083us(FPS:82) | heap:8178KB | src:197(43912) | test:136(23720) | lizard:155w
Core:
- platform_config.h (esp32/desktop): MM_RAMFUNC macro — the __ramfunc/IRAM_ATTR concept behind the platform boundary (IRAM on ESP32, empty on desktop)
- platform.h: allocInternal-backed cpuInfo() seam ("240 MHz, 2 cores", read from the running clock so a config/hardware mismatch is visible); MoonI80EncodeFn ring-diagnostic comments rewritten present-tense
- platform_esp32.cpp / platform_desktop.cpp: cpuInfo() implementations (esp_rom ticks-per-us + esp_chip_info cores; hardware_concurrency on desktop)
- platform_esp32_moon_i80.cpp: encodeRingSlice IRAM_ATTR (the ISR encode entry); InitRing clamps rowsPerBuf to the one-node limit BEFORE the heap-fit pre-check so an oversized ringRows cannot force a spurious whole-frame fallback; instrument markers renamed (diagnostic)
- platform_esp32_rmt.cpp: loopback Cap context heap-allocated so the wedged-rx-task exit leaks it alongside the capture buffer instead of dangling a stack frame
- SystemModule: cpu read-only control alongside chip
Light domain:
- ParallelSlots.h: MM_RAMFUNC on the ISR-reachable encoders (transpose pair, shift prefill/data/latch-pad, parallel slots) — placement verified via nm at IRAM addresses
- ParallelLedDriver.h: snapshotSourceForRing() now stores PRE-CORRECTED wire bytes (Correction::apply per light during the render-thread copy, outCh stride, corrected pattern-hold), encodeRows gathers with a plain memcpy in snapshot mode; ensureSnapshotCap sized by outChannels; uniformLaneCounts() ignores empty lanes so a 15-of-16-strand wall runs the cheap prefill-skip path (enc 313->228us measured); MM_RAMFUNC on encodeRows/prefillShiftRows
- DriverBase.h: ensureWire() allocates internal-first (the encoder's hottest scratch)
- MoonLedDriver.h: ring trampoline MM_RAMFUNC; ringDbg comment present-tense
Tests:
- unit_ParallelLedDriver_ring: new empty-lane-uniformity case pins the prefill-skip gate and byte-identity for the 15-of-16-strand shape (28 ring tests total)
Docs / CI:
- esp32/sdkconfig.defaults: CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240 (P4 ignores the symbol, keeps 360)
- esp32/main/CMakeLists.txt: -mauto-litpools on Xtensa — IRAM_ATTR template instantiations need their literal pools in-text (l32r "literal placed after use" otherwise); RISC-V gated off
- backlog-light: ring entry updated with the measured target-shape numbers (350 vs 262us/slice, 1.34x) and the promoted zero-pad lever; spacer-layout note states the clocked-zeros physics; full plan filename referenced
- Plan-20260718: the day's lever measurements + the 240 MHz finding appended as measurement 2
Reviews:
- 🐇 backlog roadmap phrasing: skipped — docs/backlog/ is the documented present-tense exemption (facts refreshed with today's measurements)
- 🐇 truncated plan filename in backlog: fixed
- 🐇 missing blank line after plan-doc heading: fixed
- 🐇 InitRing heap pre-check on unclamped rowsPerBuf: confirmed and fixed (clamp hoisted before the pre-check)
- 🐇 loopback Cap stack lifetime on the wedged-task path: confirmed and fixed (heap Cap, leak-with-log on wedge)
- 🐇 future-tense ring-diagnostic comments: rewritten present-tense; diagnostics themselves stay per PO decision
- 🐇 spacer LEDs need explicit zeros: accepted — backlog design note now states the serial-strand physics
KPI Details:
Desktop:
Lights: 16,384
Binary: 786 KB
[doctest] test cases: 827 | 827 passed | 0 failed | 0 skipped
tick: 126us, 100us, 6us, 126us, 19us, 3us, 280us, 71us, 17us, 27us, 165us, 127us, 22us, 6us, 46us (FPS: 7936, 10000, 166666, 7936, 52631, 333333, 3571, 14084, 58823, 37037, 6060, 7874, 45454, 166666, 21739) (per scenario)
=== 20 scenario(s), 20 passed, 0 failed ===
Platform boundary: PASS
Specs: Spec check: 90 modules, 90 ok, 0 missing, 0 outdated, 0 source-link issues, 0 docPath issues
ESP32:
Image: 1,485,982 bytes (65% partition free)
tick: 12083us (FPS: 82) heap free: 8374743
Code:
197 source files (43912 lines)
136 test files (23720 lines)
144 specs, 20 scenarios
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
docs/backlog/backlog-light.md (1)
122-126: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick winNarrow the shift-mode hardware claim to the tested path.
Line 124 says the hardware renders smooth, flicker-free content, but Lines 126-128 immediately document severe corruption for the whole-frame S3 path when the frame spills into PSRAM. Specify the verified backend/configuration (for example, the MoonI80 ring or internal-RAM case) so the status is not interpreted as applying to all shift-mode drivers.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@docs/backlog/backlog-light.md` around lines 122 - 126, Narrow the “smooth, flicker-free” hardware claim in the shiftRegister entry to the verified configurations, explicitly naming the MoonI80 streaming ring and/or whole-frame internal-RAM case. Clarify that it does not apply to whole-frame S3 operation when frames spill into PSRAM, while preserving the existing measurement details.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/core/SystemModule.h`:
- Around line 15-17: Update the hardware-facts documentation in SystemModule.h
to describe CPU clock reporting as target-dependent, matching the desktop
formatter’s “N cores” output. Apply the same wording to the related comment
lines covering CPU clock details, without changing unrelated hardware
descriptions.
---
Outside diff comments:
In `@docs/backlog/backlog-light.md`:
- Around line 122-126: Narrow the “smooth, flicker-free” hardware claim in the
shiftRegister entry to the verified configurations, explicitly naming the
MoonI80 streaming ring and/or whole-frame internal-RAM case. Clarify that it
does not apply to whole-frame S3 operation when frames spill into PSRAM, while
preserving the existing measurement details.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 6e006a38-49a7-4600-bae8-79d6b4b000c6
📒 Files selected for processing (17)
docs/backlog/backlog-light.mddocs/history/plans/Plan-20260718 - MoonI80 ring trailing-refill + loopback instrument.mdesp32/main/CMakeLists.txtesp32/sdkconfig.defaultssrc/core/SystemModule.hsrc/light/drivers/DriverBase.hsrc/light/drivers/MoonLedDriver.hsrc/light/drivers/ParallelLedDriver.hsrc/light/drivers/ParallelSlots.hsrc/platform/desktop/platform_config.hsrc/platform/desktop/platform_desktop.cppsrc/platform/esp32/platform_config.hsrc/platform/esp32/platform_esp32.cppsrc/platform/esp32/platform_esp32_moon_i80.cppsrc/platform/esp32/platform_esp32_rmt.cppsrc/platform/platform.htest/unit/light/unit_ParallelLedDriver_ring.cpp
The pin-expander streaming ring now renders 256 lights per strand cleanly (wall-verified), the size the whole ring effort targeted. The lapping refill no longer trusts its interrupt count — it derives the drain position from elapsed time (the DMA free-runs at exact wire speed) and batch-refills toward that, so a coalesced interrupt can no longer skip a slice and shift the frame. A per-buffer shared zero-pad (ringPadUs) buys refill-deadline headroom where a strip tolerates it, the frame ends on the clock rather than a count, and the GDMA interrupt runs cache-safe in IRAM. Instruments added: a `late` counter (the machine's scatter meter), a primed/lapping regime word on the driving status, and the System `cpu` control now reports cores-only on desktop.
KPI: 16384lights | Desktop:786KB | tick:131/102/8/138/21/4/278/67/18/22/166/124/22/6/45us(FPS:7633/9803/125000/7246/47619/250000/3597/14925/55555/45454/6024/8064/45454/166666/22222) | tick:14240us(FPS:70) | heap:8211KB | src:197(44095) | test:136(23765) | lizard:155w
Core:
- platform_esp32_moon_i80.cpp: rewrote the lapping EOF ISR to the clock oracle — drainPos = elapsed/sliceNs, batch-refill up to (drainPos + ringBufs − kLead) capped per firing so the pool absorbs an encode spike (only the AVERAGE refill must beat the slice); clock-keyed engine stop over the zeroed tail (a late stop just clocks more reset); interleaved shared zero-pad node (ringPadUs) mounted after each buffer; GDMA channel now intr_priority 3 + isr_cache_safe with a spi_flash_cache_enabled() defer guard (a cache-off firing during a flash write would else fault reading PSRAM-mapped data); ring cache-line size hoisted out of the ISR; `late` diagnostic
- platform.h: moonI80Ws2812InitRing gains padUs; MoonI80RingStats.late; kRingPadMaxUs bound
- SystemModule: cpu doc narrowed to "cores, plus the running clock on ESP32; desktop cores only"
Light domain:
- MoonLedDriver: ringPadUs control (0..kRingPadMaxUs, a prepare trigger) threaded through busInitRing; busRingMode() reports primed/lapping; ringDbg gains `lt`; the three ring controls documented in full (who tunes them, the one-node rows clamp, the pool-as-jitter-buffer, and that ringPadUs' ceiling is the strip's per-silicon latch threshold — measure on the wall)
- ParallelLedDriver: reinit re-issues the driving status with the ring regime word once the ring is built
- DriverBase: setDrivingInfo takes an optional one-word mode suffix
Tests:
- unit_ParallelLedDriver_ring: coalesced-EOF regression (driveRingFrameCoalesced) — batched refill is byte-identical to one-per-EOF whatever the grouping, the pin the old design could not pass
Docs / CI:
- esp32/sdkconfig.defaults: CONFIG_GDMA_CTRL_FUNC_IN_IRAM (the cache-safe ISR calls gdma_stop)
- drivers.md: ringPadUs added to the Moon card + one-liner pointing at the technical page
- backlog-light: ring entry rewritten to the shipped clock-oracle state + the measured 48-strand encode gap (466 vs 181 µs, unroll/19.2 MHz the named levers) + the white-flash residual
- Plan-20260718 lapping-v2: saved with its full outcome (phases A/B shipped, the cache guard + latch findings, phase C's verdict)
Reviews:
- 🐇 SystemModule cpu doc claimed "running clock + core count" unconditionally: fixed — desktop reports cores only
- 🐇 backlog "smooth, flicker-free" was unqualified: narrowed to the internal-RAM/ring configs, excluding whole-frame S3 PSRAM spill (which the next paragraph documents as flickering)
KPI Details:
Desktop:
Lights: 16,384
Binary: 786 KB
[doctest] test cases: 828 | 828 passed | 0 failed | 0 skipped
=== 20 scenario(s), 20 passed, 0 failed ===
Platform boundary: PASS
Specs: 90 modules, 90 ok, 0 missing, 0 outdated
ESP32:
Image: 1,487,092 bytes (65% partition free)
tick: 14240us (FPS: 70) heap free: 8401047
Code:
197 source files (44095 lines) / 136 test files (23765 lines) / 144 specs, 20 scenarios / Lizard: 155 warnings
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/platform/platform.h`:
- Around line 820-823: Update the desktop moonI80Ws2812InitRing definition in
platform_desktop.cpp to accept the new padUs parameter, matching the 12-argument
declaration in the header and preserving the existing stub behavior.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 688fc2ac-3230-4024-ba17-0e8326508e71
📒 Files selected for processing (11)
docs/backlog/backlog-light.mddocs/history/plans/Plan-20260718 - MoonI80 lapping-v2 clock-oracle ring.mddocs/moonmodules/light/drivers.mdesp32/sdkconfig.defaultssrc/core/SystemModule.hsrc/light/drivers/DriverBase.hsrc/light/drivers/MoonLedDriver.hsrc/light/drivers/ParallelLedDriver.hsrc/platform/esp32/platform_esp32_moon_i80.cppsrc/platform/platform.htest/unit/light/unit_ParallelLedDriver_ring.cpp
…ments The giant-wall target — 48 strands × 256 lights, all 12,288 — streams clean on a single core, wall-verified, and the driver now derives its own winning ring geometry: the new ringAuto control computes ringRows/ringBufs per config (visible DHCP-style values) from the bench-validated viability rule, with ringPadUs as the one per-strip hardware knob. The instruments that made the tuning possible ship with it: an average-encode counter and per-segment cycle attribution in ringDbg, plus a platform cycle-counter seam. KPI: 16384lights | Desktop:786KB | tick:129/101/6/125/20/3/277/69/17/22/163/123/22/6/45us(FPS:7751/9900/166666/8000/50000/333333/3610/14492/58823/45454/6134/8130/45454/166666/22222) | ESP32:1481KB | tick:33750us(FPS:29) | heap:8004KB | src:197(44228) | test:136(23765) | lizard:155w Core: - platform.h: cycleCount() seam (CCOUNT/DWT/rdtsc-class primitive; esp32 IRAM impl + desktop ns clock); kRingNodeMaxBytes + kRingBufsMax/Min promoted as the shared geometry bounds (single owner, driver auto + platform clamp agree); RingStats.avgEncodeUs (the pace number vs maxEncodeUs the jitter number); ring defaults kRingRowsDefault=7 / kRingBufsDefault=16 (bench-tuned) - platform_esp32_moon_i80.cpp: ea sum/count accumulated in the refill and reset per frame-arm (a free-running sum wraps in hours); file-local node/bufs constants now reference the shared platform.h owners - SystemModule cpu doc + desktop InitRing stub signature (padUs) — earlier rabbit round Light domain: - MoonLedDriver: ringAuto (default ON) — auto geometry at busInitRing: rows = one-node max, bufs = min(nSlices+1 = prime-only when it fits, kRingBufsMax, post-reserve internal-RAM budget); values written into the visible controls, re-derived on any rebuild; explicit toggle (persistence restore fires the edit path, so no edit-flip); ringPadUs default 16 (the wall's latch-safe sweet spot); ringDbg gains ea + sg/se (windowed read-and-clear — 1 s windows never wrap) - ParallelLedDriver/ParallelSlots: 64-bit strand masks split into 32-bit halves (a runtime-v 64-bit shift is an Xtensa library call; ~13% refill win, byte-identity pinned); gather memcpy → bounded byte loop; per-segment cycle accumulators at the shift branch (bench diagnostic, scope in the backlog ring entry) Tests: - All 828 cases green incl. the ParallelSlots byte-identity sweeps pinning the mask rewrite; the one intermittent (UDP-localhost) re-ran clean Docs / CI: - drivers.md Moon card: ringAuto added, ring-control wording updated - backlog-light ring entry: the achieved state + the validated viability rule ((nSlices − ringBufs) × refill ≤ frame wire time; predictions R7/B24 clean and R4/B32 fail both confirmed on the wall) - docs/history/plans: the lean-rows-1 plan (approved, pending) + the encode-unroll plan (attempted, abandoned — host bench failed its gate) Reviews: - 👾 stale "at most half" RAM-policy comment contradicted the code: fixed (comment now matches the full post-reserve budget) - 👾 avgEncodeUs free-running sum would wrap in ~9 h (the exact bug the sg/se windows fixed): fixed — reset per frame-arm - 👾 pool depth bounds (32/2) hard-coded in three places: fixed — promoted to platform.h kRingBufsMax/Min - 👾 ringAuto missing from the drivers.md card: fixed - 👾 future-tense wording in the diagnostic comments: fixed — present-tense, scope referenced generically - 👾 ringDbg/diagnostic removal NOT recommended per PO decision (tuning era ongoing) — reviewer respected, nothing removed Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 1
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
src/light/drivers/ParallelLedDriver.h (1)
1484-1491: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick winKeep the render path running during intrusive loopback.
Line 1487 expects several live frames to apply
patternHoldStrand_, buttick()returns wheneverloopbackTestis true at Line 431. The capture therefore tests an old frame rather than the injected pattern. Allow ticking whenloopbackIntrusiveis enabled, or drive the required frames explicitly.Proposed direction
- if (loopbackTest) return; + if (loopbackTest && !loopbackIntrusive) return;🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/light/drivers/ParallelLedDriver.h` around lines 1484 - 1491, Update the loopback-related tick guard so the render pipeline continues running when loopbackIntrusive is enabled, allowing patternHoldStrand_ to be applied during the delay before busLoopbackRide. Preserve the existing early-return behavior for non-intrusive loopback tests and ensure the hold is released after capture.src/light/drivers/ParallelSlots.h (1)
247-249: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick winUse American English spelling.
As per coding guidelines, use American English spelling in comments and documentation. Change "synthesise" to "synthesize".
♻️ Proposed fix
/// (×8's band is 21.1-27.6 MHz, and 80/3 = 26.67 MHz sits inside it — the shipped rate is provably the -/// only exact divide that works. A P4's APLL could synthesise an arbitrary rate and would be the only +/// only exact divide that works. A P4's APLL could synthesize an arbitrary rate and would be the only /// route to ×16; that is a separate driver-level change, not a config flag.)🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/light/drivers/ParallelSlots.h` around lines 247 - 249, Update the comment near the ×16 band explanation to use the American English spelling “synthesize” instead of “synthesise”; leave the surrounding technical explanation unchanged.Source: Coding guidelines
♻️ Duplicate comments (1)
src/light/drivers/ParallelLedDriver.h (1)
637-642: 🩺 Stability & Availability | 🔴 Critical | 🏗️ Heavy liftAbort or rebuild the DMA bus before clearing
inFlight_.Line 642 only drops software ownership. The timed-out DMA may still read that buffer, so the retry can re-encode and transmit over an active transfer. Stop/reset the bus before releasing either buffer.
This repeats the earlier stalled-transfer finding; the current flag-only fix still violates the documented timeout contract. As per path instructions, “On timeout, the caller must not re-encode/reuse that buffer because DMA may still be reading it.”
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/light/drivers/ParallelLedDriver.h` around lines 637 - 642, Update the timeout recovery logic near the inFlight_ flags to abort or reset the DMA bus before clearing either inFlight_ entry. Ensure the timed-out transfer is no longer active before the next tick can re-encode or reuse its buffer, while preserving the existing retry behavior.Source: Path instructions
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@src/light/drivers/ParallelSlots.h`:
- Around line 310-311: Guard high-half bit extraction so shifts occur only when
the bit index is within the 64-bit mask; otherwise return 0. Apply this
consistently in encodeWs2812ShiftSlots (src/light/drivers/ParallelSlots.h lines
310-311), shiftActivePins (lines 414-415), and encodeWs2812ShiftData (lines
493-495), preserving existing low- and high-half behavior for valid indices.
---
Outside diff comments:
In `@src/light/drivers/ParallelLedDriver.h`:
- Around line 1484-1491: Update the loopback-related tick guard so the render
pipeline continues running when loopbackIntrusive is enabled, allowing
patternHoldStrand_ to be applied during the delay before busLoopbackRide.
Preserve the existing early-return behavior for non-intrusive loopback tests and
ensure the hold is released after capture.
In `@src/light/drivers/ParallelSlots.h`:
- Around line 247-249: Update the comment near the ×16 band explanation to use
the American English spelling “synthesize” instead of “synthesise”; leave the
surrounding technical explanation unchanged.
---
Duplicate comments:
In `@src/light/drivers/ParallelLedDriver.h`:
- Around line 637-642: Update the timeout recovery logic near the inFlight_
flags to abort or reset the DMA bus before clearing either inFlight_ entry.
Ensure the timed-out transfer is no longer active before the next tick can
re-encode or reuse its buffer, while preserving the existing retry behavior.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 703c0cfa-7cbc-46b8-9352-4a2307157b91
📒 Files selected for processing (10)
docs/history/plans/Plan-20260718 - Lean rows=1 ring ISR.mddocs/history/plans/Plan-20260718 - Shift encode template unroll (attempted, abandoned).mddocs/moonmodules/light/drivers.mdsrc/light/drivers/MoonLedDriver.hsrc/light/drivers/ParallelLedDriver.hsrc/light/drivers/ParallelSlots.hsrc/platform/desktop/platform_desktop.cppsrc/platform/esp32/platform_esp32.cppsrc/platform/esp32/platform_esp32_moon_i80.cppsrc/platform/platform.h
| const uint8_t v = static_cast<uint8_t>(p * outPerPin + pos); | ||
| const uint32_t live = (v < 32) ? (maskLo >> v) : (maskHi >> (v - 32)); |
There was a problem hiding this comment.
🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win
Undefined behavior when shifting uint32_t by 32 or more.
If an invalid configuration produces physPins * outPerPin > 64, v evaluates to 64 or greater. The evaluation of maskHi >> (v - 32) will then attempt to right-shift a 32-bit integer by 32 or more, which invokes undefined behavior in C++. Guard the high-half shift to safely yield 0 for strands beyond the 64-bit mask's capacity.
src/light/drivers/ParallelSlots.h#L310-L311: Guard the bit extraction inencodeWs2812ShiftSlots.src/light/drivers/ParallelSlots.h#L414-L415: Apply the same guard inshiftActivePins.src/light/drivers/ParallelSlots.h#L493-L495: Apply the same guard inencodeWs2812ShiftData.
🛡️ Proposed fixes
src/light/drivers/ParallelSlots.h#L310-L311:
const uint8_t v = static_cast<uint8_t>(p * outPerPin + pos);
- const uint32_t live = (v < 32) ? (maskLo >> v) : (maskHi >> (v - 32));
+ const uint32_t live = (v < 32) ? (maskLo >> v) : ((v < 64) ? (maskHi >> (v - 32)) : 0);src/light/drivers/ParallelSlots.h#L414-L415:
const uint8_t v = static_cast<uint8_t>(p * outPerPin + pos);
- const uint32_t live = (v < 32) ? (maskLo >> v) : (maskHi >> (v - 32));
+ const uint32_t live = (v < 32) ? (maskLo >> v) : ((v < 64) ? (maskHi >> (v - 32)) : 0);src/light/drivers/ParallelSlots.h#L493-L495:
const uint8_t v = static_cast<uint8_t>(p * outPerPin + pos);
// 32-bit half test — see the maskLo/maskHi split above.
- const uint32_t live = (v < 32) ? (maskLo >> v) : (maskHi >> (v - 32));
+ const uint32_t live = (v < 32) ? (maskLo >> v) : ((v < 64) ? (maskHi >> (v - 32)) : 0);📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const uint8_t v = static_cast<uint8_t>(p * outPerPin + pos); | |
| const uint32_t live = (v < 32) ? (maskLo >> v) : (maskHi >> (v - 32)); | |
| const uint8_t v = static_cast<uint8_t>(p * outPerPin + pos); | |
| const uint32_t live = (v < 32) ? (maskLo >> v) : ((v < 64) ? (maskHi >> (v - 32)) : 0); |
📍 Affects 1 file
src/light/drivers/ParallelSlots.h#L310-L311(this comment)src/light/drivers/ParallelSlots.h#L414-L415src/light/drivers/ParallelSlots.h#L493-L495
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/light/drivers/ParallelSlots.h` around lines 310 - 311, Guard high-half
bit extraction so shifts occur only when the bit index is within the 64-bit
mask; otherwise return 0. Apply this consistently in encodeWs2812ShiftSlots
(src/light/drivers/ParallelSlots.h lines 310-311), shiftActivePins (lines
414-415), and encodeWs2812ShiftData (lines 493-495), preserving existing low-
and high-half behavior for valid indices.
…ixes Streams the MoonI80 pin-expander ring across both cores: the per-frame pool prime now fork-joins (helper primes half on core 0 while the driver primes the rest on core 1), and the snapshot became a plain immutable memcpy with correction fused into the encode. Adds honest per-driver heap reporting, a preview sub-hot-path fix, and a batch of robustness fixes (a config-change deadlock, a concurrent-encode scatter race, a loopback alloc/latch bug). 16384lights | Desktop:786KB | tick:124/102/7/124/20/3/278/69/17/22/164/123/22/6/45us | tick:29868us(FPS:33) | heap:7986KB Core: - platform_esp32_moon_i80: split startRingTransfer into primeRingRange + armRingTransfer so two cores prime disjoint buffer ranges concurrently; new moonI80Ws2812PrimeRange/ArmRing platform APIs (serial TransmitRing kept as the single-core combo/fallback). Added a frame-close latch word (count==0 close call) so the first zero-lap slice presents the register's final slot — fixes a loopback FAIL on the last bit of the last light. Fail-loud logs on every previously-silent ring-init failure path. ea encode-average latched at frame end (was raced by the tick1s readout). - platform_esp32_worker: bounded stopPinnedTask's join (300ms) with a detach/exchange ownership handshake — the unbounded wait deadlocked the device on a same-core worker teardown during a config change. Both join exit paths free the worker through the same exchange so it is freed exactly once (fixes a use-after-free on the normal path). - platform_esp32_rmt/i80/parlio/moon_i80: allocLoopbackCapture pre-allocates the RX capture buffer before the ring fragments internal DMA RAM (largest-block-first); the loopback ring steps its pool depth down until it fits a RAM-tight heap. - platform: cycleCount() / currentCore() / kMaxCores seams; PrimeRange/ArmRing decls + desktop/non-LCD stubs. Light domain: - ParallelLedDriver: the ring snapshot is now a raw srcCh-stride memcpy (was correct-then-copy at outCh stride) with correction fused into encodeRows' gather — immutable and cheaper (ts 4733->2930us, ea holds, lt=0 at 48-strand width). Per-CPU wire_ scratch (sized x kMaxCores, each core indexes its own slice) fixes a concurrent-encode scatter race the fork-join exposed. deinit() nulls encodeSrc_ on every teardown. Snapshot window clamp keys on srcCh (not outCh) so an RGB->RGBW config keeps its whole window. - DriverBase: driverHeapBytes() hook + publishHeapBytes() so raw platform::alloc buffers (snapshot, wire, symbols, preview buffers) report into the per-module dynamicBytes readout instead of reading 0. - MoonLedDriver: ringSnapshot control moved below useRing (hidden unless a ring runs), its buffer freed when off; ringSnapshot is a build-triggering control. - RmtLedDriver: reports its symbol buffer in dynamicBytes. - PreviewDriver: downsampled frames route through the resumable sender via a staging buffer (was a ~17ms synchronous socket write on the encode thread); a kept-index cache replaces the per-frame forEachCoord walk; a resumableFrames A/B toggle (affectsPrepare) frees/reallocs the buffers; degradation status when a buffer can't allocate. Tests: - unit_ParallelLedDriver_ring: copyRange fork-join byte-identity oracle; RGBW-on-RGB snapshot-window regression (fails without the srcCh clamp fix); ringSnapshot hide-behavior. - unit_PreviewDriver: dynamicBytes accounting across the resumableFrames toggle. Docs / CI: - backlog-light: removed the ring-inert-controls item (ringSnapshot now gated; asyncTransmit no longer exists). - Plan-20260719: parallel-snapshot dual-core plan saved. Reviews: - 👾 UAF in stopPinnedTask's normal-path free (raced the trampoline's post-finished access) — fixed with the shared exchange handshake. - 👾 snapshot window clamp divided by outCh but the buffer is srcCh-strided — fixed + regression test added. - 👾 stale pre-corrected comment blocks left above the rewritten gather — rewritten to the raw-memcpy reality. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 4
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/history/plans/Plan-20260718` - Lean rows=1 ring ISR.md:
- Around line 59-60: Update the plan’s Phase 1 gating language and all related
references so they consistently state whether the lean path proceeds
independently of Phase 0 results. Reconcile the conflicting statements about
periodic clock-oracle reconciliation, including the sections around the
referenced implementation and verification guidance, and ensure the final
algorithm and acceptance criteria describe one unambiguous behavior.
In `@docs/history/plans/Plan-20260719` - Parallel snapshot dual-core.md:
- Line 40: Update the line-aligned split point description to use the minimal
light-count alignment formula 64 / gcd(64, outCh), ensuring split * outCh is a
multiple of 64. Remove the lcm(64, outCh) wording and retain the goal of
preventing false sharing at the boundary.
In `@src/core/BinaryBroadcaster.h`:
- Around line 46-48: Update the comment near the BinaryBroadcaster
begin/push/end path to use American English spelling, replacing “colour” with
“color” while preserving the existing meaning and wording.
In `@test/unit/light/unit_ParallelLedDriver_ring.cpp`:
- Around line 350-352: Rename the outCh parameter in snapHalfForTest to srcCh or
stride, and update its use when forwarding to snapLineAlignedHalf so the name
reflects the source-channel stride semantics.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 3180293c-f68b-4076-b4da-c087a4bd9549
📒 Files selected for processing (22)
docs/backlog/backlog-light.mddocs/history/plans/Plan-20260718 - Lean rows=1 ring ISR.mddocs/history/plans/Plan-20260718 - Shift encode template unroll (attempted, abandoned).mddocs/history/plans/Plan-20260719 - Parallel snapshot dual-core.mddocs/moonmodules/light/drivers.mdsrc/core/BinaryBroadcaster.hsrc/light/drivers/DriverBase.hsrc/light/drivers/MoonLedDriver.hsrc/light/drivers/ParallelLedDriver.hsrc/light/drivers/ParallelSlots.hsrc/light/drivers/PreviewDriver.hsrc/light/drivers/RmtLedDriver.hsrc/platform/desktop/platform_desktop.cppsrc/platform/esp32/platform_esp32.cppsrc/platform/esp32/platform_esp32_i80.cppsrc/platform/esp32/platform_esp32_moon_i80.cppsrc/platform/esp32/platform_esp32_parlio.cppsrc/platform/esp32/platform_esp32_rmt.cppsrc/platform/esp32/platform_esp32_worker.cppsrc/platform/platform.htest/unit/light/unit_ParallelLedDriver_ring.cpptest/unit/light/unit_PreviewDriver.cpp
💤 Files with no reviewable changes (1)
- docs/backlog/backlog-light.md
| ### Phase 1 — the lean rows==1 fast branch (only if Phase 0 passes) | ||
| At `rowsPerBuf == 1` the clock oracle is unnecessary: with 1 LED/buffer the pool is deep in LED-units and |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Resolve the contradictory implementation requirements.
The plan says Phase 0 “does not gate” the work and that the lean path still ships when the benchmark misses, but this section says Phase 1 runs only when Phase 0 passes. It also rejects periodic reconciliation in Lines 26-33 and 76-80, while the code-grounding and verification sections still require it. Make the plan consistently say whether Phase 1 is unconditional and whether reconciliation is part of the lean algorithm.
Suggested wording changes
-### Phase 1 — the lean rows==1 fast branch (only if Phase 0 passes)
+### Phase 1 — the lean rows==1 fast branch
- periodic clock reconcile for coalescing safety.
+ no periodic clock reconcile; coalescing safety comes from the pool-depth margin.Also applies to: 96-101
🧰 Tools
🪛 markdownlint-cli2 (0.23.0)
[warning] 59-59: Headings should be surrounded by blank lines
Expected: 1; Actual: 0; Below
(MD022, blanks-around-headings)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/history/plans/Plan-20260718` - Lean rows=1 ring ISR.md around lines 59 -
60, Update the plan’s Phase 1 gating language and all related references so they
consistently state whether the lean path proceeds independently of Phase 0
results. Reconcile the conflicting statements about periodic clock-oracle
reconciliation, including the sections around the referenced implementation and
verification guidance, and ensure the final algorithm and acceptance criteria
describe one unambiguous behavior.
| - **Join** with a `std::atomic<bool> helperDone_`, acquire/release, polled with `platform::yield()` — byte-for-byte the `quiesceEncode()` spin idiom (Drivers.h:522-528). The core-1 caller waits the helper out, then runs the whole-buffer pattern-hold + `encodeSrc_` bias tail ONCE, and returns. Encode/prime/arm proceed on core 1 unchanged. | ||
| - The helper worker is spawned once (engage-time, beside the split's own task) and PARKED in `waitNotify`; each frame the snapshot `notifyTask`s it. No per-frame task churn. | ||
| - When the split is OFF (single-core), or the helper can't spawn: the snapshot runs the FULL [0, winLights) range inline — the shipped serial path, byte-identical. | ||
| - **Line-aligned split point**: round winLights/2 so each half's byte range starts on a 64-byte cache line (no false sharing on the boundary write); outCh-stride, so align on `lcm(64, outCh)` lights. |
There was a problem hiding this comment.
🚀 Performance & Scalability | 🟡 Minor | ⚡ Quick win
Use the byte-alignment formula, not lcm(64, outCh).
To make split * outCh a multiple of 64, the minimal light-count alignment is 64 / gcd(64, outCh). lcm(64, outCh) over-aligns substantially—for example, 64 lights instead of 16 when outCh == 4—and can unnecessarily skew the two workers’ ranges.
Suggested correction
- Line-aligned split point: round winLights/2 ... align on lcm(64, outCh) lights.
+ Line-aligned split point: round winLights/2 to a multiple of
+ 64 / gcd(64, outCh) lights so the byte offset starts on a cache-line boundary.📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| - **Line-aligned split point**: round winLights/2 so each half's byte range starts on a 64-byte cache line (no false sharing on the boundary write); outCh-stride, so align on `lcm(64, outCh)` lights. | |
| **Line-aligned split point**: round winLights/2 so each half's byte range starts on a 64-byte cache line (no false sharing on the boundary write); outCh-stride, so align on `64 / gcd(64, outCh)` lights. |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@docs/history/plans/Plan-20260719` - Parallel snapshot dual-core.md at line
40, Update the line-aligned split point description to use the minimal
light-count alignment formula 64 / gcd(64, outCh), ensuring split * outCh is a
multiple of 64. Remove the lcm(64, outCh) wording and retain the goal of
preventing false sharing at the boundary.
A slow-shift-clock switch (shiftOverclock, default OFF = 20 MHz) fixes per-strand data corruption on 74HCT595 pin-expander walls — the '595 can't reliably shift at 26.67 MHz on marginal strand wiring, so specific panels scrambled while clean strands survived. Also lands the pre-merge review fixes (a cross-core race, four misleading safety comments, a ring slice-fill dedup) and marks the shipped plans + prunes the shipped backlog ahead of the merge to main. 16384lights | Desktop:786KB | ESP32:1455KB | tick:32165us(FPS:31) Core: - Drivers: tick() now joins the wedged encode worker (stopEncodeTask) when quiesceEncode times out, before falling back to inline ticking — closes a cross-core race where the un-wedging worker and core 0 could both be inside one driver's tick() (double transmit / corrupted inFlight_). Matches prepare()/quiesce(), which already did this. - ParallelLedDriver: snapLineAlignedHalf uses 64/gcd(64,stride) lights (the minimal cache-line-aligned count) instead of 64/stride, which landed on a 63-byte boundary and re-introduced the false sharing the split avoids; the snapshot-window clamp keys on srcCh; BinaryBroadcaster + PreviewDriver comment spelling swept to American English (color). Light domain: - MoonLedDriver: shiftOverclock switch (below the pin controls) — OFF = 20 MHz (the reliability point, verified clean on two walls, hpwit's S3 rate), ON = 26.67 MHz (the overclock for short-wired rigs, 151 vs 118 fps ceiling); 16 MHz is past the WS2812 0-vs-1 threshold (all-white). A bus-rebuild trigger; the shift-clock default moved 26.67 -> 20 MHz. - platform_esp32_moon_i80: GPIO_DRIVE_CAP_3 on the data/WR/latch pins (sharper '595 edges); moonI80SetShiftClockDiv seam + g_shiftClockDiv (the loopback verifier's decode rate now derives from the live divider, not a stale constant); one shared fillSlice() backs both the EOF-ISR refill and primeRingRange (the ea real-refill diagnostic is preserved — it times real encodes only, not the cheap past-frame zero-fills); rewrote four stale/misleading comments (the two isr_cache_safe safety contracts, the termNode "restore" note, and the resolved "OPEN BUG — 8..16 slices" block) to present-state truth. Tests: - unit_ParallelLedDriver_ring: RGBW-on-RGB snapshot-window regression (fails without the srcCh clamp); snapHalfForTest param renamed to chStride. Docs / CI: - docs/history/plans: 12 plans marked (shipped), 1 (superseded) ahead of the merge. - docs/backlog: pruned the shipped 16-lane / classic-I2S / shift-register-dormant items and the resolved loopback-stall + RMT-pause + task-pinning items; rewrote the MoonI80 ring section to open-items-only; fixed the PSRAM-research clock-floor claim (20 MHz, not 21) and the backlog README index. - docs/history/reviews: the pre-merge driver-feature audit (per-driver modularity / cost / still-needed tables + merge-prep action list). Reviews: - 👾 A1 cross-core race in Drivers::tick() on a quiesce timeout — fixed (join before inline fallback). - 👾 A2 isr_cache_safe / termNode / "OPEN BUG" comments contradicted the code — fixed (rewritten present-tense). - 👾 A4 duplicated ISR/prime slice-fill — fixed (shared fillSlice, diagnostic accounting preserved). - 👾 A3/A5/A6/A7 (hot-path diagnostics, fork-join-worker primitive, ringDbg trim, stale cost numbers) — deferred to post-merge follow-up; ringDbg kept deliberately through the tuning era. - 👾 snapLineAlignedHalf lcm->gcd alignment bug + snapshot srcCh clamp + spelling — fixed with a regression test. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 13
♻️ Duplicate comments (1)
docs/history/plans/Plan-20260717 - MoonI80 runtime ring geometry (shipped).md (1)
19-32: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick winCorrect the ring-memory trade-off premise.
With the formula stated on Line 19, any fixed
ringRows/ringBufsgeometry keeps RAM constant as strand length changes—not onlyringRows=1. Reframe the comparison around absolute pool size, interrupt frequency, and DMA runway.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@docs/history/plans/Plan-20260717` - MoonI80 runtime ring geometry (shipped).md around lines 19 - 32, The ring-memory trade-off section incorrectly claims only ringRows=1 keeps RAM flat as strand length changes. Revise the RAM comparison in the “four trade-offs” section to reflect that any fixed ringRows/ringBufs geometry has constant pool size, and compare geometries by absolute RAM usage instead. Reframe the related trade-off discussion around absolute pool size, interrupt frequency, and DMA runway while preserving the existing formulas and conclusions supported by those metrics.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/backlog/backlog-core.md`:
- Line 90: Update the acceptance-criteria sentence in the backlog documentation
to use present-tense wording, replacing “When implemented” with a statement such
as “The async path selects” or “Acceptance criteria:”. Preserve all existing
implementation details and requirements.
- Line 90: Clarify the single handoff-buffer design by defining explicit
producer/consumer ownership and synchronization: the render path must not write
while the send task reads, and should drop or coalesce frames when the buffer is
owned by the sender. Update the async-path description near the PSRAM and
semaphore/notification guidance, or specify double buffering if exclusive
ownership cannot be guaranteed.
In `@docs/backlog/backlog-light.md`:
- Line 9: Update the opening description of the two ring regimes to clarify that
the implementation is present and wall-validated, while shift-register mode
remains disabled by default pending the documented GDMA descriptor-count fix.
Preserve the existing details about prime-only and clock-oracle operation,
`ringAuto`, and `shiftOverclock`.
In `@docs/history/plans/Plan-20260711` - LightPresets reusable named-preset
library (shipped).md:
- Around line 34-39: Clarify the preset reference design by persisting a
separate stable presetId for each driver and treating the Select value as a
derived display index only. Update the resolution and rebuildCorrection flow to
map presetId to the current library index/name, handle missing ids with the
existing RGB fallback and warning, and specify persistence/serialization
behavior so reorder or deletion cannot alter stored references.
In `@docs/history/plans/Plan-20260716` - LED driver rename for a human-readable UI
(shipped).md:
- Line 28: Resolve the contradiction between the immutability rule and the
ADR-0013 migration step in the plan: either keep ADR-0013 unchanged as the
authoritative source and link to it from MIGRATING.md, or explicitly revise the
immutability decision so the planned ADR modification is permitted. Update the
instructions at both the “Do NOT touch” section and the ADR-0013 step
consistently, without leaving conflicting guidance.
In `@docs/history/plans/Plan-20260718` - Lean rows=1 ring ISR (superseded by the
near-prime pool).md:
- Line 1: Rename the plan document identified by its title “Plan — Lean rows=1
ring ISR: shed per-firing overhead, test if 48 strands fit hpwit-style” to
include the required “(attempted, abandoned)” outcome marker, accurately
indicating that the plan was superseded rather than shipped.
- Around line 26-33: Make the lean-path plan consistently unconditional: remove
any Phase 0 measurement prerequisite from the heading, Phase 1, and verification
sections, and remove references requiring periodic reconciliation from the
code-grounding and test sections. Preserve the stated lean behavior of counter
advancement, ring-slot selection, refill-until-caught-up, and no timer,
division, or reconciliation.
In `@docs/history/plans/Plan-20260718` - MoonI80 lapping-v2 clock-oracle ring
(shipped).md:
- Around line 155-159: Correct the late-slice rate statement in the “Phase C”
section: with ~120 late slices per second and the stated ~2,442 total slices per
second, report approximately 4.9% instead of ~17%, or explicitly define a
different denominator if 17% is intended.
In `@docs/history/plans/Plan-20260718` - MoonI80 ring trailing-refill + loopback
instrument (shipped, refill superseded by lapping-v2).md:
- Around line 185-186: Replace the developer-local absolute source reference in
the plan’s “hpwit’s ACTUAL termination” section with a portable
repository-relative path, commit reference, or stable external URL. Apply the
same portable reference style to the embedded path in
docs/history/reviews/2026-07-20-driver-feature-audit.md at lines 143-143.
In `@docs/history/plans/Plan-20260719` - Parallel snapshot dual-core (shipped).md:
- Around line 27-39: Rewrite the initial correction-loop paragraph to remove the
claim that core 0 performs the bottom snapshot half inline after its effect
render. Make it consistent with the authoritative topology in the correction:
tickRing() runs on core 1, which handles the upper range while a persistent
helper pinned to core 0 handles the lower range, then the caller joins before
continuing. Retain the existing snapshot gating rationale only where it does not
conflict with this ownership model.
In `@docs/history/reviews/2026-07-20-driver-feature-audit.md`:
- Around line 122-140: Move the forward-looking “MERGE-PREP ACTION LIST” and
“Follow-up after merge” items out of this history record into appropriate
documents under docs/backlog/. Replace those sections with links to the backlog
items, while preserving the retrospective audit content and its references.
- Around line 5-17: Synchronize the detailed A1, A2, A3, and A4 findings and the
MERGE-PREP ACTION LIST with the status table: mark A1, A2, and A4 as fixed or
historical, and mark A3 as deferred while preserving its deliberate retention.
Update the affected sections near the listed ranges so no stale outstanding
guidance remains, while leaving the documented rationale and follow-up decisions
consistent.
In `@src/platform/esp32/platform_esp32_moon_i80.cpp`:
- Around line 1387-1390: Update the shift-clock documentation to reflect the
actual default divider: in src/platform/esp32/platform_esp32_moon_i80.cpp lines
1387-1390, mark 4 (20 MHz) as the default and 3 (26.67 MHz) as the overclock;
make the same correction to the runtime shiftClockDiv descriptions at lines
742-746 and 1140-1143.
---
Duplicate comments:
In `@docs/history/plans/Plan-20260717` - MoonI80 runtime ring geometry
(shipped).md:
- Around line 19-32: The ring-memory trade-off section incorrectly claims only
ringRows=1 keeps RAM flat as strand length changes. Revise the RAM comparison in
the “four trade-offs” section to reflect that any fixed ringRows/ringBufs
geometry has constant pool size, and compare geometries by absolute RAM usage
instead. Reframe the related trade-off discussion around absolute pool size,
interrupt frequency, and DMA runway while preserving the existing formulas and
conclusions supported by those metrics.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 2f435c9f-b601-42af-8f47-9a21bf61babe
📒 Files selected for processing (27)
docs/backlog/README.mddocs/backlog/backlog-core.mddocs/backlog/backlog-light.mddocs/backlog/backlog-mixed.mddocs/history/plans/Plan-20260711 - Flexible light profile (channel-role offsets) (shipped).mddocs/history/plans/Plan-20260711 - LightPresets reusable named-preset library (shipped).mddocs/history/plans/Plan-20260713 - Multicore Step 2 render-encode pipeline (shipped).mddocs/history/plans/Plan-20260714 - MoonI80 - our own gapless i80 driver (shipped).mddocs/history/plans/Plan-20260714 - Shift-register LED driver (shipped).mddocs/history/plans/Plan-20260715 - MoonI80 ring race-free ISR refill + source snapshot (shipped).mddocs/history/plans/Plan-20260715 - MoonI80 streaming ring (clean-room) (shipped).mddocs/history/plans/Plan-20260716 - LED driver rename for a human-readable UI (shipped).mddocs/history/plans/Plan-20260717 - MoonI80 runtime ring geometry (shipped).mddocs/history/plans/Plan-20260718 - Lean rows=1 ring ISR (superseded by the near-prime pool).mddocs/history/plans/Plan-20260718 - MoonI80 lapping-v2 clock-oracle ring (shipped).mddocs/history/plans/Plan-20260718 - MoonI80 ring trailing-refill + loopback instrument (shipped, refill superseded by lapping-v2).mddocs/history/plans/Plan-20260719 - Parallel snapshot dual-core (shipped).mddocs/history/reviews/2026-07-20-driver-feature-audit.mdsrc/core/BinaryBroadcaster.hsrc/light/drivers/Drivers.hsrc/light/drivers/MoonLedDriver.hsrc/light/drivers/ParallelLedDriver.hsrc/light/drivers/PreviewDriver.hsrc/platform/desktop/platform_desktop.cppsrc/platform/esp32/platform_esp32_moon_i80.cppsrc/platform/platform.htest/unit/light/unit_ParallelLedDriver_ring.cpp
💤 Files with no reviewable changes (1)
- docs/backlog/backlog-mixed.md
There was a problem hiding this comment.
Caution
Inline review comments failed to post. This is likely due to GitHub's internal server error or limits when posting large numbers of comments. If you are seeing this consistently it is likely a permissions issue. Please check "Moderation" -> "Code review limits" under your organization settings.
Actionable comments posted: 13
♻️ Duplicate comments (1)
docs/history/plans/Plan-20260717 - MoonI80 runtime ring geometry (shipped).md (1)
19-32: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick winCorrect the ring-memory trade-off premise.
With the formula stated on Line 19, any fixed
ringRows/ringBufsgeometry keeps RAM constant as strand length changes—not onlyringRows=1. Reframe the comparison around absolute pool size, interrupt frequency, and DMA runway.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@docs/history/plans/Plan-20260717` - MoonI80 runtime ring geometry (shipped).md around lines 19 - 32, The ring-memory trade-off section incorrectly claims only ringRows=1 keeps RAM flat as strand length changes. Revise the RAM comparison in the “four trade-offs” section to reflect that any fixed ringRows/ringBufs geometry has constant pool size, and compare geometries by absolute RAM usage instead. Reframe the related trade-off discussion around absolute pool size, interrupt frequency, and DMA runway while preserving the existing formulas and conclusions supported by those metrics.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/backlog/backlog-core.md`:
- Line 90: Update the acceptance-criteria sentence in the backlog documentation
to use present-tense wording, replacing “When implemented” with a statement such
as “The async path selects” or “Acceptance criteria:”. Preserve all existing
implementation details and requirements.
- Line 90: Clarify the single handoff-buffer design by defining explicit
producer/consumer ownership and synchronization: the render path must not write
while the send task reads, and should drop or coalesce frames when the buffer is
owned by the sender. Update the async-path description near the PSRAM and
semaphore/notification guidance, or specify double buffering if exclusive
ownership cannot be guaranteed.
In `@docs/backlog/backlog-light.md`:
- Line 9: Update the opening description of the two ring regimes to clarify that
the implementation is present and wall-validated, while shift-register mode
remains disabled by default pending the documented GDMA descriptor-count fix.
Preserve the existing details about prime-only and clock-oracle operation,
`ringAuto`, and `shiftOverclock`.
In `@docs/history/plans/Plan-20260711` - LightPresets reusable named-preset
library (shipped).md:
- Around line 34-39: Clarify the preset reference design by persisting a
separate stable presetId for each driver and treating the Select value as a
derived display index only. Update the resolution and rebuildCorrection flow to
map presetId to the current library index/name, handle missing ids with the
existing RGB fallback and warning, and specify persistence/serialization
behavior so reorder or deletion cannot alter stored references.
In `@docs/history/plans/Plan-20260716` - LED driver rename for a human-readable UI
(shipped).md:
- Line 28: Resolve the contradiction between the immutability rule and the
ADR-0013 migration step in the plan: either keep ADR-0013 unchanged as the
authoritative source and link to it from MIGRATING.md, or explicitly revise the
immutability decision so the planned ADR modification is permitted. Update the
instructions at both the “Do NOT touch” section and the ADR-0013 step
consistently, without leaving conflicting guidance.
In `@docs/history/plans/Plan-20260718` - Lean rows=1 ring ISR (superseded by the
near-prime pool).md:
- Line 1: Rename the plan document identified by its title “Plan — Lean rows=1
ring ISR: shed per-firing overhead, test if 48 strands fit hpwit-style” to
include the required “(attempted, abandoned)” outcome marker, accurately
indicating that the plan was superseded rather than shipped.
- Around line 26-33: Make the lean-path plan consistently unconditional: remove
any Phase 0 measurement prerequisite from the heading, Phase 1, and verification
sections, and remove references requiring periodic reconciliation from the
code-grounding and test sections. Preserve the stated lean behavior of counter
advancement, ring-slot selection, refill-until-caught-up, and no timer,
division, or reconciliation.
In `@docs/history/plans/Plan-20260718` - MoonI80 lapping-v2 clock-oracle ring
(shipped).md:
- Around line 155-159: Correct the late-slice rate statement in the “Phase C”
section: with ~120 late slices per second and the stated ~2,442 total slices per
second, report approximately 4.9% instead of ~17%, or explicitly define a
different denominator if 17% is intended.
In `@docs/history/plans/Plan-20260718` - MoonI80 ring trailing-refill + loopback
instrument (shipped, refill superseded by lapping-v2).md:
- Around line 185-186: Replace the developer-local absolute source reference in
the plan’s “hpwit’s ACTUAL termination” section with a portable
repository-relative path, commit reference, or stable external URL. Apply the
same portable reference style to the embedded path in
docs/history/reviews/2026-07-20-driver-feature-audit.md at lines 143-143.
In `@docs/history/plans/Plan-20260719` - Parallel snapshot dual-core (shipped).md:
- Around line 27-39: Rewrite the initial correction-loop paragraph to remove the
claim that core 0 performs the bottom snapshot half inline after its effect
render. Make it consistent with the authoritative topology in the correction:
tickRing() runs on core 1, which handles the upper range while a persistent
helper pinned to core 0 handles the lower range, then the caller joins before
continuing. Retain the existing snapshot gating rationale only where it does not
conflict with this ownership model.
In `@docs/history/reviews/2026-07-20-driver-feature-audit.md`:
- Around line 122-140: Move the forward-looking “MERGE-PREP ACTION LIST” and
“Follow-up after merge” items out of this history record into appropriate
documents under docs/backlog/. Replace those sections with links to the backlog
items, while preserving the retrospective audit content and its references.
- Around line 5-17: Synchronize the detailed A1, A2, A3, and A4 findings and the
MERGE-PREP ACTION LIST with the status table: mark A1, A2, and A4 as fixed or
historical, and mark A3 as deferred while preserving its deliberate retention.
Update the affected sections near the listed ranges so no stale outstanding
guidance remains, while leaving the documented rationale and follow-up decisions
consistent.
In `@src/platform/esp32/platform_esp32_moon_i80.cpp`:
- Around line 1387-1390: Update the shift-clock documentation to reflect the
actual default divider: in src/platform/esp32/platform_esp32_moon_i80.cpp lines
1387-1390, mark 4 (20 MHz) as the default and 3 (26.67 MHz) as the overclock;
make the same correction to the runtime shiftClockDiv descriptions at lines
742-746 and 1140-1143.
---
Duplicate comments:
In `@docs/history/plans/Plan-20260717` - MoonI80 runtime ring geometry
(shipped).md:
- Around line 19-32: The ring-memory trade-off section incorrectly claims only
ringRows=1 keeps RAM flat as strand length changes. Revise the RAM comparison in
the “four trade-offs” section to reflect that any fixed ringRows/ringBufs
geometry has constant pool size, and compare geometries by absolute RAM usage
instead. Reframe the related trade-off discussion around absolute pool size,
interrupt frequency, and DMA runway while preserving the existing formulas and
conclusions supported by those metrics.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 2f435c9f-b601-42af-8f47-9a21bf61babe
📒 Files selected for processing (27)
docs/backlog/README.mddocs/backlog/backlog-core.mddocs/backlog/backlog-light.mddocs/backlog/backlog-mixed.mddocs/history/plans/Plan-20260711 - Flexible light profile (channel-role offsets) (shipped).mddocs/history/plans/Plan-20260711 - LightPresets reusable named-preset library (shipped).mddocs/history/plans/Plan-20260713 - Multicore Step 2 render-encode pipeline (shipped).mddocs/history/plans/Plan-20260714 - MoonI80 - our own gapless i80 driver (shipped).mddocs/history/plans/Plan-20260714 - Shift-register LED driver (shipped).mddocs/history/plans/Plan-20260715 - MoonI80 ring race-free ISR refill + source snapshot (shipped).mddocs/history/plans/Plan-20260715 - MoonI80 streaming ring (clean-room) (shipped).mddocs/history/plans/Plan-20260716 - LED driver rename for a human-readable UI (shipped).mddocs/history/plans/Plan-20260717 - MoonI80 runtime ring geometry (shipped).mddocs/history/plans/Plan-20260718 - Lean rows=1 ring ISR (superseded by the near-prime pool).mddocs/history/plans/Plan-20260718 - MoonI80 lapping-v2 clock-oracle ring (shipped).mddocs/history/plans/Plan-20260718 - MoonI80 ring trailing-refill + loopback instrument (shipped, refill superseded by lapping-v2).mddocs/history/plans/Plan-20260719 - Parallel snapshot dual-core (shipped).mddocs/history/reviews/2026-07-20-driver-feature-audit.mdsrc/core/BinaryBroadcaster.hsrc/light/drivers/Drivers.hsrc/light/drivers/MoonLedDriver.hsrc/light/drivers/ParallelLedDriver.hsrc/light/drivers/PreviewDriver.hsrc/platform/desktop/platform_desktop.cppsrc/platform/esp32/platform_esp32_moon_i80.cppsrc/platform/platform.htest/unit/light/unit_ParallelLedDriver_ring.cpp
💤 Files with no reviewable changes (1)
- docs/backlog/backlog-mixed.md
🛑 Comments failed to post (12)
docs/backlog/backlog-core.md (1)
90-90: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Use present-tense acceptance-criteria wording.
Replace “When implemented” with a present-tense statement such as “The async path selects…” or “Acceptance criteria: …” to comply with the Markdown documentation rule.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@docs/backlog/backlog-core.md` at line 90, Update the acceptance-criteria sentence in the backlog documentation to use present-tense wording, replacing “When implemented” with a statement such as “The async path selects” or “Acceptance criteria:”. Preserve all existing implementation details and requirements.Source: Coding guidelines
🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
Define handoff-buffer ownership before using a single buffer.
The render path continues while the send task drains the buffer, so one buffer is unsafe unless the producer explicitly drops/coalesces frames while ownership is held. A semaphore/notification alone does not prevent the render thread from overwriting bytes that the sender is reading. Either specify that ownership protocol or use double buffering.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@docs/backlog/backlog-core.md` at line 90, Clarify the single handoff-buffer design by defining explicit producer/consumer ownership and synchronization: the render path must not write while the send task reads, and should drop or coalesce frames when the buffer is owned by the sender. Update the async-path description near the PSRAM and semaphore/notification guidance, or specify double buffering if exclusive ownership cannot be guaranteed.docs/backlog/backlog-light.md (1)
9-9: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Clarify that shift mode remains disabled by default.
The PR objectives state that the shift-register path is dormant because the GDMA descriptor-count issue still prevents most shift-mode frames from reaching the wire. This sentence says both ring regimes “ship” without that qualification, which can mislead readers into treating shift mode as production-enabled. State that the implementation is present and wall-validated, but
shiftRegisterremains disabled by default pending the documented fix.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@docs/backlog/backlog-light.md` at line 9, Update the opening description of the two ring regimes to clarify that the implementation is present and wall-validated, while shift-register mode remains disabled by default pending the documented GDMA descriptor-count fix. Preserve the existing details about prime-only and clock-oracle operation, `ringAuto`, and `shiftOverclock`.docs/history/plans/Plan-20260711 - LightPresets reusable named-preset library (shipped).md (1)
34-39: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift
Define how the stable preset id is persisted separately from the Select index.
Selectcurrently stores a uint8 index, but this design says the same control stores the stable library id. Unless the control contract is extended with explicit option values, reordering or deleting presets will still corrupt driver references. Specify a separate persistedpresetIdplus a derived display index, or explicitly change and test the Select serialization contract.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@docs/history/plans/Plan-20260711` - LightPresets reusable named-preset library (shipped).md around lines 34 - 39, Clarify the preset reference design by persisting a separate stable presetId for each driver and treating the Select value as a derived display index only. Update the resolution and rebuildCorrection flow to map presetId to the current library index/name, handle missing ids with the existing RGB fallback and warning, and specify persistence/serialization behavior so reorder or deletion cannot alter stored references.docs/history/plans/Plan-20260716 - LED driver rename for a human-readable UI (shipped).md (1)
28-28: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win
Resolve the ADR migration-documentation contradiction.
Line 28 says
docs/adr/**is immutable, but Line 48 says to move content from ADR-0013 and modify that ADR to add a link. Keep ADR-0013 authoritative and link to it fromMIGRATING.md, or explicitly revise the immutability decision; do not leave both instructions in the plan.Also applies to: 48-48
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@docs/history/plans/Plan-20260716` - LED driver rename for a human-readable UI (shipped).md at line 28, Resolve the contradiction between the immutability rule and the ADR-0013 migration step in the plan: either keep ADR-0013 unchanged as the authoritative source and link to it from MIGRATING.md, or explicitly revise the immutability decision so the planned ADR modification is permitted. Update the instructions at both the “Do NOT touch” section and the ADR-0013 step consistently, without leaving conflicting guidance.Sources: Coding guidelines, Path instructions
docs/history/plans/Plan-20260718 - Lean rows=1 ring ISR (superseded by the near-prime pool).md (2)
1-1: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Use an allowed settled outcome marker in the filename.
Because this plan is superseded rather than shipped, rename it to use the required
(attempted, abandoned)marker, or another approved outcome that accurately describes the result.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@docs/history/plans/Plan-20260718` - Lean rows=1 ring ISR (superseded by the near-prime pool).md at line 1, Rename the plan document identified by its title “Plan — Lean rows=1 ring ISR: shed per-firing overhead, test if 48 strands fit hpwit-style” to include the required “(attempted, abandoned)” outcome marker, accurately indicating that the plan was superseded rather than shipped.Source: Path instructions
26-33: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Make the lean-path plan internally consistent.
The document says measurement does not gate implementation, but the heading, Phase 1, and verification still make Phase 1 conditional on Phase 0. It also rejects periodic reconciliation while the code-grounding and test sections still require it. Choose one behavior and update every reference.
Also applies to: 48-50, 59-60, 76-80, 96-105
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@docs/history/plans/Plan-20260718` - Lean rows=1 ring ISR (superseded by the near-prime pool).md around lines 26 - 33, Make the lean-path plan consistently unconditional: remove any Phase 0 measurement prerequisite from the heading, Phase 1, and verification sections, and remove references requiring periodic reconciliation from the code-grounding and test sections. Preserve the stated lean behavior of counter advancement, ring-slot selection, refill-until-caught-up, and no timer, division, or reconciliation.docs/history/plans/Plan-20260718 - MoonI80 lapping-v2 clock-oracle ring (shipped).md (1)
155-159: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Correct the late-slice percentage.
The reported
~120/sdoes not match~17%of slices: at 66 fps and 37 slices/frame, that is about 2,442 slices/s, making 120/s roughly 4.9%. Correct the percentage or define the denominator used.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@docs/history/plans/Plan-20260718` - MoonI80 lapping-v2 clock-oracle ring (shipped).md around lines 155 - 159, Correct the late-slice rate statement in the “Phase C” section: with ~120 late slices per second and the stated ~2,442 total slices per second, report approximately 4.9% instead of ~17%, or explicitly define a different denominator if 17% is intended.docs/history/plans/Plan-20260718 - MoonI80 ring trailing-refill + loopback instrument (shipped, refill superseded by lapping-v2).md (1)
185-186: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Remove developer-local absolute paths from documentation.
docs/history/plans/Plan-20260718 - MoonI80 ring trailing-refill + loopback instrument (shipped, refill superseded by lapping-v2).md#L185-L186: replace the/Users/ewoud/...source reference with a repository-relative path, commit, or stable external reference.docs/history/reviews/2026-07-20-driver-feature-audit.md#L143-L143: replace the embedded local filesystem path with the same portable reference style.🧰 Tools
🪛 markdownlint-cli2 (0.23.0)
[warning] 185-185: Headings should be surrounded by blank lines
Expected: 1; Actual: 0; Below(MD022, blanks-around-headings)
📍 Affects 2 files
docs/history/plans/Plan-20260718 - MoonI80 ring trailing-refill + loopback instrument (shipped, refill superseded by lapping-v2).md#L185-L186(this comment)docs/history/reviews/2026-07-20-driver-feature-audit.md#L143-L143🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@docs/history/plans/Plan-20260718` - MoonI80 ring trailing-refill + loopback instrument (shipped, refill superseded by lapping-v2).md around lines 185 - 186, Replace the developer-local absolute source reference in the plan’s “hpwit’s ACTUAL termination” section with a portable repository-relative path, commit reference, or stable external URL. Apply the same portable reference style to the embedded path in docs/history/reviews/2026-07-20-driver-feature-audit.md at lines 143-143.docs/history/plans/Plan-20260719 - Parallel snapshot dual-core (shipped).md (1)
27-39: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Remove or clearly mark the superseded core-ownership description.
Lines 27-33 say core 0 performs the lower snapshot half, while the correction at Lines 35-39 says the snapshot runs on core 1 and uses a helper pinned to core 0. Rewrite the earlier paragraph so the plan has one authoritative worker topology; otherwise an implementer can wire the join at the wrong layer.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@docs/history/plans/Plan-20260719` - Parallel snapshot dual-core (shipped).md around lines 27 - 39, Rewrite the initial correction-loop paragraph to remove the claim that core 0 performs the bottom snapshot half inline after its effect render. Make it consistent with the authoritative topology in the correction: tickRing() runs on core 1, which handles the upper range while a persistent helper pinned to core 0 handles the lower range, then the caller joins before continuing. Retain the existing snapshot gating rationale only where it does not conflict with this ownership model.docs/history/reviews/2026-07-20-driver-feature-audit.md (2)
5-17: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Synchronize the audit body and action list with the status table.
The table says A1, A2, and A4 are fixed and A3 is deferred, but the detailed findings and merge-prep list still present those items as outstanding. Mark those sections as historical or update them to the current state; otherwise merge decisions will be based on stale guidance.
Also applies to: 21-35, 122-128
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@docs/history/reviews/2026-07-20-driver-feature-audit.md` around lines 5 - 17, Synchronize the detailed A1, A2, A3, and A4 findings and the MERGE-PREP ACTION LIST with the status table: mark A1, A2, and A4 as fixed or historical, and mark A3 as deferred while preserving its deliberate retention. Update the affected sections near the listed ranges so no stale outstanding guidance remains, while leaving the documented rationale and follow-up decisions consistent.
122-140: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Move executable follow-up actions out of the history record.
The merge-prep and post-merge action lists are forward-looking implementation work. Per the documentation policy, keep this file as the retrospective audit and move the actionable backlog items to
docs/backlog/, linking to them from here.🧰 Tools
🪛 markdownlint-cli2 (0.23.0)
[warning] 124-124: Headings should be surrounded by blank lines
Expected: 1; Actual: 0; Below(MD022, blanks-around-headings)
[warning] 130-130: Headings should be surrounded by blank lines
Expected: 1; Actual: 0; Below(MD022, blanks-around-headings)
[warning] 133-133: Headings should be surrounded by blank lines
Expected: 1; Actual: 0; Below(MD022, blanks-around-headings)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@docs/history/reviews/2026-07-20-driver-feature-audit.md` around lines 122 - 140, Move the forward-looking “MERGE-PREP ACTION LIST” and “Follow-up after merge” items out of this history record into appropriate documents under docs/backlog/. Replace those sections with links to the backlog items, while preserving the retrospective audit content and its references.Source: Coding guidelines
src/platform/esp32/platform_esp32_moon_i80.cpp (1)
1387-1390: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Stale shift-clock default in comments (says
3/26.67 MHz; actual default is4/20 MHz). These comments describe the default divider as3(26.67 MHz), but the shipped default iskShiftClockDivDefault = 4(20 MHz) — the reliability point (L104-114), theg_shiftClockDivinitializer, theplatform.hdeclaration doc (L862), and this PR's stated default. A maintainer reading these would infer the wrong overclock state.
src/platform/esp32/platform_esp32_moon_i80.cpp#L1387-L1390: change "3 = 26.67 MHz (default)" so4(20 MHz) is marked the default and3the overclock.src/platform/esp32/platform_esp32_moon_i80.cpp#L742-L746: correct "the runtime shiftClockDiv (default 3 = 26.67 MHz)" to the 20 MHz default.src/platform/esp32/platform_esp32_moon_i80.cpp#L1140-L1143: correct the identical "default 3 = 26.67 MHz" text to the 20 MHz default.📍 Affects 1 file
src/platform/esp32/platform_esp32_moon_i80.cpp#L1387-L1390(this comment)src/platform/esp32/platform_esp32_moon_i80.cpp#L742-L746src/platform/esp32/platform_esp32_moon_i80.cpp#L1140-L1143🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/platform/esp32/platform_esp32_moon_i80.cpp` around lines 1387 - 1390, Update the shift-clock documentation to reflect the actual default divider: in src/platform/esp32/platform_esp32_moon_i80.cpp lines 1387-1390, mark 4 (20 MHz) as the default and 3 (26.67 MHz) as the overclock; make the same correction to the runtime shiftClockDiv descriptions at lines 742-746 and 1140-1143.
Restores the wall-verified ring frame-completion, adds a prime-vs-drain barrier that closes a real (counter-blind) buffer race, and subscribes the render loop to the task watchdog so a genuine wedge reboots itself instead of hanging. Adds an expert-mode UI toggle and a documentation reorganization. The last-row sparkle on the 48x256 wall is NOT fixed here: it is root-caused to the EOF ISR encoding from a PSRAM-resident source, fully instrumented, and deferred to its own plan. KPI: desktop tick 3-279us across scenarios (16384 lights); ESP32 wall this session measured ~8.3ms wire at 23-33 fps on 12288 lights (the scraped monitor.log figure tick:37929us/FPS:26 is stale, from an older run, not this build). Core: - platform_esp32_moon_i80: frame completion is written-gated (lastWrittenSlice >= lastSlice) with lastStopUs = armUs + the whole frame's wire time INCLUDING the kTailBufs flush slice; a drain-gated completion deadlocks (the frontier-halted DMA fires no completing EOF, wall-measured as flicker-then-"no LED output") so it is deliberately not used. - platform_esp32_moon_i80: new waitWireDrained barrier inside primeRingRange holds the next prime until the previous frame's deterministic wire end, closing the race where the lapping frame's last slices (which occupy the first buffers) get repainted mid-drain; no counter observes this corruption. - platform_esp32: added ptrIsPsram residency probe (esp_ptr_external_ram) so the driver can report where an internal-first-PSRAM-fallback allocation actually landed. - platform_esp32_worker: taskWdtSubscribe/taskWdtReset subscribe the render loop to the task watchdog (idle-task checking off in sdkconfig, so this explicit subscription is what is watched); a wedge panics-and-reboots with a backtrace. - platform.h: kRingBufsMax raised 32 -> 64 so the prime-only regime stays reachable at 48x256; ptrIsPsram/taskWdt declarations. - SystemModule/Control/HttpServerModule/MoonModule: expertMode bool + per-control advanced flag (mirrors hidden/readonly), serialized and change-hashed. - sdkconfig.defaults: task WDT enabled with idle-task checks off and panic on; the classic/P4 build-dir sdkconfigs were stale and regenerated (delete-to-regenerate) so all three variants now carry CONFIG_ESP_TASK_WDT_EN=y. Light domain: - MoonLedDriver: ringDbg gains sn/lv residency fields (I internal / P PSRAM / - absent) for the two encode sources, with the legend table updated; ring controls set advanced; fork-join prime relies on the prime-side wire barrier. - ParallelLedDriver: serial snapshot on core 1 (no snapshot fork); the prime alone forks under the split. - Drivers: core-1 encode loop feeds the task WDT so a long encode is not mistaken for a wedge. UI: - app.js: expert-mode reader gates advanced controls in both render paths via the shared predicate; re-renders on toggle. - style.css: wrench marker on advanced controls. Scripts / MoonDeck: - gen_api.py: @moreinfo relocates the class-comment tail below members; @Xref for page-local links; unresolved bare-anchor links stripped. Docs / CI: - backlog-light: the ISR-PSRAM sparkle written up with the full evidence chain and the measured-shut fix space; the raise-ringBufs-darks-output robustness bug logged. - coding-standards: no-em-dash rule; @moreinfo/@Xref documentation deep-dive rule. - ADR-0015 (library is a tag, not a folder) promoted from the folder-structure proposal; multicore-analysis drafts removed, leddriver/shift-register analyses moved to history; plan outcomes marked. Reviews: - 👾 Prime-vs-wire race (HIGH): fixed via the waitWireDrained barrier in primeRingRange (single call site), not the earlier three-file seam. - 👾 WDT subscribed nothing (HIGH): fixed via taskWdtSubscribe + a real taskWdtReset; the classic link failure it surfaced was a stale build-dir sdkconfig, not missing support. - 👾 Completion reports written-not-drained (HIGH): kept written-gated by design (drain-gating deadlocks with the frontier terminator); documented at the site and in the backlog with both dead ends named. - 👾 kTailBufs hoist, helperKick comment, gen_api comment: applied. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 7
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (5)
src/light/drivers/ParallelLedDriver.h (1)
1115-1123: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick winPrevent out-of-bounds read on
patSrc.
patSrcis defined as an 8-byte array. If the pipeline carries an unusually wide format wheresrcCh > 8,std::memcpywill read past the end ofpatSrcand inject garbage intosnapshotBuf_. Clamp the copy length to prevent buffer over-reads.🛡️ Proposed fix
- uint8_t patSrc[8] = {}; // pattern in SOURCE channels (corrected downstream like every light) - for (size_t ch = 0; ch < srcCh && ch < sizeof(patSrc); ch++) - patSrc[ch] = ch < 3 ? kPatternRGB_[ch] : uint8_t{0}; - const nrOfLightsType laneRows = laneCounts_[lane]; - for (nrOfLightsType row = 0; row < laneRows; row++) { - if (static_cast<nrOfLightsType>(laneStart_[lane] + row) >= winLights) break; - std::memcpy(snapshotBuf_ + (static_cast<size_t>(laneStart_[lane]) + row) * srcCh, - patSrc, srcCh); - } + uint8_t patSrc[8] = {}; // pattern in SOURCE channels (corrected downstream like every light) + const size_t copyLen = srcCh < sizeof(patSrc) ? srcCh : sizeof(patSrc); + for (size_t ch = 0; ch < copyLen; ch++) + patSrc[ch] = ch < 3 ? kPatternRGB_[ch] : uint8_t{0}; + const nrOfLightsType laneRows = laneCounts_[lane]; + for (nrOfLightsType row = 0; row < laneRows; row++) { + if (static_cast<nrOfLightsType>(laneStart_[lane] + row) >= winLights) break; + std::memcpy(snapshotBuf_ + (static_cast<size_t>(laneStart_[lane]) + row) * srcCh, + patSrc, copyLen); + }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/light/drivers/ParallelLedDriver.h` around lines 1115 - 1123, Clamp the copy length in the snapshot-writing loop around patSrc and std::memcpy to the capacity of patSrc (8 bytes), while preserving the existing source-channel behavior. Use the clamped length for the memcpy so srcCh values greater than sizeof(patSrc) cannot read beyond patSrc.docs/history/shift-register-driver-analysis.md (3)
177-199: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick winAlign the expander control name with the shipped API.
This section names the checkbox
pinExpanderMode, but the supplied unit test exercisesMoonLedDriver::pinExpander. Use the actual name or explicitly markpinExpanderModeas historical/proposed.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@docs/history/shift-register-driver-analysis.md` around lines 177 - 199, The recommendation in section “6.1” uses the non-shipped control name pinExpanderMode while the tested API is MoonLedDriver::pinExpander. Rename the proposed checkbox references to pinExpander, or explicitly label pinExpanderMode as a historical/proposed name and identify pinExpander as the shipped API.
214-214: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winCorrect the board-validation count.
This says direct mode was proven on four boards, while the PR objectives state three boards. Update the document or provide evidence for the fourth board.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@docs/history/shift-register-driver-analysis.md` at line 214, Correct the board-validation statement in the document to match the PR objective of three boards, or add supporting evidence identifying and validating the fourth board. Keep the surrounding direct-mode and GDMA claims unchanged.
270-276: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick winClarify the frame-size units at
docs/history/shift-register-driver-analysis.md#L231-L238anddocs/history/shift-register-driver-analysis.md#L270-L276. The54/72/144 KBtable and the108/144/288 KBtable are 2× apart, so they should not be presented as the same metric. Label one as the per-strand/source-buffer size and the other as the fully encoded frame, then keep the cap and PSRAM cliff math on one unit.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@docs/history/shift-register-driver-analysis.md` around lines 270 - 276, The tables at docs/history/shift-register-driver-analysis.md lines 231-238 and 270-276 use different frame-size metrics without labeling them. Identify the 54/72/144 KB values as per-strand/source-buffer sizes and the 108/144/288 KB values in the cap analysis as fully encoded frame sizes, then ensure the 96-light cap and PSRAM-cliff calculations consistently use the fully encoded-frame metric.moondeck/docs/gen_api.py (1)
461-462: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueExplicitly set
check=Falseforsubprocess.run.Since the code intentionally handles the non-zero return code manually, explicitly setting
check=Falseclarifies the intent and satisfies the static analyzer (PLW1510).
moondeck/docs/gen_api.py#L461-L462: Addcheck=Falseto the doxygensubprocess.runcall.moondeck/docs/gen_api.py#L467-L472: Addcheck=Falseto the first moxygensubprocess.runcall.moondeck/docs/gen_api.py#L483-L488: Addcheck=Falseto the second moxygensubprocess.runcall.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@moondeck/docs/gen_api.py` around lines 461 - 462, Explicitly pass check=False to each subprocess.run invocation handling doxygen and both moxygen commands in moondeck/docs/gen_api.py at lines 461-462, 467-472, and 483-488, preserving the existing manual return-code handling.
♻️ Duplicate comments (2)
docs/history/plans/Plan-20260717 - MoonI80 runtime ring geometry (shipped).md (1)
23-30: 🎯 Functional Correctness | 🟠 MajorCorrect the ring-memory trade-off premise.
With the documented formula, any fixed
ringRows/ringBufsgeometry has constant pool RAM as strand length changes;ringRows=1is not uniquely flat. Reframe this section around absolute pool size, interrupt frequency, and DMA runway so the sweep is not optimized from a false scaling assumption.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@docs/history/plans/Plan-20260717` - MoonI80 runtime ring geometry (shipped).md around lines 23 - 30, Correct the RAM row in the trade-off table and surrounding explanation: remove the claim that only ringRows=1 keeps memory flat with strand length, since fixed ringRows and ringBufs produce constant pool RAM. Reframe the design rationale around absolute pool size, interrupt frequency, and DMA runway, while preserving the remaining per-call overhead and runway comparisons.docs/history/plans/Plan-20260718 - Lean rows=1 ring ISR (attempted, abandoned).md (1)
48-59: 🎯 Functional Correctness | 🟠 MajorResolve the contradictory lean-ring requirements.
The plan says the lean branch ships regardless of Phase 0 and removes periodic reconciliation, but later says Phase 1 is conditional and still references periodic reconciliation. Update the implementation, code-grounding, and verification sections to describe one consistent algorithm and gating policy.
Also applies to: 76-80, 87-105
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@docs/history/plans/Plan-20260718` - Lean rows=1 ring ISR (attempted, abandoned).md around lines 48 - 59, Reconcile the plan sections describing the lean rows==1 ISR branch so they specify one algorithm and gating policy. Update the implementation, code-grounding, and verification sections consistently: state whether Phase 1 is conditional on the Phase 0 measurement, and consistently document the periodic-reconciliation behavior. Remove contradictory claims while preserving the intended measurement and verification criteria.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/history/leddriver-analysis-bottom-up.md`:
- Around line 586-595: Add a blank line immediately after the “### Don't” and
“### Do” headings in the checklist section, before their respective list
content, without changing the checklist items.
In `@docs/history/plans/Plan-20260712` - Step 1.5 async transmit double-buffer
(shipped).md:
- Around line 81-85: Add a blank line immediately after the “Increment 2 — RMT”
and “Deferred: the Parlio chunk-streaming ring” headings, before their
respective paragraph content. Preserve the heading text and all surrounding plan
content unchanged.
In `@docs/history/plans/Plan-20260718` - Lean rows=1 ring ISR (attempted,
abandoned).md:
- Line 50: Add blank lines before and after each changed Markdown section
heading in the document, including the headings at the referenced locations, to
satisfy MD022 while preserving their content and hierarchy.
In `@docs/history/README.md`:
- Line 55: Update the “Parallel multi-strip output on S3/P4” entry to replace
the stale “native parallel LED output is unbuilt” wording: identify direct
parallel-driver support as shipped, and describe shift-register/expander support
as present but dormant and disabled by default. Preserve the existing driver
references and LED-driver analysis link.
In `@docs/history/reviews/2026-07-20-driver-feature-audit.md`:
- Line 145: Add a single trailing newline to
docs/history/reviews/2026-07-20-driver-feature-audit.md, leaving the document
content otherwise unchanged.
In `@docs/moonmodules/light/drivers.md`:
- Line 137: The Moon driver documentation row should qualify
pinExpander/shift-register support as experimental and disabled by default,
rather than presenting it as ready for production use. Update the capability
description and relevant defaults in the Moon row, and link the existing backlog
analysis covering the unresolved GDMA descriptor issue; preserve the normal
non-shift Moon behavior.
In `@src/core/HttpServerModule.cpp`:
- Line 1059: Update the conditional statement checking c.advanced in the
response-building logic to use braces around its sink.append call, preserving
the existing condition and output.
---
Outside diff comments:
In `@docs/history/shift-register-driver-analysis.md`:
- Around line 177-199: The recommendation in section “6.1” uses the non-shipped
control name pinExpanderMode while the tested API is MoonLedDriver::pinExpander.
Rename the proposed checkbox references to pinExpander, or explicitly label
pinExpanderMode as a historical/proposed name and identify pinExpander as the
shipped API.
- Line 214: Correct the board-validation statement in the document to match the
PR objective of three boards, or add supporting evidence identifying and
validating the fourth board. Keep the surrounding direct-mode and GDMA claims
unchanged.
- Around line 270-276: The tables at
docs/history/shift-register-driver-analysis.md lines 231-238 and 270-276 use
different frame-size metrics without labeling them. Identify the 54/72/144 KB
values as per-strand/source-buffer sizes and the 108/144/288 KB values in the
cap analysis as fully encoded frame sizes, then ensure the 96-light cap and
PSRAM-cliff calculations consistently use the fully encoded-frame metric.
In `@moondeck/docs/gen_api.py`:
- Around line 461-462: Explicitly pass check=False to each subprocess.run
invocation handling doxygen and both moxygen commands in
moondeck/docs/gen_api.py at lines 461-462, 467-472, and 483-488, preserving the
existing manual return-code handling.
In `@src/light/drivers/ParallelLedDriver.h`:
- Around line 1115-1123: Clamp the copy length in the snapshot-writing loop
around patSrc and std::memcpy to the capacity of patSrc (8 bytes), while
preserving the existing source-channel behavior. Use the clamped length for the
memcpy so srcCh values greater than sizeof(patSrc) cannot read beyond patSrc.
---
Duplicate comments:
In `@docs/history/plans/Plan-20260717` - MoonI80 runtime ring geometry
(shipped).md:
- Around line 23-30: Correct the RAM row in the trade-off table and surrounding
explanation: remove the claim that only ringRows=1 keeps memory flat with strand
length, since fixed ringRows and ringBufs produce constant pool RAM. Reframe the
design rationale around absolute pool size, interrupt frequency, and DMA runway,
while preserving the remaining per-call overhead and runway comparisons.
In `@docs/history/plans/Plan-20260718` - Lean rows=1 ring ISR (attempted,
abandoned).md:
- Around line 48-59: Reconcile the plan sections describing the lean rows==1 ISR
branch so they specify one algorithm and gating policy. Update the
implementation, code-grounding, and verification sections consistently: state
whether Phase 1 is conditional on the Phase 0 measurement, and consistently
document the periodic-reconciliation behavior. Remove contradictory claims while
preserving the intended measurement and verification criteria.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 70c75896-dce4-43fd-9760-dc67356cf883
📒 Files selected for processing (61)
docs/adr/0015-library-is-a-tag-not-a-folder.mddocs/adr/README.mddocs/backlog/README.mddocs/backlog/backlog-core.mddocs/backlog/backlog-light.mddocs/backlog/folder-structure-proposal.mddocs/backlog/livescripts-analysis-bottom-up.mddocs/backlog/livescripts-analysis-top-down.mddocs/backlog/moonlight-effect-inventory.mddocs/backlog/multicore-analysis-bottom-up.mddocs/backlog/multicore-analysis-top-down.mddocs/backlog/nrf-zephyr-target-analysis.mddocs/backlog/pins-analysis-bottom-up.mddocs/backlog/pins-analysis-top-down.mddocs/backlog/system-modules.mddocs/coding-standards.mddocs/history/README.mddocs/history/leddriver-analysis-bottom-up.mddocs/history/leddriver-analysis-top-down.mddocs/history/lessons.mddocs/history/plans/Plan-20260630 - MoonLight migration (multi-stage).mddocs/history/plans/Plan-20260702 - Docs system overhaul (Phase 0 through Docs v2) (shipped).mddocs/history/plans/Plan-20260709 - Disabling releases resources (onEnabled per module) (shipped, superseded).mddocs/history/plans/Plan-20260710 - Active-instance election primitive.mddocs/history/plans/Plan-20260710 - Rename module hooks to prepare-tick-release (shipped).mddocs/history/plans/Plan-20260710 - Scratch buffer helper for memory-holding effects (shipped).mddocs/history/plans/Plan-20260711 - Migrate MoonLight fixture presets as built-ins (shipped).mddocs/history/plans/Plan-20260712 - Step 1.5 async transmit double-buffer (shipped).mddocs/history/plans/Plan-20260714 - Shift-register LED driver (shipped).mddocs/history/plans/Plan-20260717 - MoonI80 runtime ring geometry (shipped).mddocs/history/plans/Plan-20260718 - Lean rows=1 ring ISR (attempted, abandoned).mddocs/history/plans/Plan-20260718 - MoonI80 lapping-v2 clock-oracle ring (shipped).mddocs/history/plans/Plan-20260718 - MoonI80 ring trailing-refill + loopback instrument (shipped, refill superseded by lapping-v2).mddocs/history/reviews/2026-07-20-driver-feature-audit.mddocs/history/shift-register-driver-analysis.mddocs/moonmodules/core/system.mddocs/moonmodules/light/drivers.mddocs/moonmodules/light/effects.mddocs/moonmodules/light/layouts.mddocs/moonmodules/light/modifiers.mddocs/performance.mddocs/reference/mhc-wled-esp32-p4-shield.mdesp32/sdkconfig.defaultsmoondeck/docs/gen_api.pysrc/core/Control.hsrc/core/HttpServerModule.cppsrc/core/MoonModule.hsrc/core/SystemModule.hsrc/light/drivers/LedDriverConfig.hsrc/light/drivers/MoonLedDriver.hsrc/light/drivers/ParallelLedDriver.hsrc/light/drivers/ParallelSlots.hsrc/main.cppsrc/platform/desktop/platform_desktop.cppsrc/platform/esp32/platform_esp32.cppsrc/platform/esp32/platform_esp32_i80.cppsrc/platform/esp32/platform_esp32_moon_i80.cppsrc/platform/esp32/platform_esp32_worker.cppsrc/platform/platform.hsrc/ui/app.jssrc/ui/style.css
💤 Files with no reviewable changes (4)
- docs/backlog/multicore-analysis-bottom-up.md
- docs/backlog/folder-structure-proposal.md
- docs/backlog/README.md
- docs/backlog/multicore-analysis-top-down.md
There was a problem hiding this comment.
Caution
Inline review comments failed to post. This is likely due to GitHub's internal server error or limits when posting large numbers of comments. If you are seeing this consistently it is likely a permissions issue. Please check "Moderation" -> "Code review limits" under your organization settings.
Actionable comments posted: 7
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (5)
src/light/drivers/ParallelLedDriver.h (1)
1115-1123: 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick winPrevent out-of-bounds read on
patSrc.
patSrcis defined as an 8-byte array. If the pipeline carries an unusually wide format wheresrcCh > 8,std::memcpywill read past the end ofpatSrcand inject garbage intosnapshotBuf_. Clamp the copy length to prevent buffer over-reads.🛡️ Proposed fix
- uint8_t patSrc[8] = {}; // pattern in SOURCE channels (corrected downstream like every light) - for (size_t ch = 0; ch < srcCh && ch < sizeof(patSrc); ch++) - patSrc[ch] = ch < 3 ? kPatternRGB_[ch] : uint8_t{0}; - const nrOfLightsType laneRows = laneCounts_[lane]; - for (nrOfLightsType row = 0; row < laneRows; row++) { - if (static_cast<nrOfLightsType>(laneStart_[lane] + row) >= winLights) break; - std::memcpy(snapshotBuf_ + (static_cast<size_t>(laneStart_[lane]) + row) * srcCh, - patSrc, srcCh); - } + uint8_t patSrc[8] = {}; // pattern in SOURCE channels (corrected downstream like every light) + const size_t copyLen = srcCh < sizeof(patSrc) ? srcCh : sizeof(patSrc); + for (size_t ch = 0; ch < copyLen; ch++) + patSrc[ch] = ch < 3 ? kPatternRGB_[ch] : uint8_t{0}; + const nrOfLightsType laneRows = laneCounts_[lane]; + for (nrOfLightsType row = 0; row < laneRows; row++) { + if (static_cast<nrOfLightsType>(laneStart_[lane] + row) >= winLights) break; + std::memcpy(snapshotBuf_ + (static_cast<size_t>(laneStart_[lane]) + row) * srcCh, + patSrc, copyLen); + }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/light/drivers/ParallelLedDriver.h` around lines 1115 - 1123, Clamp the copy length in the snapshot-writing loop around patSrc and std::memcpy to the capacity of patSrc (8 bytes), while preserving the existing source-channel behavior. Use the clamped length for the memcpy so srcCh values greater than sizeof(patSrc) cannot read beyond patSrc.docs/history/shift-register-driver-analysis.md (3)
177-199: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick winAlign the expander control name with the shipped API.
This section names the checkbox
pinExpanderMode, but the supplied unit test exercisesMoonLedDriver::pinExpander. Use the actual name or explicitly markpinExpanderModeas historical/proposed.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@docs/history/shift-register-driver-analysis.md` around lines 177 - 199, The recommendation in section “6.1” uses the non-shipped control name pinExpanderMode while the tested API is MoonLedDriver::pinExpander. Rename the proposed checkbox references to pinExpander, or explicitly label pinExpanderMode as a historical/proposed name and identify pinExpander as the shipped API.
214-214: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winCorrect the board-validation count.
This says direct mode was proven on four boards, while the PR objectives state three boards. Update the document or provide evidence for the fourth board.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@docs/history/shift-register-driver-analysis.md` at line 214, Correct the board-validation statement in the document to match the PR objective of three boards, or add supporting evidence identifying and validating the fourth board. Keep the surrounding direct-mode and GDMA claims unchanged.
270-276: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick winClarify the frame-size units at
docs/history/shift-register-driver-analysis.md#L231-L238anddocs/history/shift-register-driver-analysis.md#L270-L276. The54/72/144 KBtable and the108/144/288 KBtable are 2× apart, so they should not be presented as the same metric. Label one as the per-strand/source-buffer size and the other as the fully encoded frame, then keep the cap and PSRAM cliff math on one unit.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@docs/history/shift-register-driver-analysis.md` around lines 270 - 276, The tables at docs/history/shift-register-driver-analysis.md lines 231-238 and 270-276 use different frame-size metrics without labeling them. Identify the 54/72/144 KB values as per-strand/source-buffer sizes and the 108/144/288 KB values in the cap analysis as fully encoded frame sizes, then ensure the 96-light cap and PSRAM-cliff calculations consistently use the fully encoded-frame metric.moondeck/docs/gen_api.py (1)
461-462: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low valueExplicitly set
check=Falseforsubprocess.run.Since the code intentionally handles the non-zero return code manually, explicitly setting
check=Falseclarifies the intent and satisfies the static analyzer (PLW1510).
moondeck/docs/gen_api.py#L461-L462: Addcheck=Falseto the doxygensubprocess.runcall.moondeck/docs/gen_api.py#L467-L472: Addcheck=Falseto the first moxygensubprocess.runcall.moondeck/docs/gen_api.py#L483-L488: Addcheck=Falseto the second moxygensubprocess.runcall.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@moondeck/docs/gen_api.py` around lines 461 - 462, Explicitly pass check=False to each subprocess.run invocation handling doxygen and both moxygen commands in moondeck/docs/gen_api.py at lines 461-462, 467-472, and 483-488, preserving the existing manual return-code handling.
♻️ Duplicate comments (2)
docs/history/plans/Plan-20260717 - MoonI80 runtime ring geometry (shipped).md (1)
23-30: 🎯 Functional Correctness | 🟠 MajorCorrect the ring-memory trade-off premise.
With the documented formula, any fixed
ringRows/ringBufsgeometry has constant pool RAM as strand length changes;ringRows=1is not uniquely flat. Reframe this section around absolute pool size, interrupt frequency, and DMA runway so the sweep is not optimized from a false scaling assumption.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@docs/history/plans/Plan-20260717` - MoonI80 runtime ring geometry (shipped).md around lines 23 - 30, Correct the RAM row in the trade-off table and surrounding explanation: remove the claim that only ringRows=1 keeps memory flat with strand length, since fixed ringRows and ringBufs produce constant pool RAM. Reframe the design rationale around absolute pool size, interrupt frequency, and DMA runway, while preserving the remaining per-call overhead and runway comparisons.docs/history/plans/Plan-20260718 - Lean rows=1 ring ISR (attempted, abandoned).md (1)
48-59: 🎯 Functional Correctness | 🟠 MajorResolve the contradictory lean-ring requirements.
The plan says the lean branch ships regardless of Phase 0 and removes periodic reconciliation, but later says Phase 1 is conditional and still references periodic reconciliation. Update the implementation, code-grounding, and verification sections to describe one consistent algorithm and gating policy.
Also applies to: 76-80, 87-105
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@docs/history/plans/Plan-20260718` - Lean rows=1 ring ISR (attempted, abandoned).md around lines 48 - 59, Reconcile the plan sections describing the lean rows==1 ISR branch so they specify one algorithm and gating policy. Update the implementation, code-grounding, and verification sections consistently: state whether Phase 1 is conditional on the Phase 0 measurement, and consistently document the periodic-reconciliation behavior. Remove contradictory claims while preserving the intended measurement and verification criteria.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@docs/history/leddriver-analysis-bottom-up.md`:
- Around line 586-595: Add a blank line immediately after the “### Don't” and
“### Do” headings in the checklist section, before their respective list
content, without changing the checklist items.
In `@docs/history/plans/Plan-20260712` - Step 1.5 async transmit double-buffer
(shipped).md:
- Around line 81-85: Add a blank line immediately after the “Increment 2 — RMT”
and “Deferred: the Parlio chunk-streaming ring” headings, before their
respective paragraph content. Preserve the heading text and all surrounding plan
content unchanged.
In `@docs/history/plans/Plan-20260718` - Lean rows=1 ring ISR (attempted,
abandoned).md:
- Line 50: Add blank lines before and after each changed Markdown section
heading in the document, including the headings at the referenced locations, to
satisfy MD022 while preserving their content and hierarchy.
In `@docs/history/README.md`:
- Line 55: Update the “Parallel multi-strip output on S3/P4” entry to replace
the stale “native parallel LED output is unbuilt” wording: identify direct
parallel-driver support as shipped, and describe shift-register/expander support
as present but dormant and disabled by default. Preserve the existing driver
references and LED-driver analysis link.
In `@docs/history/reviews/2026-07-20-driver-feature-audit.md`:
- Line 145: Add a single trailing newline to
docs/history/reviews/2026-07-20-driver-feature-audit.md, leaving the document
content otherwise unchanged.
In `@docs/moonmodules/light/drivers.md`:
- Line 137: The Moon driver documentation row should qualify
pinExpander/shift-register support as experimental and disabled by default,
rather than presenting it as ready for production use. Update the capability
description and relevant defaults in the Moon row, and link the existing backlog
analysis covering the unresolved GDMA descriptor issue; preserve the normal
non-shift Moon behavior.
In `@src/core/HttpServerModule.cpp`:
- Line 1059: Update the conditional statement checking c.advanced in the
response-building logic to use braces around its sink.append call, preserving
the existing condition and output.
---
Outside diff comments:
In `@docs/history/shift-register-driver-analysis.md`:
- Around line 177-199: The recommendation in section “6.1” uses the non-shipped
control name pinExpanderMode while the tested API is MoonLedDriver::pinExpander.
Rename the proposed checkbox references to pinExpander, or explicitly label
pinExpanderMode as a historical/proposed name and identify pinExpander as the
shipped API.
- Line 214: Correct the board-validation statement in the document to match the
PR objective of three boards, or add supporting evidence identifying and
validating the fourth board. Keep the surrounding direct-mode and GDMA claims
unchanged.
- Around line 270-276: The tables at
docs/history/shift-register-driver-analysis.md lines 231-238 and 270-276 use
different frame-size metrics without labeling them. Identify the 54/72/144 KB
values as per-strand/source-buffer sizes and the 108/144/288 KB values in the
cap analysis as fully encoded frame sizes, then ensure the 96-light cap and
PSRAM-cliff calculations consistently use the fully encoded-frame metric.
In `@moondeck/docs/gen_api.py`:
- Around line 461-462: Explicitly pass check=False to each subprocess.run
invocation handling doxygen and both moxygen commands in
moondeck/docs/gen_api.py at lines 461-462, 467-472, and 483-488, preserving the
existing manual return-code handling.
In `@src/light/drivers/ParallelLedDriver.h`:
- Around line 1115-1123: Clamp the copy length in the snapshot-writing loop
around patSrc and std::memcpy to the capacity of patSrc (8 bytes), while
preserving the existing source-channel behavior. Use the clamped length for the
memcpy so srcCh values greater than sizeof(patSrc) cannot read beyond patSrc.
---
Duplicate comments:
In `@docs/history/plans/Plan-20260717` - MoonI80 runtime ring geometry
(shipped).md:
- Around line 23-30: Correct the RAM row in the trade-off table and surrounding
explanation: remove the claim that only ringRows=1 keeps memory flat with strand
length, since fixed ringRows and ringBufs produce constant pool RAM. Reframe the
design rationale around absolute pool size, interrupt frequency, and DMA runway,
while preserving the remaining per-call overhead and runway comparisons.
In `@docs/history/plans/Plan-20260718` - Lean rows=1 ring ISR (attempted,
abandoned).md:
- Around line 48-59: Reconcile the plan sections describing the lean rows==1 ISR
branch so they specify one algorithm and gating policy. Update the
implementation, code-grounding, and verification sections consistently: state
whether Phase 1 is conditional on the Phase 0 measurement, and consistently
document the periodic-reconciliation behavior. Remove contradictory claims while
preserving the intended measurement and verification criteria.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 70c75896-dce4-43fd-9760-dc67356cf883
📒 Files selected for processing (61)
docs/adr/0015-library-is-a-tag-not-a-folder.mddocs/adr/README.mddocs/backlog/README.mddocs/backlog/backlog-core.mddocs/backlog/backlog-light.mddocs/backlog/folder-structure-proposal.mddocs/backlog/livescripts-analysis-bottom-up.mddocs/backlog/livescripts-analysis-top-down.mddocs/backlog/moonlight-effect-inventory.mddocs/backlog/multicore-analysis-bottom-up.mddocs/backlog/multicore-analysis-top-down.mddocs/backlog/nrf-zephyr-target-analysis.mddocs/backlog/pins-analysis-bottom-up.mddocs/backlog/pins-analysis-top-down.mddocs/backlog/system-modules.mddocs/coding-standards.mddocs/history/README.mddocs/history/leddriver-analysis-bottom-up.mddocs/history/leddriver-analysis-top-down.mddocs/history/lessons.mddocs/history/plans/Plan-20260630 - MoonLight migration (multi-stage).mddocs/history/plans/Plan-20260702 - Docs system overhaul (Phase 0 through Docs v2) (shipped).mddocs/history/plans/Plan-20260709 - Disabling releases resources (onEnabled per module) (shipped, superseded).mddocs/history/plans/Plan-20260710 - Active-instance election primitive.mddocs/history/plans/Plan-20260710 - Rename module hooks to prepare-tick-release (shipped).mddocs/history/plans/Plan-20260710 - Scratch buffer helper for memory-holding effects (shipped).mddocs/history/plans/Plan-20260711 - Migrate MoonLight fixture presets as built-ins (shipped).mddocs/history/plans/Plan-20260712 - Step 1.5 async transmit double-buffer (shipped).mddocs/history/plans/Plan-20260714 - Shift-register LED driver (shipped).mddocs/history/plans/Plan-20260717 - MoonI80 runtime ring geometry (shipped).mddocs/history/plans/Plan-20260718 - Lean rows=1 ring ISR (attempted, abandoned).mddocs/history/plans/Plan-20260718 - MoonI80 lapping-v2 clock-oracle ring (shipped).mddocs/history/plans/Plan-20260718 - MoonI80 ring trailing-refill + loopback instrument (shipped, refill superseded by lapping-v2).mddocs/history/reviews/2026-07-20-driver-feature-audit.mddocs/history/shift-register-driver-analysis.mddocs/moonmodules/core/system.mddocs/moonmodules/light/drivers.mddocs/moonmodules/light/effects.mddocs/moonmodules/light/layouts.mddocs/moonmodules/light/modifiers.mddocs/performance.mddocs/reference/mhc-wled-esp32-p4-shield.mdesp32/sdkconfig.defaultsmoondeck/docs/gen_api.pysrc/core/Control.hsrc/core/HttpServerModule.cppsrc/core/MoonModule.hsrc/core/SystemModule.hsrc/light/drivers/LedDriverConfig.hsrc/light/drivers/MoonLedDriver.hsrc/light/drivers/ParallelLedDriver.hsrc/light/drivers/ParallelSlots.hsrc/main.cppsrc/platform/desktop/platform_desktop.cppsrc/platform/esp32/platform_esp32.cppsrc/platform/esp32/platform_esp32_i80.cppsrc/platform/esp32/platform_esp32_moon_i80.cppsrc/platform/esp32/platform_esp32_worker.cppsrc/platform/platform.hsrc/ui/app.jssrc/ui/style.css
💤 Files with no reviewable changes (4)
- docs/backlog/multicore-analysis-bottom-up.md
- docs/backlog/folder-structure-proposal.md
- docs/backlog/README.md
- docs/backlog/multicore-analysis-top-down.md
🛑 Comments failed to post (7)
docs/history/leddriver-analysis-bottom-up.md (1)
586-595: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Add blank lines after the checklist headings.
### Don'tand### Doneed a blank line before their following lists.Proposed fix
### Don't + - `std::function`, `std::bind`, ... ### Do + - Bound the inner loop count statically ...📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.### Don't - `std::function`, `std::bind`, `std::variant`'s `std::visit` with non-trivial visitor — all heap-allocate or have hidden vtable surface. - `new`, `malloc`, `push_back`, `std::string`, `std::vector` growth — anything that can hit the heap. - Virtual calls inside per-light or per-byte inner loops. One virtual call per `push()` for `LedDriver::push()` itself is fine; per-byte virtual dispatch in transposition is not. - `mutex.lock()`, `semaphore.take()` with non-zero timeout — block the render loop. `try_lock` returning fast is OK. - `printf` / `ESP_LOGI` — they take a global lock and write to UART. Use `ESP_DRAM_LOGE` if absolutely needed; better, log from `loop1s()` not from `push()`. - PSRAM for the *output staging buffer* that DMA reads — PSRAM is ~12 MB/s sequential vs SRAM's 80+ MB/s; DMA from PSRAM works but is slow. - Float math per-light if integer suffices. The existing code is already integer-first; preserve that in any new backend. ### Do🧰 Tools
🪛 markdownlint-cli2 (0.23.0)
[warning] 586-586: Headings should be surrounded by blank lines
Expected: 1; Actual: 0; Below(MD022, blanks-around-headings)
[warning] 595-595: Headings should be surrounded by blank lines
Expected: 1; Actual: 0; Below(MD022, blanks-around-headings)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@docs/history/leddriver-analysis-bottom-up.md` around lines 586 - 595, Add a blank line immediately after the “### Don't” and “### Do” headings in the checklist section, before their respective list content, without changing the checklist items.Source: Linters/SAST tools
docs/history/plans/Plan-20260712 - Step 1.5 async transmit double-buffer (shipped).md (1)
81-85: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Surround the new headings with blank lines.
Add one blank line after both
### Increment 2and### Deferredheadings to satisfy Markdown formatting checks.Proposed fix
### Increment 2 — RMT (RmtLedDriver.h / platform_esp32_rmt.cpp) — include only if a small delta + RMT has its own symbol-buffer shape... ### Deferred: the Parlio chunk-streaming ring (Step 4, NOT this step) + To drive Parlio *past* the 65535-byte single-transfer cap...📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.### Increment 2 — RMT (RmtLedDriver.h / platform_esp32_rmt.cpp) — include only if a small delta RMT has its own symbol-buffer shape (not the shared `dmaBuf_`), but the same block-on-`rmt_tx_wait_all_done` pattern. Same deferred-wait treatment: two symbol buffers, transmit-and-return, wait before reusing. Lower priority than LCD/Parlio (RMT is the single-/few-strand path, less fps-critical). **Include in this step only if the shared base refactor makes it a few-line delta; otherwise leave it as a tracked follow-up** (decided from the actual diff size, not up front). ### Deferred: the Parlio chunk-streaming ring (Step 4, NOT this step) To drive Parlio *past* the 65535-byte single-transfer cap (>~897 RGB lights/lane at 8-bit, ~448 at 16-bit) the whole-frame buffer must become a **small ring of ≤65535-byte chunks**, refilled as each drains: a 3-slot ring; a done/worker callback advances the ring and the encode fills the next slot; correct WS2812 inter-chunk timing (idle-LOW < 300 µs so a strand doesn't latch mid-frame); an underrun counter for diagnostics. That's a bigger structural change with its own DMA-underrun/flicker risk (see [buffering-models comparison](Plan-20260713%20-%20Multicore%20Step%202%20render-encode%20pipeline%20(shipped).md)). It is **Step 4**, its own sub-plan — the KPI (93→125 at 256/pin) does not need it, since 4096 lights fit one transfer. Recorded here so the design intent isn't lost.🧰 Tools
🪛 markdownlint-cli2 (0.23.0)
[warning] 81-81: Headings should be surrounded by blank lines
Expected: 1; Actual: 0; Below(MD022, blanks-around-headings)
[warning] 84-84: Headings should be surrounded by blank lines
Expected: 1; Actual: 0; Below(MD022, blanks-around-headings)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@docs/history/plans/Plan-20260712` - Step 1.5 async transmit double-buffer (shipped).md around lines 81 - 85, Add a blank line immediately after the “Increment 2 — RMT” and “Deferred: the Parlio chunk-streaming ring” headings, before their respective paragraph content. Preserve the heading text and all surrounding plan content unchanged.Source: Linters/SAST tools
docs/history/plans/Plan-20260718 - Lean rows=1 ring ISR (attempted, abandoned).md (1)
50-50: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Add blank lines around section headings.
The changed headings are not surrounded by blank lines, triggering MD022.
Also applies to: 59-59, 82-82, 87-87, 103-103, 113-113
🧰 Tools
🪛 markdownlint-cli2 (0.23.0)
[warning] 50-50: Headings should be surrounded by blank lines
Expected: 1; Actual: 0; Below(MD022, blanks-around-headings)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@docs/history/plans/Plan-20260718` - Lean rows=1 ring ISR (attempted, abandoned).md at line 50, Add blank lines before and after each changed Markdown section heading in the document, including the headings at the referenced locations, to satisfy MD022 while preserving their content and hierarchy.Source: Linters/SAST tools
docs/history/README.md (1)
55-55: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Update the stale “unbuilt” status.
This living index says native parallel LED output is unbuilt, but this PR adds parallel-driver support with shift mode dormant/off by default. Distinguish shipped direct support from dormant expander support.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@docs/history/README.md` at line 55, Update the “Parallel multi-strip output on S3/P4” entry to replace the stale “native parallel LED output is unbuilt” wording: identify direct parallel-driver support as shipped, and describe shift-register/expander support as present but dormant and disabled by default. Preserve the existing driver references and LED-driver analysis link.docs/history/reviews/2026-07-20-driver-feature-audit.md (1)
145-145: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Add the required final newline.
The file currently lacks a single trailing newline, triggering MD047.
🧰 Tools
🪛 markdownlint-cli2 (0.23.0)
[warning] 145-145: Files should end with a single newline character
(MD047, single-trailing-newline)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@docs/history/reviews/2026-07-20-driver-feature-audit.md` at line 145, Add a single trailing newline to docs/history/reviews/2026-07-20-driver-feature-audit.md, leaving the document content otherwise unchanged.Source: Linters/SAST tools
docs/moonmodules/light/drivers.md (1)
137-137: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Document shift-register support as dormant and disabled by default.
This row presents
pinExpanderand “6 pins → 48 strands” as a ready-to-use capability, but shift mode remains disabled by default because the unresolved GDMA descriptor issue prevents most frames from reaching the wire. Qualify this as experimental/disabled and link the backlog analysis so users do not select an unreliable path.Based on the PR objective, shift mode remains disabled by default pending the GDMA fix.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@docs/moonmodules/light/drivers.md` at line 137, The Moon driver documentation row should qualify pinExpander/shift-register support as experimental and disabled by default, rather than presenting it as ready for production use. Update the capability description and relevant defaults in the Moon row, and link the existing backlog analysis covering the unresolved GDMA descriptor issue; preserve the normal non-shift Moon behavior.src/core/HttpServerModule.cpp (1)
1059-1059: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value
Add braces around the conditional statement.
For consistency and to resolve the static analysis warning, consider wrapping the statement in braces.
♻️ Proposed refactor
- if (c.advanced) sink.append(",\"advanced\":true"); // UI shows it only in expert mode + if (c.advanced) { + sink.append(",\"advanced\":true"); // UI shows it only in expert mode + }📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.if (c.advanced) { sink.append(",\"advanced\":true"); // UI shows it only in expert mode }🧰 Tools
🪛 Clang (14.0.6)
[warning] 1059-1059: statement should be inside braces
(readability-braces-around-statements)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@src/core/HttpServerModule.cpp` at line 1059, Update the conditional statement checking c.advanced in the response-building logic to use braces around its sink.append call, preserving the existing condition and output.Source: Linters/SAST tools
Applies the actionable CodeRabbit findings and captures the last-8-panels flash investigation (root cause narrowed to the lanes>=32 encode path, cause not yet fixed) into the backlog and history docs. No functional driver change beyond the review fixes; the experimental fixes attempted this session were reverted and are absent. Light domain: - ParallelLedDriver: 🐇 clamped the loopback pattern-hold memcpy to the 8-byte pattern buffer so a fixture with srcCh > 8 (multi-channel modes) cannot read past it. Scripts / MoonDeck: - gen_api.py: 🐇 pass check=False explicitly on the doxygen and both moxygen subprocess calls, matching the manual return-code handling that follows each. Docs / CI: - backlog-light: rewrote the ring's open-item to the CORRECT flash investigation — brief white flashes on the last 8 panels (strands 40-47), brightness-gated (needs set bits), NOT hardware (clean on hpwit's driver on the same wall); records the six theories ruled out by live tests (encode source, ISR/lapping regime, tail rows, shift-clock margin, the encoder itself proven byte-correct on the host, bus-bit latch adjacency) and the pin-swap result that localizes it to the lanes>=32 (maskHi32) encode path; the ISR-PSRAM sparkle bullet it replaces was that abandoned theory. - history/plans: added the ISR-source internal-staging plan marked (attempted, abandoned) with its lesson; corrected the Plan-20260717 RAM-row claim (constant pool RAM for any fixed ringRows/ringBufs, not only ringRows=1). - history/README + shift-register-driver-analysis: present-tense the parallel-driver status (drivers ship; the shift/expander lapping path is dormant with the open flash), rename the proposed pinExpanderMode to the shipped pinExpander, de-specify an unverifiable board count, label the two frame-size tables (per-strand source vs encoded frame), trailing newline on the driver audit. - drivers.md: note the lapping-ring dormant status + open flash with the backlog link. Reviews: - 🐇 memcpy over-read on srcCh > 8: fixed (patCh clamp). - 🐇 subprocess check=False on gen_api doxygen/moxygen calls: fixed. - 🐇 doc-accuracy findings (pinExpander name, board count, frame-size table labels, blank lines, present tense): fixed in the docs above; MD022 blank-line nits on backward-looking history files skipped as low-value archive polish. - 🐇 HttpServerModule c.advanced brace style: accepted as-is (matches the adjacent brace-less readonly line; adding braces to one and not the other is worse). - 👾 Opus pre-merge Reviewer over the whole branch: clean, no blocking findings. Verified the two reverted experiments (ISR-source staging, latchBitHigh diagnostic) are fully absent, the platform boundary holds, expert mode is a clean mirror of hidden/readonly, and the hot path is allocation-free. Advisory only: ParallelLedDriver.h is large (1773 lines) and a future split candidate. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
What this delivers
The MoonI80 streaming-ring LED driver: our own gapless i80/LCD_CAM DMA path that drives 48 strands × 256 = 12,288 lights on an ESP32-S3 via a 74HC595 pin expander (one GPIO fans out to 8 strands). The branch started as "add the '595 expander to the parallel drivers" and grew into the full streaming ring that makes large single-controller walls possible, plus the supporting reliability and UX work.
Light domain — the MoonI80 ring
esp_lcd(ADR-0014), so a frame too big for internal RAM never materializes whole: it streams through a small internal buffer pool.ringAutoderives ring geometry per config;shiftOverclocktrades the fps ceiling against '595 shift margin. Live reconfiguration throughout (no reboot to change pins, strand length, or geometry).ParallelSlots.h) with the one-slot pipeline, prefill of the constant shift words, and a loopback self-test that verifies the whole chain (encode → i80 bus → '595 → latch → output).Core / platform
taskWdtSubscribe/taskWdtReset,CONFIG_ESP_TASK_WDT_*in sdkconfig).ptrIsPsramresidency probe (surfaced in the ring'ssn/lvdiagnostic).UI
System.expertModetoggle plus aControl.advancedflag (a clean mirror of the existinghidden/readonlyflags) hides device-tuning controls from the casual user; a wrench marks advanced controls. Gated in the shared render predicate so both render paths agree.Docs / tooling
@moreinfo/@xrefdirectives ingen_api.py(Description / Attributes / Methods / More-info layout); the ADR for "library is a tag, not a folder"; backlog + history cleanup.subprocess check=False, doc-accuracy fixes).Known open issue (not a blocker, backlogged)
On the 48×256 wall, brief white flashes on the last 8 panels at brightness ≥ 5. This session narrowed it decisively (see
docs/backlog/backlog-light.md): it is not hardware (the same wall runs clean on hpwit's driver), and six theories were ruled out by live tests (encode source, ISR/lapping regime, tail rows, shift-clock margin, the encoder itself — proven byte-correct on the host, and bus-bit latch adjacency). A pin-swap test localized it to the lanes-≥32 (maskHi32) encode path — the fix is a bounded next step, backlogged with the exact starting point. Prime-only geometries and smaller walls (e.g. shiffy at 3,840 lights) are clean.Verification
🤖 Generated with Claude Code