LED drivers (RMT / LCD_CAM / Parlio) + multi-protocol net I/O + P4 + audio input#17
Conversation
Records the locked driver contract from the Phase-A design pass and reorders the increment-1 plan to the contract→test-first→implement-to-green methodology, so the proof of correctness is written as a failing test before the encoder exists. Docs / CI: - leddriver-increment-1-plan.md: revised the shared-code decision — share Correction::apply() only, do NOT extract a shared correct-loop and do NOT migrate ArtNet (the loops differ irreducibly; extract only when LCD_CAM, the 2nd fused driver, proves the shape). Reordered file-by-file into Phase A (contract: LedDriverConfig.h, RmtSymbol.h stub) → Phase B (red host encoder test) → Phase C (implement encoder + driver + platform seam to green) → Phase D (HAL loopback proof + docs). Status now: Phase A locked, implementing on this branch. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Locks the encoder contract and writes its proof of correctness as a failing test BEFORE the implementation — the test-first half of the contract→test→implement methodology. The encoder test compiles, runs, and fails red against a deliberate stub (asserting real WS2812 timing: HIGH=t?hTicks/level1, LOW=period-t?h/level0, MSB-first), proving the test will catch a wrong encoder. Light domain: - LedDriverConfig.h (new): pure-data WS2812B wire timing (t0h=350ns, t1h=700ns, period=1250ns, reset=300us, invert). No platform include — host-testable. - RmtSymbol.h (new): the encode contract — makeRmtSymbol() packs the documented rmt_symbol_word_t bit-layout ((duration:15,level:1) halves) in domain code so no ESP header leaks into src/light/; encodeWs2812Symbols() declared header-only inline with a Phase-B stub body (emits nothing → test red). Tests: - unit_RmtLedEncoder.cpp (new): the CI-tier success spec as assertions — one byte MSB-first with correct 0/1 pulse widths; N lights → N*channels*8 symbols; GRB ordering comes from Correction (encoder is order-agnostic); RGBW preset → 32 symbols/light. Currently RED against the stub (4 cases, 107 assertions failing for the right reason). Registered in test/CMakeLists.txt. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Implements encodeWs2812Symbols, turning the red encoder test green: the same 4 cases / 143 assertions that failed against the Phase-B stub now pass. The encoder is now proven correct in CI — MSB-first per byte, "1" bit = HIGH t1hTicks then LOW (period-t1h), "0" bit = HIGH t0hTicks then LOW (period-t0h), GRB/RGBW ordering inherited from Correction. Pre-builds the two possible symbols once and selects per bit (no per-bit arithmetic in the inner loop). Pure domain code — platform boundary check passes. Light domain: - RmtSymbol.h: encodeWs2812Symbols implemented (was a Phase-B stub). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…dware
Completes increment 1 of the LED driver: the RmtLedDriver (classic ESP32, single WS2812B strand), the RMT platform seam, and the on-device RMT-RX loopback test. Proven across all three tiers — the CI encoder test (green), the on-chip loopback (TX→RX jumper, bytes round-trip correct), and a real 8×8 WS2812 panel lit and animated. The driver reads as a recognizable sibling of ArtNetSendDriver (same DriverBase hooks + per-light Correction::apply), fusing correct+encode into one pass; only the emit differs.
KPI: 16384lights | PC:300KB | tick:146/91/118/124/56/1/38/19/117/327us(FPS:6849/10989/8474/8064/17857/1000000/26315/52631/8547/3058) | ESP32:1083KB | tick:10038us(FPS:99) | heap:153KB | src:69(12368) | test:45(6824) | lizard:48w
NOTE: the ESP32 tick rose to ~10ms (from ~1570us baseline) because the RmtLed driver now runs every tick — ~9ms synchronously clocking the 16x16 frame onto the GPIO. This is the documented synchronous-show cost and the measured justification for the deferred core-1 driver task, NOT a regression.
Light domain:
- RmtLedDriver.h (new): DriverBase child, single-strand WS2812B/8-bit. gpio control (default 18 — a standard data pin clear of the loopback's GPIO 4/5; live re-init on change). Fuses Correction::apply + encodeWs2812Symbols per light into a once-allocated symbol buffer, then platform::rmtWs2812Show. Guarded if constexpr(platform::isEsp32) so it compiles and is inert on S3/desktop.
- Drivers.h: default brightness 255 -> 20. A fresh device with LEDs on USB 5V draws far less at 20 than full white, so first boot can't brown out the board before the user sets a safe level (this exact brown-out was hit bringing up the panel). The user raises it once their supply is known.
Core / platform:
- platform.h: RMT WS2812 seam (rmtWs2812 Init/Resolution/Show/Deinit + RxCapture), opaque RmtWs2812Handle — declarations only; the symbol encode stays domain-side so it's host-testable.
- platform_esp32_rmt.cpp (new): the peripheral — RMT TX via a copy encoder (streams the pre-built symbols), synchronous show + reset gap, and an RX one-shot capture for the loopback. IRAM ISR. Two bugs caught on hardware and fixed: RX mem_block_symbols must be >=64 and even; an explicit REQUIRES esp_driver_rmt would break IDF's implicit common-requirements (left to the default set).
- platform_desktop.cpp: no-op RMT stubs (desktop/CI stay green).
- platform_config.h (esp32 + desktop): isEsp32 / isEsp32S3 flags, keyed off CONFIG_IDF_TARGET_*; both false on desktop.
Scripts / MoonDeck:
- build_esp32.py: --loopback-test builds a test-only firmware that runs the loopback instead of the app.
Tests:
- test/device/device_RmtLoopback.cpp (new, hardware-gated via MM_LED_LOOPBACK_TEST): TX a known pattern on GPIO 4, RX-capture on the jumpered GPIO 5, decode, assert == sent — the HAL proof a GPIO emits correct WS2812 bytes. Opens with a plain-GPIO jumper continuity check ("JUMPER OK / NOT DETECTED") so a wiring fault is never mistaken for an RMT bug. Establishes test/device/.
Core / CI:
- main.cpp: register RmtLedDriver, add as a Drivers child under if constexpr(platform::isEsp32). esp32/main: device_RmtLoopback.cpp in SRCS (inert without the define), app_main runs the loopback under MM_LED_LOOPBACK_TEST, CMake forwards the define.
Docs:
- RmtLedDriver.md (new): wire contract (WS2812B timing), synchronous-show note, gpio control, cross-domain wiring, the loopback + encoder tests, prior art.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
|
There was a problem hiding this comment.
Actionable comments posted: 5
🤖 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/RmtLedDriver.h`:
- Around line 48-49: teardown()/deinit() currently never releases the heap
allocated by resizeSymbols(): free symbols_ during shutdown by deleting or
freeing the buffer and resetting symbols_ to nullptr inside deinit() (or
teardown() if you prefer teardown() to call the cleanup) to avoid retained
memory; also add a guard in resizeSymbols() to release any existing symbols_
before reallocating and ensure any code paths that replace symbols_ (e.g.,
reinitialization) likewise delete the old buffer to prevent leaks (refer to the
symbols_, resizeSymbols(), teardown(), and deinit() symbols to locate the
changes).
In `@src/platform/esp32/platform_esp32_rmt.cpp`:
- Around line 173-184: The rmtWs2812RxCapture path can call
rmt_del_channel(rxChan) without first disabling the channel if
rmt_enable(rxChan) succeeds but rmt_receive(...) fails; modify the control flow
so that whenever rmt_enable(rxChan) returns ESP_OK you always call
rmt_disable(rxChan) before rmt_del_channel(rxChan) (even on rmt_receive failure
or timeout). Specifically, after calling rmt_enable(rxChan) and before any early
exits, ensure rmt_disable(rxChan) is invoked (or use a boolean flag like enabled
= true and in cleanup check enabled to call rmt_disable), then proceed to
vQueueDelete(q) and rmt_del_channel(rxChan); keep existing xQueueReceive and got
logic intact.
- Around line 96-98: rmtWs2812Show currently ignores
rmt_transmit/rmt_tx_wait_all_done return values and waits forever (-1); change
it to check the return of rmt_transmit and call rmt_tx_wait_all_done with a
finite timeout (configurable constant or parameter) and handle timeout/error
returns by cancelling the in-flight transfer (call rmt_tx_stop(st->channel) and
ensure the channel is quiesced) before returning an error; update the function
signature/return value so callers (e.g., RmtLedDriver::loop()) can detect
failure and avoid overwriting symbols_ while a transmit is still in-flight, and
ensure symbols_ is only reused after a successful completion or after the cancel
path has fully completed.
In `@test/device/device_RmtLoopback.cpp`:
- Around line 99-106: Replace the volatile polling and unchecked task creation
with a proper RTOS synchronization and lifetime handling: change the local Cap/
cap usage so rxTask signals the creator with a task notification or event
group/queue (reference the rxTask function and mm::platform::rmtWs2812RxCapture
call), check the return value of xTaskCreate and handle failure, and make the
creating task wait for the notification (or use e.g. ulTaskNotifyTake/notify or
an event group) before returning so the RX task cannot reference the stack after
the creator exits; ensure the RX task deletes itself only after sending the
notification and that the creator joins/waits for that signal before using cap
results.
In `@test/unit/light/unit_RmtLedEncoder.cpp`:
- Around line 54-68: The test claims multi-light ordering but only asserts
symbols for the first light; either expand assertions to cover the second light
or rename the test to per-light behavior. To expand, after the existing checks
add assertions for light 1 at per-light offset = channels*8 = 3*8 = 24 symbols:
checkBit(out[24], T0H) and checkBit(out[31], T0H) for light1 byte0 MSB/LSB
(0x00), and checkBit(out[40], T1H) for light1 byte2 MSB (0xFF); use the same
mm::encodeWs2812Symbols/wire/out/checkBit symbols so ordering is fully
validated. Alternatively, rename the TEST_CASE string to indicate it only
verifies a single light’s encoding.
🪄 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: 7f408553-9d62-40cb-b72e-f8d49ddf39f7
⛔ Files ignored due to path filters (1)
scripts/build/build_esp32.pyis excluded by!**/build/**
📒 Files selected for processing (17)
docs/backlog/leddriver-increment-1-plan.mddocs/moonmodules/light/drivers/RmtLedDriver.mdesp32/main/CMakeLists.txtesp32/main/main.cppsrc/light/drivers/Drivers.hsrc/light/drivers/LedDriverConfig.hsrc/light/drivers/RmtLedDriver.hsrc/light/drivers/RmtSymbol.hsrc/main.cppsrc/platform/desktop/platform_config.hsrc/platform/desktop/platform_desktop.cppsrc/platform/esp32/platform_config.hsrc/platform/esp32/platform_esp32_rmt.cppsrc/platform/platform.htest/CMakeLists.txttest/device/device_RmtLoopback.cpptest/unit/light/unit_RmtLedEncoder.cpp
Replaces the standalone loopback test firmware with a control-driven self-test in the normal app (tick a checkbox, jumper two pins, read PASS/FAIL from the module status field) — no separate build, no sticky-cache footgun. Along the way: fixes a buffer-lifecycle bug found on hardware, processes the CodeRabbit review, makes any control able to show/hide other controls live (and fixes the UI to actually re-render when it does — a Network static-IP toggle could wedge the UI), and adds tests that pin all of it. KPI: 16384lights | PC:300KB | tick:122/92/121/118/57/1/36/19/117/326us(FPS:8196/10869/8264/8474/17543/1000000/27777/52631/8547/3067) | ESP32:1095KB | tick:2690us(FPS:371) | heap:170KB | src:69(12607) | test:45(6818) | lizard:49w NOTE: the ESP32 tick (2690us/371fps) reflects the bench device's persisted 8x8 grid (64 lights) from interactive testing, not the 16x16 default (~10ms) — a device-state artifact, not a code speedup. Light domain: - RmtLedDriver: loopback self-test moved in-firmware as controls — gpio (TX, now uint16 so the UI renders a number field not a slider), loopbackTest (checkbox, persistent on/off mode), loopbackRxPin (uint16, shown only while the test is on via always-add-then-setHidden). onUpdate re-runs the test on any relevant change (gpio/rxPin/toggle) so pins can be set in any order; result + severity go to the MoonModule status slot (PASS/jumper/unsupported are flash literals, only FAIL borrows an on-demand buffer freed on teardown). Default gpio 4->18 reverted to 18. - RmtLedDriver lifecycle fix: a CodeRabbit leak-fix had put the symbol-buffer free in deinit(), but reinit() calls deinit() on every rebuild — so a gpio change / topology rebuild freed the buffer loop() needs and the driver silently stopped transmitting (RmtLed:1us). Split the concerns: resizeSymbols/freeSymbols (plain heap, all platforms) vs reinit/deinit (RMT channel, ESP32-only); teardown owns the buffer free. teardown now fully reverses setup (MoonModule contract). Core / platform: - platform: rmtWs2812Loopback(txGpio, rxGpio) — self-contained TX->RX round-trip + GPIO continuity pre-check, all hardware in src/platform so the driver stays platform-free; no-op result off ESP32. Carries the CodeRabbit RMT fixes: RX always disabled before delete, finite rmt_tx_wait_all_done timeout. - HttpServerModule: rebuildControls() now runs after EVERY control change, not only Select — any control can re-evaluate which controls are visible (the bespoke Select-only gate is removed). rebuildControls is clear()+onBuildControls, required cheap+idempotent, so the common no-change case costs nothing. - NetworkModule: control reorder — mDNS before addressing, so addressing sits adjacent to the static-IP fields it conditions. UI: - app.js: syncVisibleControls — when a module's set of visible controls changes (a hidden flag flipped), reconcile the rendered rows position-stably (insert/remove in place, never tear-down-and-reappend). The prior WS-update path never added/removed rows, so a newly-revealed control didn't appear until a page reload; a naive re-append also looped every tick and wedged the UI on the Network static-IP toggle. Scripts / MoonDeck: - build_esp32.py: dropped --loopback-test and the MM_LED_LOOPBACK_TEST plumbing (esp32/main/main.cpp app_main hook, CMake SRCS + define) — the self-test is in the normal firmware now. Tests: - conditional_controls.h: shared helper pinning the conditional-control invariant (always bound, hidden flag tracks the condition, rebuild re-evaluates) — checkConditionalControl / setControlValue / isVisible. - unit_NetworkModule: static-IP fields track the addressing mode (visible iff Static, always bound). - unit_RmtLedDriver_lifecycle: loopbackRxPin tracks loopbackTest; setup/teardown is repeatable with no residual state (ASAN-backed). Both proven to fail on an injected regression. - unit_RmtLedEncoder: renamed the per-light test to reflect it encodes one light (CodeRabbit) + added byte-order asserts. - deleted test/device/device_RmtLoopback.cpp (superseded by the in-firmware self-test). Docs: - RmtLedDriver.md: documents the in-firmware self-test + new controls, drops the old test-firmware reference. backlog: static-IP-on-STA is UI-only/not wired (spec from the three open questions); JS-test gap for syncVisibleControls. increment-1 plan: rmtWs2812Show fuller-error-handling deferred + the one-time flicker observation. Reviews: - 🐇 symbol-buffer leak on teardown — fixed (freed on teardown; lifecycle test pins it). - 🐇 RX channel deleted without disable on receive-fail — fixed (always disable once enabled). - 🐇 rmtWs2812Show waited forever — fixed minimal (finite 1s timeout); fuller return/cancel deferred to the core-1 task with a backlog note. - 🐇 loopback test rig used volatile polling + unchecked xTaskCreate — checked the create; kept polling (stack lifetime already safe). - 🐇 encoder test named "N lights" tested one — renamed + clarified (the suggested expand-to-light-1 was wrong; the encoder is per-light). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 3
♻️ Duplicate comments (1)
src/platform/esp32/platform_esp32_rmt.cpp (1)
96-105:⚠️ Potential issue | 🟠 Major | 🏗️ Heavy liftPropagate TX failures instead of falling through after a failed wait.
Line 104 still ignores
rmt_transmit(...), and Line 105 ignoresrmt_tx_wait_all_done(...). If submission fails or the 1 s wait times out, this function returns without cancelling/quiescing the channel, so the caller can reuse or overwritesymbolswhile the prior transfer is still in flight. This needs an explicit failure path that aborts/re-enables the channel and reports failure back to the caller.🤖 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 96 - 105, Check and propagate errors from rmt_transmit(...) and rmt_tx_wait_all_done(...): capture their return values and if either indicates failure or timeout, abort the in-flight transfer on st->channel (e.g. stop/disable/clear the channel or DMA for that channel, reinitialize or re-enable it so it is no longer actively using symbols), ensure any resources/flags that indicate an ongoing transmit are cleared, and return a failure status to the caller so it won't reuse/overwrite symbols while the previous transfer remains active; modify the function around rmt_transmit, rmt_tx_wait_all_done, st->channel, st->encoder, symbols, symbolCount, and txCfg to implement this error-path and propagate the error.
🤖 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/RmtLedDriver.md`:
- Around line 22-23: The docs state gpio and loopbackRxPin are uint8_t but the
implementation in RmtLedDriver.h defines both controls as uint16_t; update the
documentation to reflect the actual control types by changing the types for
`gpio` and `loopbackRxPin` to uint16_t so they match the implementation (and UI
behavior), or alternatively change the implementation in RmtLedDriver.h
(symbols: gpio, loopbackRxPin) to uint8_t if the intent is to use 8-bit
types—ensure the type in the docs and the type in RmtLedDriver.h are identical.
In `@src/light/drivers/RmtLedDriver.h`:
- Around line 245-249: reinit() currently calls platform::rmtWs2812Init and
assigns its result directly to inited_, which silently disables output on
failure; change it so that a failed platform::rmtWs2812Init does not silently
turn off output but instead surfaces the failure (e.g., log an error/record an
error status or return a failure bool) so callers and loop() can report why
output died. Update reinit() (and if necessary deinit()) to detect
platform::rmtWs2812Init failure, emit a clear diagnostic (using your project
logger or an error/status field), and return or set an explicit error state
rather than just flipping inited_. Ensure references to inited_, reinit(),
platform::rmtWs2812Init, and loop() are updated so loop() can observe and report
the init failure.
- Around line 8-10: Add a direct `#include` <cstring> to RmtLedDriver.h so use of
std::strcmp is valid and the header is self-contained; then update the
initialization path in RmtLedDriver (where platform::rmtWs2812Init(...) is
called and inited_ is set) to detect a failed init and surface that failure to
users—set inited_ accordingly and emit a visible status/log message or expose
the error via the existing status string/loopback result instead of silently
no-oping in loop(), so callers and logs can observe the rmtWs2812Init failure
during startup or status reporting.
---
Duplicate comments:
In `@src/platform/esp32/platform_esp32_rmt.cpp`:
- Around line 96-105: Check and propagate errors from rmt_transmit(...) and
rmt_tx_wait_all_done(...): capture their return values and if either indicates
failure or timeout, abort the in-flight transfer on st->channel (e.g.
stop/disable/clear the channel or DMA for that channel, reinitialize or
re-enable it so it is no longer actively using symbols), ensure any
resources/flags that indicate an ongoing transmit are cleared, and return a
failure status to the caller so it won't reuse/overwrite symbols while the
previous transfer remains active; modify the function around rmt_transmit,
rmt_tx_wait_all_done, st->channel, st->encoder, symbols, symbolCount, and txCfg
to implement this error-path and propagate the error.
🪄 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: d46e365d-67a0-4ee1-91e4-70ff3905654d
📒 Files selected for processing (16)
docs/backlog/backlog.mddocs/backlog/leddriver-increment-1-plan.mddocs/moonmodules/light/drivers/RmtLedDriver.mdesp32/main/CMakeLists.txtsrc/core/HttpServerModule.cppsrc/core/NetworkModule.hsrc/light/drivers/RmtLedDriver.hsrc/platform/desktop/platform_desktop.cppsrc/platform/esp32/platform_esp32_rmt.cppsrc/platform/platform.hsrc/ui/app.jstest/CMakeLists.txttest/unit/core/conditional_controls.htest/unit/core/unit_NetworkModule.cpptest/unit/light/unit_RmtLedDriver_lifecycle.cpptest/unit/light/unit_RmtLedEncoder.cpp
💤 Files with no reviewable changes (1)
- esp32/main/CMakeLists.txt
…tooling
Ports MoonLight's 3D sine-wave Ripples effect, which needed the name "Ripples" — the existing effect under that name is actually concentric rings, so it's renamed to Rings. Also fills in the effects/modifiers that had no screenshots, hardens the screenshot script against the traps that just cost real time (stale server binary, missing chromium, forgotten --gif), gives flash_esp32 a --baud knob, adds a Pages landing page so the bare site root stops 404'ing, and clears the CodeRabbit review.
KPI: 16384lights | PC:300KB | tick:132/97/111/111/53/1/35/18/110/306us(FPS:7575/10309/9009/9009/18867/1000000/28571/55555/9090/3267) | ESP32:1089KB | tick:2783us(FPS:359) | heap:169KB | src:70(12713) | test:45(6845) | lizard:50w
NOTE: the ESP32 tick (2783us/359fps) reflects the bench device's persisted 8x8 grid (64 lights) from interactive testing, not the 16x16 default (~10ms) — device state, not a code change.
Light domain:
- RipplesEffect (rewritten): MoonLight's 3D water-surface ripple — per (x,z) column the distance from centre sets a sine wave phase, lighting one pixel at y = h/2·(1+sin(dist/interval + time)). Dim::D3, controls speed (0-99) + interval (1-254). Studied from ewowi/projectMM-v1, reimplemented against EffectBase (our hsvToRgb, our buffer layout); float trig matches the existing Plasma/LavaLamp wave effects.
- RingsEffect (new = the old RipplesEffect, renamed): expanding concentric rings from random centres (count/speed/thickness/hue_shift). Same code, new name — "Ripples" now holds the MoonLight effect.
- RmtLedDriver: reinit() now surfaces an RMT init failure via the status field ("RMT init failed — check the gpio pin", Severity::Error) instead of silently no-op'ing in loop(); clears it on a later successful init. Added explicit #include <cstring>.
Core / CI:
- main.cpp / scenario_runner: register RingsEffect alongside RipplesEffect.
Scripts / MoonDeck:
- screenshot_modules.py: stale-server check (diffs the running server's types against main.cpp's registerType calls — catches a leftover build/macos/projectMM serving old code on :8080, the exact bug that screenshotted the pre-rename effect); MODULES-list drift warning (a registered effect/modifier with no list entry is silently never captured — how GameOfLife/MultiplyModifier/CheckerboardModifier went imageless); --gif reminder; new --no-modifier (raw effects, not folded by the default MultiplyModifier) and --grid N (32 is the sweet spot — 128 spreads sparse effects to dots through the preview downsampler); fixed the chromium-install prereq (uv run --with playwright …, not plain uv run playwright …) + macOS cache path note. Added GameOfLife/Multiply/Checkerboard to MODULES, dropped the stale MirrorModifier entry.
- flash_esp32.py: --baud flag. Default stays 460800 (reliable on every board); --baud 921600 matches the web installer for ~2x speed but some USB bridges drop to "chip stopped responding" at that rate, so it's opt-in.
Tests:
- unit_CheckerboardEffect / unit_Layer_zero_grid: the old rings smoke + zero-grid tests follow the rename to Rings; added matching tests for the new Ripples. scenario_AllEffects_grid_sizes: the grid-size entries (which measured the rings effect) follow to RingsEffect.
Docs:
- New RingsEffect.md + RipplesEffect.md (3D, prior-art credit to MoonLight/v1). Screenshots: all 17 effects/modifiers now have a PNG+GIF — added GameOfLife/Multiply/Checkerboard, regenerated Rings/Ripples at 32x32 no-modifier; old Ripples assets renamed to Rings. performance.md "Ripples" perf rows → "Rings" (they measured rings). backlog: Ripples moved out of the D2→D3 promotion list (it's D3 now). landing/index.html + release.yml deploy step — moonmodules.org/projectMM/ now serves a Flash-now landing page instead of 404 (no mkdocs; one static file).
- RmtLedDriver.md: gpio/loopbackRxPin documented as uint16_t (match the code; uint16 renders a number field not a slider).
Reviews:
- 🐇 doc said gpio/loopbackRxPin uint8_t but code is uint16_t — doc fixed to uint16_t.
- 🐇 reinit() silently disabled output on init failure — now sets an Error status so the user sees why output is dark.
- 🐇 RmtLedDriver.h used std::strcmp without including <cstring> — added (was transitively included).
- 🐇 rmtWs2812Show transmit-error handling — already addressed in the prior review (minimal finite timeout done; fuller return/cancel deferred to the core-1 task with a backlog note); no 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 (1)
src/light/drivers/RmtLedDriver.h (1)
215-231:⚠️ Potential issue | 🟠 Major | ⚡ Quick winPreserve init-failure status after loopback reinit.
After Line 215,
reinit()can setkInitFailMsg, but Lines 216-231 then overwrite status with loopback PASS/FAIL/jumper results. That masks the real failure whileloop()is disabled (!inited_), which is misleading.Suggested fix
deinit(); const auto r = platform::rmtWs2812Loopback(gpio, loopbackRxPin); reinit(); + if (!inited_) { + // reinit() already published the init-failure status. + return; + } if (!r.jumperDetected) { clearFailBuf(); setStatus("loopback: jumper not detected", Severity::Warning);🤖 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/RmtLedDriver.h` around lines 215 - 231, reinit() can set the initial failure status (kInitFailMsg) but the subsequent loopback logic (in the block that calls clearFailBuf(), setStatus(...) based on r.jumperDetected / r.pass / failBuf_) overwrites it; update the loopback handling in the function containing reinit() and the loopback block so that if inited_ is false or current status equals kInitFailMsg you skip/return early and do not call setStatus for loopback results—i.e., check inited_ or compare against kInitFailMsg before executing the jumper/pass/fail setStatus logic (leave failBuf_ allocation and messaging unchanged for real loopback failures when inited_ is true).
🤖 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/RmtLedDriver.md`:
- Line 24: The documentation for RmtLedDriver incorrectly states that
loopbackTest is a one-shot auto-reset flag; update the docs to match the
implementation by describing loopbackTest as a persistent mode on RmtLedDriver
that, while true, reruns the RMT TX→RX loopback test whenever relevant controls
change and only stops when loopbackTest is cleared; mention that results are
written to the module's status field and that the flag must be manually reset to
disable repeated runs.
In `@docs/moonmodules/light/effects/RingsEffect.md`:
- Line 16: The docs and code disagree on the allowed range for the "thickness"
control: the docs say 1-16 but the code call controls_.addUint8("thickness",
thickness, 1, 255) (see RingsEffect controls setup). Fix by either updating the
documentation to state the actual 1-255 range, or change the control definition
in src/light/effects/RingsEffect.h to clamp the max to 16 (e.g., change the
addUint8 max from 255 to 16 and ensure any internal uses of thickness respect
that range); reference the controls_.addUint8("thickness", ...) invocation and
the RingsEffect control handling when making the change.
In `@docs/tests/unit-tests.md`:
- Line 72: The test description line "RipplesEffect spatial variation" is a
fragment and must be completed or removed; update the tests documentation by
either expanding that entry into a full sentence explaining what the
RipplesEffect spatial variation test verifies (e.g., expected behavior, inputs,
and assertions) or delete the placeholder line so it matches the surrounding
complete descriptions; search for the exact phrase "RipplesEffect spatial
variation" to locate the entry to edit.
In `@scripts/docs/screenshot_modules.py`:
- Around line 361-364: The three print calls that currently use f-strings
without any interpolation should be changed to plain string literals: locate the
print statements that start with print(f" The running binary..."), print(f"
on :8080. Fix: pkill -f projectMM then rebuild + run ONE server:"), and
print(f" cmake --build build -j && ./build/projectMM") in
screenshot_modules.py and remove the leading f from each string so they become
normal print("...") calls (no other changes).
---
Outside diff comments:
In `@src/light/drivers/RmtLedDriver.h`:
- Around line 215-231: reinit() can set the initial failure status
(kInitFailMsg) but the subsequent loopback logic (in the block that calls
clearFailBuf(), setStatus(...) based on r.jumperDetected / r.pass / failBuf_)
overwrites it; update the loopback handling in the function containing reinit()
and the loopback block so that if inited_ is false or current status equals
kInitFailMsg you skip/return early and do not call setStatus for loopback
results—i.e., check inited_ or compare against kInitFailMsg before executing the
jumper/pass/fail setStatus logic (leave failBuf_ allocation and messaging
unchanged for real loopback failures when inited_ is true).
🪄 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: 8d9ab62c-f382-4556-a867-f2f946ed405c
⛔ Files ignored due to path filters (11)
docs/assets/screenshots/CheckerboardModifier.gifis excluded by!**/*.gifdocs/assets/screenshots/CheckerboardModifier.pngis excluded by!**/*.pngdocs/assets/screenshots/GameOfLifeEffect.gifis excluded by!**/*.gifdocs/assets/screenshots/GameOfLifeEffect.pngis excluded by!**/*.pngdocs/assets/screenshots/MultiplyModifier.gifis excluded by!**/*.gifdocs/assets/screenshots/MultiplyModifier.pngis excluded by!**/*.pngdocs/assets/screenshots/RingsEffect.gifis excluded by!**/*.gifdocs/assets/screenshots/RingsEffect.pngis excluded by!**/*.pngdocs/assets/screenshots/RipplesEffect.gifis excluded by!**/*.gifdocs/assets/screenshots/RipplesEffect.pngis excluded by!**/*.pngscripts/build/flash_esp32.pyis excluded by!**/build/**
📒 Files selected for processing (21)
.github/workflows/release.ymldocs/backlog/backlog.mddocs/landing/index.htmldocs/moonmodules/light/drivers/RmtLedDriver.mddocs/moonmodules/light/effects/GameOfLifeEffect.mddocs/moonmodules/light/effects/RingsEffect.mddocs/moonmodules/light/effects/RipplesEffect.mddocs/moonmodules/light/modifiers/CheckerboardModifier.mddocs/moonmodules/light/modifiers/MultiplyModifier.mddocs/performance.mddocs/tests/scenario-tests.mddocs/tests/unit-tests.mdscripts/docs/screenshot_modules.pysrc/light/drivers/RmtLedDriver.hsrc/light/effects/RingsEffect.hsrc/light/effects/RipplesEffect.hsrc/main.cpptest/scenario_runner.cpptest/scenarios/light/scenario_AllEffects_grid_sizes.jsontest/unit/light/unit_CheckerboardEffect.cpptest/unit/light/unit_Layer_zero_grid.cpp
| - `count` (uint8_t, default 4, range 1-8) — number of simultaneously active rings | ||
| - `speed` (uint8_t, default 60, range 1-255) — expansion rate | ||
| - `thickness` (uint8_t, default 3, range 1-16) — ring thickness in pixels | ||
| - `hue_shift` (uint8_t, default 0, range 0-255) — global hue rotation |
There was a problem hiding this comment.
Documentation-code mismatch: thickness range.
The documentation states thickness range is 1-16, but the code at src/light/effects/RingsEffect.h:29 declares controls_.addUint8("thickness", thickness, 1, 255). Either update the docs to reflect the 1-255 range or clamp the control range in code to 1-16 if that's the intended visual constraint.
🤖 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/effects/RingsEffect.md` at line 16, The docs and code
disagree on the allowed range for the "thickness" control: the docs say 1-16 but
the code call controls_.addUint8("thickness", thickness, 1, 255) (see
RingsEffect controls setup). Fix by either updating the documentation to state
the actual 1-255 range, or change the control definition in
src/light/effects/RingsEffect.h to clamp the max to 16 (e.g., change the
addUint8 max from 255 to 16 and ensure any internal uses of thickness respect
that range); reference the controls_.addUint8("thickness", ...) invocation and
the RingsEffect control handling when making the change.
| print(f" The running binary is older than the current source. Most likely a") | ||
| print(f" second projectMM (e.g. build/macos/projectMM from MoonDeck) is still") | ||
| print(f" on :8080. Fix: pkill -f projectMM then rebuild + run ONE server:") | ||
| print(f" cmake --build build -j && ./build/projectMM") |
There was a problem hiding this comment.
Remove extraneous f prefixes from strings without placeholders.
Five print statements use f-string syntax but contain no {…} interpolation. Static analysis (Ruff F541) flags these correctly. Use plain strings instead.
🧰 Tools
🪛 Ruff (0.15.15)
[error] 361-361: f-string without any placeholders
Remove extraneous f prefix
(F541)
[error] 362-362: f-string without any placeholders
Remove extraneous f prefix
(F541)
[error] 363-363: f-string without any placeholders
Remove extraneous f prefix
(F541)
[error] 364-364: f-string without any placeholders
Remove extraneous f prefix
(F541)
🤖 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 `@scripts/docs/screenshot_modules.py` around lines 361 - 364, The three print
calls that currently use f-strings without any interpolation should be changed
to plain string literals: locate the print statements that start with print(f"
The running binary..."), print(f" on :8080. Fix: pkill -f projectMM then
rebuild + run ONE server:"), and print(f" cmake --build build -j &&
./build/projectMM") in screenshot_modules.py and remove the leading f from each
string so they become normal print("...") calls (no other changes).
Source: Linters/SAST tools
The RMT LED driver now drives multiple WS2812 strands — one RMT channel per pin, on classic ESP32 and the S3 — with per-pin LED counts. A new ArtNetReceiveEffect turns any device into an ArtNet receiver, proven end-to-end by a new live test that runs every board in the MoonDeck device list as sender and listener.
KPI: 16384lights | PC:300KB | tick:131/91/117/116/56/1/36/19/115/322us(FPS:7633/10989/8547/8620/17857/1000000/27777/52631/8695/3105) | ESP32:1103KB | tick:2953us(FPS:338) | heap:155KB | src:72(13224) | test:47(7291) | lizard:52w
Core
- platform.h: rmtWs2812Show split into rmtWs2812Transmit + rmtWs2812Wait so N channels clock out concurrently (a multi-pin frame costs the longest strand, not the sum); new delayUs primitive owns the WS2812 inter-frame latch; UdpSocket gains bind() + non-blocking recvFrom() for ArtNet receive
- platform_config.h (esp32 + desktop): new rmtTxChannels constant from the IDF SOC config (8 classic, 4 S3, 0 desktop) replaces the isEsp32 guards — S3 support falls out, and a future RMT-bearing chip works untouched
- platform_esp32_rmt.cpp: mem_block_symbols and the RX floor use SOC_RMT_MEM_WORDS_PER_CHANNEL (64 classic / 48 S3 — a hardcoded 64 made every S3 channel init fail)
- platform_esp32.cpp / platform_desktop.cpp: delayUs, UDP bind/recvFrom (lwIP and POSIX/Winsock), updated RMT stubs
Light domain
- RmtLedDriver: gpio control replaced by comma-separated pins + ledsPerPin text controls; consecutive buffer slices per pin with even-split remainder; transmit-all/wait-all loop; all-or-nothing channel init with the failing pin named in the status; loopback self-test runs on the first pin and releases all TX channels first so the RX capture can always allocate. NOTE: a persisted non-default gpio value is dropped by the rename (default "18" equals the old default; re-enter the pin in the UI once)
- ArtNetPacket.h (new): shared OpDmx wire format — buildArtDmxPacket moved out of ArtNetSendDriver plus the inverse parseArtDmxPacket, so sender and receiver can never drift
- ArtNetReceiveEffect (new): ArtNet input as an effect; staging buffer gives hold-last-frame semantics (the layer clears its buffer every tick), bounded non-blocking drain (128 packets/tick) keeps a packet flood from wedging the render loop
- main.cpp: RmtLedDriver registered on any chip with rmtTxChannels > 0; ArtNetReceiveEffect registered in the effects cluster
Scripts / MoonDeck
- run_artnet_live.py (new): multi-device ArtNet matrix test over the moondeck.json device list — each board once the sender, all others listen; the PC seeds each round's sender with a known colour and every reception is asserted byte-exact through the device's /ws preview stream (sender brightness + channel-order correction replicated host-side); enables a disabled ArtNetSend for relay legs; restores all mutated state (grid, ip, enabled, added modules) in a finally. Proven 4/4 on the bench (PC↔esp32-16mb↔desktop)
- _preview_ws.py (new): stdlib-only RFC 6455 client that reads 0x02 preview frames and asserts a solid colour
- moondeck_config.json: ArtNet Live Test card on the Live tab
Tests
- unit_RmtLedDriver_pins.cpp (new): pin-list parsing (bad token, duplicates, chip cap), count assignment (explicit, short list, even split, clamping, zero grid), and per-pin slice offsets
- unit_RmtLedDriver_lifecycle.cpp: pins-change regression case (rebuild must not free the symbol buffer); gpio→pins rename
- unit_ArtNetReceiveEffect.cpp (new): build→parse round-trip + reject cases, universe placement/clamping, hold-last-frame, staging lifecycle, and a real localhost UDP round-trip through the new platform receive path
- unit_ArtNetSendDriver_packet.cpp: call sites moved to the shared free functions
- scenario JSONs: refreshed observed blocks from the gate run
Docs / CI
- RmtLedDriver.md: rewritten for multi-pin (slicing semantics, concurrent show, classic+S3, first-pin loopback)
- ArtNetReceiveEffect.md: promoted from the draft folder (draft deleted); wire contract by reference to the shared header; live-test pointer
- testing.md + MoonDeck.md: the multi-device live test documented as a script-shaped live-tier test (a device matrix needs loops the declarative scenario JSON can't express)
- backlog: leddriver-increment-2-plan.md added and indexed (2a implemented, 2b LCD_CAM open); new pending-investigation entry for the ~0.5s LED pauses with the ranked suspects and the bench data point
KPI Details:
Desktop:
Lights: 16,384
Binary: 300 KB
tick: 131us, 91us, 117us, 116us, 56us, 1us, 36us, 19us, 115us, 322us (FPS: 7633, 10989, 8547, 8620, 17857, 1000000, 27777, 52631, 8695, 3105) (per scenario)
=== 12 scenario(s), 12 passed, 0 failed ===
Platform boundary: PASS
Specs: 32 modules, 32 ok, 0 missing, 0 outdated
ESP32 (esp32-16mb, live 15s capture):
Image: 1,239,513 bytes (70% partition free)
Flash (code+data): 1103 KB
DRAM: 38,544 / 180,736 (142,192 free)
tick: 2953us (FPS: 338) heap free: 159492
Code:
72 source files (13224 lines)
47 test files (7291 lines)
43 specs, 12 scenarios
Lizard: 52 warnings
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The ArtNet send/receive modules become protocol-neutral NetworkSend / NetworkReceive: the sender speaks ArtNet, E1.31/sACN or DDP via a protocol select, and the receiver accepts all three at once on their well-known ports — including ArtPollReply, so the device appears automatically in Resolume/Madrix/xLights node lists.
KPI: 16384lights | PC:320KB | tick:114/87/113/113/54/1/36/18/113/326us(FPS:8771/11494/8849/8849/18518/1000000/27777/55555/8849/3067) | ESP32:1105KB | tick:3164us(FPS:316) | heap:150KB | src:74(13607) | test:48(7643) | lizard:54w
Core
- platform.h: UdpSocket::recvFrom optionally exposes the datagram's source address and new sendToAddr replies on a bound, unconnected socket (both backends) — the two primitives ArtNet discovery needs
Light domain
- E131Packet.h + DdpPacket.h (new): shared wire formats (build + parse free functions, byte layouts in the header comments), the ArtNetPacket.h shape; DDP is the documented fast path — 480 RGB lights/packet vs ArtNet's 170, and per-packet cost dominates the wire time
- ArtNetPacket.h: isArtPoll + buildArtPollReply (the 239-byte reply controllers parse)
- NetworkSendDriver (renamed from ArtNetSendDriver): protocol select with the destination port following it (6454/5568/4048); one chunk loop replaces sendUniverse — 510-channel universes for ArtNet/E1.31, 1440-byte byte-offset chunks with push-on-last for DDP; E1.31 identity = MAC-derived CID, source name projectMM, priority 100; universe rule documented (offset = (universe − universe_start) × 510, no hidden 1-based clamping)
- NetworkReceiveEffect (renamed from ArtNetReceiveEffect): binds all three protocol ports simultaneously and autodetects per packet (WLED's multi-port pattern, MoonLight's single-node-three-protocols scope) — no protocol control by design, the port control is deleted; new channels_per_universe control (510 default, 512 for Madrix-style senders) with payload clamped to the universe slot; answers ArtPoll with ArtPollReply; status shows "receiving <protocol>"; applyDmx factored into universe math + shared clamped applyBytes (DDP's byte addressing, overflow-safe)
- main.cpp: registrations and wiring renamed; fixed the startup printf that passed the raw 4-octet ip array to %s (printed garbage since the line was added)
Scripts / MoonDeck
- run_network_live.py (renamed from run_artnet_live.py): the PC seed sweep sends via all three protocols per round (per-protocol rotated colour), relay legs cycle the sender's protocol control via a global leg counter; restores protocol alongside ip/enabled. Proven 8/8 + explicit DDP relay leg on the bench (PC↔esp32-16mb↔desktop)
- moondeck_config.json: card renamed to Network Live Test
Tests
- unit_NetworkReceiveEffect_protocols.cpp (new): E1.31 + DDP build→parse round-trips and reject cases, cross-protocol rejects, ArtPoll/ArtPollReply layout, applyBytes clamping incl. hostile 32-bit offsets, channels_per_universe 510/512 placement, and a localhost round-trip driving all three protocol sockets of one effect at once
- unit_NetworkSendDriver_packet.cpp: byte-exact E1.31 (ACN root/framing/DMP offsets) and DDP header layout cases beside the existing ArtNet ones
- unit_NetworkSendDriver_no_alloc_in_loop.cpp: the no-realloc contract now pinned per protocol path (virtual clock steps past the fps limiter)
- unit_NetworkReceiveEffect.cpp: follows the dropped port control; localhost test binds the real protocol ports
Docs / CI
- NetworkSendDriver.md + NetworkReceiveEffect.md: rewritten for multi-protocol (chunking table, universe rule + sACN interop line, discovery section, channels_per_universe semantics, unicast-only scope); renamed with the modules
- backlog: new "E1.31 multicast receive (IGMP join) — deferred" entry; async-send entry notes it covers all three protocols
- testing.md / MoonDeck.md / architecture.md: renamed references; live test described as the three-protocol matrix
NOTE: the type renames reset persisted ArtNetSendDriver/ArtNetReceiveEffect settings once (accepted pre-1.0; the wired-by-code NetworkSend reappears with defaults, a user-added receive effect must be re-added). Found during the gates: scripts/scenario/run_scenario.py resolves build/<host>/test/mm_scenarios while the commit-gate build command builds build/ — a stale binary there made 4 scenarios fail spuriously until build_desktop.py refreshed it; consider a staleness warning in the wrapper.
KPI Details:
Desktop:
Lights: 16,384
Binary: 320 KB
302 test cases | 302 passed | 0 failed
tick: 114us, 87us, 113us, 113us, 54us, 1us, 36us, 18us, 113us, 326us (FPS: 8771, 11494, 8849, 8849, 18518, 1000000, 27777, 55555, 8849, 3067) (per scenario)
=== 12 scenario(s), 12 passed, 0 failed ===
Platform boundary: PASS
Specs: 32 modules, 32 ok, 0 missing, 0 outdated
ESP32 (esp32-16mb, live 15s capture):
Image: 1,241,405 bytes (70% partition free)
Flash (code+data): 1105 KB
DRAM: 38,544 / 180,736 (142,192 free)
tick: 3164us (FPS: 316) heap free: 153656
Code:
74 source files (13607 lines)
48 test files (7643 lines)
43 specs, 12 scenarios
Lizard: 54 warnings
Co-Authored-By: Claude Fable 5 <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 (2)
test/unit/light/unit_RmtLedDriver_lifecycle.cpp (1)
27-27:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winRoot cause: helper setup does not assert buffer allocation success. Both
wire(...)helpers proceed aftersrc.allocate(...)without checking the result, which can mask setup failures and produce misleading downstream assertions.🤖 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_RmtLedDriver_lifecycle.cpp` at line 27, The helper setup calls src.allocate(lights, 3) but does not assert its return value, so allocation failures can be masked by subsequent wire(...) helpers; update the test setup to check the result of src.allocate (e.g., assertTrue/assert that it returns success) immediately after calling src.allocate(lights, 3) and fail the test if allocation fails so downstream wire(...) calls only run when the buffer is actually allocated.src/platform/esp32/platform_esp32_rmt.cpp (1)
238-294: 🧹 Nitpick | 🔵 Trivial | 💤 Low valueStatic
rxSymbolsbuffer is not thread-safe for concurrent calls.The
static uint32_t rxSymbols[kCapMax]at line 263 would cause data races ifrmtWs2812Loopbackwere called concurrently from multiple threads. Since this is a test-only function invoked from a UI control, concurrent calls are unlikely but possible if a user rapidly triggers the test.Consider either documenting this limitation or adding a guard (e.g., a static flag to reject concurrent calls).
🤖 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 238 - 294, The static rxSymbols buffer in rmtWs2812Loopback (static uint32_t rxSymbols[kCapMax]) can race if rmtWs2812Loopback is called concurrently; fix by removing the static buffer and instead allocate the capture buffer per-call and pass its pointer through the Cap struct into the rxTask (or alternatively add a static atomic_flag/mutex guard inside rmtWs2812Loopback to reject/serialize concurrent calls), update Cap to hold a uint32_t* buffer and capacity, set cap.buffer = new uint32_t[kCapMax] (or use a stack/std::vector if allowed), and ensure the rxTask and caller use that per-call buffer and free it after rmtWs2812Deinit to avoid leaks.
♻️ Duplicate comments (1)
docs/tests/unit-tests.md (1)
56-56:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winComplete the incomplete test description.
The line "RipplesEffect spatial variation" is a fragment without explanation. This was flagged in a previous review and remains unresolved. Every other test description in this file is either a complete sentence or paired with explanatory text.
Either expand this into a complete sentence explaining what the test verifies (e.g., "RipplesEffect shows spatial variation across the grid with different pixels at different positions"), or remove the placeholder if the description is redundant with the line above.
🤖 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/tests/unit-tests.md` at line 56, The test description line "RipplesEffect spatial variation" is an incomplete fragment; update it to a complete sentence that explains what the test verifies (for example: "RipplesEffect shows spatial variation across the grid, producing different pixel values at different positions"), or remove the placeholder if it duplicates the previous line; edit the line in docs/tests/unit-tests.md so the test description is a full, self-contained sentence.
🤖 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 `@scripts/scenario/_preview_ws.py`:
- Line 97: Remove the redundant socket.timeout from the exception tuple and
catch only TimeoutError; locate the except clause that currently reads "except
(TimeoutError, socket.timeout):" in scripts/scenario/_preview_ws.py and replace
it with a single "except TimeoutError:" so you don't redundantly handle the same
exception alias.
In `@test/unit/light/unit_NetworkReceiveEffect_protocols.cpp`:
- Around line 252-287: The test relies on real mm::platform::UdpSocket
round-trips and timing; replace the network-dependent sends and polling with
direct packet injection so the test is deterministic. Remove creation/connection
of artTx/e131Tx/ddpTx and their sendTo calls and instead build packets with
mm::buildArtDmxPacket / mm::buildE131Packet / mm::buildDdpPacket and pass them
directly to the NetworkReceiveEffect processing entrypoint (e.g., call the layer
or NetworkReceiveEffect method that accepts a packet buffer such as
r.layer.loop/receivePacket/processPacket or a mocked receivePacket function) to
simulate arrival on the correct protocol/port; also eliminate delayMs polling by
invoking the processing synchronously and then assert the buffer contents via
r.layer.buffer().data() and r.fx.status(). Ensure any socket class usage in the
test (mm::platform::UdpSocket) is removed or replaced by a lightweight mock that
calls the same packet handler.
---
Outside diff comments:
In `@src/platform/esp32/platform_esp32_rmt.cpp`:
- Around line 238-294: The static rxSymbols buffer in rmtWs2812Loopback (static
uint32_t rxSymbols[kCapMax]) can race if rmtWs2812Loopback is called
concurrently; fix by removing the static buffer and instead allocate the capture
buffer per-call and pass its pointer through the Cap struct into the rxTask (or
alternatively add a static atomic_flag/mutex guard inside rmtWs2812Loopback to
reject/serialize concurrent calls), update Cap to hold a uint32_t* buffer and
capacity, set cap.buffer = new uint32_t[kCapMax] (or use a stack/std::vector if
allowed), and ensure the rxTask and caller use that per-call buffer and free it
after rmtWs2812Deinit to avoid leaks.
In `@test/unit/light/unit_RmtLedDriver_lifecycle.cpp`:
- Line 27: The helper setup calls src.allocate(lights, 3) but does not assert
its return value, so allocation failures can be masked by subsequent wire(...)
helpers; update the test setup to check the result of src.allocate (e.g.,
assertTrue/assert that it returns success) immediately after calling
src.allocate(lights, 3) and fail the test if allocation fails so downstream
wire(...) calls only run when the buffer is actually allocated.
---
Duplicate comments:
In `@docs/tests/unit-tests.md`:
- Line 56: The test description line "RipplesEffect spatial variation" is an
incomplete fragment; update it to a complete sentence that explains what the
test verifies (for example: "RipplesEffect shows spatial variation across the
grid, producing different pixel values at different positions"), or remove the
placeholder if it duplicates the previous line; edit the line in
docs/tests/unit-tests.md so the test description is a full, self-contained
sentence.
🪄 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: 1b54c4d6-cbf4-45b9-a30e-79219964272f
⛔ Files ignored due to path filters (1)
docs/assets/screenshots/NetworkSendDriver.pngis excluded by!**/*.png
📒 Files selected for processing (52)
docs/architecture.mddocs/backlog/README.mddocs/backlog/backlog.mddocs/backlog/leddriver-increment-2-plan.mddocs/backlog/moonmodules_draft/light/effects/ArtNetReceiveEffect.mddocs/moonmodules/core/FilesystemModule.mddocs/moonmodules/light/drivers/ArtNetSendDriver.mddocs/moonmodules/light/drivers/NetworkSendDriver.mddocs/moonmodules/light/drivers/RmtLedDriver.mddocs/moonmodules/light/effects/NetworkReceiveEffect.mddocs/testing.mddocs/tests/scenario-tests.mddocs/tests/unit-tests.mdscripts/MoonDeck.mdscripts/docs/screenshot_modules.pyscripts/moondeck_config.jsonscripts/scenario/_preview_ws.pyscripts/scenario/run_network_live.pysrc/light/ArtNetPacket.hsrc/light/DdpPacket.hsrc/light/E131Packet.hsrc/light/drivers/Correction.hsrc/light/drivers/NetworkSendDriver.hsrc/light/drivers/RmtLedDriver.hsrc/light/effects/NetworkReceiveEffect.hsrc/main.cppsrc/platform/desktop/platform_config.hsrc/platform/desktop/platform_desktop.cppsrc/platform/esp32/platform_config.hsrc/platform/esp32/platform_esp32.cppsrc/platform/esp32/platform_esp32_rmt.cppsrc/platform/platform.htest/CMakeLists.txttest/scenario_runner.cpptest/scenarios/core/scenario_MoonModule_control_change.jsontest/scenarios/light/scenario_AllEffects_grid_sizes.jsontest/scenarios/light/scenario_GridLayout_grid_sizes.jsontest/scenarios/light/scenario_GridLayout_resize.jsontest/scenarios/light/scenario_Layer_base_pipeline.jsontest/scenarios/light/scenario_Layer_buildup.jsontest/scenarios/light/scenario_Layer_memory_1to1.jsontest/scenarios/light/scenario_Layouts_mutation.jsontest/scenarios/light/scenario_MultiplyModifier_memory_lut.jsontest/scenarios/light/scenario_MultiplyModifier_pipeline.jsontest/scenarios/light/scenario_modifier_swap.jsontest/unit/light/unit_ArtNetSendDriver_packet.cpptest/unit/light/unit_NetworkReceiveEffect.cpptest/unit/light/unit_NetworkReceiveEffect_protocols.cpptest/unit/light/unit_NetworkSendDriver_no_alloc_in_loop.cpptest/unit/light/unit_NetworkSendDriver_packet.cpptest/unit/light/unit_RmtLedDriver_lifecycle.cpptest/unit/light/unit_RmtLedDriver_pins.cpp
💤 Files with no reviewable changes (3)
- docs/moonmodules/light/drivers/ArtNetSendDriver.md
- docs/backlog/moonmodules_draft/light/effects/ArtNetReceiveEffect.md
- test/unit/light/unit_ArtNetSendDriver_packet.cpp
| TEST_CASE("NetworkReceiveEffect receives all three protocols at once over localhost") { | ||
| Rig r; | ||
| r.fx.setup(); | ||
| REQUIRE(r.fx.status() == nullptr); // all three binds succeeded | ||
|
|
||
| uint8_t cid[mm::E131_CID_LENGTH]; | ||
| cidFill(cid); | ||
|
|
||
| mm::platform::UdpSocket artTx, e131Tx, ddpTx; | ||
| REQUIRE((artTx.open() && artTx.connect("127.0.0.1", mm::ARTNET_PORT))); | ||
| REQUIRE((e131Tx.open() && e131Tx.connect("127.0.0.1", mm::E131_PORT))); | ||
| REQUIRE((ddpTx.open() && ddpTx.connect("127.0.0.1", mm::DDP_PORT))); | ||
|
|
||
| // Three distinct payloads at three distinct buffer positions: ArtNet → | ||
| // universe 0 (offset 0), E1.31 → universe 1 (offset 510), DDP → byte 600. | ||
| uint8_t a[3] = {11, 12, 13}, e[3] = {21, 22, 23}, d[3] = {31, 32, 33}; | ||
| uint8_t pkt[mm::E131_HEADER_SIZE + 3]; | ||
| artTx.sendTo(pkt, mm::buildArtDmxPacket(pkt, 0, 0, a, 3)); | ||
| e131Tx.sendTo(pkt, mm::buildE131Packet(pkt, 1, 0, cid, e, 3)); | ||
| ddpTx.sendTo(pkt, mm::buildDdpPacket(pkt, 600, true, d, 3)); | ||
|
|
||
| bool landed = false; | ||
| for (int i = 0; i < 100 && !landed; i++) { | ||
| r.layer.loop(); | ||
| const uint8_t* buf = r.layer.buffer().data(); | ||
| landed = buf[0] == 11 && buf[510] == 21 && buf[600] == 31; | ||
| if (!landed) mm::platform::delayMs(1); | ||
| } | ||
| CHECK(landed); | ||
| CHECK(r.fx.status() != nullptr); // "receiving <protocol>" diagnostic is set | ||
|
|
||
| artTx.close(); | ||
| e131Tx.close(); | ||
| ddpTx.close(); | ||
| r.fx.teardown(); | ||
| } |
There was a problem hiding this comment.
Move the localhost socket round-trip out of unit tests.
Line 252 to Line 287 depends on real UDP sockets and timing (delayMs polling), which makes this unit case nondeterministic and environment-sensitive.
As per coding guidelines, "test/**: Unit and integration tests using doctest. Verify tests cover edge cases and match the specifications in docs/moonmodules/. Tests should not depend on timing or network."
🧰 Tools
🪛 Clang (14.0.6)
[warning] 252-252: function 'DOCTEST_ANON_FUNC_20' has cognitive complexity of 38 (threshold 25)
(readability-function-cognitive-complexity)
[note] 255-255: +1, including nesting penalty of 0, nesting level increased to 1
(clang)
[note] 255-255: +2, including nesting penalty of 1, nesting level increased to 2
(clang)
[note] 255-255: +2, including nesting penalty of 1, nesting level increased to 2
(clang)
[note] 261-261: +1, including nesting penalty of 0, nesting level increased to 1
(clang)
[note] 261-261: +1
(clang)
[note] 261-261: +2, including nesting penalty of 1, nesting level increased to 2
(clang)
[note] 261-261: +2, including nesting penalty of 1, nesting level increased to 2
(clang)
[note] 262-262: +1, including nesting penalty of 0, nesting level increased to 1
(clang)
[note] 262-262: +1
(clang)
[note] 262-262: +2, including nesting penalty of 1, nesting level increased to 2
(clang)
[note] 262-262: +2, including nesting penalty of 1, nesting level increased to 2
(clang)
[note] 263-263: +1, including nesting penalty of 0, nesting level increased to 1
(clang)
[note] 263-263: +1
(clang)
[note] 263-263: +2, including nesting penalty of 1, nesting level increased to 2
(clang)
[note] 263-263: +2, including nesting penalty of 1, nesting level increased to 2
(clang)
[note] 274-274: +1, including nesting penalty of 0, nesting level increased to 1
(clang)
[note] 274-274: +1
(clang)
[note] 277-277: +1
(clang)
[note] 278-278: +2, including nesting penalty of 1, nesting level increased to 2
(clang)
[note] 280-280: +1, including nesting penalty of 0, nesting level increased to 1
(clang)
[note] 280-280: +2, including nesting penalty of 1, nesting level increased to 2
(clang)
[note] 280-280: +2, including nesting penalty of 1, nesting level increased to 2
(clang)
[note] 281-281: +1, including nesting penalty of 0, nesting level increased to 1
(clang)
[note] 281-281: +2, including nesting penalty of 1, nesting level increased to 2
(clang)
[note] 281-281: +2, including nesting penalty of 1, nesting level increased to 2
(clang)
[warning] 253-253: variable name 'r' is too short, expected at least 3 characters
(readability-identifier-length)
[warning] 257-257: do not declare C-style arrays, use std::array<> instead
(modernize-avoid-c-arrays)
[warning] 260-260: multiple declarations in a single statement reduces readability
(readability-isolate-declaration)
[warning] 267-267: do not declare C-style arrays, use std::array<> instead
(modernize-avoid-c-arrays)
[warning] 267-267: multiple declarations in a single statement reduces readability
(readability-isolate-declaration)
[warning] 267-267: variable name 'a' is too short, expected at least 3 characters
(readability-identifier-length)
[warning] 267-267: variable name 'e' is too short, expected at least 3 characters
(readability-identifier-length)
[warning] 267-267: variable name 'd' is too short, expected at least 3 characters
(readability-identifier-length)
[warning] 268-268: do not declare C-style arrays, use std::array<> instead
(modernize-avoid-c-arrays)
[warning] 278-278: statement should be inside braces
(readability-braces-around-statements)
🪛 Cppcheck (2.20.0)
[error] 269-269: Uninitialized variable
(uninitvar)
[style] 263-263: The function 'replaceChildAt' is never used.
(unusedFunction)
[style] 275-275: The function 'moveChildTo' 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_NetworkReceiveEffect_protocols.cpp` around lines 252 -
287, The test relies on real mm::platform::UdpSocket round-trips and timing;
replace the network-dependent sends and polling with direct packet injection so
the test is deterministic. Remove creation/connection of artTx/e131Tx/ddpTx and
their sendTo calls and instead build packets with mm::buildArtDmxPacket /
mm::buildE131Packet / mm::buildDdpPacket and pass them directly to the
NetworkReceiveEffect processing entrypoint (e.g., call the layer or
NetworkReceiveEffect method that accepts a packet buffer such as
r.layer.loop/receivePacket/processPacket or a mocked receivePacket function) to
simulate arrival on the correct protocol/port; also eliminate delayMs polling by
invoking the processing synchronously and then assert the buffer contents via
r.layer.buffer().data() and r.fx.status(). Ensure any socket class usage in the
test (mm::platform::UdpSocket) is removed or replaced by a lightweight mock that
calls the same packet handler.
Source: Coding guidelines
The ESP32-S3 now drives LED strips from the LCD_CAM peripheral: 8 strands in parallel from one DMA transfer, proven on a live strip after fixing three stacked hardware bugs (partial-bus rejection, pin edits silently ignored, "0" pulses too long for modern WS2812B). Provisioning gains a SET_TX_POWER vendor RPC so brown-out-prone boards (LOLIN S3) can join WiFi straight from the web installer or MoonDeck.
KPI: 16384lights | PC:322KB | tick:114/85/116/115/54/1/35/18/111/319us(FPS:8771/11764/8620/8695/18518/1000000/28571/55555/9009/3134) | ESP32:1120KB | tick:3904us(FPS:256) | heap:8384KB | src:78(14597) | test:50(7926) | lizard:59w
(ESP32 tick measured on the S3 while actively driving 64 LEDs over the LCD bus — the synchronous frame wall-time, not an idle baseline.)
Core
- platform_esp32_lcd.cpp: new esp_lcd i80 backend — full-frame pre-encode into one DMA buffer, gapless tx_color, done-callback semaphore; pclk 2.67 MHz (375 ns slots — newer WS2812B revisions cap T0H at ~380 ns, the lineage's 412 ns zeros washed strips white on a direct 3.3 V data line); loopback transmits the caller's REAL frame back-to-back and bit-verifies the whole capture, logging granted pclk + pulse-width stats and the first corrupted light
- platform_esp32_rmt.cpp: rmtWs2812RxCapture gains the RMT DMA backend for captures beyond one hardware memory block (whole-frame loopback captures); loopbackJumperOk shared with the LCD test
- platform.h / platform_desktop.cpp / platform_config.h: lcdWs2812* API + lcdLanes SOC constant (8 on i80 chips, 0 elsewhere); improvProvisioningInit extended with the TX-power handoff
- ImprovProvisioningModule: applies a pending TX-power setting before WiFi credentials (pre-association — the only moment that can save a brown-out board)
- NetworkModule: setTxPowerSetting clamps, persists and syncs the cap
Light domain
- LcdLedDriver (new): 8 lanes, consecutive buffer slices via shared pins/ledsPerPin semantics; requires exactly 8 pins (the i80 layer rejects partial buses — unused lanes take 0 lights and idle LOW); reinit rebuilds the bus on any pin change (capacity-only check kept transmitting on the old GPIOs); fused lifecycle, platform-owned DMA buffer
- LcdSlots.h (new): host-testable 3-slot encoder (mask HIGH / data / LOW), idle-LOW rule for short strands
- PinList.h (new): parsePinList/assignCounts extracted from RmtLedDriver (second user)
UI
- install-orchestrator.js + index.html: web installer pushes the board's TX-power cap over Improv before provisioning (and on each retry)
- install-picker.js: reads the cap from the selected board's boards.json controls
Scripts / MoonDeck
- improv_provision.py: --tx-power and --board (boards.json lookup + SET_BOARD push)
- moondeck: Board dropdown wired through pass_board (replaces the always-ambiguous _deduce_board)
- build_esp32.py: export ESP_ROM_ELF_DIR in the hand-built IDF env — its absence failed builds intermittently at the post-build gdbinit step
- _preview_ws.py: stdlib preview-WebSocket client used by live scenario verification
Tests
- unit_LcdLedEncoder: byte-exact slot triplets — transpose, MSB-first, idle-LOW, GRB, RGBW
- unit_LcdLedDriver: lane slicing, frame math (RGBW growth, 64-byte rounding, 800+64 latch pad), exactly-8-pins rule, bad-pin recovery, zero grid, teardown
- unit_RmtLedDriver_pins/_lifecycle: PinList call sites; wire() helpers REQUIRE allocate success
Docs / CI
- LcdLedDriver.md (new): wire contract (375 ns slots + why), 8-pin rule, real-frame loopback, prior art (Adafruit / hpwit / FastLED — studied, not copied)
- ImprovProvisioningModule.md + MoonDeck.md: SET_TX_POWER / SET_BOARD RPCs, --tx-power, Board dropdown
- decisions.md: LCD bench-debug lessons (all-8-GPIOs bus contract, capacity!=identity reinit trap, test-the-real-frame principle, WS2812B T0H <=380 ns)
- leddriver-increment-2-plan.md: 2b hardware-proven, five recorded deviations
- test inventories regenerated
Reviews
- CodeRabbit: _preview_ws.py caught (TimeoutError, socket.timeout) — fixed: socket.timeout is an alias since Python 3.10
- CodeRabbit: unchecked src.allocate in RMT lifecycle test helper — fixed with REQUIRE; same pattern fixed in the pins and LcdLed helpers (the latter asserts allocate == (lights > 0) — its zero-grid case is deliberate)
- CodeRabbit: unit-tests.md "RipplesEffect spatial variation" fragment — fixed at the source (missing description line above the TEST_CASE) and regenerated
- CodeRabbit: replace real-UDP round-trip in NetworkReceive protocols test with packet injection — skipped: that case IS the bind/port-autodetect proof; injection would bypass the path under test, and the file's other cases already inject directly
- CodeRabbit: static rxSymbols race in the RMT loopback — skipped: loopback runs only from the single-threaded control path (synchronous network input), concurrent invocation cannot occur
KPI Details:
Desktop:
Lights: 16,384
Binary: 322 KB
[doctest] test cases: 316 | 316 passed | 0 failed | 0 skipped
tick: 114us, 85us, 116us, 115us, 54us, 1us, 35us, 18us, 111us, 319us (FPS: 8771, 11764, 8620, 8695, 18518, 1000000, 28571, 55555, 9009, 3134) (per scenario)
=== 12 scenario(s), 12 passed, 0 failed ===
Platform boundary: PASS
Specs: Spec check: 33 modules, 33 ok, 0 missing, 0 outdated, 0 source-link issues
ESP32:
Image: 1,257,921 bytes (31% partition free)
Flash (code+data): 1120 KB
DRAM: 38,616 / 180,736 (142,120 free)
tick: 3904us (FPS: 256) heap free: 8585847
Code:
78 source files (14597 lines)
50 test files (7926 lines)
44 specs, 12 scenarios
Lizard: 59 warnings
Co-Authored-By: Claude Fable 5 <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 (1)
test/scenarios/light/scenario_AllEffects_grid_sizes.json (1)
26-26:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winUpdate stale driver reference in scenario description.
Line 26 still says the scenario rebuilds with “ArtNet”, but the fixture/build now wires
NetworkSendDriver(Line 84, Line 159). Please align the description with the actual module setup to keep test intent unambiguous.🤖 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/scenarios/light/scenario_AllEffects_grid_sizes.json` at line 26, Update the scenario description string to reflect the current driver name: replace the outdated "ArtNet" reference with "NetworkSendDriver" so the prose matches the actual setup where the fixture wires NetworkSendDriver; keep other terms like PreviewDriver, clear_children, replace_module and set_control unchanged to preserve behavior description and clarify the test intent.
🤖 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/install/install-orchestrator.js`:
- Around line 183-186: sendSetTxPowerFrame (and the other spots that construct a
frame with IMPROV_CMD_SET_TX_POWER) currently coerces dBm with a bitwise
operation (dBm & 0xFF) which silently accepts invalid inputs; update these
places to validate and normalize txPower first: coerce input using
Number(txPower), ensure Number.isInteger(value) and that it falls within the
allowed bounds (e.g. 0–255 or the device-specific min/max), and if invalid
reject/throw or return an error before building the frame instead of encoding a
bad byte; reference sendSetTxPowerFrame and IMPROV_CMD_SET_TX_POWER to find all
occurrences to fix.
In `@scripts/moondeck.py`:
- Around line 1048-1057: The fallback to derive a board via _deduce_board()
never fires under the current pass_board contract (firmware is not supplied), so
change the pass_board handling to require an explicit board: in the pass_board
branch stop calling _deduce_board(params.get("firmware", "")) and only use
params.get("board"); if board is missing emit a clear warning/error (or raise)
so --board is not silently omitted; update any tests/docs referencing pass_board
behavior accordingly.
In `@src/light/drivers/LcdLedDriver.h`:
- Around line 3-8: LcdLedDriver.h currently pulls in platform/platform.h and
performs hardware-facing lifecycle operations from the non-platform layer;
remove the direct platform include and any direct hardware calls from
LcdLedDriver.h and instead call into platform-abstraction entry points (e.g.
add/replace calls with platform::initLedHardware(...),
platform::startLedCycle(...), platform::stopLedCycle(...) or similarly named
functions implemented under src/platform) so non-platform code only references
abstract platform APIs; if you need compile-time branching use platform_config.h
flags with if constexpr in the header (or provide a small thin inline wrapper
that dispatches to the platform implementation) and keep functions like
encodeWs2812LcdSlots, DriverBase/Correction usage, and parsePinList/assignCounts
unchanged in the non-platform layer.
- Around line 138-139: The call to platform::lcdWs2812Transmit(lcd_,
frameBytes_) currently ignores its boolean return so a transmit failure is
masked; modify the loop in LcdLedDriver to capture the bool result from
platform::lcdWs2812Transmit and handle failure explicitly (e.g., log an error,
set/clear a driver error state, and break/return/throw to avoid continuing as if
the frame was sent). Use the existing symbols lcdWs2812Transmit, lcdWs2812Wait,
lcd_, and frameBytes_ to locate the code and ensure the handler calls the
appropriate error reporting path in this class (or returns a failure status)
before calling platform::lcdWs2812Wait or retrying.
- Around line 84-87: The code currently calls clearStatus() whenever
isTestControl is true and loopbackTest is false, which clears real driver errors
when loopback is toggled off; fix this by only clearing status when entering
test-control mode (i.e., on the transition into isTestControl) and not on a
loopback toggle—introduce a stored previous flag (e.g., prevIsTestControl) and
change the condition to something like: if (isTestControl && !loopbackTest &&
!prevIsTestControl) { clearFailBuf(); clearStatus(); } then update
prevIsTestControl each cycle so toggling loopbackTest off while already in
test-control does not clear existing non-loopback errors; keep the existing
else-if branch for (loopbackTest && (isTestControl || isPinControl)) untouched.
In `@src/platform/esp32/platform_esp32_lcd.cpp`:
- Around line 167-181: lcdWs2812Wait currently drops the xSemaphoreTake() result
allowing a late DMA completion to leave the semaphore set and let the next wait
return early while the prior transfer still owns st->buf; change lcdWs2812Wait
to return a bool, capture the xSemaphoreTake result, and on timeout (pdFALSE)
drain any late completion tokens by repeatedly calling xSemaphoreTake(st->done,
0) until it returns pdFALSE, then return false so callers can avoid reusing
st->buf; update callers of lcdWs2812Wait to check the returned status before
starting a new lcdWs2812Transmit, and ensure lcdWs2812Transmit either checks the
completion status or is only called when lcdWs2812Wait returned true.
In `@test/scenarios/light/scenario_AllEffects_grid_sizes.json`:
- Around line 4123-4125: Replace future-dated measurement timestamps
"2026-06-11" with the actual run date "2026-06-10" wherever they appear in the
JSON "at" arrays (specifically the entries that currently read
["2026-06-07","2026-06-11"] around the "at" arrays at the two affected records);
update both occurrences referenced (the one near line 4125 and the one near line
4361/4359-4361) so measurement provenance is chronologically consistent.
---
Outside diff comments:
In `@test/scenarios/light/scenario_AllEffects_grid_sizes.json`:
- Line 26: Update the scenario description string to reflect the current driver
name: replace the outdated "ArtNet" reference with "NetworkSendDriver" so the
prose matches the actual setup where the fixture wires NetworkSendDriver; keep
other terms like PreviewDriver, clear_children, replace_module and set_control
unchanged to preserve behavior description and clarify the test intent.
🪄 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: 538746b3-6d4b-457e-9258-ad13561d4ad8
⛔ Files ignored due to path filters (2)
scripts/build/build_esp32.pyis excluded by!**/build/**scripts/build/improv_provision.pyis excluded by!**/build/**
📒 Files selected for processing (39)
docs/backlog/leddriver-increment-2-plan.mddocs/history/decisions.mddocs/install/index.htmldocs/install/install-orchestrator.jsdocs/moonmodules/core/ImprovProvisioningModule.mddocs/moonmodules/light/drivers/LcdLedDriver.mddocs/tests/scenario-tests.mddocs/tests/unit-tests.mdesp32/main/CMakeLists.txtscripts/MoonDeck.mdscripts/moondeck.pyscripts/moondeck_config.jsonscripts/moondeck_ui/app.jsscripts/moondeck_ui/index.htmlscripts/scenario/_preview_ws.pysrc/core/ImprovProvisioningModule.hsrc/core/NetworkModule.hsrc/light/drivers/LcdLedDriver.hsrc/light/drivers/LcdSlots.hsrc/light/drivers/PinList.hsrc/light/drivers/RmtLedDriver.hsrc/main.cppsrc/platform/desktop/platform_config.hsrc/platform/desktop/platform_desktop.cppsrc/platform/esp32/platform_config.hsrc/platform/esp32/platform_esp32_improv.cppsrc/platform/esp32/platform_esp32_lcd.cppsrc/platform/esp32/platform_esp32_rmt.cppsrc/platform/platform.hsrc/ui/install-picker.jstest/CMakeLists.txttest/scenarios/light/scenario_AllEffects_grid_sizes.jsontest/scenarios/light/scenario_Layouts_mutation.jsontest/scenarios/light/scenario_modifier_swap.jsontest/unit/light/unit_CheckerboardEffect.cpptest/unit/light/unit_LcdLedDriver.cpptest/unit/light/unit_LcdLedEncoder.cpptest/unit/light/unit_RmtLedDriver_lifecycle.cpptest/unit/light/unit_RmtLedDriver_pins.cpp
| async function sendSetTxPowerFrame(port, dBm) { | ||
| const frame = buildImprovFrame(IMPROV_FRAME_TYPE_RPC, | ||
| new Uint8Array([IMPROV_CMD_SET_TX_POWER, 1, dBm & 0xFF])); | ||
| const writer = port.writable.getWriter(); |
There was a problem hiding this comment.
Validate txPower before frame encoding.
Line 185 coerces with dBm & 0xFF, so invalid inputs (e.g., "abc", -1, 20.5) become unintended bytes. This can silently bypass or misapply the brownout mitigation path.
Proposed fix
async function sendSetTxPowerFrame(port, dBm) {
+ if (!Number.isInteger(dBm) || dBm < 0 || dBm > 21) {
+ throw new Error(`txPower must be an integer in 0..21 dBm, got: ${dBm}`);
+ }
const frame = buildImprovFrame(IMPROV_FRAME_TYPE_RPC,
- new Uint8Array([IMPROV_CMD_SET_TX_POWER, 1, dBm & 0xFF]));
+ new Uint8Array([IMPROV_CMD_SET_TX_POWER, 1, dBm]));
@@
if (txPower != null) {
trackProgress("set-tx-power");
+ if (!Number.isInteger(txPower) || txPower < 0 || txPower > 21) {
+ throw new Error(`invalid txPower: ${txPower} (expected integer 0..21)`);
+ }
if (onLog) onLog(`[orchestrator] SET_TX_POWER ${txPower} dBm (boards.json cap)`);
await sendSetTxPowerFrame(port, txPower);
await new Promise(r => setTimeout(r, 200));
}Also applies to: 664-668, 787-791
🤖 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/install/install-orchestrator.js` around lines 183 - 186,
sendSetTxPowerFrame (and the other spots that construct a frame with
IMPROV_CMD_SET_TX_POWER) currently coerces dBm with a bitwise operation (dBm &
0xFF) which silently accepts invalid inputs; update these places to validate and
normalize txPower first: coerce input using Number(txPower), ensure
Number.isInteger(value) and that it falls within the allowed bounds (e.g. 0–255
or the device-specific min/max), and if invalid reject/throw or return an error
before building the frame instead of encoding a bad byte; reference
sendSetTxPowerFrame and IMPROV_CMD_SET_TX_POWER to find all occurrences to fix.
| #include "light/drivers/Drivers.h" // DriverBase, Correction | ||
| #include "light/drivers/LcdSlots.h" // encodeWs2812LcdSlots (host-testable) | ||
| #include "light/drivers/LedDriverConfig.h" | ||
| #include "light/drivers/PinList.h" // parsePinList / assignCounts (shared with RmtLedDriver) | ||
| #include "platform/platform.h" | ||
|
|
There was a problem hiding this comment.
🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift
Move hardware-facing lifecycle calls behind src/platform implementation boundaries.
This file directly includes platform/platform.h and performs hardware-facing operations from src/light/.... That breaks the repository’s platform-boundary rule for non-platform code.
As per coding guidelines, "src/**/!(platform)/**/*.{h,cpp}: Platform abstraction: no #ifdef, platform-specific #include, or hardware API call outside src/platform/; use if constexpr on platform_config.h flags for compile-time branching."
Also applies to: 111-140, 240-275
🤖 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/LcdLedDriver.h` around lines 3 - 8, LcdLedDriver.h
currently pulls in platform/platform.h and performs hardware-facing lifecycle
operations from the non-platform layer; remove the direct platform include and
any direct hardware calls from LcdLedDriver.h and instead call into
platform-abstraction entry points (e.g. add/replace calls with
platform::initLedHardware(...), platform::startLedCycle(...),
platform::stopLedCycle(...) or similarly named functions implemented under
src/platform) so non-platform code only references abstract platform APIs; if
you need compile-time branching use platform_config.h flags with if constexpr in
the header (or provide a small thin inline wrapper that dispatches to the
platform implementation) and keep functions like encodeWs2812LcdSlots,
DriverBase/Correction usage, and parsePinList/assignCounts unchanged in the
non-platform layer.
Source: Coding guidelines
| platform::lcdWs2812Transmit(lcd_, frameBytes_); | ||
| platform::lcdWs2812Wait(lcd_, 1000 /* ms */); |
There was a problem hiding this comment.
Handle lcdWs2812Transmit failure explicitly.
Line 138 drops the boolean return from platform::lcdWs2812Transmit. If transmit fails, the loop silently proceeds and only waits, masking a hard output fault.
Proposed fix
- platform::lcdWs2812Transmit(lcd_, frameBytes_);
+ if (!platform::lcdWs2812Transmit(lcd_, frameBytes_)) {
+ setStatus("LCD transmit failed", Severity::Error);
+ return;
+ }
platform::lcdWs2812Wait(lcd_, 1000 /* ms */);📝 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.
| platform::lcdWs2812Transmit(lcd_, frameBytes_); | |
| platform::lcdWs2812Wait(lcd_, 1000 /* ms */); | |
| if (!platform::lcdWs2812Transmit(lcd_, frameBytes_)) { | |
| setStatus("LCD transmit failed", Severity::Error); | |
| return; | |
| } | |
| platform::lcdWs2812Wait(lcd_, 1000 /* ms */); |
🤖 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/LcdLedDriver.h` around lines 138 - 139, The call to
platform::lcdWs2812Transmit(lcd_, frameBytes_) currently ignores its boolean
return so a transmit failure is masked; modify the loop in LcdLedDriver to
capture the bool result from platform::lcdWs2812Transmit and handle failure
explicitly (e.g., log an error, set/clear a driver error state, and
break/return/throw to avoid continuing as if the frame was sent). Use the
existing symbols lcdWs2812Transmit, lcdWs2812Wait, lcd_, and frameBytes_ to
locate the code and ensure the handler calls the appropriate error reporting
path in this class (or returns a failure status) before calling
platform::lcdWs2812Wait or retrying.
| bool lcdWs2812Transmit(LcdWs2812Handle& h, size_t bytes) { | ||
| auto* st = static_cast<LcdState*>(h.impl); | ||
| if (!st || bytes == 0 || bytes > st->cap) return false; | ||
| // lcd_cmd = -1: no command phase — the transfer is one continuous GDMA | ||
| // data stream, gapless at the pclk rate from internal SRAM. | ||
| return esp_lcd_panel_io_tx_color(st->io, -1, st->buf, bytes) == ESP_OK; | ||
| } | ||
|
|
||
| void lcdWs2812Wait(LcdWs2812Handle& h, uint32_t timeoutMs) { | ||
| auto* st = static_cast<LcdState*>(h.impl); | ||
| if (!st) return; | ||
| // Finite timeout, same self-healing stance as rmtWs2812Wait: a timed-out | ||
| // frame is dropped and the driver re-encodes the whole frame next tick. | ||
| xSemaphoreTake(st->done, pdMS_TO_TICKS(timeoutMs)); | ||
| } |
There was a problem hiding this comment.
A timed-out transfer can make the next wait return early while DMA still owns the buffer.
lcdWs2812Wait() drops the xSemaphoreTake() result, and lcdWs2812Transmit() starts the next async transfer without draining any late completion token from st->done. If one frame times out and completes afterward, the callback leaves the semaphore set; the next lcdWs2812Wait() can then succeed immediately and the caller will re-encode into st->buf while the prior transfer is still in flight. This backend needs a real quiesce/error path on timeout, or at minimum a drained completion token plus a returned status the caller can honor before reusing the DMA 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_lcd.cpp` around lines 167 - 181,
lcdWs2812Wait currently drops the xSemaphoreTake() result allowing a late DMA
completion to leave the semaphore set and let the next wait return early while
the prior transfer still owns st->buf; change lcdWs2812Wait to return a bool,
capture the xSemaphoreTake result, and on timeout (pdFALSE) drain any late
completion tokens by repeatedly calling xSemaphoreTake(st->done, 0) until it
returns pdFALSE, then return false so callers can avoid reusing st->buf; update
callers of lcdWs2812Wait to check the returned status before starting a new
lcdWs2812Transmit, and ensure lcdWs2812Transmit either checks the completion
status or is only called when lcdWs2812Wait returned true.
Fixes a release-blocker: every classic ESP32 boot-looped on the current tree because the LCD driver was wrongly activated on it. Adds a whole-frame RMT loopback self-test and a per-protocol network round-trip latency probe, and lands the network test scripts' shared helpers in one module. The blue-flicker investigation is resolved (hardware signal integrity, not firmware) and documented.
KPI: 16384lights | PC:322KB | tick:120/88/116/118/55/1/36/19/113/331us(FPS:8333/11363/8620/8474/18181/1000000/27777/52631/8849/3021) | ESP32:1136KB | tick:3495us(FPS:286) | heap:8397KB | src:78(14821) | test:50(7975) | lizard:61w
Core
- platform_config.h / platform_esp32_lcd.cpp: gate lcdLanes and the LCD backend on SOC_LCDCAM_I80_LCD_SUPPORTED, not SOC_LCD_I80_SUPPORTED. The classic ESP32 sets the latter for its unrelated I2S-LCD peripheral, so the LcdLedDriver was wired at boot on the classic chip and hung initialising an esp_lcd i80 bus it doesn't have (TG1WDT boot-loop). Bisect-proven: the prior commit boots, this tree looped; verified fixed on hardware (classic boots with LcdLed absent, S3 keeps it).
- platform_esp32_rmt.cpp: new rmtWs2812LoopbackFrame — transmits a real frame back to back and bit-verifies the whole capture (the frame-rate/interference detector the 24-bit burst can't be); RX capture caps mem_block to one channel on no-DMA chips (classic) so it can't claim neighbours' memory.
- NetworkModule: syncTxPower skips the radio call when there's no real cap to apply (default board), avoiding a redundant esp_wifi_set_max_tx_power in the radio-start stack.
- platform.h / platform_desktop.cpp: rmtWs2812LoopbackFrame declaration + desktop stub; RmtLoopbackResult gains bitsChecked/firstBadBit.
Light domain
- RmtLedDriver: loopbackFrame control runs the whole-frame self-test (reports first corrupted bit/light); onUpdate re-derives real status on test-off so a config error survives. loop() render path unchanged.
- LcdLedDriver: loop() waits only when transmit started (a failed tx_color gave no callback, so an unconditional wait stalled 1000 ms every tick).
- Drivers: lightPreset defaults to GRB (the WS2812 wire order — a strip shows correct colours out of the box; PreviewDriver reads the RGB source so it's unaffected).
UI / Scripts
- moondeck: the Live-tab device checkboxes (selected) now drive which devices network tests run against — both the matrix test and the round-trip probe honour them (was: all online, ignoring the boxes). Removed the dead _deduce_board fallback at the pass_board call site.
- _net_probe.py (new): shared lights-over-UDP surface (ports, ArtNet/E1.31/DDP builders, the selected-device loader) extracted from run_network_live so both network tests import from a neutral module.
- run_network_roundtrip.py (new): PC->device->PC latency probe, sweeps all three protocols per device, runs across every checked device, prints a median-per-protocol comparison. Tolerant of transient preview-WS drops; restores each device's grid.
- moondeck_config.json: Network Round-trip card in the Live tab.
Tests
- unit_RmtLedDriver_pins: loop() crash-safety across single-pin / multi-pin / zero-grid / pre-init configs (the concurrency path is ESP32-only via if constexpr, so the host pins the reachable contract; the transmit path is proven by the hardware loopback).
Docs
- RmtLedDriver.md: loopbackFrame control + a Troubleshooting section for flicker (the buffer->loopback->TX-power elimination chain; the fix is a level shifter / series resistor, not firmware).
- decisions.md: the flicker-diagnosis playbook (eliminate firmware with measurements before blaming the wire).
- backlog.md: round-trip drop/reorder measurement deferred, with the reason (send re-clock breaks 1:1 frame tracking) and the path to do it.
- Drivers.md: GRB default noted. MoonDeck.md: round-trip + checkbox behaviour. Three unreferenced board reference images added under docs/assets/boards/.
Reviews
- CodeRabbit (prior batch, already in this tree): _preview_ws.py socket.timeout dedup; unchecked src.allocate in test helpers; unit-tests.md fragment; NetworkReceive UDP test kept (the bind/autodetect proof); static rxSymbols race accepted (single-threaded control path).
KPI Details:
Desktop:
Lights: 16,384
Binary: 322 KB
[doctest] test cases: 317 | 317 passed | 0 failed | 0 skipped
tick: 120us, 88us, 116us, 118us, 55us, 1us, 36us, 19us, 113us, 331us (FPS: 8333, 11363, 8620, 8474, 18181, 1000000, 27777, 52631, 8849, 3021) (per scenario)
=== 12 scenario(s), 12 passed, 0 failed ===
Platform boundary: PASS
Specs: Spec check: 33 modules, 33 ok, 0 missing, 0 outdated, 0 source-link issues
ESP32:
Image: 1,278,049 bytes (70% partition free)
Flash (code+data): 1136 KB
tick: 3495us (FPS: 286) heap free: 8598619
Code:
78 source files (14821 lines)
50 test files (7975 lines)
44 specs, 12 scenarios
Lizard: 61 warnings
Co-Authored-By: Claude Fable 5 <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)
scripts/MoonDeck.md (1)
349-349:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winUse
uv runinstead of barepython3in documentation examples.The example command uses
python3 scripts/build/host_wifi.py, but the coding guideline requires all Python invocations to go throughuv run. As per coding guidelines, "never type python or python3 directly; always use uv run (applies to shell commands, CMake add_custom_command/execute_process, documentation examples)".📝 Proposed fix
-The host-WiFi reader lives at [scripts/build/host_wifi.py](build/host_wifi.py) and runs standalone for diagnosis (`python3 scripts/build/host_wifi.py` prints the resolved SSID + password). +The host-WiFi reader lives at [scripts/build/host_wifi.py](build/host_wifi.py) and runs standalone for diagnosis (`uv run scripts/build/host_wifi.py` prints the resolved SSID + password).🤖 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 `@scripts/MoonDeck.md` at line 349, Update the documentation example that runs the host-WiFi reader to use the project's wrapper command instead of calling Python directly: replace the example invocation "python3 scripts/build/host_wifi.py" with "uv run scripts/build/host_wifi.py" (or the project's canonical "uv run" form) in the MoonDeck.md section describing scripts/build/host_wifi.py so all Python invocations follow the coding guideline.Source: Coding guidelines
src/light/drivers/RmtLedDriver.h (1)
355-403: 🧹 Nitpick | 🔵 Trivial | 💤 Low value
loopbackFramePASS case allocates failBuf_ unnecessarily for the bit count message.When
loopbackFrameis true and the test passes, the code allocatesfailBuf_(line 376) just to format a status with the bit count. This conflicts with the design comment stating "failBuf_is only allocated when a loopback or channel init FAILs" (lines 245-248). The allocation persists until teardown or a non-FAIL result explicitly clears it.This is minor since
clearFailBuf()is called at line 374 before the allocation, but the naming/documentation mismatch could cause confusion. Consider using a flash literal for PASS messages or updating the comment to reflect that PASS with bit counts also uses the 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/RmtLedDriver.h` around lines 355 - 403, The PASS branch for loopbackFrame currently allocates failBuf_ just to format a "loopback PASS (%u bits)" message, violating the invariant that failBuf_ is only created on FAIL; change the PASS handling in the r.pass && loopbackFrame branch to avoid assigning to failBuf_ (remove the platform::alloc call and failBuf_ assignment) and instead format the message into a temporary local buffer or use a literal status message before calling setStatus; keep the existing clearFailBuf() call and only allocate failBuf_ in the failure branches (or alternatively update the comment that documents failBuf_ usage if you deliberately want to allow PASS allocations).
🤖 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 `@scripts/moondeck_ui/app.js`:
- Around line 592-612: renderBoardSelect is adding a new change listener on the
"board-select" element every render which causes duplicate saveState() calls;
replace the stacked addEventListener with a single assigned handler (e.g., set
select.onchange = async () => { state.provisionBoard = select.value; await
saveState(); }) or install the listener once outside renderBoardSelect so the
handler for change is not reattached on each call; ensure the handler still
updates state.provisionBoard and awaits saveState().
In `@scripts/scenario/run_network_roundtrip.py`:
- Around line 116-128: Read and store the current Grid width/height via
_grid_value(client, "Width", ...) and _grid_value(client, "Height", ...) before
any POST /api/modules call, and always attempt to restore those saved values in
a finally block (or unconditionally after the try/except) rather than gating
restore on the local added flag; change _grid_value so it does not silently
return a hardcoded 16 on any exception (instead return None or propagate the
error) so the caller can detect failed reads and avoid restoring an incorrect
default, and make the restore path check for None and log/report failures from
POST /api/state or RESTores while still attempting to restore even if module add
failed.
In `@src/light/drivers/LcdLedDriver.h`:
- Around line 350-357: Populate and propagate the first corrupted light index:
in platform_esp32_lcd.cpp’s lcdWs2812Loopback() compute the first mismatch (you
already compute mismatch) and assign it into RmtLoopbackResult::firstBadBit
before returning; then update src/light/drivers/LcdLedDriver.h’s fail formatting
to include that index (use r.firstBadBit or convert bits->light index the same
way as RmtLedDriver) when composing failBuf_ passed to setStatus so the status
reads the required “first corrupted light” value per the docs.
In `@src/platform/esp32/platform_config.h`:
- Around line 42-58: Add a decision entry in docs/history/decisions.md
documenting that the esp_lcd i80 driver must gate on
CONFIG_SOC_LCDCAM_I80_LCD_SUPPORTED (not CONFIG_SOC_LCD_I80_SUPPORTED), explain
that CONFIG_SOC_LCD_I80_SUPPORTED refers to the unrelated classic I2S-LCD on
older ESP32 chips which caused boot hangs when used, and note that
CONFIG_SOC_LCDCAM_I80_LCD_SUPPORTED is defined only for LCD_CAM-capable chips
(S3/P4); reference the lcdLanes gating in src/platform/esp32/platform_config.h
and the esp_lcd i80 initialization behavior so future maintainers understand why
the specific SOC macro is required.
In `@src/platform/esp32/platform_esp32_rmt.cpp`:
- Around line 314-317: The short loopback currently sets r.firstBadBit = 0 on
failure which is inconsistent with the frame loopback that sets the actual
mismatch index; update the short loopback logic (the block that assigns
r.bitsChecked, r.firstBadBit, and r.pass in the short loopback routine) to
compute and store the actual first mismatched bit index (like the frame loopback
does — use the same mismatch detection approach/name) and set r.firstBadBit to
that index when r.pass is false so RmtLedDriver's reporting (which reads
r.firstBadBit) shows the real bad-bit position rather than 0.
---
Outside diff comments:
In `@scripts/MoonDeck.md`:
- Line 349: Update the documentation example that runs the host-WiFi reader to
use the project's wrapper command instead of calling Python directly: replace
the example invocation "python3 scripts/build/host_wifi.py" with "uv run
scripts/build/host_wifi.py" (or the project's canonical "uv run" form) in the
MoonDeck.md section describing scripts/build/host_wifi.py so all Python
invocations follow the coding guideline.
In `@src/light/drivers/RmtLedDriver.h`:
- Around line 355-403: The PASS branch for loopbackFrame currently allocates
failBuf_ just to format a "loopback PASS (%u bits)" message, violating the
invariant that failBuf_ is only created on FAIL; change the PASS handling in the
r.pass && loopbackFrame branch to avoid assigning to failBuf_ (remove the
platform::alloc call and failBuf_ assignment) and instead format the message
into a temporary local buffer or use a literal status message before calling
setStatus; keep the existing clearFailBuf() call and only allocate failBuf_ in
the failure branches (or alternatively update the comment that documents
failBuf_ usage if you deliberately want to allow PASS allocations).
🪄 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: 2cda25ee-7274-445d-bde5-44b478e0c5c0
⛔ Files ignored due to path filters (5)
docs/assets/boards/esp32-d0-16mb.jpgis excluded by!**/*.jpgdocs/assets/boards/esp32-p4-nano.jpgis excluded by!**/*.jpgdocs/assets/boards/esp32-s3-n16r8v.jpgis excluded by!**/*.jpgscripts/build/build_esp32.pyis excluded by!**/build/**scripts/build/improv_provision.pyis excluded by!**/build/**
📒 Files selected for processing (46)
docs/backlog/backlog.mddocs/backlog/leddriver-increment-2-plan.mddocs/history/decisions.mddocs/install/index.htmldocs/install/install-orchestrator.jsdocs/moonmodules/core/ImprovProvisioningModule.mddocs/moonmodules/light/Drivers.mddocs/moonmodules/light/drivers/LcdLedDriver.mddocs/moonmodules/light/drivers/RmtLedDriver.mddocs/tests/scenario-tests.mddocs/tests/unit-tests.mdesp32/main/CMakeLists.txtscripts/MoonDeck.mdscripts/moondeck.pyscripts/moondeck_config.jsonscripts/moondeck_ui/app.jsscripts/moondeck_ui/index.htmlscripts/scenario/_net_probe.pyscripts/scenario/_preview_ws.pyscripts/scenario/run_network_live.pyscripts/scenario/run_network_roundtrip.pysrc/core/ImprovProvisioningModule.hsrc/core/NetworkModule.hsrc/light/drivers/Drivers.hsrc/light/drivers/LcdLedDriver.hsrc/light/drivers/LcdSlots.hsrc/light/drivers/PinList.hsrc/light/drivers/RmtLedDriver.hsrc/main.cppsrc/platform/desktop/platform_config.hsrc/platform/desktop/platform_desktop.cppsrc/platform/esp32/platform_config.hsrc/platform/esp32/platform_esp32_improv.cppsrc/platform/esp32/platform_esp32_lcd.cppsrc/platform/esp32/platform_esp32_rmt.cppsrc/platform/platform.hsrc/ui/install-picker.jstest/CMakeLists.txttest/scenarios/light/scenario_AllEffects_grid_sizes.jsontest/scenarios/light/scenario_Layouts_mutation.jsontest/scenarios/light/scenario_modifier_swap.jsontest/unit/light/unit_CheckerboardEffect.cpptest/unit/light/unit_LcdLedDriver.cpptest/unit/light/unit_LcdLedEncoder.cpptest/unit/light/unit_RmtLedDriver_lifecycle.cpptest/unit/light/unit_RmtLedDriver_pins.cpp
👮 Files not reviewed due to content moderation or server errors (7)
- docs/moonmodules/light/drivers/LcdLedDriver.md
- docs/moonmodules/light/drivers/RmtLedDriver.md
- docs/tests/scenario-tests.md
- docs/tests/unit-tests.md
- src/core/ImprovProvisioningModule.h
- src/core/NetworkModule.h
- src/platform/esp32/platform_esp32_improv.cpp
Adds the Waveshare ESP32-P4-NANO as a build target — boots projectMM, comes up on Ethernet (IP101 PHY), serves the web UI/API. WiFi is compiled out (the P4 has no native radio; it arrives via the on-board ESP32-C6 in a later round). First of four P4 rounds (board+Ethernet → Parlio driver → C6 WiFi → Parlio loopback). Also folds in a CodeRabbit review pass on the network/LED tooling, and pins the ESP-IDF commit the builds were validated against.
KPI: 16384lights | PC:322KB | tick:117/87/114/115/55/1/36/19/112/326us(FPS:8547/11494/8771/8695/18181/1000000/27777/52631/8928/3067) | ESP32-S3:tick:3520us(FPS:284) | ESP32-P4:tick:10253us(FPS:97,over-Ethernet) | heap:8397KB | src:78(14886) | test:50(7975) | lizard:61w
Core
- platform_config.h: isEsp32P4 chip-id branch; EthPinConfig struct + per-target `ethPins` constant — the Olimex RMII/PHY pins are now a named config, the P4-NANO gets its own (IP101 addr 1, MDC 31, MDIO 52, reset 51, external RMII clock IN on GPIO 50) selected by `if constexpr (isEsp32P4)`. Keeps the platform boundary clean (no scattered #ifdefs in ethInit).
- platform_esp32.cpp: ethInit reads ethPins instead of literals (clock mode/gpio, smi mdc/mdio, phy addr/reset, IP101-vs-generic ctor); the IP101 ctor + header are #ifdef-guarded to esp32p4 (the symbol only exists on that build — if constexpr discards a dead branch but still requires it to compile). chipModel() gains CHIP_ESP32P4 -> "ESP32-P4".
- platform_esp32_lcd.cpp: LCD loopback now populates RmtLoopbackResult::firstBadBit/bitsChecked so the driver can name the first corrupted light.
Light domain
- LcdLedDriver: loopback FAIL status now reports `bad bit N/M (light K)` from firstBadBit, matching the spec's "names the first corrupted light".
- RmtLedDriver: loopbackFrame PASS uses a static literal, not a failBuf_ alloc — restores the invariant that failBuf_ is FAIL-only (setStatus holds the pointer, so a temporary would dangle).
Scripts / Build
- build_esp32.py: esp32p4-eth firmware variant (chip esp32p4, eth-only). Fixed the eth-fragment detector to match `-eth` as well as `.eth` — without it the P4 fragment would silently set MM_NO_ETH and stub Ethernet out.
- idf_component.yml: espressif/ip101 managed PHY component (IDF v6 moved per-PHY drivers out of esp_eth core; pulled only because the P4 references it).
- setup_esp_idf.py: PINNED_IDF_COMMIT + drift warning — the build IDF is a dev snapshot, and a silent `git pull` / fresh clone landing elsewhere is now visible.
- run_network_roundtrip.py: grid restore hardened — _grid_value returns None on a failed read (don't restore a guessed value), and the finally block restores the grid unconditionally (the grid POSTs precede the module-add, so it can be left changed even if the add fails).
- moondeck_ui/app.js: renderBoardSelect uses `onchange =` not addEventListener, so a re-render replaces the handler instead of stacking duplicate saveState() calls.
ESP32 config
- sdkconfig.defaults.esp32p4-eth + partitions/esp32p4_16mb.csv: 16 MB flash / 32 MB PSRAM / EMAC enable; RMII pins live in C (ethPins), not sdkconfig.
Docs
- boards.json: Waveshare ESP32-P4-NANO entry. building.md: esp32p4-eth variant row. MoonDeck.md: host_wifi example uses `uv run` not python3. decisions.md: P4 round-1 lessons (per-board EthPinConfig, P4-no-native-WiFi, IP101 managed component) + the SOC_LCDCAM_I80_LCD_SUPPORTED gate rationale (the near-identical macro that boot-looped the classic ESP32). backlog.md: rounds 2-4 status + ESP-IDF pinning/version-schedule notes.
Reviews
- CodeRabbit: app.js duplicate change-listener -> onchange assignment.
- CodeRabbit: roundtrip grid-restore robustness (None on read fail, unconditional restore).
- CodeRabbit: LcdLed loopback names the first corrupted light (firstBadBit propagated + formatted).
- CodeRabbit: MoonDeck.md python3 -> uv run.
- CodeRabbit: RmtLed PASS branch no longer allocs failBuf_ (invariant restored; CodeRabbit's "temporary buffer" fix rejected — would dangle since setStatus holds the pointer).
- CodeRabbit: decisions.md SOC-macro gate entry added.
- CodeRabbit: RMT short-loopback firstBadBit=0 — skipped: the short test displays sent/got hex, never firstBadBit, so the precision would be unused dead code.
KPI Details:
Desktop:
Lights: 16,384
Binary: 322 KB
[doctest] test cases: 317 | 317 passed | 0 failed | 0 skipped
tick: 117us, 87us, 114us, 115us, 55us, 1us, 36us, 19us, 112us, 326us (FPS: 8547, 11494, 8771, 8695, 18181, 1000000, 27777, 52631, 8928, 3067) (per scenario)
=== 12 scenario(s), 12 passed, 0 failed ===
Platform boundary: PASS
Specs: Spec check: 33 modules, 33 ok, 0 missing, 0 outdated, 0 source-link issues
ESP32-S3 (esp32s3-n16r8):
tick: 3520us (FPS: 284) heap free: 8598607
ESP32-P4 (esp32p4-eth, over Ethernet):
Image: 1,050,386 bytes (75% partition free)
tick: 10253us (FPS: 97) Ethernet IP 192.168.1.132, chip ESP32-P4, flash 16MB, 32MB PSRAM
Code:
78 source files (14886 lines)
50 test files (7975 lines)
44 specs, 12 scenarios
Lizard: 61 warnings
Co-Authored-By: Claude Fable 5 <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 (5)
docs/building.md (1)
137-137:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winUpdate stale variant count wording.
Line 137 still says “all four variants can coexist on disk,” but this section now includes additional variants (including
esp32p4-eth). Please make this count-neutral (e.g., “all listed variants”) to avoid confusion.🤖 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/building.md` at line 137, Update the sentence that currently reads “all four variants can coexist on disk” to a count-neutral phrasing such as “all listed variants can coexist on disk” in the documentation text referencing build/esp32-<firmware>/; ensure the surrounding lines that mention build_esp32.py pointing idf.py -B to the per-firmware dir and the clean command scripts/build/clean_esp32.py --firmware (or --all) remain unchanged.src/light/drivers/RmtLedDriver.h (2)
383-388:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winCompute the bad-light index from the active channel count.
The FAIL message hardcodes
24bits per light, butloopbackFramepassescorrection_->outChannelsinto the platform loopback. RGBW frames are 32 bits per light, so this reports the wrong light number when 4-channel output is active.Suggested fix
if (failBuf_ && loopbackFrame) { + const unsigned bitsPerLight = + static_cast<unsigned>(correction_ ? correction_->outChannels : 3) * 8u; std::snprintf(failBuf_, kFailBufLen, "loopback FAIL: bad bit %u/%u (light %u)", static_cast<unsigned>(r.firstBadBit), static_cast<unsigned>(r.bitsChecked), - static_cast<unsigned>(r.firstBadBit / 24)); + bitsPerLight ? static_cast<unsigned>(r.firstBadBit / bitsPerLight) : 0u); setStatus(failBuf_, Severity::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/light/drivers/RmtLedDriver.h` around lines 383 - 388, The FAIL log computes the light index using a hardcoded 24 bits per light; change it to use the active channel count from correction_->outChannels used when building loopbackFrame. Update the snprintf call that writes to failBuf_ (using kFailBufLen) so the last formatted value is computed as static_cast<unsigned>(r.firstBadBit / correction_->outChannels) (or store correction_->outChannels into a local unsigned variable first), leaving r.firstBadBit and r.bitsChecked unchanged so the message reports the correct light index for RGBW (32-bit) and other channel counts.
200-208:⚠️ Potential issue | 🟠 Major | ⚡ Quick winSkip
rmtWs2812Wait()for channels whose transmit never started.If
platform::rmtWs2812Transmit()fails on any pin, this code still waits that channel for 1000 ms. One bad pin can therefore stall the render tick for up topinCount_seconds while no frame is actually in flight.Suggested fix
+ bool started[kMaxPins] = {}; for (uint8_t i = 0; i < pinCount_; i++) { if (pinCounts_[i] == 0) continue; - platform::rmtWs2812Transmit(rmt_[i], symbols_ + pinOffsets_[i], - static_cast<size_t>(pinCounts_[i]) * outCh * 8); + started[i] = platform::rmtWs2812Transmit( + rmt_[i], symbols_ + pinOffsets_[i], + static_cast<size_t>(pinCounts_[i]) * outCh * 8); } for (uint8_t i = 0; i < pinCount_; i++) { - if (pinCounts_[i] == 0) continue; + if (!started[i]) continue; platform::rmtWs2812Wait(rmt_[i], 1000 /* ms */); }As per coding guidelines, "
<rmtWs2812Transmit()> starts transmission and returns immediately" and "<rmtWs2812Wait()> must be bounded by timeoutMs; timeouts drop the frame so the caller can re-encode next tick."🤖 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/RmtLedDriver.h` around lines 200 - 208, The loop calls platform::rmtWs2812Wait() for every channel even when platform::rmtWs2812Transmit() failed, allowing a bad pin to stall the tick; modify the transmit loop (using rmt_, pinCounts_, pinOffsets_, symbols_, outCh) to capture/record which transmit calls actually started (e.g., check and store the return status from rmtWs2812Transmit for each index) and then only call platform::rmtWs2812Wait(rmt_[i], 1000) for those indices where transmit succeeded; ensure the transmit return is checked and any failed transmits are skipped so waits are bounded by the existing timeout.Source: Coding guidelines
scripts/scenario/run_network_roundtrip.py (1)
166-169:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winUse the real median for even repeat counts.
latencies[len(latencies) // 2]picks the upper middle sample. With the default--repeats 10, the printed “median” is biased high.Suggested fix
+import statistics @@ - median = latencies[len(latencies) // 2] + median = statistics.median(latencies)🤖 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 `@scripts/scenario/run_network_roundtrip.py` around lines 166 - 169, The printed median is using the upper-middle element (latencies[len(latencies) // 2]) which biases results for even repeat counts; change the median calculation to compute the true median: let n = len(latencies), if n is odd set median = latencies[n // 2], else set median = (latencies[n // 2 - 1] + latencies[n // 2]) / 2.0 (ensure float division) before the print; reference the existing variables latencies and repeats in run_network_roundtrip.py.src/platform/esp32/platform_esp32.cpp (1)
269-321:⚠️ Potential issue | 🟠 MajorFix
ethInit()failure cleanup to avoid leaking/poisoning Ethernet state
- On
esp_eth_driver_install()failure,ethNetif_(fromesp_netif_new) and themac/phyinstances are never torn down, and the factory calls aren’t checked fornullptr.- On
esp_eth_start()failure (after event handlers are registered), the code returnsfalsewithout stopping/uninstalling the driver (esp_eth_driver_uninstall(eth_handle)), without unregisteringethEventHandlerfromETH_EVENT/IP_EVENT_ETH_GOT_IP, and without deletingmac/phyand destroyingethNetif_(esp_netif_destroyforesp_netif_new).🤖 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 269 - 321, The ethInit() path leaks resources and doesn't check factory nulls: ensure esp_eth_mac_new_esp32 and the phy creators (esp_eth_phy_new_ip101 / esp_eth_phy_new_generic) are checked for nullptr and on any failure clean up ethNetif_ via esp_netif_destroy and free any created mac/phy; if esp_eth_driver_install fails, uninstall/cleanup mac, phy and destroy ethNetif_; if esp_eth_start fails (after registering handlers), call esp_event_handler_unregister for both ETH_EVENT and IP_EVENT_ETH_GOT_IP, call esp_eth_driver_uninstall(eth_handle) and esp_eth_stop/esp_eth_driver_uninstall as appropriate, delete mac/phy and destroy ethNetif_; reference these symbols when adding checks and teardown: ethInit, esp_netif_new/esp_netif_destroy, ethNetif_, esp_eth_mac_new_esp32, esp_eth_phy_new_ip101, esp_eth_phy_new_generic, esp_eth_driver_install, esp_eth_driver_uninstall, esp_eth_start, esp_eth_stop, esp_event_handler_register, esp_event_handler_unregister.
🤖 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/building.md`:
- Line 137: Update the sentence that currently reads “all four variants can
coexist on disk” to a count-neutral phrasing such as “all listed variants can
coexist on disk” in the documentation text referencing build/esp32-<firmware>/;
ensure the surrounding lines that mention build_esp32.py pointing idf.py -B to
the per-firmware dir and the clean command scripts/build/clean_esp32.py
--firmware (or --all) remain unchanged.
In `@scripts/scenario/run_network_roundtrip.py`:
- Around line 166-169: The printed median is using the upper-middle element
(latencies[len(latencies) // 2]) which biases results for even repeat counts;
change the median calculation to compute the true median: let n =
len(latencies), if n is odd set median = latencies[n // 2], else set median =
(latencies[n // 2 - 1] + latencies[n // 2]) / 2.0 (ensure float division) before
the print; reference the existing variables latencies and repeats in
run_network_roundtrip.py.
In `@src/light/drivers/RmtLedDriver.h`:
- Around line 383-388: The FAIL log computes the light index using a hardcoded
24 bits per light; change it to use the active channel count from
correction_->outChannels used when building loopbackFrame. Update the snprintf
call that writes to failBuf_ (using kFailBufLen) so the last formatted value is
computed as static_cast<unsigned>(r.firstBadBit / correction_->outChannels) (or
store correction_->outChannels into a local unsigned variable first), leaving
r.firstBadBit and r.bitsChecked unchanged so the message reports the correct
light index for RGBW (32-bit) and other channel counts.
- Around line 200-208: The loop calls platform::rmtWs2812Wait() for every
channel even when platform::rmtWs2812Transmit() failed, allowing a bad pin to
stall the tick; modify the transmit loop (using rmt_, pinCounts_, pinOffsets_,
symbols_, outCh) to capture/record which transmit calls actually started (e.g.,
check and store the return status from rmtWs2812Transmit for each index) and
then only call platform::rmtWs2812Wait(rmt_[i], 1000) for those indices where
transmit succeeded; ensure the transmit return is checked and any failed
transmits are skipped so waits are bounded by the existing timeout.
In `@src/platform/esp32/platform_esp32.cpp`:
- Around line 269-321: The ethInit() path leaks resources and doesn't check
factory nulls: ensure esp_eth_mac_new_esp32 and the phy creators
(esp_eth_phy_new_ip101 / esp_eth_phy_new_generic) are checked for nullptr and on
any failure clean up ethNetif_ via esp_netif_destroy and free any created
mac/phy; if esp_eth_driver_install fails, uninstall/cleanup mac, phy and destroy
ethNetif_; if esp_eth_start fails (after registering handlers), call
esp_event_handler_unregister for both ETH_EVENT and IP_EVENT_ETH_GOT_IP, call
esp_eth_driver_uninstall(eth_handle) and esp_eth_stop/esp_eth_driver_uninstall
as appropriate, delete mac/phy and destroy ethNetif_; reference these symbols
when adding checks and teardown: ethInit, esp_netif_new/esp_netif_destroy,
ethNetif_, esp_eth_mac_new_esp32, esp_eth_phy_new_ip101,
esp_eth_phy_new_generic, esp_eth_driver_install, esp_eth_driver_uninstall,
esp_eth_start, esp_eth_stop, esp_event_handler_register,
esp_event_handler_unregister.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 525e038f-f26f-449b-8991-2bbf6d4e73f8
⛔ Files ignored due to path filters (3)
esp32/partitions/esp32p4_16mb.csvis excluded by!**/*.csvscripts/build/build_esp32.pyis excluded by!**/build/**scripts/build/setup_esp_idf.pyis excluded by!**/build/**
📒 Files selected for processing (15)
docs/backlog/backlog.mddocs/building.mddocs/history/decisions.mddocs/install/boards.jsonesp32/main/idf_component.ymlesp32/sdkconfig.defaults.esp32p4-ethscripts/MoonDeck.mdscripts/moondeck_ui/app.jsscripts/scenario/run_network_roundtrip.pysrc/light/drivers/LcdLedDriver.hsrc/light/drivers/RmtLedDriver.hsrc/platform/esp32/platform_config.hsrc/platform/esp32/platform_esp32.cppsrc/platform/esp32/platform_esp32_lcd.cpptest/scenarios/light/scenario_AllEffects_grid_sizes.json
Adds the Parlio LED driver — the ESP32-P4's parallel WS2812 path, a sibling of the LCD_CAM driver. Up to 8 strands clock out from one autonomous DMA transfer. Proven on the P4 by init + tick-scaling + a robustness sweep (no strip wired yet; the loopback self-test and a real panel are round 4). Second of four P4 rounds. Also folds in a CodeRabbit review pass on the RMT/Ethernet/roundtrip code, and documents that the P4 carries all three LED peripherals (RMT + LCD_CAM + Parlio).
KPI: 16384lights | PC:340KB | tick:119/89/116/117/56/2/36/19/114/329us(FPS:8403/11235/8620/8547/17857/500000/27777/52631/8771/3039) | ESP32-S3:tick:3485us(FPS:286) | ESP32-P4:tick:~11900us(over-Ethernet,3-drivers-wired) | heap:8397KB | src:80(15507) | test:51(8190) | lizard:64w
Core
- platform_esp32_parlio.cpp (new): Parlio TX unit + DMA frame buffer, single-shot transmit/wait, done-callback semaphore. Always an 8-wide bus (Parlio's data_width must be a power of two; matches the encoder's 8-bit bus byte), unused lanes' GPIOs set to -1. Loopback is a round-4 stub. Inert #else stubs off Parlio chips.
- platform_config.h: parlioLanes SOC-gated flag (8 on Parlio chips, 0 elsewhere); desktop config gets parlioLanes=0.
- platform.h / platform_desktop.cpp: parlioWs2812* API + desktop stubs.
- platform_esp32.cpp (CodeRabbit): ethInit now null-checks the MAC/PHY factories and cleans up (del PHY/MAC, uninstall driver, unregister handlers, destroy netif) on every failure path — a broken PHY/cable degrades instead of leaking.
Light domain
- ParlioLedDriver.h (new): mirrors LcdLedDriver but simpler — no clockPin/dcPin (Parlio self-clocks), no exactly-8-pins rule (runs on 1-8 lanes). Reuses the LcdSlots.h encoder as-is (a Parlio bus byte == an i80 bus byte). Default pins are 8 strapping-SAFE GPIOs (20-27) with ledsPerPin="64" putting all 64 on lane 0 (a serpentine 8x8 panel); the other lanes idle LOW at zero cost (parallel transfer time is set by the longest lane). Pin-change rebuild + fused lifecycle like the LCD driver.
- LcdSlots.h: encoder comment notes it serves both LCD_CAM and Parlio.
- RmtLedDriver.h (CodeRabbit): FAIL light index uses outChannels*8 (24 RGB / 32 RGBW) not a hardcoded /24; loop() now waits only on channels whose transmit started (a failed transmit gave no callback, so a bad pin stalled the tick for the full timeout).
UI / Scripts
- run_network_roundtrip.py (CodeRabbit): true median for even sample counts (average of the two middle elements), not the upper-middle element.
Tests
- unit_ParlioLedDriver.cpp (new): lane slicing, frame math, the 1-8-lanes-accepted rule (the Parlio-vs-i80 difference), over-8 rejection, bad-pin recovery, zero-grid + loop() crash-safety, teardown.
Docs
- ParlioLedDriver.md (new): wire contract (shared encoder), the no-WR/DC + any-lane-count simplifications, the P4-carries-all-three note + the 20-strand multi-driver pin budget (RMT 4 + LCD 8 + Parlio 8) with the DMA/tick caveat.
- RmtLedDriver.md (CodeRabbit + P4): P4 in the chip-support list (4 DMA-backed channels); the modern-RMT-driver (v2, IDF-5.x+; legacy removed in IDF v6) note; clarified the FastLED-ping-pong prior-art line so it doesn't collide with the IDF driver-version terminology.
- building.md (CodeRabbit): "all variants coexist on disk" (was "all four").
- decisions.md: the Parlio-is-simpler-than-i80 lesson (shared encoder, no sacrificial pins, power-of-two data_width) + the strapping-pin default footgun (34-38 are P4 boot pins — pull the strapping list from the datasheet, "it booted" doesn't prove a strapping pin is safe to drive).
- backlog.md: round 2 done, rounds 3-4 remain. New P4-NANO detail image under docs/assets/boards/. Test inventories regenerated.
Reviews
- CodeRabbit: ethInit resource leak / missing null-checks — fixed (cleanup lambda, del/uninstall on every failure path; builds clean on both Olimex-generic and IP101 Ethernet paths)
- CodeRabbit: RMT FAIL light index hardcoded /24 — fixed (use outChannels*8 for RGBW correctness)
- CodeRabbit: RMT waits on channels whose transmit failed — fixed (record started[], wait only those)
- CodeRabbit: roundtrip median biased for even N — fixed (true median)
- CodeRabbit: building.md "all four variants" — fixed (count-neutral)
KPI Details:
Desktop:
Lights: 16,384
Binary: 340 KB
[doctest] test cases: 327 | 327 passed | 0 failed | 0 skipped
tick: 119us, 89us, 116us, 117us, 56us, 2us, 36us, 19us, 114us, 329us (FPS: 8403, 11235, 8620, 8547, 17857, 500000, 27777, 52631, 8771, 3039) (per scenario)
=== 12 scenario(s), 12 passed, 0 failed ===
Platform boundary: PASS
Specs: Spec check: 34 modules, 34 ok, 0 missing, 0 outdated, 0 source-link issues
ESP32-S3 (esp32s3-n16r8):
tick: 3485us (FPS: 286) heap free: 8598611
ESP32-P4 (esp32p4-eth, over Ethernet, all 3 LED drivers wired):
Image: 1,066,256 bytes (75% partition free)
tick: ~11900us (FPS: ~83) chip ESP32-P4, Parlio inits clean on strapping-safe pins 20-27
Code:
80 source files (15507 lines)
51 test files (8190 lines)
45 specs, 12 scenarios
Lizard: 64 warnings
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The ESP32-P4 Parlio LED driver now verifies its own WS2812 output on real silicon: tick loopbackTest, it transmits a real frame out the data pin and bit-checks the capture on the RX pin. Closes the Parlio round-4 gap — the control was wired but stubbed; the body now exists and passes on hardware (jumper GPIO 32 → 33). KPI: 16384lights | PC:340KB | tick:115/88/114/114/55/1/36/18/112/328us(FPS:8695/11363/8771/8771/18181/1000000/27777/55555/8928/3048) | tick:12987us(FPS:77) | heap:33226KB | src:80(15704) | test:51(8190) Core - platform_esp32_parlio.cpp: implemented parlioWs2812Loopback — a private Parlio TX unit transmits the caller's real frame back to back like the render loop while rmtWs2812RxCapture (the shared transmitter-agnostic RMT-RX capture, P4 DMA backend) reads the whole frame off the jumpered RX pin and bit-verifies every WS2812 bit; shares detail::loopbackJumperOk (defined in the RMT file) for the continuity pre-check, no duplication. Mirrors lcdWs2812Loopback minus the i80 WR/DC pins. Added the kPclkHz slot-rate constant the private unit needs. Light domain - ParlioLedDriver: runLoopbackSelfTest builds the real frame (pattern on lane 0), deinits the live unit, calls the platform loopback, and reports loopback PASS / loopback FAIL: bad bit N/M (light K) / jumper not detected — the LcdLedDriver shape, minus the i80 full-bus workaround (Parlio runs on 1 lane). Docs / CI - ParlioLedDriver.md: rewrote the loopbackTest control and Tests sections present-tense (the round-4-stub language is gone); the self-test is now a whole-frame RMT-RX-captured bit-verify. - backlog.md + inmp441-mic-plan.md: saved the approved INMP441 mic plan (volume + FFT 16-band, the next increment) under the audio section. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The first audio-reactive capability: an INMP441-class I2S microphone drives a 16-band spectrum analyser and a VU meter on the LEDs. MicModule reads the mic, runs an FFT, and publishes an AudioFrame (overall level + 16 frequency bands + dominant peak) that AudioSpectrumEffect and AudioVolumeEffect consume. Tune it with two knobs — floor (noise floor) and gain (sensitivity); proven on the ESP32-S3. KPI: 16384lights | PC:340KB | tick:118/90/115/119/55/1/36/19/114/331us(FPS:8474/11111/8695/8403/18181/1000000/27777/52631/8771/3021) | tick:3928us(FPS:254) | heap:8356KB | src:87(16581) | test:53(8468) Core - MicModule: SystemModule Peripheral child — reads an I2S block (non-blocking, cross-tick accumulator so the render tick never stalls on the ~23ms-per-block fill), computes the RMS level, windows + FFTs into 16 log-spaced bands. Controls: wsPin/sdPin/sckPin, sampleRate (dropdown), floor, gain; read-only level + peakHz. Effects reach the live frame via the static MicModule::latestFrame() so a UI-added effect still finds the one mic, and add/remove in any order returns the live frame or a static silent one — never null. - platform: audioMic*/audioFft seam in platform.h + hasI2sMic SOC flag (both platform_config.h); platform_esp32_i2s.cpp is the only IDF-touching part (i2s_std read on the left slot + esp-dsp float dsps_fft2r_fc32); desktop stub uses a naive DFT so the whole window->FFT->bands path runs in CI. - Control: widened ControlDescriptor min/max from int16_t to int32_t so addUint16 can declare a bounded range; writeControlMetadata + the write-clamp emit/honour it for Uint8/Uint16/Int16 consistently. Light domain - AudioFrame: POD snapshot (level + peakHz + peakMag + bands[16]) — the whole producer/consumer contract; effects never touch I2S or the FFT. - AudioLevel.h / AudioBands.h: pure host-tested DSP — DC-strip + RMS level, Hann window, log-spaced 16-band mapping + peak pick, a shared magToByte log/dB scale. No platform header. - AudioSpectrumEffect: 16 bands across the grid X (scales to any width), bars bottom-up; on a grid >=3 tall the bottom row is a horizontal level/volume meter and the bars sit above it. colorMode = height-gradient or per-band hue. - AudioVolumeEffect: a whole-grid VU pulsing with level (green->red), brightness control. UI - app.js: a bounded uint16 control now renders as a slider (matching uint8/int16), unbounded stays a number input. Scripts / MoonDeck - build_esp32.py / idf_component.yml: add espressif/esp-dsp (the float FFT); esp32/main/CMakeLists.txt adds platform_esp32_i2s.cpp. Tests - unit_AudioLevel.cpp + unit_AudioBands.cpp: silence/DC->0, level tracks amplitude, floor gates, gain scales, a tone lands in the right band and peakHz tracks it end-to-end through the desktop DFT, degenerate input never crashes. Docs / CI - MicModule.md + AudioSpectrumEffect.md + AudioVolumeEffect.md specs; architecture.md Peripherals section (MicModule is the first concrete one); decisions.md (two-seams host-testable DSP, the latestFrame static-accessor variation, the boot-loop + blocking-read bugs found on hardware, ship-the-manual-core / design-fresh-from-datasheet). backlog: inmp441 plan promoted + removed; adaptive auto-conditioning noted as a future increment (a prototype was built and removed — tune it in a quiet room, not a noisy one). Reviews - 👾 Reviewer (Opus) over the audio diff: no architectural concerns; processed 2 must-fix + 2 should-fix, all stale-reference/cleanup (removed a duplicate decisions.md entry, a stale levelRaw comment, a comment fragment; added the missing <cstring> include to AudioSpectrumEffect to match every other effect). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 7
♻️ Duplicate comments (1)
docs/moonmodules/light/drivers/RmtLedDriver.md (1)
29-29:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winUpdate
loopbackTestbehavior description to match implementation.The documentation still describes
loopbackTestas "one-shot" with "Auto-resets after running," but the unresolved past review comment indicates the implementation is a persistent mode that reruns when relevant controls change and must be manually cleared.🤖 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/RmtLedDriver.md` at line 29, The docs description for loopbackTest is inaccurate: instead of a one-shot auto-resetting flag, update the RmtLedDriver.md text for `loopbackTest` to state that it is a persistent mode that remains set until explicitly cleared, that the loopback will re-run whenever relevant controls or inputs change, and that the test result is written to the module's status field; reference the `loopbackTest` control name and the module's status field in the description and remove wording about "one-shot" and "auto-resets after running."
🤖 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/effects/AudioVolumeEffect.md`:
- Line 13: The RGBW note in AudioVolumeEffect.md is incorrect: update the
sentence that says "On an RGBW grid the white channel is left off" to reflect
the Correction contract and coding guidelines — state that lightPreset controls
physical channel order and RGBW support, and that RGBW presets emit four
channels where the white channel is derived from RGB (min(R,G,B)) after
brightness scaling/correction rather than simply being left off; keep the rest
of the description about the green→red ramp and modifiers/layouts unchanged.
In `@scripts/scenario/run_network_roundtrip.py`:
- Around line 167-169: Replace the manual median calculation using index math
with statistics.median(latencies); update the code that assigns to the variable
median (referencing the local variable latencies) to call
statistics.median(latencies) instead, and ensure the statistics module is
imported at the top of the file (add "import statistics" if it’s not already
present).
In `@src/light/effects/AudioSpectrumEffect.h`:
- Around line 85-113: The gradient branch in AudioSpectrumEffect (colorMode ==
0) computes frac using (h > 1) ? (row * 255 / h) : mag, which makes 1D (h==1)
frames use mag but multi-row gradients mis-scale and the current 1D case still
ends up as bright green for mag==0; change the frac calculation so that when h
== 1 it is driven directly by mag, and when h > 1 compute the vertical gradient
using row * 255 / (h - 1) (use denominator h-1 to map bottom→0 and top→255).
Update the frac assignment in the colour-height branch (the code that assigns r
= frac; g = 255 - frac; b = 0) accordingly so silent 1D frames are dark and
multi-row gradients scale correctly.
In `@src/platform/esp32/platform_esp32_i2s.cpp`:
- Around line 73-81: The comment states the populated slot is the RIGHT slot but
the code sets slotCfg.slot_mask = I2S_STD_SLOT_LEFT; fix by changing the slot
mask to I2S_STD_SLOT_RIGHT (update the assignment on the slotCfg instance
created with I2S_STD_PHILIPS_SLOT_DEFAULT_CONFIG) so the populated INMP441 slot
is captured; keep or update the comment to note that wiring the mic L/R-to-GND
would require flipping back to I2S_STD_SLOT_LEFT.
In `@src/ui/app.js`:
- Around line 1012-1020: The number input handler can send NaN or out-of-range
values; change the debounce callback so it parses with an explicit radix
(parseInt(input.value, 10)), then normalize the result into the uint16 range: if
parseInt yields NaN, set to 0, and clamp negatives and values >65535 to 0..65535
before calling sendControl(moduleName, ctrl.name, ...); keep updating
dragTs[key] and still call debounceSend(key, 500, ...) but pass the sanitized
integer instead of raw parseInt(input.value).
In `@test/unit/light/unit_AudioBands.cpp`:
- Line 28: Replace the non-portable M_PI usage in the expression computing s
(const double s = amp24 * std::sin(2.0 * M_PI * cycles * ...)) with the C++20
constant std::numbers::pi_v<double>, and add `#include` <numbers> to the test
file(s); update the same change in test/unit/light/unit_AudioLevel.cpp as well
so both tests use std::numbers::pi_v<double> for portability on MSVC.
In `@test/unit/light/unit_AudioLevel.cpp`:
- Around line 29-31: The current assignment does a left shift on a signed value
(v[i] = static_cast<int32_t>(s) << 8), which is UB for negative samples; change
the code to perform the shift on an unsigned type (or on a wider signed type)
and then cast back. For example, take the int32_t sample =
static_cast<int32_t>(s) (or round/clamp as needed), then do uint32_t shifted =
static_cast<uint32_t>(sample) << 8 (or int64_t tmp =
static_cast<int64_t>(sample) << 8) and finally assign v[i] =
static_cast<int32_t>(shifted); update the code around v[i], s and the sample
conversion in unit_AudioLevel.cpp accordingly.
---
Duplicate comments:
In `@docs/moonmodules/light/drivers/RmtLedDriver.md`:
- Line 29: The docs description for loopbackTest is inaccurate: instead of a
one-shot auto-resetting flag, update the RmtLedDriver.md text for `loopbackTest`
to state that it is a persistent mode that remains set until explicitly cleared,
that the loopback will re-run whenever relevant controls or inputs change, and
that the test result is written to the module's status field; reference the
`loopbackTest` control name and the module's status field in the description and
remove wording about "one-shot" and "auto-resets after running."
🪄 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: 1569188e-1868-400c-975a-31b4e7c1fa11
⛔ Files ignored due to path filters (1)
docs/assets/boards/ESP32-P4-NANO-details-inter.jpgis excluded by!**/*.jpg
📒 Files selected for processing (41)
docs/architecture.mddocs/backlog/backlog.mddocs/building.mddocs/history/decisions.mddocs/moonmodules/core/MicModule.mddocs/moonmodules/light/drivers/ParlioLedDriver.mddocs/moonmodules/light/drivers/RmtLedDriver.mddocs/moonmodules/light/effects/AudioSpectrumEffect.mddocs/moonmodules/light/effects/AudioVolumeEffect.mddocs/tests/scenario-tests.mddocs/tests/unit-tests.mdesp32/main/CMakeLists.txtesp32/main/idf_component.ymlscripts/scenario/run_network_roundtrip.pysrc/core/Control.cppsrc/core/Control.hsrc/core/MicModule.hsrc/light/AudioBands.hsrc/light/AudioFrame.hsrc/light/AudioLevel.hsrc/light/drivers/LcdSlots.hsrc/light/drivers/ParlioLedDriver.hsrc/light/drivers/RmtLedDriver.hsrc/light/effects/AudioSpectrumEffect.hsrc/light/effects/AudioVolumeEffect.hsrc/main.cppsrc/platform/desktop/platform_config.hsrc/platform/desktop/platform_desktop.cppsrc/platform/esp32/platform_config.hsrc/platform/esp32/platform_esp32.cppsrc/platform/esp32/platform_esp32_i2s.cppsrc/platform/esp32/platform_esp32_parlio.cppsrc/platform/platform.hsrc/ui/app.jstest/CMakeLists.txttest/scenarios/light/scenario_AllEffects_grid_sizes.jsontest/scenarios/light/scenario_GridLayout_grid_sizes.jsontest/scenarios/light/scenario_modifier_swap.jsontest/unit/light/unit_AudioBands.cpptest/unit/light/unit_AudioLevel.cpptest/unit/light/unit_ParlioLedDriver.cpp
Disables WiFi modem power-save (a partial fix for the intermittent HTTP/WiFi stalls seen on the S3 — the radio's DTIM sleep is the documented prime suspect), and processes a batch of CodeRabbit findings on the audio feature: portability and an undefined-behaviour fix in the tests, a UI input-sanitisation guard, an effect-writes-RGB-only cleanup, and three stale-comment/doc corrections. KPI: 16384lights | PC:340KB | tick:118/89/118/116/56/1/38/20/118/337us(FPS:8474/11235/8474/8620/17857/1000000/26315/50000/8474/2967) | tick:4241us(FPS:235) | heap:8355KB | src:87(16597) | test:53(8475) Core - platform_esp32.cpp: call esp_wifi_set_ps(WIFI_PS_NONE) after esp_wifi_start() — IDF defaults to WIFI_PS_MIN_MODEM, whose DTIM sleep causes intermittent multi-hundred-ms TCP/HTTP stalls (UDP keeps flowing); a wall-powered LED controller has no battery to save. Non-fatal if it fails. Reduces but does not fully eliminate the wedge — a second cause remains (backlog). Light domain - AudioVolumeEffect: write logical RGB only — drop the dead `cpl>=4` white-channel write; channel order and RGBW white (W=min(R,G,B)) are the driver's Correction, like every other effect. - AudioSpectrumEffect: gradient runs over h-1 rows so the top row reaches full red; on a 1D strip the single row scales colour by magnitude so a silent frame is dark instead of bright green. Core (platform) - platform_esp32_i2s.cpp: the slot-mask comment said RIGHT while the code reads LEFT (the bench mic's L/R-to-GND wiring) — corrected the comment to match the working code, noting which way to flip for the opposite wiring. UI - app.js: the unbounded-uint16 number input now parses with an explicit radix and clamps NaN/out-of-range to 0..65535, so junk never reaches the device. Scripts / MoonDeck - run_network_roundtrip.py: use statistics.median() instead of the hand-rolled index math (equivalent, clearer). Tests - unit_AudioLevel.cpp / unit_AudioBands.cpp: use std::numbers::pi_v<double> (M_PI is non-portable on MSVC) and shift via int64_t before casting back (left-shifting a negative int32 is UB). Docs / CI - RmtLedDriver.md + RmtLedDriver.h: loopbackTest is a persistent on/off mode (re-runs on relevant changes, verdict in the status field), not a one-shot auto-resetting flag — corrected the doc and the stale .h comment. Reviews - 🐇 AudioVolumeEffect.md RGBW "white left off" — fixed: it's Correction's W=min(R,G,B), and removed the matching dead effect code. - 🐇 AudioSpectrum gradient mis-scale + 1D bright-at-silence — fixed (h-1 denominator; 1D scales by magnitude). - 🐇 i2s slot comment RIGHT vs code LEFT — fixed the comment (kept the working LEFT slot; flipping the code would break the bench mic). - 🐇 app.js number input NaN/out-of-range — fixed with radix + clamp. - 🐇 M_PI non-portable + signed <<8 UB in the audio tests — fixed (std::numbers::pi_v, int64_t shift). - 🐇 run_network_roundtrip median — switched to statistics.median(). - 🐇 RmtLedDriver.md loopbackTest "one-shot/auto-resets" — fixed: it's a persistent mode (also corrected the stale .h comment). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Lands the audio DC-blocker high-pass, a new "Industry standards, our own code" CLAUDE.md principle plus a no-em-dash writing rule, a consolidated ESP-IDF v6.x version/adoption doc, and the scaffold for ESP32-P4 WiFi via the on-board ESP32-C6. The P4 WiFi path boots and proves out the co-processor link, but WiFi STA is blocked on incompatible C6 slave firmware (a hardware-provisioning step, tracked in the backlog), so it is committed as an honest, isolated increment that does not affect other targets. KPI: 16384lights | PC:340KB | tick:121/93/122/127/57/2/38/20/125/339us(FPS:8264/10752/8196/7874/17543/500000/26315/50000/8000/2949) | tick:21551us(FPS:46) | heap:33066KB | src:87(16737) | test:53(8518) | lizard:68w Core: - platform.h / platform_esp32 / platform_desktop: added coprocessorWifi() seam reporting the WiFi co-processor's firmware version (esp_hosted_get_coprocessor_fwversion on P4, "" on native-radio targets). Compiled out on non-co-processor builds via the new platform::hasWifiCoprocessor flag. - platform_esp32.cpp: P4 WiFi runs on the C6 via esp_wifi_remote (API-compatible, so the existing WiFi seam is unchanged); removed a redundant+harmful esp_hosted bring-up prelude — esp_hosted self-inits at boot via a constructor, and the explicit connect_to_slave was a transport reconfigure that reset the slave and broke the live SDIO link. - platform_esp32_rmt.cpp: documented why rmtWs2812Wait deliberately does NOT cancel a timed-out transfer — rmt_disable on an active transfer triggers an interrupt-WDT panic on classic ESP32 (esp-idf#17692); the dropped frame self-heals instead. (🐇 CodeRabbit PR#17 finding: addressed as no-change-with-rationale.) - platform_esp32_improv.cpp: flagged the cold-WiFi Improv scan ordering on the P4 remote path (bench-verify item). - platform_config.h (esp32+desktop): hasWifiCoprocessor = isEsp32P4 && hasWiFi. - SystemModule: wifiCoproc read-only control, compile-gated on hasWifiCoprocessor. Light domain: - AudioLevel.h: DcBlocker one-pole/one-zero IIR high-pass (~40 Hz, R=0.99), continuous across blocks, removing MEMS DC bias + sub-bass rumble before analysis. - MicModule.h: wired the DC-blocker into loop()/reinit(); switched the level/peakHz read-outs from addText+setReadOnly to addReadOnly (display-only type, matching SystemModule). Scripts / MoonDeck: - build_esp32.py: added the esp32p4-eth-wifi firmware variant (C6 remote WiFi). KNOWN ISSUE documented: the C6 slave-target Kconfig auto-default fires only on set-target, so the wrapper's plain `build` reconfigure drops it back to ESP32-H2 — this variant currently needs a manual rm -rf + set-target + build sequence. Tests: - unit_AudioLevel.cpp: three DcBlocker tests (constant DC filtered out, AC tone passes, reset is state-clearing + null-safe). Docs / CI: - CLAUDE.md: new "Industry standards, our own code" principle (study-don't-copy + spec-and-test method, consolidated from the Documentation section); rephrased for energy; em-dash-free. - coding-standards.md: no-em-dash-in-prose rule. - building.md: consolidated "ESP-IDF version" section (v6.0 vs v6.1-dev, 30-month support, how to check), the v6.0-floor rule with an explicit-exception clause (P4 esp_wifi_remote is the accepted exception), and a v6.x-adoption table (EIM, network_provisioning, PSA, CMake v2, MCP) with phased how/when. - MicModule.md: documented the full signal path incl. the DC-blocker; added a Prior art credit to Frank (softhack007) and a forward-looking adaptive-noise-gate analysis (his concept, our judgement: good idea, industry-standard, tight on the <30ms budget, decompose into steps — per-band floor first); made this the first fully em-dash-free spec. - SystemModule.md: wifiCoproc control. - backlog.md: GyroDriver→core move (reverse-engineered), P4 round-3 status (C6 link proven, STA blocked on slave firmware, build-reproducibility note), adaptive-noise-gate build item, ESP-IDF pinning trim. - decisions.md: P4 remote-WiFi seam (API-compat → no abstraction needed), the v6.0-floor-exception decision, the RMT-no-cancel lesson; audio DSP-choice rationale moved to MicModule.md. - README.md, architecture.md: em-dash sweep (no content change). P4 WiFi status: the C6 SDIO link comes up cleanly at boot (no NVS/assert/hang), but the wifiCoproc readout shows "not detected" — the NANO's C6 runs absent/incompatible esp_hosted slave firmware, so WiFi STA cannot associate yet. Reflashing the C6 with version-matched slave firmware is the next (hardware) step. All non-P4 targets and the eth-only P4 build are unaffected. 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/platform/esp32/platform_esp32.cpp (2)
747-759:⚠️ Potential issue | 🟠 Major | ⚡ Quick winFail
bind()if the socket never becomes non-blocking.The contract in Line 756 says this path must not stall the render loop, but
fcntl(F_GETFL/F_SETFL)failures are ignored and the method still returnstrue. If that happens,recvFrom()runs on a blocking UDP socket and can wedge the tick exactly where this helper is meant to protect it.💡 Suggested fix
bool UdpSocket::bind(uint16_t port) { if (fd_ < 0) return false; int reuse = 1; - setsockopt(fd_, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse)); + if (setsockopt(fd_, SOL_SOCKET, SO_REUSEADDR, &reuse, sizeof(reuse)) != 0) return false; sockaddr_in addr{}; addr.sin_family = AF_INET; addr.sin_port = htons(port); addr.sin_addr.s_addr = htonl(INADDR_ANY); if (::bind(fd_, reinterpret_cast<const sockaddr*>(&addr), sizeof(addr)) != 0) return false; // Non-blocking so the render loop's drain never stalls waiting for a packet. int flags = fcntl(fd_, F_GETFL, 0); - fcntl(fd_, F_SETFL, flags | O_NONBLOCK); + if (flags < 0) return false; + if (fcntl(fd_, F_SETFL, flags | O_NONBLOCK) != 0) return false; return true; }🤖 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 747 - 759, UdpSocket::bind currently ignores errors from fcntl so the socket may remain blocking; change it to check the return of fcntl(fd_, F_GETFL, 0) and treat -1 as failure (return false), and after computing newflags call fcntl(fd_, F_SETFL, newflags) and if that returns -1 also return false (optionally close fd_ or mark fd_ invalid per the class convention) so that bind() only returns true when the socket is successfully set to non-blocking; reference symbols: UdpSocket::bind, fd_, fcntl, F_GETFL, F_SETFL, O_NONBLOCK, and recvFrom().Source: Coding guidelines
55-57:⚠️ Potential issue | 🟡 MinorAdd explicit
fcntlheader forO_NONBLOCKusage in ESP32 platform.
src/platform/esp32/platform_esp32.cppcallsfcntl(..., F_GETFL/F_SETFL, ...)and usesO_NONBLOCK(around lines 755-759, 923-926, 935-936) but has no direct include of<fcntl.h>/<sys/fcntl.h>, making the build dependent on transitive header order under-Werror.🤖 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 55 - 57, The file platform_esp32.cpp uses fcntl (F_GETFL/F_SETFL) and O_NONBLOCK but lacks an explicit fcntl header; add an appropriate include (e.g. <fcntl.h> or <sys/fcntl.h>) at the top of src/platform/esp32/platform_esp32.cpp so the uses in the fcntl(...) calls (around the code paths handling socket non-blocking behavior using O_NONBLOCK) compile reliably without relying on transitive headers.Source: Coding guidelines
src/core/MicModule.h (2)
178-203:⚠️ Potential issue | 🟠 Major | ⚡ Quick winClear published audio state when the mic is reinitialized or torn down.
After a successful run, a bad pin/rate change leaves
filled_andframe_untouched.latestFrame()then keeps serving the last non-silent frame indefinitely, so audio-reactive effects can freeze on stale data even though capture is no longer running.🐛 Proposed fix
void deinit() { if constexpr (!platform::hasI2sMic) return; if (inited_) platform::audioMicDeinit(mic_); inited_ = false; + filled_ = 0; + 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/core/MicModule.h` around lines 178 - 203, The reinit() and deinit() paths must clear the published audio state so latestFrame() won't return stale data: when reinitializing (in reinit()) after deinit() or when teardown (in deinit()) set filled_ = false and reset/zero the frame_ buffer (and any related published pointers) so no old frame is served; keep the existing dc_.reset() behavior but ensure the same clearing happens on both failure-to-init and on normal deinit() so functions like latestFrame() and any audio-reactive effects see silence instead of stale frames.
19-23: 🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift
MicModuledoes not fit insrc/core/in its current shape.This header pulls
platform/platform.hdirectly intosrc/core/and owns the I2S/FFT platform seams itself, so the module is no longer platform-independent. Please move it out ofsrc/core/or invert this dependency behind a platform-neutral audio-source interface.As per coding guidelines,
src/core/**:Must be platform-independent — no platform includes.🤖 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/MicModule.h` around lines 19 - 23, MicModule currently pulls platform/platform.h and owns I2S/FFT seams, violating the platform-independent rule for src/core; either move MicModule out of src/core or refactor it to depend on a platform-neutral audio source interface. Remove the direct include of platform/platform.h from MicModule.h and introduce an abstract core-facing interface (e.g., IAudioSource or AudioInput with methods like readFrame()/start()/stop()/getLevels()) in src/core, keep MicModule referencing only that interface (and MoonModule/AudioFrame/AudioLevel), then implement the platform-specific I2S/FFT code in a platform folder that provides a concrete adapter class implementing the new interface and include that adapter only in platform-specific build units. Ensure all references to platform/platform.h and any I2S/FFT symbols are moved into the adapter implementation so src/core/MicModule.h remains platform-neutral.Source: Coding guidelines
src/platform/desktop/platform_desktop.cpp (1)
396-402:⚠️ Potential issue | 🔴 Critical | ⚡ Quick winUpdate the desktop Improv stub to the new signature.
platform.hnow declaresimprovProvisioningInit(..., uint8_t* txPowerOut, std::atomic<bool>* txPowerReady), but this definition still stops atboardReady. That declaration/definition mismatch is a compile break for desktop builds.🐛 Proposed fix
bool improvProvisioningInit(const ImprovDeviceInfo& /*info*/, char* /*ssidOut*/, size_t /*ssidOutLen*/, char* /*passwordOut*/, size_t /*passwordOutLen*/, std::atomic<bool>* /*ready*/, char* statusBuf, size_t statusBufLen, char* /*boardOut*/, size_t /*boardOutLen*/, - std::atomic<bool>* /*boardReady*/) { + std::atomic<bool>* /*boardReady*/, + uint8_t* /*txPowerOut*/, + std::atomic<bool>* /*txPowerReady*/) {🤖 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/desktop/platform_desktop.cpp` around lines 396 - 402, The function definition for improvProvisioningInit in platform_desktop.cpp does not match the updated declaration in platform.h (missing txPowerOut and txPowerReady), causing a build error; update the improvProvisioningInit signature to include the new trailing parameters uint8_t* txPowerOut and std::atomic<bool>* txPowerReady (keeping existing parameters like statusBuf/statusBufLen and boardReady), and if the stub doesn't use them simply accept the parameters (and mark as unused) so the declaration/definition match.
🤖 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`:
- Around line 276-277: This paragraph incorrectly advises pinning
Buffer*/Correction* at wiring time; update it to state that Drivers must not
hold raw intra-tree pointers across rebuilds but must re-resolve per-build
references (e.g., in onBuildState()) or use a safe indirection (lookup by
id/weak ref) so mutation-safe rebuilds (add/delete/replace) won’t dangle;
mention the specific symbols Drivers, Layer, Buffer*, Correction*, and
onBuildState() to guide contributors to rebind rather than cache raw pointers.
In `@docs/history/decisions.md`:
- Around line 768-780: The paragraph that states "prefer not to look at prior
art at all" in the "Designed fresh..." section should be revised to avoid
contradicting the repo principle; change that sentence to a
branch-scoped/qualified recommendation (e.g., mark it "branch-local/superseded")
or rewrite it as "don't trace structure; do reference behaviour" so it advocates
referencing proven behaviour without copying structure; update the surrounding
text to include a short note that this lesson is a branch-specific retrospective
and add the alternative guidance "Reference, don't copy" (or "Common patterns
first") to make the intent consistent with the repository's ongoing process.
In `@docs/history/README.md`:
- Line 13: Several edited docs still contain em dashes (—) despite the new repo
prose rule banning them; update the touched files (docs/history/README.md,
docs/moonmodules/light/effects/AudioVolumeEffect.md, CLAUDE.md,
docs/backlog/backlog.md) to replace every em dash with the approved alternative
(e.g., a hyphen "-", comma, parenthesis, or rephrase the sentence) so the branch
is internally consistent with the new "no-em-dash" standard; search each file
for "—" and apply consistent replacements, then run any prose linting/CI checks
to confirm compliance.
In `@esp32/main/idf_component.yml`:
- Around line 28-35: The idf_component.yml entries for espressif/esp_wifi_remote
and espressif/esp_hosted currently use version: "*" which allows unpinned,
drifting dependencies for target == esp32p4; update these two components
(espressif/esp_wifi_remote and espressif/esp_hosted) to exact version strings or
commit a generated idf_component.lock (checked-in lockfile) and reference it so
the resolved versions are frozen for esp32p4 builds; ensure the change updates
the version fields for those component entries or adds the lockfile to the repo
and adjusts CI/build to use it.
In `@src/light/AudioLevel.h`:
- Around line 43-50: The process(...) method can produce y values outside the
int32_t range before narrowing, causing undefined behavior when casting; fix by
clamping the computed float y to [INT32_MIN, INT32_MAX] (e.g., using std::clamp
or manual bounds checks) before assigning back into samples[i], and ensure you
reference the same locals (xPrev, yPrev, r, n, samples) so semantics are
unchanged except for the safe clamp prior to the static_cast<int32_t>.
In `@src/light/effects/AudioSpectrumEffect.h`:
- Around line 107-116: The height-gradient math uses (h - 1) and checks (h > 1)
but the spectrum only spans specH rows (specH == h - 1 when the bottom row is a
level meter), so change the condition and denominator to use specH instead of h:
compute frac using (specH > 1) ? static_cast<uint8_t>(static_cast<uint32_t>(row)
* 255u / (specH - 1)) : mag, and update the r/g assignments to check (specH > 1)
and use frac (or the scaled mag branch) accordingly in the blocks that set r and
g (the code around variables frac, r, g and the ternary expressions).
In `@src/light/effects/AudioVolumeEffect.h`:
- Around line 43-48: The loop in AudioVolumeEffect that writes per-light
channels only sets the first three bytes (r,g,b) and leaves any additional
channels from channelsPerLight() unchanged, causing stale data to persist;
update the per-light write in the for (size_t off = 0; off < total; off += cpl)
loop to explicitly clear or set all remaining channels beyond RGB (for indices 3
.. cpl-1) to a defined logical value (e.g., 0) after assigning r,g,b so the
buffer has no leftover data; locate this logic in the AudioVolumeEffect
class/function and add a small inner loop that sets buf[off + idx] = 0 for idx =
3 to cpl-1 (respecting channelsPerLight()), leaving any driver-side RGBW
correction behavior unchanged.
---
Outside diff comments:
In `@src/core/MicModule.h`:
- Around line 178-203: The reinit() and deinit() paths must clear the published
audio state so latestFrame() won't return stale data: when reinitializing (in
reinit()) after deinit() or when teardown (in deinit()) set filled_ = false and
reset/zero the frame_ buffer (and any related published pointers) so no old
frame is served; keep the existing dc_.reset() behavior but ensure the same
clearing happens on both failure-to-init and on normal deinit() so functions
like latestFrame() and any audio-reactive effects see silence instead of stale
frames.
- Around line 19-23: MicModule currently pulls platform/platform.h and owns
I2S/FFT seams, violating the platform-independent rule for src/core; either move
MicModule out of src/core or refactor it to depend on a platform-neutral audio
source interface. Remove the direct include of platform/platform.h from
MicModule.h and introduce an abstract core-facing interface (e.g., IAudioSource
or AudioInput with methods like readFrame()/start()/stop()/getLevels()) in
src/core, keep MicModule referencing only that interface (and
MoonModule/AudioFrame/AudioLevel), then implement the platform-specific I2S/FFT
code in a platform folder that provides a concrete adapter class implementing
the new interface and include that adapter only in platform-specific build
units. Ensure all references to platform/platform.h and any I2S/FFT symbols are
moved into the adapter implementation so src/core/MicModule.h remains
platform-neutral.
In `@src/platform/desktop/platform_desktop.cpp`:
- Around line 396-402: The function definition for improvProvisioningInit in
platform_desktop.cpp does not match the updated declaration in platform.h
(missing txPowerOut and txPowerReady), causing a build error; update the
improvProvisioningInit signature to include the new trailing parameters uint8_t*
txPowerOut and std::atomic<bool>* txPowerReady (keeping existing parameters like
statusBuf/statusBufLen and boardReady), and if the stub doesn't use them simply
accept the parameters (and mark as unused) so the declaration/definition match.
In `@src/platform/esp32/platform_esp32.cpp`:
- Around line 747-759: UdpSocket::bind currently ignores errors from fcntl so
the socket may remain blocking; change it to check the return of fcntl(fd_,
F_GETFL, 0) and treat -1 as failure (return false), and after computing newflags
call fcntl(fd_, F_SETFL, newflags) and if that returns -1 also return false
(optionally close fd_ or mark fd_ invalid per the class convention) so that
bind() only returns true when the socket is successfully set to non-blocking;
reference symbols: UdpSocket::bind, fd_, fcntl, F_GETFL, F_SETFL, O_NONBLOCK,
and recvFrom().
- Around line 55-57: The file platform_esp32.cpp uses fcntl (F_GETFL/F_SETFL)
and O_NONBLOCK but lacks an explicit fcntl header; add an appropriate include
(e.g. <fcntl.h> or <sys/fcntl.h>) at the top of
src/platform/esp32/platform_esp32.cpp so the uses in the fcntl(...) calls
(around the code paths handling socket non-blocking behavior using O_NONBLOCK)
compile reliably without relying on transitive headers.
🪄 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: b1b58476-7582-44ca-bca0-9d513dfee11c
⛔ Files ignored due to path filters (1)
scripts/build/build_esp32.pyis excluded by!**/build/**
📒 Files selected for processing (32)
CLAUDE.mdREADME.mddocs/architecture.mddocs/backlog/backlog.mddocs/building.mddocs/coding-standards.mddocs/history/README.mddocs/history/decisions.mddocs/moonmodules/core/MicModule.mddocs/moonmodules/core/SystemModule.mddocs/moonmodules/light/drivers/RmtLedDriver.mddocs/moonmodules/light/effects/AudioVolumeEffect.mdesp32/main/idf_component.ymlesp32/sdkconfig.defaults.esp32p4-eth-wifiscripts/scenario/run_network_roundtrip.pysrc/core/MicModule.hsrc/core/SystemModule.hsrc/light/AudioLevel.hsrc/light/drivers/RmtLedDriver.hsrc/light/effects/AudioSpectrumEffect.hsrc/light/effects/AudioVolumeEffect.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_i2s.cppsrc/platform/esp32/platform_esp32_improv.cppsrc/platform/esp32/platform_esp32_rmt.cppsrc/platform/platform.hsrc/ui/app.jstest/unit/light/unit_AudioBands.cpptest/unit/light/unit_AudioLevel.cpp
| - *Shared-struct (pull):* `Drivers` hands every child driver a `Buffer*` (source) plus a `Correction*` (shared brightness/reorder/white), and `Layer` exposes its pixel buffer to `Drivers` directly on the identity-mapping fast path: each consumer holds a `const`-pointer set once at wiring time and reads it per frame. | ||
| - *Push to a core sink:* `PreviewDriver` owns the preview wire format (a one-time coordinate table + per-frame RGB point list) and pushes the bytes to a `BinaryBroadcaster` (the core HTTP server). The server broadcasts them over WebSocket without knowing they're a preview: the format and the light types stay entirely in the driver. See [PreviewDriver](moonmodules/light/drivers/PreviewDriver.md). |
There was a problem hiding this comment.
Fix the pointer-lifetime story in this pipeline note.
This says the Buffer*/Correction* consumers are “set once at wiring time,” but the same architecture doc later treats Drivers → Layer as a rebuild-time link that must be re-resolved in onBuildState() to stay mutation-safe. Please align this section with the robustness contract so it does not tell contributors to pin an intra-tree pointer across add/delete/replace rebuilds.
🤖 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` around lines 276 - 277, This paragraph incorrectly
advises pinning Buffer*/Correction* at wiring time; update it to state that
Drivers must not hold raw intra-tree pointers across rebuilds but must
re-resolve per-build references (e.g., in onBuildState()) or use a safe
indirection (lookup by id/weak ref) so mutation-safe rebuilds
(add/delete/replace) won’t dangle; mention the specific symbols Drivers, Layer,
Buffer*, Correction*, and onBuildState() to guide contributors to rebind rather
than cache raw pointers.
Source: Learnings
| ### Friend-repo activity digests | ||
|
|
||
| Monthly logs of what shipped on related open-source LED projects — the live landscape projectMM watches to sharpen its own designs (we study their approaches, we don't copy their code). Generated by the [digest prompt](#digest-prompt-reusable) below. | ||
| Monthly logs of what shipped on related open-source LED projects — the live landscape projectMM watches to sharpen its own designs under the *Industry standards, our own code* principle ([CLAUDE.md § Principles](../../CLAUDE.md#principles)): study to think, write fresh, never copy. Generated by the [digest prompt](#digest-prompt-reusable) below. |
There was a problem hiding this comment.
Apply the new no-em-dash prose rule consistently across docs/history/README.md, docs/moonmodules/light/effects/AudioVolumeEffect.md, CLAUDE.md, and docs/backlog/backlog.md.
The shared root cause is that this PR introduces a repo-wide prose rule banning em dashes, but several touched docs still add or retain — in the newly edited text. Please normalize the touched prose in each of those files so the new standards doc does not immediately contradict the rest of the branch.
🧰 Tools
🪛 LanguageTool
[grammar] ~13-~13: Ensure spelling is correct
Context: ...ource LED projects — the live landscape projectMM watches to sharpen its own designs unde...
(QB_NEW_EN_ORTHOGRAPHY_ERROR_IDS_1)
🤖 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 13, Several edited docs still contain em
dashes (—) despite the new repo prose rule banning them; update the touched
files (docs/history/README.md,
docs/moonmodules/light/effects/AudioVolumeEffect.md, CLAUDE.md,
docs/backlog/backlog.md) to replace every em dash with the approved alternative
(e.g., a hyphen "-", comma, parenthesis, or rephrase the sentence) so the branch
is internally consistent with the new "no-em-dash" standard; search each file
for "—" and apply consistent replacements, then run any prose linting/CI checks
to confirm compliance.
Source: Coding guidelines
…bit review Renames the audio module from MicModule to AudioModule (it does audio acquisition plus level/FFT analysis, and will gain line-in / USB sources beyond the I²S mic, so the name now reflects the module, not one source). Makes the primary-source references in the docs clickable (datasheets, Espressif API pages, vendor sites) per the spec-and-test principle. Processes the CodeRabbit review on the prior commit: six findings fixed, six skipped with a stated reason. KPI: 16384lights | PC:340KB | tick:121/89/119/122/56/2/37/21/117/342us(FPS:8264/11235/8403/8196/17857/500000/27027/47619/8547/2923) | tick:21177us(FPS:47) | heap:33066KB | src:87(16753) | test:53(8518) | lizard:68w Core: - MicModule.h → AudioModule.h (git mv, history preserved): class MicModule → AudioModule, registration string "MicModule" → "AudioModule", the latestFrame() static, the main.cpp wiring. The mic-source platform seam (hasI2sMic, audioMicRead, AudioMicHandle) is deliberately NOT renamed: it correctly names today's actual source; it broadens when line-in lands (concrete-first). Verified live on the S3: AudioModule registers and runs (AudioModule:523us in the tick breakdown), no boot-loop. Light domain: - AudioLevel.h: clamp the DcBlocker IIR output to the int32 range before narrowing (a settling transient could push the float past int32 bounds, which is UB on the cast). 🐇 #1. - AudioSpectrumEffect.h: fix a height-gradient off-by-one — the gradient divided by (h-1) but the spectrum spans specH rows; when the bottom row is the level meter (specH == h-1) the top spectrum row never reached full red. Now divides by (specH-1). 🐇 #4. - AudioVolumeEffect.h: defensively zero any per-light channels beyond RGB (buffers are RGB/cpl=3 today; this keeps the write correct for any cpl and leaves no stale bytes). 🐇 #3. Scripts / build: - idf_component.yml: pin esp_wifi_remote ~1.6.1 and esp_hosted ~2.12.9 (the P4-NANO-validated versions) instead of "*", so the P4 build can't silently drift to a new minor; also corrected the stale "esp_hosted bring-up prelude" comment (that prelude was removed). 🐇 #2. Verified the pins resolve and the P4 builds + boots. Docs / CI: - AudioModule.md (was MicModule.md): retitled; opening reframed as "acquires an audio source ... named for what it does, not one source" (present-tense, no claim line-in exists yet). Added primary-source links: INMP441 datasheet, esp-dsp, ESP-IDF I2S API, Hann window. First fully em-dash-free module spec. - RmtLedDriver.md / LcdLedDriver.md / ParlioLedDriver.md: linked WS2812B datasheet, ESP-IDF RMT v2 / esp_lcd / Parlio API pages, the v6.0 migration guide (legacy-RMT removal), ESP32-P4 product page. (Caught + fixed an agent-guessed Parlio URL that 404'd; all links verified live.) - SystemModule.md / building.md: linked ESP32-C6, esp_hosted, esp_wifi_remote, Waveshare ESP32-P4-NANO. - decisions.md: reworded the "designed fresh" audio lesson so it no longer reads as "don't look at prior art at all" (which fought the study-with-respect principle); now "reference proven behaviour, don't trace structure," with the flat-mic / no-correction-table call as the example. 🐇 #6. - AudioVolumeEffect.md: em-dashes removed (touched alongside the effect). 🐇 #7 (partial). - AudioSpectrumEffect.md / AudioFrame.h / test files: AudioModule rename propagated (@module annotations, references). Reviews — 🐇 CodeRabbit, prior commit (b79d54f): - Fixed #1 (DcBlocker int32 clamp), #2 (component version pins), #3 (AudioVolumeEffect extra-channel clear), #4 (AudioSpectrumEffect specH off-by-one), #6 (decisions.md prior-art wording), #7 (AudioVolumeEffect.md em-dashes). - Skipped #5 (architecture.md Buffer*/Correction* "pin at wiring time"): describes the identity-mapping fast path where the pointer is genuinely stable; the suggestion proposes a different architecture, not a doc fix; paragraph untouched this branch. - Skipped #7 (README.md / backlog.md bulk em-dash sweep): the rule is "as files are touched, not a single sweep"; a ~176-dash reformat is its own task. - Skipped #8 (AudioModule stale frame): already handled — latestFrame() returns the static silent frame when active_ is null (teardown nulls it); reinit keeps the same active mic and refreshes within one loop. - Skipped #9 (AudioModule platform boundary): not a violation — the boundary check passes and the neutral platform.h facade is included by ~10 core modules; the rule forbids platform-specific code outside src/platform/, not the abstract interface. An IAudioSource indirection is exactly the bespoke abstraction the architecture avoids. - Skipped #10 (desktop improv signature): decl and definition match exactly; the desktop build passes, which proves it. - Skipped #11/#12 (platform_esp32.cpp fcntl error-handling / include): pre-existing code not changed this branch; expanding into untouched code violates minimal-change scope. Hardware verified this commit: S3 boots clean at 225 FPS with AudioModule live; P4 boots clean at 60 FPS on Ethernet with the pinned components resolved and wifiCoproc reporting "not detected" (the known C6-slave-firmware blocker, unchanged). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… fixes Processes the Event-2 (pre-merge) Reviewer pass over the branch. The headline is a structural deduplication of the three LED drivers: the parallel WS2812 drivers (S3 LCD_CAM i80, P4 Parlio) were ~250 of ~370 lines byte-for-byte identical, now one shared CRTP base. Also fixes four stale doc/comment items, makes the AudioModule enabled-toggle actually stop its FFT cost, and removes two dead platform flags. No control names or output behaviour change; the LED hot path is unchanged (measured zero overhead, see below). KPI: 16384lights | PC:340KB | tick:116/95/118/115/56/1/37/20/113/332us(FPS:8620/10526/8474/8695/17857/1000000/27027/50000/8849/3012) | ESP32:1178KB | tick:17341us(FPS:57) | heap:33068KB | src:88(16543) | test:53(8518) | lizard:65w Light domain (the dedup, per the No-duplication rule): - ParallelLedDriver.h (new): a CRTP base ParallelLedDriver<Derived> holding the shared body of the parallel drivers — the pins/ledsPerPin/loopback controls, parseConfig, the per-ROW fused correct+encode loop(), reinit/deinit, the loopback self-test. Binding is CRTP (static polymorphism), NOT a virtual second hierarchy: the base calls derived()->busX() resolved at compile time, no vtable, no per-light indirection — so it stays inside the data-over-objects / hot-path rules and "the one deliberate class hierarchy is the module tree" rule. - LcdLedDriver.h: 378 → 92 lines, now a derived shell supplying only the i80-specific pieces (clockPin/dcPin, the exactly-8-pins rule via kExactLaneCount, the platform::lcdWs2812* calls). - ParlioLedDriver.h: 362 → 82 lines, the simpler shell (no clock/dc, kExactLaneCount=false, kClockHz). - Drivers.h (DriverBase): absorbs the status-string lifecycle (configErr_/failBuf_, setConfigErr/clearConfigErr/failBufEnsure/clearFailBuf) that was triplicated verbatim across all three drivers (RMT too). - RmtLedDriver.h: loses its status-lifecycle copy (now inherited); symbol-per-light model stays standalone (genuinely different from the parallel drivers — concrete-first boundary). Core: - AudioModule.h: removed respectsEnabled()=false so the Scheduler honours `enabled` — disabling the module now skips loop() entirely, stopping the FFT (the real per-tick cost). Verified on hardware: 524us enabled -> 0us disabled. The FFT is never gated while enabled (it is the capability audio effects consume, not an optional cost). - platform_config.h (esp32 + desktop): removed the dead isEsp32/isEsp32S3 family flags (no users anywhere); kept isEsp32P4 (drives ethPins + hasWifiCoprocessor). Desktop now mirrors with isEsp32P4=false. Docs / CI: - architecture.md: § Firmware vs board now lists the two P4 firmwares (esp32p4-eth, esp32p4-eth-wifi); § Drivers updated for the LCD_CAM/Parlio drivers and the four-driver shared Correction pointer. (👾 MUST-FIX 1, 2) - sdkconfig.defaults.esp32p4-eth-wifi: corrected the comment that described an esp_hosted bring-up prelude the code deliberately does NOT do (it was removed; esp_hosted self-inits at boot). Following the stale comment would reintroduce the bench-fixed SDIO teardown. (👾 MUST-FIX 3) - platform_esp32_lcd.cpp: the header + #else/#endif comments now name the correct SOC_LCDCAM_I80_LCD_SUPPORTED macro (they said SOC_LCD_I80_SUPPORTED — the exact macro confusion that caused the classic-ESP32 boot loop). (👾 MUST-FIX 4) - check_specs.py: skip CRTP template bases (template<...> class X : public DriverBase) — shared infrastructure, not a registered module; the concrete derived classes still carry the docs. - backlog.md: the LED-driver duplication item updated (driver-logic dedup landed via the CRTP base; the platform loopback capture+verify extraction remains as a follow-up). Reviews — 👾 Reviewer (Fable 5, over git diff main...HEAD; verdict "merge with noted fixes"): - Fixed all 4 MUST-FIX (stale docs/comments, above). - SHOULD-CONSIDER #1/#2 (Lcd/Parlio driver-logic + status duplication): FIXED via the CRTP base + DriverBase status lifecycle (chose to do it now per the No-duplication rule rather than backlog). The remaining platform loopback capture+verify duplication is the tracked follow-up. - SHOULD-CONSIDER #4 (AudioModule enabled toggle couldn't stop the FFT): FIXED (above); the FFT itself is must-have, only the disabled-still-runs gap was the bug. - SHOULD-CONSIDER #5 (unused isEsp32/isEsp32S3 flags): FIXED (removed). - SHOULD-CONSIDER #3 (audio headers in src/light/ giving a core->light arrow): accepted as-is — documented producer/consumer placement, boundary check passes. Verification: 56 driver/encoder unit tests pass, desktop build zero-warning, spec + platform-boundary clean. All three ESP32 targets (esp32, esp32s3-n16r8, esp32p4-eth-wifi) build clean under -Werror, including both CRTP instantiations. On hardware: S3 LcdLedDriver loopback bit-perfect over a 13->12 jumper (1536/1536 symbols captured, 0-bits 15..15 / 1-bits 30..30 ticks, zero jitter); P4 ParlioLedDriver renders the S3's audio spectrum live over the DDP path. Hot-path A/B (same S3, same config): pre-dedup ~3421-3442us/frame vs CRTP ~3425-3436us/frame — statistically identical, zero overhead (CRTP is static dispatch; the per-light loop is unchanged). 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 (3)
test/unit/light/unit_AudioBands.cpp (1)
31-31:⚠️ Potential issue | 🟠 MajorAvoid UB in signed sample left-shift (AudioBands/AudioLevel).
static_cast<int64_t>(s) << 8shifts a potentially negative value (sine-baseds), which is undefined behavior in C++. Apply the same change in bothtest/unit/light/unit_AudioBands.cpp(line 31) andtest/unit/light/unit_AudioLevel.cpp(line 33).Proposed fix
- const int64_t sample = static_cast<int64_t>(s) << 8; // avoid signed <<8 UB + const int64_t sample = static_cast<int64_t>(s) * 256; // defined for negative samples🤖 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_AudioBands.cpp` at line 31, The left-shift is applied to a signed value causing undefined behavior; change the shift to operate on an unsigned type and then cast back to int64_t. Locate the `sample` initialization (currently `const int64_t sample = static_cast<int64_t>(s) << 8;`) in unit_AudioBands.cpp and mirror the same edit in unit_AudioLevel.cpp: perform the << 8 on a uint64_t (e.g. cast `s` to uint64_t, shift, then static_cast<int64_t>` the result) so the shift is defined while preserving the intended numeric value.src/core/AudioModule.h (2)
23-28:⚠️ Potential issue | 🟠 Major | 🏗️ Heavy liftKeep
src/coreplatform-independent; this module currently crosses the platform boundary.
AudioModuleis undersrc/core/but directly includes platform headers and invokes platform audio APIs. That couples core to hardware-facing seams and breaks the repository’s core/domain boundary contract.Please move this module to the light domain (or isolate platform calls behind a non-core boundary) so
src/core/**stays platform-agnostic.As per coding guidelines,
src/core/**“must be platform-independent — no platform includes.” Based on learnings, core infrastructure and light-domain code should stay separated as much as practical.Also applies to: 121-123, 129-130, 149-150, 188-193, 209-210
🤖 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/AudioModule.h` around lines 23 - 28, AudioModule in src/core currently includes platform headers (platform/platform.h) and uses platform audio APIs, violating the core platform-independence rule; either move the entire AudioModule (the class defined in src/core/AudioModule.h and any related uses) into the light domain, or refactor by extracting a pure-core abstraction (e.g., IAudioDevice / AudioPlatformInterface) placed in core with no platform includes and implement the platform-specific adapter in light which includes platform/platform.h; update references so AudioModule depends only on the core interface and the platform implementation is wired in from the light layer (this also applies to the other platform-using spots you noted around the file).Sources: Coding guidelines, Learnings
192-197:⚠️ Potential issue | 🟠 Major | ⚡ Quick winReset published audio state on deinit/reinit failure to avoid stale frames.
On Line 192,
reinit()deinitializes before re-init. If Line 193 init fails,latestFrame()still exposes priorframe_contents becausedeinit()only clearsinited_. This leaves stale non-silent output after mic init failure.Suggested fix
void deinit() { if constexpr (!platform::hasI2sMic) return; if (inited_) platform::audioMicDeinit(mic_); inited_ = false; + filled_ = 0; + dc_.reset(); + frame_ = AudioFrame{}; }Also applies to: 208-212
🤖 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/AudioModule.h` around lines 192 - 197, When reinit() calls deinit() then fails to reinitialize (platform::audioMicInit returns false) the previous non-silent frame_ remains exposed via latestFrame(); fix by resetting published audio state on deinit/reinit failure: in the failure path after deinit() and a failed platform::audioMicInit (and likewise in the other reinit failure path around the second init call), clear or zero-out frame_, mark it as silent/uninitialized and update any timestamp/state so latestFrame() returns a silent frame until init succeeds (alternatively modify deinit() to always clear frame_ and related flags like inited_ so stale frames cannot be returned).
♻️ Duplicate comments (2)
src/platform/esp32/platform_esp32_lcd.cpp (1)
183-189:⚠️ Potential issue | 🟠 Major | 🏗️ Heavy liftHandle LCD transfer timeout as an explicit failure before buffer reuse.
Line 188 drops the
xSemaphoreTakeresult. If a wait times out and completion arrives later, the next wait can consume a stale token and allow reuse ofst->bufwhile DMA still owns it.Suggested direction
-void lcdWs2812Wait(LcdWs2812Handle& h, uint32_t timeoutMs) { +bool lcdWs2812Wait(LcdWs2812Handle& h, uint32_t timeoutMs) { auto* st = static_cast<LcdState*>(h.impl); - if (!st) return; - xSemaphoreTake(st->done, pdMS_TO_TICKS(timeoutMs)); + if (!st) return false; + const bool done = (xSemaphoreTake(st->done, pdMS_TO_TICKS(timeoutMs)) == pdTRUE); + if (!done) { + // Drain any late completion token so the next wait cannot succeed immediately + // on a previous transfer's callback. + while (xSemaphoreTake(st->done, 0) == pdTRUE) {} + } + return done; }Then update callers to branch on the returned
booland avoid starting/reusing frame buffers when completion timed out.#!/bin/bash # Verify API shape and timeout handling in LCD wait path. rg -nP 'void\s+lcdWs2812Wait\s*\(|xSemaphoreTake\s*\(\s*st->done' src/platform/esp32/platform_esp32_lcd.cpp src/platform/platform.h # Find all call sites that currently cannot observe timeout success/failure. rg -nP '\blcdWs2812Wait\s*\(' src/platform src/light 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 `@src/platform/esp32/platform_esp32_lcd.cpp` around lines 183 - 189, Change lcdWs2812Wait to return a boolean success flag instead of void: check xSemaphoreTake(st->done, pdMS_TO_TICKS(timeoutMs)) against pdTRUE and return true on success, false on timeout; do not ignore the result so a late completion cannot be consumed by the next wait. Update all callers of lcdWs2812Wait to branch on the returned bool (avoid reusing or restarting a frame and avoid reusing st->buf when the wait returned false) and ensure any cleanup or retry logic handles the timeout case explicitly; reference lcdWs2812Wait, st->done, and st->buf when changing the implementation and call sites.src/light/AudioLevel.h (1)
50-55:⚠️ Potential issue | 🟠 Major | ⚡ Quick winClamp still lets a UB cast path through at the positive limit.
On Line 53-Line 55,
2147483647.0frounds to2147483648.0fin float32, and the strict>check can lety == 2147483648.0freachstatic_cast<int32_t>(...), which is undefined behavior.Suggested fix
+#include <limits> @@ - const float clamped = y < -2147483648.0f ? -2147483648.0f - : y > 2147483647.0f ? 2147483647.0f : y; - samples[i] = static_cast<int32_t>(clamped); + const double yd = static_cast<double>(y); + if (yd >= static_cast<double>(std::numeric_limits<int32_t>::max())) { + samples[i] = std::numeric_limits<int32_t>::max(); + } else if (yd <= static_cast<double>(std::numeric_limits<int32_t>::min())) { + samples[i] = std::numeric_limits<int32_t>::min(); + } else { + samples[i] = static_cast<int32_t>(y); + }#!/bin/bash python - <<'PY' import struct def f32(x: float) -> float: return struct.unpack('!f', struct.pack('!f', x))[0] a = f32(2147483647.0) b = f32(2147483648.0) print("f32(2147483647.0) =", a) print("f32(2147483648.0) =", b) print("same-representation =", a == b) PY🤖 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/AudioLevel.h` around lines 50 - 55, The clamp still misses a UB path because the float literal 2147483647.0f has the same float representation as 2147483648.0f; change the positive bound check to use >= so values that compare equal to that rounded representation are saturated too. In the clamping expression that produces clamped (the ternary using y and 2147483647.0f/2147483648.0f) replace the second operator from ">" to ">=" so samples[i] = static_cast<int32_t>(clamped) cannot receive an out‑of‑range float; alternatively you can compute the positive bound from static_cast<float>(std::numeric_limits<int32_t>::max()) and use >= for clarity.
🤖 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`:
- Around line 396-397: The wording about LED driver lane counts is misleading;
update the text to state that LcdLedDriver (LCD_CAM/i80) requires all 8 data
GPIOs (not variable lanes), whereas Parlio is the peripheral that supports 1–8
lanes (DMA-driven), and change the phrase “up to 8 lanes each” to clearly
distinguish the two backends and their lane requirements (reference LcdLedDriver
and Parlio).
In `@docs/backlog/backlog.md`:
- Around line 291-301: Move the completed "Audio-reactive input on ESP32 — DONE
(level + FFT 16-band), with follow-ups deferred" section out of
docs/backlog/backlog.md into a new or existing docs/history/ entry: copy the
entire heading and its body (including bullets about per-band noise-floor,
adaptive conditioning, adaptive noise gate, pin auto-scan, beat/onset detection
and the GyroDriver note), remove that block from backlog.md so the backlog
contains only open work, and ensure any relative links remain correct after
relocation; use the heading text to locate the block to move.
In `@docs/moonmodules/core/AudioModule.md`:
- Line 3: Update the module description to clarify that AudioModule exposes an
AudioFrame each render tick but only recomputes the analysed values (overall
sound level, 16-band spectrum, dominant peak) when a full sample block has been
accumulated; reference AudioModule and AudioFrame in the sentence and replace
"one analysed AudioFrame per render tick" with wording that distinguishes frame
emission cadence from analysis recomputation cadence (e.g., emit frame every
render tick, analyses updated only on completed sample blocks).
In `@src/light/drivers/ParallelLedDriver.h`:
- Around line 230-236: The fast-path reuse condition in ParallelLedDriver (the
if using inited_, derived()->busCapacity(), frameBytes_, busPinsCurrent(), and
memset(dmaBuf_,...)) must also verify that the current lane count matches the
lane count that was used when the bus was initialized; add a stored
initialized_lane_count_ (or similar) member, compare derived()->laneCount() (or
the actual lane-count accessor) against initialized_lane_count_ in that if
condition so a lane-count change forces a rebuild, update
initialized_lane_count_ whenever the bus is rebuilt/initialized, and apply the
same change to both fast-path locations (the blocks around dmaBuf_ memset at the
two shown spots).
- Around line 72-86: onUpdate currently calls runLoopbackSelfTest() when
loopbackTest is true for both test and pin changes, but for pin-related updates
(isPinControl) the internal lane config (laneList_, laneCount_, frameBytes_) may
be stale; modify onUpdate so that when isPinControl is true you first call
parseConfig() and reinit() to refresh laneList_/laneCount_/frameBytes_ before
invoking runLoopbackSelfTest(), while preserving the existing behavior for
toggling the test (isTestControl) and the clearFailBuf()/clearStatus() branch;
update the conditional branches around isPinControl/isTestControl accordingly to
ensure the config is reloaded prior to runLoopbackSelfTest().
---
Outside diff comments:
In `@src/core/AudioModule.h`:
- Around line 23-28: AudioModule in src/core currently includes platform headers
(platform/platform.h) and uses platform audio APIs, violating the core
platform-independence rule; either move the entire AudioModule (the class
defined in src/core/AudioModule.h and any related uses) into the light domain,
or refactor by extracting a pure-core abstraction (e.g., IAudioDevice /
AudioPlatformInterface) placed in core with no platform includes and implement
the platform-specific adapter in light which includes platform/platform.h;
update references so AudioModule depends only on the core interface and the
platform implementation is wired in from the light layer (this also applies to
the other platform-using spots you noted around the file).
- Around line 192-197: When reinit() calls deinit() then fails to reinitialize
(platform::audioMicInit returns false) the previous non-silent frame_ remains
exposed via latestFrame(); fix by resetting published audio state on
deinit/reinit failure: in the failure path after deinit() and a failed
platform::audioMicInit (and likewise in the other reinit failure path around the
second init call), clear or zero-out frame_, mark it as silent/uninitialized and
update any timestamp/state so latestFrame() returns a silent frame until init
succeeds (alternatively modify deinit() to always clear frame_ and related flags
like inited_ so stale frames cannot be returned).
In `@test/unit/light/unit_AudioBands.cpp`:
- Line 31: The left-shift is applied to a signed value causing undefined
behavior; change the shift to operate on an unsigned type and then cast back to
int64_t. Locate the `sample` initialization (currently `const int64_t sample =
static_cast<int64_t>(s) << 8;`) in unit_AudioBands.cpp and mirror the same edit
in unit_AudioLevel.cpp: perform the << 8 on a uint64_t (e.g. cast `s` to
uint64_t, shift, then static_cast<int64_t>` the result) so the shift is defined
while preserving the intended numeric value.
---
Duplicate comments:
In `@src/light/AudioLevel.h`:
- Around line 50-55: The clamp still misses a UB path because the float literal
2147483647.0f has the same float representation as 2147483648.0f; change the
positive bound check to use >= so values that compare equal to that rounded
representation are saturated too. In the clamping expression that produces
clamped (the ternary using y and 2147483647.0f/2147483648.0f) replace the second
operator from ">" to ">=" so samples[i] = static_cast<int32_t>(clamped) cannot
receive an out‑of‑range float; alternatively you can compute the positive bound
from static_cast<float>(std::numeric_limits<int32_t>::max()) and use >= for
clarity.
In `@src/platform/esp32/platform_esp32_lcd.cpp`:
- Around line 183-189: Change lcdWs2812Wait to return a boolean success flag
instead of void: check xSemaphoreTake(st->done, pdMS_TO_TICKS(timeoutMs))
against pdTRUE and return true on success, false on timeout; do not ignore the
result so a late completion cannot be consumed by the next wait. Update all
callers of lcdWs2812Wait to branch on the returned bool (avoid reusing or
restarting a frame and avoid reusing st->buf when the wait returned false) and
ensure any cleanup or retry logic handles the timeout case explicitly; reference
lcdWs2812Wait, st->done, and st->buf when changing the implementation and call
sites.
🪄 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: e0301fad-3aea-4342-8a8b-71a21544e96c
📒 Files selected for processing (32)
docs/architecture.mddocs/backlog/backlog.mddocs/building.mddocs/history/decisions.mddocs/moonmodules/core/AudioModule.mddocs/moonmodules/core/SystemModule.mddocs/moonmodules/light/drivers/LcdLedDriver.mddocs/moonmodules/light/drivers/ParlioLedDriver.mddocs/moonmodules/light/drivers/RmtLedDriver.mddocs/moonmodules/light/effects/AudioSpectrumEffect.mddocs/moonmodules/light/effects/AudioVolumeEffect.mdesp32/main/idf_component.ymlesp32/sdkconfig.defaults.esp32p4-eth-wifiscripts/check/check_specs.pysrc/core/AudioModule.hsrc/light/AudioFrame.hsrc/light/AudioLevel.hsrc/light/drivers/Drivers.hsrc/light/drivers/LcdLedDriver.hsrc/light/drivers/ParallelLedDriver.hsrc/light/drivers/ParlioLedDriver.hsrc/light/drivers/RmtLedDriver.hsrc/light/effects/AudioSpectrumEffect.hsrc/light/effects/AudioVolumeEffect.hsrc/main.cppsrc/platform/desktop/platform_config.hsrc/platform/desktop/platform_desktop.cppsrc/platform/esp32/platform_config.hsrc/platform/esp32/platform_esp32_i2s.cppsrc/platform/esp32/platform_esp32_lcd.cpptest/unit/light/unit_AudioBands.cpptest/unit/light/unit_AudioLevel.cpp
| ### Audio-reactive input on ESP32 — DONE (level + FFT 16-band), with follow-ups deferred | ||
|
|
||
| The first audio-reactive capability **shipped**: [AudioModule](../moonmodules/core/AudioModule.md) reads an INMP441 I²S mic and publishes an `AudioFrame` (sound level + 16-band spectrum + dominant peak) consumed by [AudioVolumeEffect](../moonmodules/light/effects/AudioVolumeEffect.md) and [AudioSpectrumEffect](../moonmodules/light/effects/AudioSpectrumEffect.md). It is a **plain, manual** spectrum: a log/dB display scale with two knobs, `floor` (noise floor) and `gain` (sensitivity), plus a standard DC-blocker high-pass (~40 Hz) on the input. Built step by step; deferred follow-ups, each its own increment: | ||
|
|
||
| - **Per-band noise-floor (kill a steady single-frequency hum)** — the bench mic picks up a constant ~258 Hz tone (a mains harmonic via the mic/supply) that lights one band even in silence. A high-pass can't remove it (it's well above the ~40 Hz DC-blocker cutoff) without also killing real bass; the clean fix is a per-band adaptive floor that learns each band's idle baseline and subtracts it, so a constant tone in one band gates to dark while the others stay sensitive. Minimal version ≈ 16 floats of state + ~16 ops/frame. This is the next concrete audio step. | ||
| - **Adaptive conditioning** — auto noise-floor / auto-gain / smoothing so the display self-calibrates to a room ("sound off → dark, sound on → vivid") instead of being tuned by hand. A self-calibrating version was prototyped and removed; the manual `floor`/`gain` is the shipped baseline. Reinvent from scratch when wanted, and **tune it in a quiet room** — a noisy environment (a strong, varying low-frequency ambient) is the adversarial case that made the prototype hard to settle. (The per-band floor above is the first piece of this.) | ||
| - **Adaptive noise gate** — replace the borrowed `squelch`/`floor`-as-gate with a real noise gate: asymmetric bang-bang timing (open fast, close slow), a relative "detect silence" test (thresholds as factors of a learned floor, not absolute sample counts), keying off the RMS envelope we already compute, GEQ/FFT bands left untouched. A softhack007 concept; analysed and judged in full (good idea, industry-standard, but tight on the <30ms budget; decompose into steps rather than overhaul) in [AudioModule.md § Adaptive noise gate](../moonmodules/core/AudioModule.md#adaptive-noise-gate-forward-looking). The recommended sequencing: the per-band floor above is step 1 (its complementary frequency-domain half), the relative-threshold-over-RMS is the cheap high-value cherry-pick as step 2, hysteresis/timing step 3, log-domain + soft-gate optional. Eventually retires the manual squelch. | ||
| - **Pin auto-scan** — detect the mic's `sdPin` with `wsPin`/`sckPin` fixed (a noise-prompt + confirm convenience); ships today with explicit pin controls. | ||
| - **Beat / onset detection** beyond the raw peak; more audio effects (2D / palette-driven frequency-reactive). | ||
|
|
||
| ### GyroDriver → core Peripheral move + AudioModule-consistency pass (branched, not merged) |
There was a problem hiding this comment.
Move this shipped item out of the backlog.
docs/backlog/backlog.md says completed items are removed, but this section is marked DONE and still lives in the backlog. Please move the summary to docs/history/ and keep this file for open work only.
🤖 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.md` around lines 291 - 301, Move the completed
"Audio-reactive input on ESP32 — DONE (level + FFT 16-band), with follow-ups
deferred" section out of docs/backlog/backlog.md into a new or existing
docs/history/ entry: copy the entire heading and its body (including bullets
about per-band noise-floor, adaptive conditioning, adaptive noise gate, pin
auto-scan, beat/onset detection and the GyroDriver note), remove that block from
backlog.md so the backlog contains only open work, and ensure any relative links
remain correct after relocation; use the heading text to locate the block to
move.
LED-driver and mic pins now default to UNSET so adding a driver or the mic never grabs a GPIO the user soldered elsewhere — the runtime form of "assign a default only when it cannot do harm." Peripheral- and chip-fixed pins (the i80 WR/DC, the Ethernet map) keep their defaults; only user-wired pins go empty. Plus a batch of CodeRabbit fixes and the audio module's first lifecycle + mutation tests. KPI: 16384lights | PC:340KB | tick:124/90/119/121/57/1/38/19/127/346/10us(FPS:8064/11111/8403/8264/17543/1000000/26315/52631/7874/2890/100000) | tick:6723us(FPS:148) | heap:150KB | src:88(16541) | test:54(8690) | lizard:64w Core - AudioModule: deinit() now zeroes frame_ (and filled_) so a failed reinit or teardown publishes silence instead of a frozen last frame via latestFrame(); clarified the header/spec that the AudioFrame emits every tick but its analysis only recomputes on a completed sample block. Light domain - RmtLedDriver / LcdLedDriver / ParlioLedDriver: data pins + loopbackRxPin now default empty (user-soldered → the driver idles with a "set pins" status until configured); i80 clockPin/dcPin keep their peripheral-required defaults; bench wiring preserved as comments. Dropped the Parlio constructor entirely (base zero-inits) and the ledsPerPin "64" bench default → empty even-split. - ParallelLedDriver: fast-path bus reuse now also checks the lane count (busLaneCount_) so a Parlio 8→4 change that keeps frameBytes_ still forces a rebuild; the loopback self-test refreshes parseConfig()/reinit() before running on a pin edit so it never tests a stale lane config. - AudioLevel: clamp the DC-blocker output to 2147483520.0f (largest float < 2^31), not INT32_MAX which rounds up to 2^31 and casts out of range. - platform_esp32_lcd / platform_esp32_parlio: documented the internal-only DMA buffer choice and the tracked PSRAM follow-up at each alloc site. Tests - unit_AudioModule (new): module lifecycle — idle when unconfigured, repeatable setup/teardown, teardown clears the active mic (latestFrame → silence), last-setup-wins under any add/remove order. - scenario_Audio_mutation (new): add/configure/remove the mic + a consumer effect while the pipeline renders; the hard case (remove the producer while a consumer is live) keeps rendering on silent audio — the boot-loop robustness end-to-end through the Scheduler. - RmtLedDriver / LcdLedDriver / ParlioLedDriver: pin the empty-default idle (no GPIO claimed until pins are set); test helpers back-fill bench pins for the slicing cases. - scenario_runner: registered SystemModule + AudioModule + the two audio effects. Docs / CI - architecture.md: corrected the parallel-driver lane counts — LcdLedDriver (i80) requires exactly 8 GPIOs, ParlioLedDriver does 1–8 lanes. - AudioModule.md: added a Prior-art subsection on Troy (troyhacks) — his esp-dsp radix-4 FFT + biquad pre-filters, the EarLevel biquad tool, dl_fft, and DedeHai's integer-FFT / MSGEQ7 work — analysed and compared to our esp-dsp radix-2 path. - RmtLedDriver.md / NetworkSendDriver.md / NetworkReceiveEffect.md: linked the primary-source standards (IDF RMT peripheral; Art-Net / ANSI E1.31 / DDP specs) at the page tops, per Industry-standards-our-own-code. - backlog.md: added the MCU→Board→Device config-provenance model (with txPowerSetting as a Device-level worked example), the i80 WR/DC-bypass and LCD/Parlio-PSRAM follow-ups; reframed the audio section to open work only. - decisions.md: the pin-default lesson (boot-loop → "default only when it cannot do harm"). Reviews (🐇 CodeRabbit) - 🐇 architecture.md lane-count wording — fixed (8-fixed vs 1–8). - 🐇 backlog.md audio "DONE" block — reframed to open follow-ups (shipped status already in decisions.md), not a wholesale history move since the body is open work. - 🐇 AudioModule.md frame cadence — fixed (emit every tick, analyse on completed block). - 🐇 ParallelLedDriver fast-path lane count — fixed (busLaneCount_ check). - 🐇 ParallelLedDriver loopback on stale pin config — fixed (parseConfig/reinit before self-test on pin edits). - 🐇 AudioModule stale frame_ on reinit failure — fixed (deinit zeroes frame_). - 🐇 AudioLevel float-bound UB — fixed (clamp to 2147483520.0f; the suggested >→>= would not help as INT32_MAX rounds to 2^31). - 🐇 AudioModule includes platform.h (domain boundary) — accepted as-is: including the platform abstraction is the accepted seam (9 core modules do it, MoonModule.h included); the boundary check passes. - 🐇 unit_AudioBands signed <<8 UB — accepted as-is: project is C++20, where signed left-shift is well-defined. - 🐇 lcdWs2812Wait should return bool — deferred: rmt/parlio waits are also void; the void+self-heal stance is intentional and changing one of three breaks symmetry. 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 (6)
src/core/AudioModule.h (1)
31-31:⚠️ Potential issue | 🟠 Major | 🏗️ Heavy liftMove platform-coupled audio orchestration out of
src/core/**(or invert through a core-owned interface).
AudioModuledirectly includesplatform/platform.hand invokes platform seams across lifecycle/hot path. That breaks the core platform-independence contract and hard-couplessrc/coreto platform details.As per coding guidelines: files under
src/core/**must be platform-independent and should not include platform-specific dependencies.Also applies to: 128-129, 135-136, 155-156, 194-207, 221-224
🤖 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/AudioModule.h` at line 31, AudioModule currently includes the platform-specific header "platform/platform.h" and calls platform seams directly, violating core platform-independence; remove the direct include and platform calls from src/core by defining a core-owned interface (e.g., IAudioPlatform or IAudioDriver) in the core layer and replace all direct platform interactions in AudioModule (constructor, init/shutdown/hot-path methods inside class AudioModule) with calls to that interface; inject an implementation of the interface from the platform layer (via constructor parameter or setter) and have the platform-specific code implement the interface in platform/ so AudioModule no longer includes platform/platform.h or references platform symbols.Source: Coding guidelines
src/platform/esp32/platform_esp32_rmt.cpp (1)
112-129:⚠️ Potential issue | 🟠 MajorAvoid
rmt_disable()inrmtWs2812DeinitwhenrmtWs2812Waitmay have timed out (src/platform/esp32/platform_esp32_rmt.cpp:112-141)
rmtWs2812Wait()intentionally does not cancel a timed-out transfer viarmt_disable(), butrmtWs2812Deinit()unconditionally callsrmt_disable(st->channel)beforermt_del_channel(). If the TX is still active afterrmt_tx_wait_all_done(st->channel, timeoutMs)times out, teardown/reinit can hit the same classic ESP32 panic path (esp-idf#17692).rmtWs2812Loopback()still setsfirstBadBitto0for any failure (r.firstBadBit = r.pass ? kBits : 0;) instead of the actual first mismatch index. (src/platform/esp32/platform_esp32_rmt.cpp:422-424)🤖 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 112 - 129, rmtWs2812Deinit currently always calls rmt_disable(st->channel) which can trigger the classic-ESP32 panic if rmt_tx_wait_all_done timed out; update rmtWs2812Deinit to observe the result of rmt_tx_wait_all_done (or otherwise check whether the channel is still actively transmitting) and only call rmt_disable when the transfer has completed successfully, otherwise skip rmt_disable and proceed to rmt_del_channel(); also fix rmtWs2812Loopback so that when a loopback check fails you set r.firstBadBit to the actual mismatch index instead of unconditionally setting it to 0 (use the computed mismatch position when r.pass is false, otherwise leave r.firstBadBit = kBits).docs/moonmodules/core/AudioModule.md (1)
62-100:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winMove forward-looking design content out of module spec docs.
The “Adaptive noise gate: forward-looking” section introduces planned behavior in
docs/moonmodules/core/AudioModule.md; this should live in backlog/history docs, with a short link here, to keep module docs strictly “current-state” and avoid spec drift.Based on learnings: "Code, comments, and documentation describe the system as it is now (present tense only). Exceptions: docs/backlog/ and docs/history/."
🤖 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/core/AudioModule.md` around lines 62 - 100, The "Adaptive noise gate: forward-looking" section in AudioModule.md is future design prose and should be moved out of the current-state module spec: remove or replace the full section headed "Adaptive noise gate: forward-looking" with a short one-line pointer to the backlog/history (e.g., docs/backlog or docs/history) and migrate the detailed concept, constraints, verdict, decomposition, and sequencing text into a new backlog/history doc; ensure the AudioModule.md retains only present-tense shipped behavior and a brief link to the new backlog entry.Source: Learnings
test/unit/light/unit_LcdLedDriver.cpp (1)
37-41:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winComment/code mismatch on latch pad constant.
The comment on Line 37 says "784 latch pad" but the code on Line 40 uses
800 + 64 = 864. If 800 is the latch pad and 64 is alignment slack, the comment should say "800 latch pad + 64 slack" (or similar). If 784 is correct, the code is wrong.Proposed fix (assuming 800 + 64 is correct)
-// frameBytes = maxLaneLights × outCh × 24 + 784 latch pad, rounded up to 64. +// frameBytes = maxLaneLights × outCh × 24 + 800 latch pad + 64 slack, rounded up to 64.🤖 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_LcdLedDriver.cpp` around lines 37 - 41, The comment and calculation in expectFrame disagree: the comment says "784 latch pad" but the code uses "800 + 64" (864) as latch+slack. Fix by making them consistent: either change the comment to "800 latch pad + 64 alignment slack" to match the arithmetic in expectFrame, or if 784 is the intended latch pad, change the calculation to use 784 + 64 (i.e., replace 800 with 784) so the code matches the comment; update the comment and/or constant in the expectFrame function accordingly.test/scenarios/light/scenario_AllEffects_grid_sizes.json (1)
22-24:⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift
scenario_AllEffects_grid_sizesno longer matches its “every effect” contract.
RipplesEffectwas removed from the sweep while the scenario name/description still claims all effects. That drops coverage for one live effect and makes perf history incomplete.Please either re-add a
RipplesEffectmeasurement block or rename/reword the scenario to reflect a subset sweep.As per coding guidelines, tests should match the specifications in
docs/moonmodules/.Also applies to: 26-26
🤖 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/scenarios/light/scenario_AllEffects_grid_sizes.json` around lines 22 - 24, The scenario named scenario_AllEffects_grid_sizes no longer includes RipplesEffect but still claims to cover "all effects"; either restore a RipplesEffect measurement block into the effects list (so the array that currently lists "RingsEffect","LavaLampEffect","GameOfLifeEffect" also includes "RipplesEffect") or rename/reword scenario_AllEffects_grid_sizes and its description to accurately describe the subset of effects tested; update any doc string or test metadata that references scenario_AllEffects_grid_sizes to keep tests and docs in sync with the new effect list.Source: Coding guidelines
docs/architecture.md (1)
276-277:⚠️ Potential issue | 🟠 MajorFix Buffer/Correction lifetime contract in
docs/architecture.md(line 276-277)**
Code showsmm::Drivers::onBuildState()re-resolveslayer_fromLayers::activeLayer()and then callspassBufferToDrivers()every rebuild, which updates each child driver’ssetSourceBuffer(...)andsetLayer(...)(and clears them tonullptrwhen there’s no active Layer). So consumers are not “set once at wiring time” across mutations; update line 276-277 to state the pointers are (re)bound duringDrivers::onBuildStateand are only guaranteed valid until the next rebuild, aligning with the robustness rule at line 184.🤖 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` around lines 276 - 277, Update the statement about Buffer*/Correction* lifetime to reflect that mm::Drivers::onBuildState() re-resolves layer_ via Layers::activeLayer() and calls passBufferToDrivers() on each rebuild, which rebinds child drivers via setSourceBuffer(...) and setLayer(...) (and clears them to nullptr when no active Layer); say the pointers are (re)bound during Drivers::onBuildState() and are only guaranteed valid until the next rebuild, matching the robustness rule at line 184.Source: Learnings
🤖 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/LcdLedDriver.md`:
- Line 34: The relative link in docs/moonmodules/light/drivers/LcdLedDriver.md
currently points to ../../tests/unit-tests.md which resolves incorrectly from
this directory; update the link to go up one more level (use
../../../tests/unit-tests.md) so the "unit tests § LcdLedDriver" anchor resolves
correctly.
In `@docs/moonmodules/light/drivers/ParlioLedDriver.md`:
- Line 56: Update the ParlioLedDriver documentation to present-tense by removing
or relocating the forward-looking sentence "Driving a real strip is a later
increment." to the docs/backlog area; keep the current text describing the
tick-scaling and whole-frame loopback self-test (jumper GPIO 32 → 33) as-is but
delete the roadmap sentence from this file and add it to docs/backlog with a
short note that it’s future work.
In `@src/core/AudioModule.h`:
- Around line 199-205: The status "mic: set wsPin / sdPin / sckPin" can remain
stale after a successful mic init because only kInitFailMsg is cleared; update
the mic initialization success path (the function that checks wsPin, sdPin,
sckPin and calls setStatus) to unconditionally clear any prior status message
after a successful init — not just kInitFailMsg — by invoking the module's
status-clear method (or call setStatus with a neutral/OK message) in the success
branch so the earlier "mic: set wsPin / sdPin / sckPin" is removed; look for
symbols wsPin, sdPin, sckPin, setStatus and kInitFailMsg to locate the code to
change.
In `@src/light/drivers/RmtLedDriver.h`:
- Around line 69-70: loopbackRxPin should not default to 0 because GPIO0 is a
valid pin; change its default to an explicit "unset" sentinel (e.g., uint16_t
loopbackRxPin = UINT16_MAX or an optional/invalid value) and update the code
that consumes loopbackRxPin (the loopback setup/receive logic that currently
uses loopbackRxPin directly) to check for that sentinel before performing any
loopback/attach actions; only use a real pin (or default to the bench pin) when
loopbackRxPin has been explicitly configured and avoid touching GPIO0
implicitly.
In `@src/platform/esp32/platform_esp32_parlio.cpp`:
- Around line 231-239: The transmitOnce lambda currently ignores the return
value of parlio_tx_unit_transmit and xSemaphoreTake(st->done,...), so failures
to enqueue TX or timeouts are silent and waste time; update transmitOnce to
capture parlio_tx_unit_transmit's result and, if it indicates failure,
log/report the error and abort the loopback early, and check xSemaphoreTake's
return (expect pdTRUE) and treat a timeout as a failure (log/report and abort or
set a failure flag) instead of proceeding; reference the transmitOnce lambda,
parlio_tx_unit_transmit, xSemaphoreTake, and st->done when making the checks and
ensure the calling loop observes the failure flag to stop further cadence
iterations.
---
Outside diff comments:
In `@docs/architecture.md`:
- Around line 276-277: Update the statement about Buffer*/Correction* lifetime
to reflect that mm::Drivers::onBuildState() re-resolves layer_ via
Layers::activeLayer() and calls passBufferToDrivers() on each rebuild, which
rebinds child drivers via setSourceBuffer(...) and setLayer(...) (and clears
them to nullptr when no active Layer); say the pointers are (re)bound during
Drivers::onBuildState() and are only guaranteed valid until the next rebuild,
matching the robustness rule at line 184.
In `@docs/moonmodules/core/AudioModule.md`:
- Around line 62-100: The "Adaptive noise gate: forward-looking" section in
AudioModule.md is future design prose and should be moved out of the
current-state module spec: remove or replace the full section headed "Adaptive
noise gate: forward-looking" with a short one-line pointer to the
backlog/history (e.g., docs/backlog or docs/history) and migrate the detailed
concept, constraints, verdict, decomposition, and sequencing text into a new
backlog/history doc; ensure the AudioModule.md retains only present-tense
shipped behavior and a brief link to the new backlog entry.
In `@src/core/AudioModule.h`:
- Line 31: AudioModule currently includes the platform-specific header
"platform/platform.h" and calls platform seams directly, violating core
platform-independence; remove the direct include and platform calls from
src/core by defining a core-owned interface (e.g., IAudioPlatform or
IAudioDriver) in the core layer and replace all direct platform interactions in
AudioModule (constructor, init/shutdown/hot-path methods inside class
AudioModule) with calls to that interface; inject an implementation of the
interface from the platform layer (via constructor parameter or setter) and have
the platform-specific code implement the interface in platform/ so AudioModule
no longer includes platform/platform.h or references platform symbols.
In `@src/platform/esp32/platform_esp32_rmt.cpp`:
- Around line 112-129: rmtWs2812Deinit currently always calls
rmt_disable(st->channel) which can trigger the classic-ESP32 panic if
rmt_tx_wait_all_done timed out; update rmtWs2812Deinit to observe the result of
rmt_tx_wait_all_done (or otherwise check whether the channel is still actively
transmitting) and only call rmt_disable when the transfer has completed
successfully, otherwise skip rmt_disable and proceed to rmt_del_channel(); also
fix rmtWs2812Loopback so that when a loopback check fails you set r.firstBadBit
to the actual mismatch index instead of unconditionally setting it to 0 (use the
computed mismatch position when r.pass is false, otherwise leave r.firstBadBit =
kBits).
In `@test/scenarios/light/scenario_AllEffects_grid_sizes.json`:
- Around line 22-24: The scenario named scenario_AllEffects_grid_sizes no longer
includes RipplesEffect but still claims to cover "all effects"; either restore a
RipplesEffect measurement block into the effects list (so the array that
currently lists "RingsEffect","LavaLampEffect","GameOfLifeEffect" also includes
"RipplesEffect") or rename/reword scenario_AllEffects_grid_sizes and its
description to accurately describe the subset of effects tested; update any doc
string or test metadata that references scenario_AllEffects_grid_sizes to keep
tests and docs in sync with the new effect list.
In `@test/unit/light/unit_LcdLedDriver.cpp`:
- Around line 37-41: The comment and calculation in expectFrame disagree: the
comment says "784 latch pad" but the code uses "800 + 64" (864) as latch+slack.
Fix by making them consistent: either change the comment to "800 latch pad + 64
alignment slack" to match the arithmetic in expectFrame, or if 784 is the
intended latch pad, change the calculation to use 784 + 64 (i.e., replace 800
with 784) so the code matches the comment; update the comment and/or constant in
the expectFrame function accordingly.
🪄 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: fa6f93e8-2f2b-403b-a505-dfcc82e8ba77
📒 Files selected for processing (29)
docs/architecture.mddocs/backlog/backlog.mddocs/history/decisions.mddocs/moonmodules/core/AudioModule.mddocs/moonmodules/light/drivers/LcdLedDriver.mddocs/moonmodules/light/drivers/NetworkSendDriver.mddocs/moonmodules/light/drivers/ParlioLedDriver.mddocs/moonmodules/light/drivers/RmtLedDriver.mddocs/moonmodules/light/effects/NetworkReceiveEffect.mddocs/tests/scenario-tests.mddocs/tests/unit-tests.mdsrc/core/AudioModule.hsrc/light/AudioLevel.hsrc/light/drivers/LcdLedDriver.hsrc/light/drivers/ParallelLedDriver.hsrc/light/drivers/ParlioLedDriver.hsrc/light/drivers/RmtLedDriver.hsrc/main.cppsrc/platform/esp32/platform_esp32_lcd.cppsrc/platform/esp32/platform_esp32_parlio.cppsrc/platform/esp32/platform_esp32_rmt.cpptest/CMakeLists.txttest/scenario_runner.cpptest/scenarios/light/scenario_AllEffects_grid_sizes.jsontest/scenarios/light/scenario_Audio_mutation.jsontest/unit/light/unit_AudioModule.cpptest/unit/light/unit_LcdLedDriver.cpptest/unit/light/unit_ParlioLedDriver.cpptest/unit/light/unit_RmtLedDriver_pins.cpp
|
|
||
| ## Tests | ||
|
|
||
| Full case list in the generated [unit tests § LcdLedDriver](../../tests/unit-tests.md#lcdleddriver) (regenerated from the test files, never drifts). What's covered: |
There was a problem hiding this comment.
Fix broken relative link to unit-test docs.
Line 34 points to ../../tests/unit-tests.md, which resolves under docs/moonmodules/tests/... and breaks from this directory. It should go up one more level.
Suggested fix
-Full case list in the generated [unit tests § LcdLedDriver](../../tests/unit-tests.md#lcdleddriver) (regenerated from the test files, never drifts). What's covered:
+Full case list in the generated [unit tests § LcdLedDriver](../../../tests/unit-tests.md#lcdleddriver) (regenerated from the test files, never drifts). What's covered:📝 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.
| Full case list in the generated [unit tests § LcdLedDriver](../../tests/unit-tests.md#lcdleddriver) (regenerated from the test files, never drifts). What's covered: | |
| Full case list in the generated [unit tests § LcdLedDriver](../../../tests/unit-tests.md#lcdleddriver) (regenerated from the test files, never drifts). What's covered: |
🤖 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/LcdLedDriver.md` at line 34, The relative link
in docs/moonmodules/light/drivers/LcdLedDriver.md currently points to
../../tests/unit-tests.md which resolves incorrectly from this directory; update
the link to go up one more level (use ../../../tests/unit-tests.md) so the "unit
tests § LcdLedDriver" anchor resolves correctly.
|
|
||
| - **Encoder (CI, host):** shared with the LCD driver — the 3-slot byte layout is covered under [LcdLedDriver](LcdLedDriver.md#tests); not re-tested here. | ||
| - **Driver (CI, host):** lane slicing (including unequal leds-per-lane), frame-byte math (RGBW growth, alignment rounding, latch pad), the **1–8 lanes accepted** rule (the Parlio-vs-i80 difference), over-8 rejection, bad-pin status + recovery, the empty-default idle (no GPIO claimed until pins are set), zero-grid + loop() crash-safety, teardown. | ||
| - **Hardware:** tick-scaling across grid sizes proves frames clock out; the whole-frame loopback self-test (jumper GPIO 32 → 33) bit-verifies the wire signal on the P4. Driving a real strip is a later increment. |
There was a problem hiding this comment.
Keep module docs present-tense and move roadmap text to backlog.
Line 56 says “Driving a real strip is a later increment.” In docs/moonmodules/**, this should describe current behavior; move forward-looking text to docs/backlog/.
Based on learnings: documentation outside docs/backlog/ and docs/history/ should describe the system as it is now (present tense only).
🤖 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/ParlioLedDriver.md` at line 56, Update the
ParlioLedDriver documentation to present-tense by removing or relocating the
forward-looking sentence "Driving a real strip is a later increment." to the
docs/backlog area; keep the current text describing the tick-scaling and
whole-frame loopback self-test (jumper GPIO 32 → 33) as-is but delete the
roadmap sentence from this file and add it to docs/backlog with a short note
that it’s future work.
Source: Learnings
| // Pins unset (the default until the user wires a mic): stay idle, don't | ||
| // attempt an I2S init. A 0 GPIO is not a valid mic pin, and initialising | ||
| // I2S on unset/arbitrary pins is what hung a mic-less board's boot. | ||
| if (wsPin == 0 || sdPin == 0 || sckPin == 0) { | ||
| setStatus("mic: set wsPin / sdPin / sckPin", Severity::Status); | ||
| return; | ||
| } |
There was a problem hiding this comment.
Clear stale status unconditionally after successful mic init.
If the prior state was “mic: set wsPin / sdPin / sckPin”, successful init does not clear it because Line 218 only clears kInitFailMsg. That leaves a stale status after a healthy init.
Suggested patch
- if (status() == kInitFailMsg) clearStatus();
+ clearStatus();Also applies to: 218-219
🤖 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/AudioModule.h` around lines 199 - 205, The status "mic: set wsPin /
sdPin / sckPin" can remain stale after a successful mic init because only
kInitFailMsg is cleared; update the mic initialization success path (the
function that checks wsPin, sdPin, sckPin and calls setStatus) to
unconditionally clear any prior status message after a successful init — not
just kInitFailMsg — by invoking the module's status-clear method (or call
setStatus with a neutral/OK message) in the success branch so the earlier "mic:
set wsPin / sdPin / sckPin" is removed; look for symbols wsPin, sdPin, sckPin,
setStatus and kInitFailMsg to locate the code to change.
| uint16_t loopbackRxPin = 0; // jumper this to the first pin for the test | ||
| // (unset by default; bench used pin 5) |
There was a problem hiding this comment.
loopbackRxPin is documented as unset, but defaults to GPIO 0.
Line 69 sets loopbackRxPin = 0, and Line 351 consumes it directly. That is not an unset state; it can run loopback against GPIO0 before the user configures RX.
Suggested fix
+ static constexpr uint16_t kUnsetPin = 0xFFFF;
- uint16_t loopbackRxPin = 0; // jumper this to the first pin for the test
+ uint16_t loopbackRxPin = kUnsetPin; // jumper this to the first pin for the test
// (unset by default; bench used pin 5)
@@
void runLoopbackSelfTest() {
@@
+ if (loopbackRxPin == kUnsetPin) {
+ clearFailBuf();
+ setStatus("loopback: set loopbackRxPin", Severity::Warning);
+ return;
+ }
const uint8_t txPin = static_cast<uint8_t>(pinList_[0]);
const uint8_t rxPin = static_cast<uint8_t>(loopbackRxPin);Based on learnings: “Default only when it cannot do harm.”
Also applies to: 350-352
🤖 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/RmtLedDriver.h` around lines 69 - 70, loopbackRxPin should
not default to 0 because GPIO0 is a valid pin; change its default to an explicit
"unset" sentinel (e.g., uint16_t loopbackRxPin = UINT16_MAX or an
optional/invalid value) and update the code that consumes loopbackRxPin (the
loopback setup/receive logic that currently uses loopbackRxPin directly) to
check for that sentinel before performing any loopback/attach actions; only use
a real pin (or default to the bench pin) when loopbackRxPin has been explicitly
configured and avoid touching GPIO0 implicitly.
Source: Learnings
| // The Parlio-specific transmit: ship one frame (length in BITS, not bytes) | ||
| // and wait for its done-callback. Everything else (capture, cadence, bit- | ||
| // verify) is the shared helper. | ||
| parlio_transmit_config_t xcfg = {}; | ||
| xcfg.idle_value = 0; // lines rest LOW between frames (the latch) | ||
| auto transmitOnce = [st, frameBytes, &xcfg]() { | ||
| parlio_tx_unit_transmit(st->unit, st->buf, frameBytes * 8, &xcfg); | ||
| xSemaphoreTake(st->done, pdMS_TO_TICKS(1000)); | ||
| }; |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Locate the file and show relevant surrounding code
FILE="src/platform/esp32/platform_esp32_parlio.cpp"
if [ ! -f "$FILE" ]; then
echo "Missing file: $FILE"
exit 1
fi
echo "=== Context around lines 200-280 ==="
nl -ba "$FILE" | sed -n '200,280p'
echo "=== Search for st->done usage in this file ==="
rg -n "st->done|xSemaphoreTake|xSemaphoreGive|done\s*=" "$FILE" || true
echo "=== Search for parlio_tx_unit_transmit usage in this file ==="
rg -n "parlio_tx_unit_transmit" "$FILE" || true
echo "=== Search for done callback / ISR that likely gives the semaphore ==="
rg -n "done.*callback|callback.*done|parlio.*cb|on_.*done|SemaphoreGive" "$FILE" || trueRepository: MoonModules/projectMM
Length of output: 164
🏁 Script executed:
#!/bin/bash
set -euo pipefail
FILE="src/platform/esp32/platform_esp32_parlio.cpp"
test -f "$FILE"
echo "=== Context around the referenced section (lines ~200-280) ==="
# cat -n is available; limit output
cat -n "$FILE" | sed -n '200,280p'
echo "=== Search for semaphore wait / give usage ==="
rg -n "xSemaphoreTake|xSemaphoreGive|st->done|done\s*=|SemaphoreGive|SemaphoreHandle_t" "$FILE" || true
echo "=== Search for parlio_tx_unit_transmit call sites ==="
rg -n "parlio_tx_unit_transmit" "$FILE" || true
echo "=== Search for the done callback / ISR path that likely signals st->done ==="
rg -n "parlio.*callback|callback.*parlio|done.*callback|tx.*done|on_.*done|SemaphoreGive" "$FILE" || trueRepository: MoonModules/projectMM
Length of output: 4835
🏁 Script executed:
#!/bin/bash
set -euo pipefail
FILE="src/platform/esp32/platform_esp32_parlio.cpp"
echo "=== Context around detail helpers and captureAndVerifyFrame (lines 1-220) ==="
cat -n "$FILE" | sed -n '1,220p'
echo "=== Extract captureAndVerifyFrame body only (if any) ==="
# Use a narrower range likely covering implementation
cat -n "$FILE" | sed -n '220,360p'Repository: MoonModules/projectMM
Length of output: 13766
🏁 Script executed:
#!/bin/bash
set -euo pipefail
rg -n "void captureAndVerifyFrame\\(" -S . || true
rg -n "captureAndVerifyFrame\\(" src/platform -S || true
# If found, print a focused window around the definition.
FILE="$(rg -n "void captureAndVerifyFrame\\(" -S . | head -n 1 | cut -d: -f1 || true)"
if [ -n "${FILE:-}" ] && [ -f "$FILE" ]; then
echo "=== Definition file: $FILE ==="
start=$(($(rg -n "void captureAndVerifyFrame\\(" "$FILE" | head -n 1 | cut -d: -f1)-20)) || true
start=$(( start<1 ? 1 : start ))
end=$(( start+120 ))
cat -n "$FILE" | sed -n "${start},${end}p"
fi
# Also find RmtLoopbackResult handling to see how failure is represented.
rg -n "struct RmtLoopbackResult|RmtLoopbackResult" src/platform -S || trueRepository: MoonModules/projectMM
Length of output: 10770
🏁 Script executed:
#!/bin/bash
set -euo pipefail
FILE="src/platform/platform.h"
test -f "$FILE"
echo "=== RmtLoopbackResult definition ==="
cat -n "$FILE" | sed -n '320,430p'Repository: MoonModules/projectMM
Length of output: 7830
🏁 Script executed:
#!/bin/bash
set -euo pipefail
FILE="src/platform/esp32/platform_esp32_lcd.cpp"
test -f "$FILE"
echo "=== Context around lcdWs2812Loopback transmitOnce ==="
cat -n "$FILE" | sed -n '200,320p'
echo "=== Search for transmitOnce lambdas in lcd file ==="
rg -n "transmitOnce" "$FILE" || trueRepository: MoonModules/projectMM
Length of output: 4760
Handle Parlio loopback TX enqueue/wait failures explicitly.
The loopback transmitOnce ignores both parlio_tx_unit_transmit(...) and the return value of xSemaphoreTake(st->done, ...). If TX enqueue fails or the done-callback never arrives, the code still proceeds after the full timeout without any diagnostics (and with potentially wasted time during the loopback cadence), even though the final pass/fail is derived from captured/verified symbols.
Suggested patch
- auto transmitOnce = [st, frameBytes, &xcfg]() {
- parlio_tx_unit_transmit(st->unit, st->buf, frameBytes * 8, &xcfg);
- xSemaphoreTake(st->done, pdMS_TO_TICKS(1000));
- };
+ auto transmitOnce = [st, frameBytes, &xcfg]() {
+ if (parlio_tx_unit_transmit(st->unit, st->buf, frameBytes * 8, &xcfg) != ESP_OK) {
+ ESP_LOGE(PAR_TAG, "loopback: transmit failed");
+ return;
+ }
+ if (xSemaphoreTake(st->done, pdMS_TO_TICKS(1000)) != pdTRUE) {
+ ESP_LOGE(PAR_TAG, "loopback: transmit timeout");
+ }
+ };📝 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.
| // The Parlio-specific transmit: ship one frame (length in BITS, not bytes) | |
| // and wait for its done-callback. Everything else (capture, cadence, bit- | |
| // verify) is the shared helper. | |
| parlio_transmit_config_t xcfg = {}; | |
| xcfg.idle_value = 0; // lines rest LOW between frames (the latch) | |
| auto transmitOnce = [st, frameBytes, &xcfg]() { | |
| parlio_tx_unit_transmit(st->unit, st->buf, frameBytes * 8, &xcfg); | |
| xSemaphoreTake(st->done, pdMS_TO_TICKS(1000)); | |
| }; | |
| // The Parlio-specific transmit: ship one frame (length in BITS, not bytes) | |
| // and wait for its done-callback. Everything else (capture, cadence, bit- | |
| // verify) is the shared helper. | |
| parlio_transmit_config_t xcfg = {}; | |
| xcfg.idle_value = 0; // lines rest LOW between frames (the latch) | |
| auto transmitOnce = [st, frameBytes, &xcfg]() { | |
| if (parlio_tx_unit_transmit(st->unit, st->buf, frameBytes * 8, &xcfg) != ESP_OK) { | |
| ESP_LOGE(PAR_TAG, "loopback: transmit failed"); | |
| return; | |
| } | |
| if (xSemaphoreTake(st->done, pdMS_TO_TICKS(1000)) != pdTRUE) { | |
| ESP_LOGE(PAR_TAG, "loopback: transmit timeout"); | |
| } | |
| }; |
🤖 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_parlio.cpp` around lines 231 - 239, The
transmitOnce lambda currently ignores the return value of
parlio_tx_unit_transmit and xSemaphoreTake(st->done,...), so failures to enqueue
TX or timeouts are silent and waste time; update transmitOnce to capture
parlio_tx_unit_transmit's result and, if it indicates failure, log/report the
error and abort the loopback early, and check xSemaphoreTake's return (expect
pdTRUE) and treat a timeout as a failure (log/report and abort or set a failure
flag) instead of proceeding; reference the transmitOnce lambda,
parlio_tx_unit_transmit, xSemaphoreTake, and st->done when making the checks and
ensure the calling loop observes the failure flag to stop further cadence
iterations.
…ot principle Folds in the pre-merge Reviewer (Fable) findings and a second CodeRabbit batch, then surfaces the project's strongpoints where new readers see them: a "No reboot to apply a configuration change" principle in CLAUDE.md (linked to architecture.md), and README bullets for live reconfiguration, robustness, parallel LED output, multi-protocol I/O, audio, and "industry standards, our own code". Also moves the audio DSP headers core-ward to fix the one domain-boundary inversion, and trims the permission allow-list. KPI: 16384lights | PC:340KB | tick:118/88/118/127/56/1/38/19/120/334/9us(FPS:8474/11363/8474/7874/17857/1000000/26315/52631/8333/2994/111111) | ESP32:1152KB | tick:5319us(FPS:188) | heap:153KB | src:88(16567) | test:54(8719) | lizard:66w Core - AudioFrame.h / AudioLevel.h / AudioBands.h: moved src/light/ → src/core/ — they are domain-neutral DSP (DC-blocker, RMS, Hann, bands) and were the only core→light includes in the tree; AudioModule (core) now depends only on core. Updated all include sites + the platform.h prose. - AudioModule: a successful mic init now clears ANY prior status (not just kInitFailMsg), so the "set wsPin / sdPin / sckPin" note doesn't persist after the user fills the pins in. Light domain - RmtLedDriver: the loopback self-test now re-parses (parseConfig/reinit) before running on a `pins` edit — onUpdate fires before the buildState sweep, so without it the test transmitted on the stale pin and showed a verdict for the previous one. Mirrors the ParallelLedDriver fix; the dedup had left RMT with the un-fixed copy. Core/Light (platform) - platform_esp32_lcd / platform_esp32_parlio: the loopback `transmitOnce` now checks the transmit-enqueue result and the done-callback semaphore, logging a failure instead of letting it surface only as a later capture mismatch (both siblings, same handling). Tests - unit_RmtLedDriver_lifecycle: new test pinning the stale-pin loopback fix (red→green proven — fails without the fix, passes with it). - unit_LcdLedDriver: corrected the expectFrame comment (800 latch pad + 64 slack, was "784"). - scenario_AllEffects_grid_sizes: description now lists the 14 covered effects and names RipplesEffect as the gap (its measurement block is backlogged), instead of claiming "every effect". Docs / CI - README: "What makes projectMM different" gains live-reconfiguration (no reboot), robustness, parallel WS2812 output (RMT/LCD_CAM/Parlio), Art-Net/E1.31/DDP send+receive, and audio-reactive; "Under the hood" gains "Industry standards, our own code" (spec + test, no copied code). The old "survives reboots" line was corrected to lead with live reconfig. - CLAUDE.md: new principle "No reboot to apply a configuration change" (links architecture.md § Live reconfiguration, scopes out the firmware-OTA power cycle); softened the absolute "never copy/trace" to "carry the ideas forward, write our own code rather than copying theirs"; trimmed a duplicated Industry-standards restatement in the history-folder note; the permission-review gate now records a tracked settings.local.cleaned.json snapshot after approval, with the why (CC re-accumulates allow entries, so the live gitignored file looks like it "resets back"). - architecture.md: new § Live reconfiguration (every config change applies live via the onBuildState sweep, OTA the one power-cycle exception); corrected the Buffer*/Correction* lifetime note (pointers are re-bound each rebuild via Drivers::onBuildState/passBufferToDrivers, valid only until the next); corrected the parallel-driver lane counts (Lcd exactly-8, Parlio 1–8); added delayUs to the platform scheduling list. - decisions.md: live-reconfiguration lesson — the no-reboot property falls out of the prepare-pass for free; MoonLight's "initless drivers" is the credited lineage but a different mechanism (their no-initLed Context vs our generic onBuildState rebuild), so the word stays with MoonLight. - driver specs (Rmt/Lcd/Parlio/NetworkSend/Audio): link the new § Live reconfiguration at each live-change control; fixed the unit-test inventory links (../../tests → ../../../tests); added primary-source standard links (RMT peripheral, Art-Net/E1.31/DDP); removed a forward-looking "later increment" sentence (already tracked in backlog Round 4). - backlog: RipplesEffect AllEffects-sweep coverage entry. Permissions - .claude/settings.local.json: removed 134 one-off allow entries (hard-coded device-IP curls, dead MicModule/LcdLed payloads, /tmp scratch, bare python3 -c) — 208 → 74; saved the cleaned list as the tracked settings.local.cleaned.json baseline. Reviews - 👾 Reviewer (Fable, whole-branch): core→light audio includes — fixed (moved to core); RMT stale-pin loopback — fixed + test; pacing-doc drift — fixed (non-blocking millis() gate, not a blocking delay); delayUs undocumented — fixed. - 🐇 CodeRabbit: driver-doc test-link depth — fixed (3 docs); AudioModule stale status — fixed; Parlio/LCD transmitOnce error handling — fixed; expectFrame comment — fixed; AllEffects RipplesEffect — reworded + backlogged; Buffer*/Correction* lifetime prose — fixed; "later increment" present-tense — removed. - 🐇 firstBadBit "always 0" — not a real issue: the code already sets the actual mismatch index; skipped. - 🐇 RMT loopbackRxPin=0 sentinel — skipped: 0=unset is consistent with all user-soldered pins, and the jumper continuity pre-check handles a wrong/unset pin. - 🐇 AudioModule includes platform.h — skipped: the platform abstraction include is the accepted seam (9 core modules use it); boundary check passes. - 🐇 unit_AudioBands signed <<8 — skipped: C++20 defines signed left-shift. - 🐇 lcdWs2812Wait should return bool — skipped: rmt/parlio waits are also void; the void + self-heal stance is intentional. - 🐇 adaptive-noise-gate section "move out" — skipped: it already carries a justified present-tense exception (a deliberate decision). - 🐇 rmtWs2812Deinit unconditional rmt_disable — deferred: a narrow classic-ESP32 panic risk needing the same hardware validation as the documented no-cancel stance; a blind fix could wedge teardown. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The LED-driver + multi-platform output increment: WS2812 across three peripherals (RMT, S3 LCD_CAM i80, P4 Parlio), multi-protocol network I/O (Art-Net / E1.31 / DDP both directions), ESP32-P4 board support, and an audio-reactive input module (I²S mic + esp-dsp FFT). Built contract-first → test-first → implement-to-green throughout, each peripheral proven on real hardware (CI encoder test + on-chip loopback self-test + a real strip/panel).
What's in it
LED drivers — three peripherals, one shared encoder
DriverBase.RmtSymbol.h,LcdSlots.h) so they're host-testable with no ESP32. Each driver has an on-device loopback self-test that bit-verifies the wire signal (RMT-RX capture, transmitter-agnostic), shared viadetail::captureAndVerifyFrame.Multi-protocol network I/O
ESP32-P4 support — P4-NANO board (Ethernet-only landed; WiFi-via-C6-coprocessor scaffolded, C6 slave-firmware provisioning tracked in backlog). All three LED peripherals coexist on the P4, each driver auto-wired off its SOC-capability gate.
Audio-reactive input — AudioModule reads an INMP441-class I²S mic and publishes an
AudioFrame(RMS level + 16-band log spectrum + dominant peak) via esp-dsp's float FFT; AudioVolumeEffect / AudioSpectrumEffect consume it through a static accessor (add/remove in any order). Designed fresh from the datasheet + textbook DSP (DC-blocker, Hann window, RMS, geometric bands), not traced from prior projects.Pin-default safety — user-soldered pins (LED strands, mic) default unset: adding a driver or the mic never grabs a GPIO the user wired elsewhere ("assign a default only when it cannot do harm"). Peripheral-/chip-fixed pins (i80 WR/DC, the Ethernet map) keep their defaults. This fixed a classic-ESP32 boot-loop (auto-wired mic → blocking I²S init on a mic-less board).
Methodology
Every peripheral followed lock the contract → write the proof as a failing (red) test → implement to green → prove on hardware. Success is a verified red→green transition plus an on-device loopback going green, not an opinion. The CRTP dedup was proven zero-overhead by an A/B tick measurement on the same S3.
Testing
Reviews
CodeRabbit findings processed across the branch (line-level bugs). The final commit processed the latest batch (7 fixed, 3 accepted-with-reason — see commit body). Event-2 Reviewer pass over the whole branch diff run at merge.
🤖 Generated with Claude Code