Skip to content

Faster, wider LED output: SWAR encode, async DMA, a second core, 16 lanes, and many-tube network send - #50

Merged
ewowi merged 15 commits into
mainfrom
next-iteration
Jul 13, 2026
Merged

Faster, wider LED output: SWAR encode, async DMA, a second core, 16 lanes, and many-tube network send#50
ewowi merged 15 commits into
mainfrom
next-iteration

Conversation

@ewowi

@ewowi ewowi commented Jul 11, 2026

Copy link
Copy Markdown
Collaborator

Eight commits of LED-output work: the drivers get faster (a SWAR transpose, async DMA, a second core), wider (16 lanes, a classic-ESP32 i80 driver), and able to feed a wall of network tubes from one driver. Along the way the light-preset model became a reusable library, the 1 Hz WebSocket push became a diff, and the module UI stopped growing without limit.

Measured on hardware (P4 + S3, 128×128 grid): the multicore split alone took the board from 32 → 46 fps (+44%) and moved 85% of the output stage off the render core (Drivers 15,404 → 2,261 µs). The async DMA double-buffer took the P4 from 48 → 76 fps by hiding the WS2812 wire behind the encode. Both are switches, both default on.

What's in it

LED output — faster

  • SWAR transpose (ParallelSlots.h) — the bit→slot encode is 85% of driver time; the branch-free 3-delta-swap (Warren, Hacker's Delight) replaced a per-bit-per-lane gather. Zero table, less code, ~20% whole-board fps.
  • Async DMA double-buffer (asyncTransmit, default on) — encode frame N+1 while the peripheral clocks out frame N. A new wireUs KPI reports the WS2812 wire floor directly, so the real output ceiling is visible rather than inferred.
  • Multicore render↔encode split (multicore, default on) — a core-1 worker runs the whole output stage (LED encode, ArtNet build, preview build) while core 0 renders the next frame and serves the network. One rule, no per-driver opt-out: the container owns the mechanism. This also fixed a real failure — a heavy encode on core 0 was starving the network stack until HTTP stopped answering.

LED output — wider

  • 16 lanes on LCD_CAM (S3/P4) and Parlio (P4); bus width derives from the pin count.
  • Classic-ESP32 i80 driver — the same I80LedDriver now drives the classic chip's I2S peripheral in i80 mode (IDF routes one API to I2S on classic, LCD_CAM on S3/P4). Measured ceiling 2,048 lights, and it degrades with a status rather than crashing.
  • Any-channel-count fixtures — an RGBW panel and a GRB strip can share one board, each with its own preset and brightness.

Network output — many tubes, one driver

  • NetworkSendDriver feeds one or more receivers: ips takes a range (192.168.1.70-74) or a list (192.168.1.60,61,62,65), and lightsPerIp splits the window across them using the same broadcasting idiom as an LED driver's ledsPerPin. Each tube is unicast only its own slice.
  • The destination now defaults to unset. Broadcasting a 128×128 grid is ~97 Art-Net universes × 50 fps ≈ 4,850 pkt/s — measured starving an ESP32's network stack until it stopped answering HTTP. Art-Net 4 requires unicast ("there are no conditions in which broadcast is allowed"); an unconfigured driver now idles with a status instead of flooding the LAN.

Light presets

  • A reusable, editable library instead of a per-driver enum: named channel-role wirings referenced by a stable id, resolved once at config time into the flat correction (the render loop never touches a preset). Ships 13 built-in fixtures.

Core

  • Diff-on-the-wire state push — the 1 Hz WebSocket update sends only changed leaves instead of re-serialising the whole ~34 KB tree. This fixed a visible 1 Hz LED stutter: the periodic push runs inline on the render thread, so a heavy tick1s steals from the render budget at exactly its cadence.
  • MoonModule::quiesce() — core parks a module's worker before any structural mutation of its children, so deleting a driver mid-encode can't free memory the worker is inside. Fixes a use-after-free.
  • A worker-task seam (spawnPinnedTask/notifyTask/waitNotify) — textbook FreeRTOS SPSC on ESP32, std::thread+condvar on desktop so the cross-core invariants are host-testable.

UI

  • Every top-level module shows its children as tabs (one at a time, drag to reorder) instead of a stack that grows without limit. Tabs carry a fault dot, so an error on a background tab is still visible.

Testing

  • A sanitizer gate that did not exist — ASan + TSan, locally (check_sanitizers.py) and in CI. The multicore split put a second thread beside code that had been single-threaded for its whole life; hand-written tests prove the races we thought of, sanitizers find the rest. On macOS, Apple's ASan runtime hangs during its own startup while Homebrew's works, and TSan crashes under both — the script probes each runtime first and reports SKIPPED rather than a misleading pass.

Breaking changes

No migration code (per ADR-0013); each break is documented and the lost value re-populates on next use.

  • NetworkSendDriver.ipips: a persisted NetworkSend driver is dropped on first boot after this change. Re-add it once per board.
  • DevicesModule persisted key colourcolor: a cached Hue bridge's light count reads as 0 until the bridge is next heard.
  • .config/LayoutGroup.jsonLayouts.json, DriverGroup.jsonDrivers.json; localStorage mm.selectedModulemm_selected.

Verification

  • 778 unit tests, 16 scenarios, zero build warnings (-Wall -Wextra -Werror).
  • ASan clean on the concurrency suites. TSan has not run against the multicore code yet — it can't run on macOS, and the CI job that covers it is new in this PR; it gets its first real exercise here.
  • Hardware: ESP32-P4, ESP32-S3 (SE16 + LightCrafter 16), ESP32-WROVER. All three chips build clean; the drivers were verified driving real strips.

Light presets become a reusable, editable library: named channel-role wirings a driver references by stable id, resolved once at config time into its flat correction (the render loop never touches a preset). Ships 13 built-in fixtures — the colour orders plus par/curtain fixtures and three moving heads migrated from MoonLight — and fixes three live-editing bugs (dropdown collapse, drag reorder, and an LED blank on preset edit).

16384lights | Desktop:666KB | tick:120/92/121/15/2/283/60/18/21/163/118/17/1/36us(FPS:8333/10869/8264/66666/500000/3533/16666/55555/47619/6134/8474/58823/1000000/27777) | ESP32:1395KB | src:192(37686) | test:128(20022) | lizard:136w

Core:
- LightPresetsModule: the reusable named-preset library (Drivers submodule), editable-list rows, uncapped role-pool storage, resolves a preset id into a driver's flat Correction on the cold path; seeded built-ins are data-driven (a width-agnostic role-array table) with a static_assert guarding name length; non-deletable boot-wired singleton
- ChannelRole: added WarmWhite, Yellow, UV colour roles (append-only after White, so persisted role bytes never renumber)
- Control: EditableListSource CRUD hooks (add/delete/move/setField) on stable row ids — the editable-list primitive, first consumed by LightPresets
- HttpServerModule: /api/list PATCH+POST+DELETE handlers; afterListMutation does a tier-1 correction refresh (Drivers::rebuildAllCorrections) instead of prepareTree, so a preset edit no longer reinits driver peripherals (which blanked the strip for a tick)
- FilesystemModule/JsonUtil: streaming save + heap-backed parser, removing the 2 KB file cap and fixed node arena so a wide multi-preset config serialises without truncation
- FileManagerModule: cached total, refreshed on throttled tick1s (no LittleFS block-walk on a control write)

Light domain:
- Correction: per-driver owned correction with role-array-derived offsets and a WhiteMode enum; rebuildBrightness bakes the LUT even without a library
- DriverBase: template-method defineControls (defineCorrectionControls + defineDriverControls); preset Select built from the library; whiteMode hidden unless the referenced preset carries white; dangling-preset id falls back to the default built-in
- Drivers: rebuildAllCorrections() re-resolves every driver's correction without a structural rebuild; acceptsChildRoles narrowed to "driver" so the add-module picker isn't flooded with generic system modules
- Rmt/Parallel/NetworkSend/Hue drivers: adapt output width live on a channels-per-light change; the two LED drivers chain onControlChanged to DriverBase (fixing a dead localBrightness control)

UI:
- app.js: list rebuild guarded by a JSON signature (device pushes full state ~1/s; unconditional rebuild collapsed open dropdowns and broke drag); open-panel state keyed on stable row id; drag-to-reorder armed by a capture-phase mousedown gate on the handle (the module-card pattern); shared listRowKey across both render paths
- style.css: editable-list drag handle, dragging + drop-target states

Tests:
- unit_LightPresetsModule: built-in seed + resolution, WarmWhite counts as white, moving-head width, append-only role-index regression, and a missing-preset fallback-to-default (migration safety)
- unit_Drivers_container: rebuildAllCorrections re-resolves without re-preparing; Drivers accepts only driver children; LightPresets is a non-deletable singleton
- persistence/Correction/driver tests updated for the 13 built-ins and the new correction model

Docs/CI:
- supporting.md + LightPresetsModule @card lightpresets.png; CLAUDE.md: bench boards are free test rigs (with a rigorous-change heads-up); saved the migration plan (shipped)

Reviews:
- 👾 JsonUtil cap removal trades a fixed arena for transient debounced heap — accepted: required by the unbounded-preset feature, off the hot path, verified on P4 all session with no fragmentation
- 👾 driver preset-name option pointers borrow into the library's presets_ array — accepted: afterListMutation rebuilds every driver's controls before any serialize, so the borrowed names are refreshed before a stale read; the missing-preset fallback test exercises delete-under-a-reference

Note: ESP32 tick/FPS absent from the KPI line — the configured KPI board is offline this session (the P4 testbench is on an alternate serial port); desktop KPI is complete.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 11, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 350b5224-3e4a-4c17-93b5-3655a74d1d01

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 46.39% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is clearly related to the main changes: faster/wider LED output, 16-lane support, async DMA, multicore split, and multi-destination network send.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch next-iteration

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/light/ChannelRole.h`:
- Around line 34-39: Add a compile-time static_assert near kChannelRoleCount
that verifies the options array length matches the ChannelRole enum’s
final/sentinel value, preserving the index-alignment contract used by
Correction.h and LightPresetsModule.h. Ensure the assertion catches either an
enum entry or option string being added without updating the other.

In `@src/light/drivers/Correction.h`:
- Around line 151-160: Update the white-channel handling in apply() so
out[offWhite] is explicitly set to 0 when whiteMode is WhiteMode::None, while
preserving the existing Accurate and other enabled-mode behavior that writes the
computed white component.

In `@src/light/drivers/LightPresetsModule.h`:
- Around line 238-243: Replace the channel-index conversion in the field-update
logic with strtol, validating errno for range errors, the end pointer for a
fully numeric suffix, and the parsed value before using it as an index. Reject
malformed or out-of-range channel names instead of coercing them to zero, while
preserving the existing p.channelCount bounds check and role assignment
behavior.

In `@test/unit/core/unit_FilesystemModule_persistence.cpp`:
- Line 330: Update the inline comment on scheduler.setup() to state that it
seeds 13 built-ins, matching the seeded count used by the subsequent assertions.
🪄 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: 0ea404a8-ee7e-4e29-ae13-d5431d0aec69

📥 Commits

Reviewing files that changed from the base of the PR and between d07d870 and 5920ae6.

⛔ Files ignored due to path filters (5)
  • docs/assets/deviceModels/serg-minishield pinout.png is excluded by !**/*.png
  • docs/assets/deviceModels/serg-minishield.jpg is excluded by !**/*.jpg
  • docs/assets/deviceModels/serg-unishield-v5 pinout.png is excluded by !**/*.png
  • docs/assets/deviceModels/serg-unishield-v5.jpg is excluded by !**/*.jpg
  • docs/assets/light/drivers/lightpresets.png is excluded by !**/*.png
📒 Files selected for processing (39)
  • CLAUDE.md
  • docs/backlog/backlog-light.md
  • docs/history/plans/Plan-20260711 - Flexible light profile (channel-role offsets).md
  • docs/history/plans/Plan-20260711 - LightPresets reusable named-preset library.md
  • docs/history/plans/Plan-20260711 - Migrate MoonLight fixture presets as built-ins (shipped).md
  • docs/moonmodules/light/supporting.md
  • src/core/Control.h
  • src/core/FileManagerModule.cpp
  • src/core/FilesystemModule.cpp
  • src/core/FilesystemModule.h
  • src/core/HttpServerModule.cpp
  • src/core/HttpServerModule.h
  • src/core/JsonUtil.h
  • src/light/ChannelRole.h
  • src/light/drivers/Correction.h
  • src/light/drivers/DriverBase.h
  • src/light/drivers/Drivers.h
  • src/light/drivers/HueDriver.h
  • src/light/drivers/LightPresetsModule.h
  • src/light/drivers/NetworkSendDriver.h
  • src/light/drivers/ParallelLedDriver.h
  • src/light/drivers/PreviewDriver.h
  • src/light/drivers/RmtLedDriver.h
  • src/main.cpp
  • src/ui/app.js
  • src/ui/style.css
  • test/CMakeLists.txt
  • test/unit/core/unit_Control_list.cpp
  • test/unit/core/unit_FilesystemModule_persistence.cpp
  • test/unit/core/unit_JsonUtil_parse.cpp
  • test/unit/light/unit_Correction.cpp
  • test/unit/light/unit_Drivers_container.cpp
  • test/unit/light/unit_LcdLedDriver.cpp
  • test/unit/light/unit_LightPresetsModule.cpp
  • test/unit/light/unit_NetworkSendDriver_no_alloc_in_loop.cpp
  • test/unit/light/unit_ParlioLedDriver.cpp
  • test/unit/light/unit_RmtLedDriver_lifecycle.cpp
  • test/unit/light/unit_RmtLedDriver_pins.cpp
  • web-installer/deviceModels.json

Comment thread src/light/ChannelRole.h
Comment thread src/light/drivers/Correction.h Outdated
Comment thread src/light/drivers/LightPresetsModule.h Outdated
Comment thread test/unit/core/unit_FilesystemModule_persistence.cpp Outdated
Fixes a periodic LED stutter that appeared only with a browser open: the device re-serialised the whole ~34 KB module tree every second on the render thread. Now it sends the full state once on connect and a small patch of only-changed values each second (~1–2 KB), so the render tick is no longer stolen by the state push. Also hoists the repeated channel-role option list out of every preset row and processes four review findings.

16384lights | Desktop:666KB | tick:123/92/118/15/2/284/59/17/21/165/117/16/1/36us(FPS:8130/10869/8474/66666/500000/3521/16949/58823/47619/6060/8547/62500/1000000/27777) | ESP32:1399KB | src:192(37954) | test:129(20175) | lizard:137w

Core:
- HttpServerModule: diff-on-the-wire WS state push — full state on connect / after a structural change, then buildStatePatch sends only controls (and live per-module telemetry) whose value changed, detected by FNV-1a value-hash compare against a global cache (so device-side writes like tickTimeUs are caught the same as setControl writes, no per-write instrumentation). The cache is a growable ScratchBuffer<LeafHash> (no fixed cap). requestFullResync() after every structural mutation. The one large full-state frame drains through the resumable tick20ms sender, off the render tick.
- Control: writeListOptionSets — a list emits its shared select options ONCE (optionSets), rows reference them via optionsRef, instead of re-inlining the identical option array in every row (the preset channel-role pickers were ~20 KB of pure duplication per second).
- JsonSink: detach() hands the built heap buffer to a caller (unique_ptr::release idiom), so the state frame moves into the resumable sender with no copy.

Light domain:
- ChannelRole: static_assert pins the option-string array length to the enum count (index-alignment can't silently drift).
- Correction: apply() now writes the white channel to 0 when whiteMode is None — the reused per-frame buffer otherwise held a stale value and stuck the white LED on after switching to None.
- LightPresetsModule: writeListRowDetail references the shared channelRole option set; setListRowField parses the ch<N> index with strtol (rejects a malformed suffix / out-of-range) instead of atoi coercing it to channel 0.

UI:
- app.js: apply {patch:[...]} frames onto the in-memory state via applyStatePatch + the existing per-control update (no rebuild); full {modules} frames re-render (structural resync). Select fields resolve options from optionsRef against the list's optionSets. Editable list scrolls at ~5 rows with the +Add button pinned and scroll position preserved across a WS refresh.
- style.css: .list-scroll (capped, scrollable) + the pinned add-button footer.

Tests:
- unit_HttpServerModule_apply: buildStatePatch yields an empty patch when nothing changed and a one-entry patch on a device-side value change (value-compare, not a dirty flag).
- unit_JsonSink_detach: detach ownership — no double-free / no leak (ASAN), fixed/empty-mode no-ops.
- unit_LightPresetsModule: options serialised once (optionsRef), malformed ch<N> field rejected; unit_Correction: whiteMode None forces white to 0; persistence + list-metadata tests updated for optionSets.

Docs/CI:
- CLAUDE.md: new Hard Rule "the sub-hot path is a hot path too" — tick20ms/tick1s run inline on the render thread, so periodic work must be cheap / amortised / skipped-when-unchanged, with the option-hoist + diff-on-the-wire as the canonical examples.
- Saved the diff-on-the-wire, resumable-text-send (shipped) and list-detail-on-demand (attempted, abandoned) plans.

Reviews:
- 👾 buildStatePatch grow-branch would corrupt the non-preserving ScratchBuffer cache if it ever fired (unreachable — structural changes always resync first): removed the bad resize, made the branch emit-but-don't-cache; fixed two stale comments (resize is non-preserving; name uniqueness comes from dedup-at-setup + ensureUniqueName-per-mutation).
- 🐇 ChannelRole options/enum alignment static_assert added; whiteMode-None stale white fixed; ch<N> index parsed with strtol not atoi — all three verified against current code and test-pinned.

Note: ESP32 tick/FPS absent from the KPI line — collect_kpi's configured serial port is not the port any bench board is currently on; desktop KPI is complete and the ESP32 firmware built + flashed clean to all four bench boards (2×P4, classic Olimex, S3).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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/core/HttpServerModule.cpp (3)

1910-1935: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

afterListMutation() doesn't request a full resync, but its own comment says a mutation reshapes other modules' option metadata.

The comment here explicitly states: "adding/removing a light preset changes the option set of every driver's preset Select." That's exactly the kind of change requestFullResync() exists for (see the identical rationale at the 5 module-CRUD call sites). Without it, only the client that performed the mutation sees the update — via its own refetchState() HTTP GET in app.js. Any other connected WS client (a second tab, another observer) keeps receiving only value-patches and never learns that a driver's preset dropdown grew/shrank/renamed, until an unrelated structural change or reconnect happens to trigger a resync.

🐛 Proposed fix
     if (auto* drivers = static_cast<Drivers*>(findModuleByName("Drivers")))
         drivers->rebuildAllCorrections();
+    // A list edit can reshape other modules' Select option sets (e.g. every driver's `preset`
+    // dropdown) even when no leaf VALUE changed — the diff-on-wire patch can't describe that, so
+    // force a full resync for every connected client, not just the one that made the edit.
+    requestFullResync();
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/HttpServerModule.cpp` around lines 1910 - 1935, Update
HttpServerModule::afterListMutation() to call scheduler_->requestFullResync()
after rebuilding controls and corrections, ensuring every connected WebSocket
client receives changed option metadata after a list mutation. Keep the existing
dirty-marking, control rebuilding, and correction refresh behavior unchanged.

1958-1963: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Reorder to isn't bounds-checked before the uint8_t cast — can silently move a row to the wrong index.

to < 0 is checked, but a to beyond 255 wraps on static_cast<uint8_t>(to) (e.g. to=2615) before moveListRow's internal if (to >= n) to = n - 1; clamp ever sees the original value. If the wrapped value happens to land inside [0, n), the row moves to an unintended position instead of being rejected or clamped to the list end. handleMoveModule (a few hundred lines up) guards this correctly with to >= parent->childCount(); this path lacks the equivalent check.

🐛 Proposed fix
     if (mm::json::hasKey(jsonBody, "to")) {
         int to = mm::json::parseInt(jsonBody, "to");
-        if (to < 0 || !src->moveListRow(id, static_cast<uint8_t>(to))) {
+        if (to < 0 || to > 255 || !src->moveListRow(id, static_cast<uint8_t>(to))) {
             sendResponse(conn, 400, "application/json", "{\"error\":\"move failed\"}");
             return;
         }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/HttpServerModule.cpp` around lines 1958 - 1963, Update the reorder
handling in the JSON “to” branch of HttpServerModule to validate the parsed
integer before converting it to uint8_t, rejecting values above the valid list
boundary instead of allowing wraparound. Mirror the bounds-checking behavior
used by handleMoveModule, then call moveListRow only with a validated index and
preserve the existing 400 response for invalid moves.

1884-1887: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Row id parsed with strtoul but never validated as clean digits.

/api/list/<mod>/<ctrl>/<id> accepts any garbage after the second slash: a non-numeric id (".../xyz") silently resolves to outId = 0, and trailing junk after digits (".../5abc") silently truncates to 5. Elsewhere in this same file, Content-Length parsing deliberately rejects non-numeric/trailing-junk input with strtol + errno/end-pointer checks rather than tolerating a garbage prefix — this path skips that same rigor, so a malformed id can silently target row 0 (or an unintended row) instead of failing with 400.

🛡️ Proposed fix
     if (s2 && s2[1]) {   // an id segment follows the control
-        outId = static_cast<uint32_t>(std::strtoul(s2 + 1, nullptr, 10));
-        outHasId = true;
+        char* idEnd = nullptr;
+        const unsigned long parsedId = std::strtoul(s2 + 1, &idEnd, 10);
+        if (idEnd == s2 + 1 || *idEnd != 0) {
+            sendResponse(conn, 400, "application/json", "{\"error\":\"bad id\"}");
+            return nullptr;
+        }
+        outId = static_cast<uint32_t>(parsedId);
+        outHasId = 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/core/HttpServerModule.cpp` around lines 1884 - 1887, Validate the id
substring in the parsing block before setting outId or outHasId: parse with an
end pointer and errno checks, require at least one digit, reject overflow and
any trailing characters, and return the existing bad-request response for
malformed ids. Preserve successful parsing for clean decimal ids and avoid
marking outHasId when validation fails.
src/ui/app.js (1)

1783-1796: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

uint8 field editor doesn't guard against NaN or clamp to f.min/f.max before sending.

Unlike the other numeric control renderers in this file (e.g. the pin and stepper cases, which fall back on NaN and clamp with Math.max/Math.min), this path sends parseInt(inp.value) straight to listSetField. An empty/invalid input yields NaN, which JSON.stringify turns into value:null — sent to the server instead of a sane fallback, and the browser's min/max attributes alone don't stop a typed out-of-range value from being submitted in every browser.

🛡️ Proposed fix
             inp.addEventListener("change", () => {
                 dragTs[dragKey] = Date.now();
-                listSetField(moduleName, ctrlName, id, f.name, parseInt(inp.value));
+                let v = parseInt(inp.value, 10);
+                if (Number.isNaN(v)) v = f.value ?? 0;
+                if (f.min !== undefined) v = Math.max(f.min, v);
+                if (f.max !== undefined) v = Math.min(f.max, v);
+                listSetField(moduleName, ctrlName, id, f.name, v);
             });
🤖 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/ui/app.js` around lines 1783 - 1796, Update the uint8 editor’s change
handler in the uint8 field renderer to parse the input with a safe fallback and
clamp the resulting value to f.min and f.max before calling listSetField.
Preserve the existing timestamp update and ensure invalid or empty input never
sends NaN.
src/core/HttpServerModule.h (1)

42-50: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Class doc still describes the pre-diff-on-wire behavior.

"Server pushes full state JSON as text frames from tick1s()" is no longer accurate — tick1s()/pushStateToWebSockets() now sends the full state only on connect or after a structural change, and a {"patch":[...]} of changed leaves otherwise (see buildStatePatch in the .cpp). This doc block wasn't touched by this PR and now misdescribes current behavior to future readers.

As per coding guidelines, "Use present tense in code and comments; do not describe historical removals, former behavior, plans, or future behavior."

📝 Proposed fix
-/// base64), up to 4 concurrent clients. Server pushes full state JSON as text frames from
-/// `tick1s()`. Binary frames take two paths, both without a frame-sized buffer: a synchronous
+/// base64), up to 4 concurrent clients. `tick1s()` pushes the full state as text once — on
+/// connect or after a structural change — and a small `{"patch":[...]}` of changed leaves
+/// otherwise (see `buildStatePatch`), never re-serialising the whole ~30 KB tree every second.
+/// Binary frames take two paths, both without a frame-sized buffer: a synchronous
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/HttpServerModule.h` around lines 42 - 50, The HttpServerModule class
documentation should describe the current WebSocket state-update behavior:
update the tick1s()/pushStateToWebSockets() description to state that full state
JSON is sent on connection or structural changes, while changed leaves are
otherwise sent as a {"patch":[...]} message built by buildStatePatch. Keep the
existing framing, buffering, and client-limit details unchanged.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@CLAUDE.md`:
- Around line 40-41: Update the wording in the CLAUDE.md paragraph around
writeListOptionSets to use present tense instead of “was,” and use the British
spelling “serialise” for consistency with the surrounding text. Preserve the
existing explanation and performance details.

In `@src/core/Control.cpp`:
- Around line 193-195: Remove the detailed optionSets/optionsRef contract
comments near the shared option-set declaration in Control.cpp, leaving only a
brief reference to the authoritative documentation on ListSource in Control.h.
Preserve the declaration and behavior unchanged.

In `@src/core/HttpServerModule.cpp`:
- Line 1519: Remove the duplicated explanatory comments from all five
requestFullResync() call sites in HttpServerModule.cpp, leaving the calls
unchanged. Rely on the authoritative rationale documented with
requestFullResync() in HttpServerModule.h.
- Around line 2115-2145: Ensure the full-state resync branch around
fullResyncPending_ can proceed even when preview traffic keeps the shared
buffered sender busy. Give the resync priority by using a separate queue/sender
or preempting the preview transfer, while preserving chunked non-blocking
delivery and ensuring a newly connected client does not remain blank.
- Around line 855-879: Ensure metadata changes from rebuildControls() trigger a
full state resync instead of relying solely on buildStatePatch() control-value
hashing. Update the NetworkModule and HueDriver runtime rebuild paths to request
resynchronization whenever visible schema metadata changes, including hidden,
readonly, or options fields.

In `@src/light/drivers/Correction.h`:
- Around line 151-167: Update Correction::rebuild() so ChannelRole::WarmWhite is
cached alongside ChannelRole::White, ensuring apply() writes the warm-white
channel for warm-white-only and RGB+CCT presets. Preserve the existing
white-mode behavior and add a regression test covering a warm-white-only preset.

In `@test/unit/light/unit_Correction.cpp`:
- Around line 134-136: Update the whiteMode None comment in the RGBW correction
test to state that the white channel is forced or written to 0 each frame,
matching the out[3] == 0 assertion and the authoritative inline comment; remove
the misleading “untouched” wording.

---

Outside diff comments:
In `@src/core/HttpServerModule.cpp`:
- Around line 1910-1935: Update HttpServerModule::afterListMutation() to call
scheduler_->requestFullResync() after rebuilding controls and corrections,
ensuring every connected WebSocket client receives changed option metadata after
a list mutation. Keep the existing dirty-marking, control rebuilding, and
correction refresh behavior unchanged.
- Around line 1958-1963: Update the reorder handling in the JSON “to” branch of
HttpServerModule to validate the parsed integer before converting it to uint8_t,
rejecting values above the valid list boundary instead of allowing wraparound.
Mirror the bounds-checking behavior used by handleMoveModule, then call
moveListRow only with a validated index and preserve the existing 400 response
for invalid moves.
- Around line 1884-1887: Validate the id substring in the parsing block before
setting outId or outHasId: parse with an end pointer and errno checks, require
at least one digit, reject overflow and any trailing characters, and return the
existing bad-request response for malformed ids. Preserve successful parsing for
clean decimal ids and avoid marking outHasId when validation fails.

In `@src/core/HttpServerModule.h`:
- Around line 42-50: The HttpServerModule class documentation should describe
the current WebSocket state-update behavior: update the
tick1s()/pushStateToWebSockets() description to state that full state JSON is
sent on connection or structural changes, while changed leaves are otherwise
sent as a {"patch":[...]} message built by buildStatePatch. Keep the existing
framing, buffering, and client-limit details unchanged.

In `@src/ui/app.js`:
- Around line 1783-1796: Update the uint8 editor’s change handler in the uint8
field renderer to parse the input with a safe fallback and clamp the resulting
value to f.min and f.max before calling listSetField. Preserve the existing
timestamp update and ensure invalid or empty input never sends NaN.
🪄 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: 95a87e12-fd64-46f0-aba3-21237d544bae

📥 Commits

Reviewing files that changed from the base of the PR and between 5920ae6 and 0bcb3c5.

📒 Files selected for processing (21)
  • CLAUDE.md
  • docs/history/plans/Plan-20260711 - Diff-on-the-wire state push (send only what changed) (shipped).md
  • docs/history/plans/Plan-20260711 - List detail on demand (fix 1Hz state-push stutter) (attempted, abandoned).md
  • docs/history/plans/Plan-20260711 - State push off the render tick (resumable text frame) (shipped).md
  • src/core/Control.cpp
  • src/core/Control.h
  • src/core/HttpServerModule.cpp
  • src/core/HttpServerModule.h
  • src/core/JsonSink.h
  • src/light/ChannelRole.h
  • src/light/drivers/Correction.h
  • src/light/drivers/LightPresetsModule.h
  • src/ui/app.js
  • src/ui/style.css
  • test/CMakeLists.txt
  • test/unit/core/unit_Control_list.cpp
  • test/unit/core/unit_FilesystemModule_persistence.cpp
  • test/unit/core/unit_HttpServerModule_apply.cpp
  • test/unit/core/unit_JsonSink_detach.cpp
  • test/unit/light/unit_Correction.cpp
  • test/unit/light/unit_LightPresetsModule.cpp

Comment thread CLAUDE.md Outdated
Comment thread src/core/Control.cpp Outdated
Comment thread src/core/HttpServerModule.cpp
Comment thread src/core/HttpServerModule.cpp Outdated
Comment thread src/core/HttpServerModule.cpp
Comment thread src/light/drivers/Correction.h Outdated
Comment thread test/unit/light/unit_Correction.cpp Outdated
Optimizes the parallel LED encode with a branch-free SWAR transpose (~20% fps on the P4 at 16K lights), makes WebSocket clients re-read schema changes live (fixing the connect-to-first-preview lag), and lights up warm-white/yellow/UV channels from RGB. Also processes a batch of CodeRabbit findings and sets the project's spelling convention to American English.

tick:123/93/122/15/2/290/61/18/22/164/120/17/1/36us(FPS:8130/10752/8196/66666/500000/3448/16393/55555/45454/6097/8333/58823/1000000/27777) (desktop; ESP32 serial KPI capture skipped, port not connected; live network ticks confirm healthy ESP32: P4 30fps / S3 79fps / Shelly 27fps at their operational grids)

Core
- HttpServerModule: WS schema-resync hook, MoonModule::rebuildControls() now signals a full state resync via a static schema-changed hook (mirrors FilesystemModule::noteDirty), so a metadata change (hidden flags, option sets from a control set / list mutation / async WiFi-Hue callback) reaches every connected client where the value-patch couldn't carry it; a pending resync fast-paths on tick20ms and preempts an in-flight preview frame, cutting connect-to-first-preview latency from up to ~1s to a few tens of ms; the two former per-site resync spots are subsumed by the one hook
- HttpServerModule: cancelBufferedSend() now frees an owned state-frame body (self-safe for any caller), and release() reuses it (net subtraction); id parse hardened (strtoul + errno + trailing-junk reject), list-reorder to-index bounds-checked before the uint8 cast
- DevicesModule / HueDriver: colour to color for identifiers and the internal device-list wire key (write + read + tests all changed; round-trip verified)
- MoonModule: setSchemaChangedHook static hook (the noteDirty decoupling shape)

Light domain
- LcdSlots: the WS2812 data-slot transpose is now a branch-free SWAR 8x8 bit-matrix transpose (Warren, Hacker's Delight 7-3 delta-swap; the shape FastLED's transpose8x1 uses), zero table, bit-perfect vs the prior per-bit-per-lane gather; the measured lever after the encode-dominates finding
- Correction: synthesise WarmWhite / Yellow / UV from RGB via the existing whiteMode (WW = min RGB, Yellow = min(R,G), UV = blue-excess), so those channels light up out of the box; presetHasWhite to presetHasSynthChannel (matches all four synthesised roles)
- LcdLedDriver: reject a data lane that collides with clockPin/dcPin via a validateBusPins CRTP hook (base no-op default), so an i80 pin overlap fails loud instead of silently corrupting a lane
- Correction: the test-only curated-preset enum + overload moved out of production into test/unit/light/correction_presets.h; production Correction now has one rebuild

UI
- app.js: the uint8 list-field editor clamps to min/max and NaN-guards empty input on change, so a stray value never reaches the device

Tests
- unit_LcdLedEncoder: exhaustive SWAR-transpose-equals-naive-gather over all lane patterns and masks
- unit_Correction: WW/Y/UV synthesis + whiteMode-None zeroing; unit_HttpServerModule_apply: schema-resync hook fires on rebuildControls; unit_LcdLedDriver: clockPin/dcPin collision rejected; DevicesModule/HueDriver: color round-trip

Docs / CI
- CLAUDE.md + coding-standards.md: American-English spelling convention (code, wire keys, docs), converted opportunistically; sub-hot-path present-tense + spelling fixes
- Two new multicore-analysis backlog docs (bottom-up survey + top-down build plan, with the Step 2 pipeline decision); performance.md multi-pin LED-driving results with the bench pins used; gpio-usage usable-LED-pin table; backlog-core/light + pins-analysis updates

Reviews
- 👾 cancelBufferedSend() self-safety (free an owned body regardless of caller): fixed
- 🐇 app.js uint8 clamp/NaN-guard: fixed
- 🐇 HttpServerModule id-parse + list-reorder bounds: fixed
- 🐇 Correction WarmWhite not cached (apply never wrote it): fixed by synthesising WW/Y/UV
- 🐇 Control.cpp optionSets comment, requestFullResync call-site duplication, HttpServerModule.h WS doc, unit_Correction whiteMode-None comment, CLAUDE.md tense/spelling: fixed

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 15

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (6)
test/unit/light/unit_Correction.cpp (1)

14-14: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use American English spelling in comments.

Five instances in changed lines violate the coding guideline for **/*.{cpp,h,hpp,cc,cxx}: "colour" (lines 14, 232), "behaviour" (lines 134, 234), and "synthesised" (line 231) should be "color", "behavior", and "synthesized" respectively.

✏️ Proposed fixes
 // outChannels bytes with a named offset per colour role (offRed/offGreen/offBlue, and
-// outChannels bytes with a named offset per colour role (offRed/offGreen/offBlue, and
+// outChannels bytes with a named offset per color role (offRed/offGreen/offBlue, and
 // this is the byte-identical behaviour the earlier RGBW tests already pin. whiteMode =
-// this is the byte-identical behaviour the earlier RGBW tests already pin. whiteMode =
+// this is the byte-identical behavior the earlier RGBW tests already pin. whiteMode =
-// WarmWhite / Yellow / UV are synthesised from RGB off the SAME whiteMode as White, so a fixture
-// carrying them lights up (best-effort approximations, not a colour model yet): WW ≈ min(RGB),
-// Yellow ≈ min(R,G), UV ≈ the blue-excess max(0, B-max(R,G)). This is the "all channels burn so
-// you can eyeball a fixture" behaviour the finding asked for; a real per-emitter model comes later.
+// WarmWhite / Yellow / UV are synthesized from RGB off the SAME whiteMode as White, so a fixture
+// carrying them lights up (best-effort approximations, not a color model yet): WW ≈ min(RGB),
+// Yellow ≈ min(R,G), UV ≈ the blue-excess max(0, B-max(R,G)). This is the "all channels burn so
+// you can eyeball a fixture" behavior the finding asked for; a real per-emitter model comes later.

Also applies to: 134-134, 231-234

🤖 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_Correction.cpp` at line 14, Update the changed comments
in unit_Correction.cpp to use American English spelling: replace “colour” with
“color,” “behaviour” with “behavior,” and “synthesised” with “synthesized.”

Source: Coding guidelines

src/light/drivers/Correction.h (1)

115-150: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Yellow/UV pick up the white-subtracted RGB when White + Yellow/UV roles coexist in Accurate mode.

r -= w; g -= w; b -= w; (inside the offWhite != kAbsent block) mutates r/g/b in place, and the offYellow/offUV lines run AFTER it, reading the mutated values. The comment right above states the other emitters "are additive stand-ins only ... so they don't subtract," but for a fixture that carries both a cold-White channel and a Yellow/UV channel, Yellow/UV silently end up computed from the desaturated RGB when whiteMode == Accurate. offWarmWhite is unaffected (it only reads the pre-subtraction w).

🐛 Proposed fix — compute the additive stand-ins before the Accurate subtraction
         } else {
             const uint8_t w = r < g ? (r < b ? r : b) : (g < b ? g : b);  // min(r,g,b): the white component
-            if (offWhite != kAbsent) {
-                if (whiteMode == WhiteMode::Accurate) { r -= w; g -= w; b -= w; }  // pull white out of RGB
-                out[offWhite] = w;
-            }
             // warm white ≈ the white component (same as cold white for a warm-white-only strip).
             if (offWarmWhite != kAbsent) out[offWarmWhite] = w;
             // yellow ≈ min(R,G) (the shared red+green component).
             if (offYellow != kAbsent)    out[offYellow] = r < g ? r : g;
             // UV is out of gamut...
             if (offUV != kAbsent) {
                 const uint8_t rg = r > g ? r : g;
                 out[offUV] = b > rg ? static_cast<uint8_t>(b - rg) : 0;
             }
+            // White (and its Accurate-mode RGB subtraction) runs LAST so it doesn't leak into the
+            // additive stand-ins above, which must read the ORIGINAL RGB per their own contract.
+            if (offWhite != kAbsent) {
+                if (whiteMode == WhiteMode::Accurate) { r -= w; g -= w; b -= w; }  // pull white out of RGB
+                out[offWhite] = w;
+            }
         }
🤖 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/Correction.h` around lines 115 - 150, Update apply so the
additive stand-ins for offYellow and offUV are computed from the original
corrected RGB values before WhiteMode::Accurate subtracts w from r, g, and b.
Preserve the existing offWhite subtraction and RGB output behavior, and leave
offWarmWhite based on the pre-subtraction w.
docs/backlog/backlog-light.md (1)

11-23: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

"optimisation" → "optimization" in freshly-written text.

This whole increment section (folding in the old "1..8-pin LCD" item) is new prose in this PR; docs/coding-standards.md (added in this same PR) requires US spelling everywhere, converted opportunistically when the file is touched.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/backlog/backlog-light.md` around lines 11 - 23, Change the newly added
prose in the “16-lane parallel output + variable 1..16 lanes + transpose
optimization” section to use the US spelling “optimization” instead of
“optimisation,” including the heading and any occurrences in that section.
src/light/drivers/ParallelLedDriver.h (1)

318-337: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Stale "Grow-only" comment now contradicts the new exact-match reuse logic.

The unchanged header comment at line 320 ("Grow-only; the buffer is re-zeroed on every reinit so a shrunken frame's latch pad can't hold stale row bytes") describes the OLD >=-based reuse (never shrinks, reuses whenever big enough). The new logic below it requires an EXACT match (==), meaning it now rebuilds on shrink AND on an oversized-but-not-exact capacity — no longer "grow-only" reuse semantics. Two comments now state overlapping, conflicting facts about the same reinit() behavior.

✏️ Proposed fix
     // --- bus + DMA buffer (hardware; this peripheral's targets only). Fused on
     // purpose: max_transfer_bytes is fixed at bus creation, so growing the frame
-    // means re-creating the bus and its buffer together. Grow-only; the buffer is
-    // re-zeroed on every reinit so a shrunken frame's latch pad can't hold stale
-    // row bytes. ---
+    // means re-creating the bus and its buffer together; see reinit() below for the
+    // exact-match reuse rule and why grow-only reuse was replaced. ---

As per coding guidelines, **/*.{cpp,h,md}: "Do not duplicate logic, facts, or explanatory comments; keep a fact in one authoritative location and reference it elsewhere."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/light/drivers/ParallelLedDriver.h` around lines 318 - 337, Update the
header comment above reinit() to remove the outdated “Grow-only” behavior and
avoid duplicating the detailed exact-match reuse explanation below. Keep only
the authoritative facts that the bus and DMA buffer are rebuilt together when
required and that the buffer is cleared during reinitialization, consistent with
the current exact-match condition in reinit().

Source: Coding guidelines

src/light/drivers/DriverBase.h (1)

213-239: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Treat a zero preset id as unresolved here src/light/drivers/DriverBase.h:213-239 — on a fresh driver, presetId_ starts at 0 while constructors like RmtLedDriver/ParallelLedDriver set presetRef_ to "GRB". The current presetSel_ < n && idAt(presetSel_) != presetId_ check fires immediately and rewrites the durable reference to the first built-in ("RGB"), so the driver loses its intended default. Existing tests cover the "RGB" path and a user-picked preset, but not this fresh-"GRB" case.

🤖 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/DriverBase.h` around lines 213 - 239, The user-pick
detection in buildPresetOptions must not treat presetId_ == 0 as an established
selection. Update the condition around presetSel_, presetId_, and lib->idAt() so
a zero id is resolved through the persisted presetRef_ first, preserving
constructor defaults such as "GRB"; retain the existing user-picked behavior
once presetId_ is nonzero.
src/core/HttpServerModule.cpp (1)

2149-2169: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Don't preempt an in-flight owned state frame here. cancelBufferedSend() drops the current WS message and resets the cursors, so a second resync can splice a new header into the middle of an already-partially-sent frame and corrupt the stream for any client that has started draining the old one. Only cancel borrowed preview frames, and leave fullResyncPending_ set until startBufferedTextSend() can actually take ownership.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/HttpServerModule.cpp` around lines 2149 - 2169, Update the
fullResyncPending_ handling to avoid canceling an in-flight owned state frame:
only cancel buffered borrowed preview frames, and preserve an active owned state
send. Keep fullResyncPending_ true until startBufferedTextSend() successfully
accepts the new payload, then establish the baseline and clear the flag.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/backlog/backlog-light.md`:
- Around line 161-167: Update the Parlio chunked transfer bullet to use the
American spelling “behavior” instead of “behaviour” in the parallel-I2S driver
sentence, leaving the rest of the bullet unchanged.

In `@docs/backlog/multicore-analysis-bottom-up.md`:
- Line 3: Fix the broken Appendix A link in the document introduction by
changing the fragment to the generated Markdown anchor for “Appendix A — frame
pacing, decided against,” or add and reference an explicit stable anchor for
that appendix. Ensure the resulting fragment satisfies markdownlint MD051.

In `@docs/backlog/multicore-analysis-top-down.md`:
- Around line 11-17: Add the text language identifier to the opening fenced
block containing the Step 0–Step 3 diagram, changing it from an untyped fence to
a text fence while preserving the diagram content and formatting.

In `@docs/performance.md`:
- Around line 239-251: Update the performance section to use current-state,
present-tense wording throughout, removing historical phrases such as “after ...
landed” and future or backlog references such as “coming” and “backlogged.” Keep
only currently verified behavior, limits, diagnostics, and acceptance floors;
move pending work or deferred status details to the appropriate backlog
documentation.

In `@src/core/DevicesModule.h`:
- Around line 309-310: Update the documentation comment for colorCount to use
the American spelling “color-capable” instead of “colour-capable,” leaving the
declaration and remaining comment text unchanged.
- Around line 162-166: The restoreList() color-count migration must preserve
values stored under the legacy "colour" key. Read "color" first, fall back to
"colour" when it is absent, retain the existing 0–127 clamping, and add a
regression test covering restoration from a legacy persisted file.

In `@src/core/HttpServerModule.h`:
- Around line 43-74: Update the rewritten class documentation in
HttpServerModule, replacing the British spellings “recognisable,”
“re-serialising,” and both occurrences of “serialise/serialises” with their
American spellings while preserving the surrounding meaning and formatting.
- Around line 137-147: Update cancelBufferedSend() so preview rebuild
cancellation cannot discard an in-flight state resync. Track or preserve whether
the active buffered frame is a full-state send, and when cancelling that frame
restore fullResyncPending_ (or equivalent pending-state behavior) so the resync
is retried; keep preview buffer ownership cleanup unchanged.

In `@src/core/MoonModule.h`:
- Around line 209-216: The rebuildControls() hook currently triggers full
WebSocket resynchronization for ordinary value updates. Update the
schemaChangedHook_ invocation so it runs only when control metadata or options
change, or move the notification to callers that perform schema changes;
preserve the existing value-patch path for Scheduler::setControl() updates.

In `@src/light/drivers/Correction.h`:
- Around line 62-73: Update the newly added comments near the synthesized
emitter handling to use US spelling: replace “synthesised” with “synthesized”
and “behaviour” with “behavior” in the comments around the
offWarmWhite/offYellow/offUV declarations and the related lines near the “Every
synthesised emitter” and “standard RGBW auto-white behaviour” references.

In `@src/light/drivers/LcdLedDriver.h`:
- Around line 68-85: The validateBusPins method must also reject an identical
configured clockPin and dcPin before iterating over data lanes. Add a direct
check for both pins being set and equal, return an appropriate validation error,
and preserve the existing lane-collision checks and nullptr success path.

In `@src/platform/esp32/platform_esp32_parlio.cpp`:
- Around line 135-157: Update the comments in parlioWs2812Init and its
surrounding transfer-limit explanation to use American English spelling by
replacing both occurrences of “colour” with “color”; leave the guard logic and
kParlioMaxTransferBytes unchanged.

In `@test/unit/core/unit_HttpServerModule_apply.cpp`:
- Around line 312-330: Add a test-only setup seam in HttpServerModule that
installs the schema-changed hook without binding or starting the TCP listener,
then update the “schema-changed hook: rebuildControls() requests a full WS
resync” test to use it instead of http.setup(). Preserve the existing hook,
resync assertions, and cleanup behavior.

In `@test/unit/light/correction_presets.h`:
- Line 3: Update the comment describing the Correction helper to use the
American English spelling “color” instead of “colour”; do not change the
helper’s behavior or surrounding documentation.

In `@test/unit/light/unit_LcdLedEncoder.cpp`:
- Around line 115-139: Update the comment above the test case to replace the
British spelling “behaviour-identical” with the American spelling
“behavior-identical”; leave the exhaustive validation logic unchanged.

---

Outside diff comments:
In `@docs/backlog/backlog-light.md`:
- Around line 11-23: Change the newly added prose in the “16-lane parallel
output + variable 1..16 lanes + transpose optimization” section to use the US
spelling “optimization” instead of “optimisation,” including the heading and any
occurrences in that section.

In `@src/core/HttpServerModule.cpp`:
- Around line 2149-2169: Update the fullResyncPending_ handling to avoid
canceling an in-flight owned state frame: only cancel buffered borrowed preview
frames, and preserve an active owned state send. Keep fullResyncPending_ true
until startBufferedTextSend() successfully accepts the new payload, then
establish the baseline and clear the flag.

In `@src/light/drivers/Correction.h`:
- Around line 115-150: Update apply so the additive stand-ins for offYellow and
offUV are computed from the original corrected RGB values before
WhiteMode::Accurate subtracts w from r, g, and b. Preserve the existing offWhite
subtraction and RGB output behavior, and leave offWarmWhite based on the
pre-subtraction w.

In `@src/light/drivers/DriverBase.h`:
- Around line 213-239: The user-pick detection in buildPresetOptions must not
treat presetId_ == 0 as an established selection. Update the condition around
presetSel_, presetId_, and lib->idAt() so a zero id is resolved through the
persisted presetRef_ first, preserving constructor defaults such as "GRB";
retain the existing user-picked behavior once presetId_ is nonzero.

In `@src/light/drivers/ParallelLedDriver.h`:
- Around line 318-337: Update the header comment above reinit() to remove the
outdated “Grow-only” behavior and avoid duplicating the detailed exact-match
reuse explanation below. Keep only the authoritative facts that the bus and DMA
buffer are rebuilt together when required and that the buffer is cleared during
reinitialization, consistent with the current exact-match condition in reinit().

In `@test/unit/light/unit_Correction.cpp`:
- Line 14: Update the changed comments in unit_Correction.cpp to use American
English spelling: replace “colour” with “color,” “behaviour” with “behavior,”
and “synthesised” with “synthesized.”
🪄 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: 6734cb73-b382-4524-accb-ab04a42c3ec4

📥 Commits

Reviewing files that changed from the base of the PR and between 0bcb3c5 and 5e7f525.

📒 Files selected for processing (39)
  • CLAUDE.md
  • docs/backlog/README.md
  • docs/backlog/backlog-core.md
  • docs/backlog/backlog-light.md
  • docs/backlog/multicore-analysis-bottom-up.md
  • docs/backlog/multicore-analysis-top-down.md
  • docs/backlog/pins-analysis-top-down.md
  • docs/coding-standards.md
  • docs/performance.md
  • docs/reference/gpio-usage.md
  • src/core/Control.cpp
  • src/core/DevicesModule.h
  • src/core/HttpServerModule.cpp
  • src/core/HttpServerModule.h
  • src/core/MoonModule.h
  • src/light/drivers/Correction.h
  • src/light/drivers/DriverBase.h
  • src/light/drivers/HueDriver.h
  • src/light/drivers/LcdLedDriver.h
  • src/light/drivers/LcdSlots.h
  • src/light/drivers/LightPresetsModule.h
  • src/light/drivers/ParallelLedDriver.h
  • src/light/effects/TextEffect.h
  • src/platform/esp32/platform_esp32_parlio.cpp
  • src/ui/app.js
  • test/unit/core/unit_DevicesModule_hue.cpp
  • test/unit/core/unit_HttpServerModule_apply.cpp
  • test/unit/light/correction_presets.h
  • test/unit/light/unit_Correction.cpp
  • test/unit/light/unit_HueDriver.cpp
  • test/unit/light/unit_LcdLedDriver.cpp
  • test/unit/light/unit_LcdLedEncoder.cpp
  • test/unit/light/unit_LightPresetsModule.cpp
  • test/unit/light/unit_NetworkSendDriver_no_alloc_in_loop.cpp
  • test/unit/light/unit_ParlioLedDriver.cpp
  • test/unit/light/unit_RmtLedDriver_lifecycle.cpp
  • test/unit/light/unit_RmtLedDriver_pins.cpp
  • test/unit/light/unit_RmtLedEncoder.cpp
  • web-installer/deviceModels.json

Comment thread docs/backlog/backlog-light.md Outdated
Comment thread docs/backlog/multicore-analysis-bottom-up.md Outdated
Comment thread docs/backlog/multicore-analysis-top-down.md Outdated
Comment thread docs/performance.md Outdated
Comment thread src/core/DevicesModule.h
Comment on lines 162 to +166
// Clamp the persisted count to the valid range (0..127, the HueDriver's
// colourCount_ range) so a corrupt or hand-edited entry can't wrap into a bogus
// colorCount_ range) so a corrupt or hand-edited entry can't wrap into a bogus
// value when narrowed. 0 for non-bridge rows (the key is absent → readInt = 0).
const long colour = mm::json::readInt(mm::json::member(doc, el, "colour"));
d.colourCount = static_cast<uint8_t>(colour < 0 ? 0 : (colour > 127 ? 127 : colour));
const long color = mm::json::readInt(mm::json::member(doc, el, "color"));
d.colorCount = static_cast<uint8_t>(color < 0 ? 0 : (color > 127 ? 127 : color));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Preserve the legacy persisted Hue count during the key rename.

restoreList() reads only "color", while devices persisted by the previous schema contain "colour". On upgrade, the missing key becomes zero, so layout sizing and UI metadata lose the cached count until a live Hue report arrives—and may never recover while the bridge is offline. Read "color" first and fall back to "colour", and add a legacy-file regression test.

Proposed migration
-                const long color = mm::json::readInt(mm::json::member(doc, el, "color"));
+                const mm::json::JsonNode* colorNode = mm::json::member(doc, el, "color");
+                if (!colorNode) colorNode = mm::json::member(doc, el, "colour");
+                const long color = mm::json::readInt(colorNode);
📝 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.

Suggested change
// Clamp the persisted count to the valid range (0..127, the HueDriver's
// colourCount_ range) so a corrupt or hand-edited entry can't wrap into a bogus
// colorCount_ range) so a corrupt or hand-edited entry can't wrap into a bogus
// value when narrowed. 0 for non-bridge rows (the key is absent → readInt = 0).
const long colour = mm::json::readInt(mm::json::member(doc, el, "colour"));
d.colourCount = static_cast<uint8_t>(colour < 0 ? 0 : (colour > 127 ? 127 : colour));
const long color = mm::json::readInt(mm::json::member(doc, el, "color"));
d.colorCount = static_cast<uint8_t>(color < 0 ? 0 : (color > 127 ? 127 : color));
// Clamp the persisted count to the valid range (0..127, the HueDriver's
// colorCount_ range) so a corrupt or hand-edited entry can't wrap into a bogus
// value when narrowed. 0 for non-bridge rows (the key is absent → readInt = 0).
const mm::json::JsonNode* colorNode = mm::json::member(doc, el, "color");
if (!colorNode) colorNode = mm::json::member(doc, el, "colour");
const long color = mm::json::readInt(colorNode);
d.colorCount = static_cast<uint8_t>(color < 0 ? 0 : (color > 127 ? 127 : color));
🤖 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/DevicesModule.h` around lines 162 - 166, The restoreList()
color-count migration must preserve values stored under the legacy "colour" key.
Read "color" first, fall back to "colour" when it is absent, retain the existing
0–127 clamping, and add a regression test covering restoration from a legacy
persisted file.

Comment thread src/light/drivers/I80LedDriver.h
Comment thread src/platform/esp32/platform_esp32_parlio.cpp
Comment thread test/unit/core/unit_HttpServerModule_apply.cpp Outdated
Comment thread test/unit/light/correction_presets.h Outdated
Comment thread test/unit/light/unit_ParallelSlots.cpp
…eanup

Widens the parallel LED drivers to 16 lanes (LCD_CAM reaches the full 16384-light target; Parlio caps at ~4096 on a contiguous-block limit), makes MoonDeck name the board behind each USB port (path/chip/board), and cleans up the driver source-window and per-pin controls. Also removes all one-off migration code in favour of a robust-by-default loader + a documented-breaks policy (ADR 0013).

KPI: 16384lights | Desktop:666KB | tick:120/97/119/15/2/285/59/17/21/165/119/17/1/36us(FPS:8333/10309/8403/66666/500000/3508/16949/58823/47619/6060/8403/58823/1000000/27777) | ESP32:1400KB | src:192(38358) | test:129(20594) | lizard:141w
(ESP32 tick/FPS: collect_kpi skipped its live capture — the configured port drifted; live HTTP read of the S3 testbench .159 = tick 12800us / 78fps at a working config.)

Core:
- MoonModule: gate the WS full-resync on a real schema change — rebuildControls() now compares a schemaSignature() before/after, so an ordinary value patch (a slider drag) no longer forces a ~30 KB full-tree resend. The signature recurses into children (rebuildControls rebuilds the subtree) and hashes Select option strings (the stable-address bind pattern renames options in place), so a child driver's preset-dropdown change still resyncs.
- HttpServerModule: don't lose an in-flight state resync — cancelBufferedSend() re-arms fullResyncPending_ when it drops a still-draining owned state frame, and the resync push keeps the flag set until startBufferedTextSend() accepts, and never stomps an owned drain already in flight (only preempts a borrowed preview). Added installSchemaHookForTest() so the hook can be tested without binding a TCP port.
- FilesystemModule / DevicesModule: removed the migrateRenamedConfigs one-time file cleanup and the DevicesModule legacy-key path — migration code narrates a past state a present-tense reader never sees; the persistence loader is already robust (absent→default, stale→clamp, unknown→ignore).

Light domain:
- ParallelLedDriver / LcdLedDriver / ParlioLedDriver: 16-lane parallel output — bus width derived from pin count (<=8 → 8-bit, 9..16 → 16-bit), the two-pass transposeLanes16x8, PSRAM-first DMA buffer. LcdLedDriver rejects clockPin==dcPin up front (the i80 bus needs two distinct control lines) and warns (does not idle) on a data lane sharing WR/DC.
- DriverBase: count control defaults to the uint16 max (= all lights, clamped to the buffer) instead of a magic 0; a driver reports "driving N of M lights" as a neutral info status so consumption is shown, not guessed; buildPresetOptions() no longer treats presetId_==0 (the fresh-driver default) as a user pick, so a persisted preset name resolves correctly.
- PinList (assignCounts): ledsPerPin single value now broadcasts to every pin (the NumPy/CSS scalar idiom — "one number = that many each"), empty still even-splits, a list still maps per pin.
- Correction: under WhiteMode::Accurate, compute the additive Yellow/UV stand-ins from the pre-subtraction RGB (White subtracts last), so pulling white out of RGB can't corrupt them; documented the per-emitter theory (White/WarmWhite = broadband achromatic; Yellow/UV = saturated/out-of-gamut, no honest RGB synthesis).

UI:
- app.js: lsRead drops its legacy-key parameter (migration removed).
- install-picker: the web installer's port dropdown shows the detected chip ("Port selected — ESP32-P4"); the status line drops the now-redundant chip prefix.

Scripts / MoonDeck:
- moondeck.py: port identity — describe_serial_ports() names the board behind each USB port at three levels (path → chip → specific board) from the USB descriptor + the device registry, an "Identify" button probes unknown ports with esptool, and a passive port watcher narrates plug/unplug with flap detection. PID 0x1001 (shared across S3/C3/C6/H2/P4) maps to the generic native-USB family, not a specific chip; a registry match's real chip wins over the USB guess.

Tests:
- unit_MoonModule: schemaSignature pins value-invisible / schema-visible, Select in-place rename, and child-subtree recursion.
- unit_HttpServerModule_apply: schema hook resyncs only on a real schema change, including a child-triggered one.
- unit_Correction: Accurate mode drives Yellow from pre-subtraction RGB (the witness case).
- unit_LcdLedDriver: clockPin==dcPin is rejected up front.
- unit_RmtLedDriver_pins: assignCounts single-value broadcast + short-list even-split.
- test_moondeck_port_identity: 3-level resolution, esptool probe parsing, the P4-not-mislabeled-as-S3 case, and the adapter-swap correction.

Docs / CI:
- ADR 0013: no migration code — robust persistence + documented breaks (the settled policy; lists the known format breaks).
- performance.md: 16-lane LCD_CAM (reaches 16384) + Parlio (linear to 4096, then the contiguous-block ceiling; the 93-vs-125-fps encode/transmit-serial decomposition) sweeps.
- multicore-analysis-top-down: corrected the "DMA-wait ~0" claim (the transmit currently blocks); added Step 1.5 (async transmit + double-buffer) and re-scoped Step 4 (chunked transfer as the Parlio-specific 16K lever).
- drivers.md: shared-controls card (localBrightness/preset/whiteMode/start/count) + the ledsPerPin broadcast semantics.
- backlog: fixture-model sub-item for per-emitter targets + the Yellow-default decision and hardware test.

Reviews:
- 🐇 Correction apply() order: Yellow/UV now computed from pre-subtraction RGB — fixed.
- 🐇 DriverBase preset-0 not a user pick — fixed.
- 🐇 LcdLedDriver clockPin==dcPin check — added.
- 🐇 HttpServerModule cancelBufferedSend / fullResyncPending resync loss — fixed (re-arm + accept-gate).
- 🐇 MoonModule schema hook over-fired on value patches — fixed (schemaSignature gate).
- 🐇 spelling (British→American) across comments/prose — fixed.
- 🐇 mkdocs Appendix A anchor, fenced-block language, present-tense performance.md — fixed.
- 👾 schemaSignature missed child-subtree + in-place Select renames (would drop preset-dropdown resyncs) — fixed (recurse + hash option strings), tests added.
- 👾 MoonDeck PID 0x1001 mislabeled the P4 as esp32-s3 — fixed (generic native-USB, registry chip wins), test added.
- 👾 Parlio PSRAM-first comment oversold (internal fallback governs on P4) — comment corrected to the measured reality.
- 👾 performance.md LCD reject→warn stale fact + backlog-core stale migration refs — fixed.
- 👾 folded the "driving %u of %u" format literal into setDrivingInfo (dedup) — fixed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 16

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
moondeck/moondeck_ui/app.js (1)

676-708: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Hint can describe a port no longer in the dropdown.

If the persisted current port isn't in the new ports list, the <select> falls back to the blank "--" option, but updatePortDeviceHint(current) still renders the hint for the (now absent) stale port — a mismatch between what's shown selected and what the hint describes.

🔧 Proposed fix
-    updatePortDeviceHint(current);
+    updatePortDeviceHint(select.value);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@moondeck/moondeck_ui/app.js` around lines 676 - 708, Update renderPortOptions
so the hint uses the port actually represented by the rebuilt select: detect
whether current matches an entry in ports, use current only when present,
otherwise use the blank selection value, and pass that effective value to
updatePortDeviceHint.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@docs/adr/0013-no-migration-code-robust-persistence-plus-documented-breaks.md`:
- Around line 7-11: The ADR narrative uses past-tense descriptions of migration
code and repository history. Update the affected sentences to describe the
current repository state in present tense, including the statements about the
two migrations and their removal, while preserving the existing meaning and
surrounding rationale.
- Around line 23-25: Update the “Known breaking changes” section to document the
persistence key rename from `colour` to `color` used by
DevicesModule::restoreList(), noting that cached Hue counts stored under
`colour` reset to zero until the bridge reconnects.

In `@docs/backlog/backlog-core.md`:
- Line 228: Update the backlog documentation wording from “do not
over-generalise” to “do not over-generalize,” preserving the surrounding scope
and meaning of the lane-ceiling guidance.

In `@docs/backlog/backlog-light.md`:
- Line 13: Update the design-record link in the backlog entry to reference the
shipped plan filename, preserving the existing relative path and link text while
adding the “(shipped)” suffix before the .md extension.

In `@docs/backlog/multicore-analysis-top-down.md`:
- Line 16: Update the dependency-graph documentation line to use the American
spelling “optimization” instead of “optimisation,” leaving the diagram structure
and surrounding text unchanged.

In `@docs/history/plans/Plan-20260712` - 16-lane parallel LED output (shipped).md:
- Around line 21-80: Add blank lines after each flagged Markdown heading and
before and after the fenced code block in the plan document, including the
headings around the listed sections. Preserve all existing text and code content
while making the file satisfy MD022 and MD031.

In `@moondeck/moondeck_ui/app.js`:
- Around line 710-750: Update refreshPorts and the identify-ports click handler
to catch fetch, non-2xx, JSON, and malformed-response failures instead of
allowing them to throw silently. Log a clear user-visible error through
appendLog; keep the Identify handler’s finally block so the button is always
restored, and validate the expected ports data before calling renderPortOptions.

In `@moondeck/moondeck_ui/index.html`:
- Around line 64-72: Update the new buttons identified by `identify-ports` and
`watch-ports` to explicitly declare `type="button"`, preserving their existing
labels, titles, and behavior.

In `@moondeck/moondeck.py`:
- Around line 1123-1135: Update the /api/identify-ports request handling around
probe_port_chip to validate requested paths against the current
list_serial_ports() result before probing. Build a known-port set and only call
probe_port_chip for requested paths present in that set, preserving the existing
response behavior for valid ports and excluding unknown paths.

In `@src/light/drivers/DriverBase.h`:
- Around line 253-263: Update DriverBase’s kWindowAll/count_ and the
windowSlice() handling so the default window cannot cap output at 65535 when
nrOfLightsType supports larger buffers. Use a non-colliding sentinel or preserve
the 0-as-all convention, ensuring a fresh driver expands its window to the full
bufN while explicit real counts remain unchanged.

In `@src/light/drivers/LcdLedDriver.h`:
- Around line 85-99: Update parseConfig() to detect the clockPin == dcPin
collision separately and route it through the configuration error path instead
of setConfigWarn. Preserve warning handling for per-lane collisions, and retain
validateBusPins()’s specific “they must differ” message.

In `@src/light/drivers/ParallelLedDriver.h`:
- Around line 201-225: Initialize the local wire buffer in encodeRows with
zeroed storage, matching the analogous buffer in runLoopbackSelfTest. Keep the
existing lane population and encodeWs2812LcdSlots flow unchanged.

In `@src/platform/esp32/platform_config.h`:
- Around line 84-91: Update the comments immediately above lcdLanes to remove
the stale 8-lane/half-DMA-footprint rationale and describe the current 16-lane
limit. Revise the note about widening so it accurately states that the
configured maximum is controlled by changing this constant, while preserving the
existing explanation of valid LCD data-pin widths.

In `@src/platform/esp32/platform_esp32_lcd.cpp`:
- Around line 142-154: Update the pre-allocation check in lcdWs2812Init() so
initialization is allowed when either the internal DMA-capable heap or PSRAM can
provide bufferBytes, while retaining HEAP_RESERVE enforcement for internal RAM.
Ensure boards whose draw buffer fits only in PSRAM reach the PSRAM-first
createState() allocation path.

In `@test/unit/light/unit_Correction.cpp`:
- Line 196: Rename the affected test descriptions in the Correction roles array
tests from British spelling (“colour”/“colours”) to American spelling
(“color”/“colors”), including the tests at the referenced locations, without
changing their behavior.

In `@web-installer/deviceModels.json`:
- Around line 899-904: Update the SE 16 V1 device-model entry identified by id
"LcdLed" to explicitly configure clockPin and dcPin to GPIOs not used by its
16-lane controls.pins mapping, rather than relying on LcdLedDriver defaults of
10 and 11. Preserve the existing data-lane pin list and use the board’s correct
dedicated control-pin assignments.

---

Outside diff comments:
In `@moondeck/moondeck_ui/app.js`:
- Around line 676-708: Update renderPortOptions so the hint uses the port
actually represented by the rebuilt select: detect whether current matches an
entry in ports, use current only when present, otherwise use the blank selection
value, and pass that effective value to updatePortDeviceHint.
🪄 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: e7218ff5-ce63-47c5-84f1-3bf7f7652146

📥 Commits

Reviewing files that changed from the base of the PR and between 5e7f525 and fe8fd0d.

📒 Files selected for processing (44)
  • docs/adr/0013-no-migration-code-robust-persistence-plus-documented-breaks.md
  • docs/adr/README.md
  • docs/backlog/backlog-core.md
  • docs/backlog/backlog-light.md
  • docs/backlog/multicore-analysis-bottom-up.md
  • docs/backlog/multicore-analysis-top-down.md
  • docs/history/plans/Plan-20260712 - 16-lane parallel LED output (shipped).md
  • docs/moonmodules/light/drivers.md
  • docs/performance.md
  • moondeck/moondeck.py
  • moondeck/moondeck_ui/app.js
  • moondeck/moondeck_ui/index.html
  • moondeck/moondeck_ui/style.css
  • src/core/DevicesModule.h
  • src/core/FilesystemModule.cpp
  • src/core/FilesystemModule.h
  • src/core/HttpServerModule.cpp
  • src/core/HttpServerModule.h
  • src/core/MoonModule.h
  • src/light/drivers/Correction.h
  • src/light/drivers/DriverBase.h
  • src/light/drivers/LcdLedDriver.h
  • src/light/drivers/LcdSlots.h
  • src/light/drivers/ParallelLedDriver.h
  • src/light/drivers/ParlioLedDriver.h
  • src/light/drivers/PinList.h
  • src/light/drivers/RmtLedDriver.h
  • src/platform/esp32/platform_config.h
  • src/platform/esp32/platform_esp32_lcd.cpp
  • src/platform/esp32/platform_esp32_parlio.cpp
  • src/ui/app.js
  • src/ui/install-picker-boards.js
  • src/ui/install-picker.js
  • test/python/test_moondeck_port_identity.py
  • test/unit/core/unit_HttpServerModule_apply.cpp
  • test/unit/core/unit_MoonModule.cpp
  • test/unit/light/correction_presets.h
  • test/unit/light/unit_Correction.cpp
  • test/unit/light/unit_LcdLedDriver.cpp
  • test/unit/light/unit_LcdLedEncoder.cpp
  • test/unit/light/unit_ParlioLedDriver.cpp
  • test/unit/light/unit_RmtLedDriver_pins.cpp
  • web-installer/deviceModels.json
  • web-installer/install.js
💤 Files with no reviewable changes (2)
  • src/core/FilesystemModule.cpp
  • src/core/FilesystemModule.h

Comment on lines +7 to +11
Persisted config and UI state carry keys/values that drift as the schema evolves (a control renamed, a config file renamed, a wire key re-spelled). The reflex fix is *migration code*: a fallback that reads the old key when the new one is absent, or a one-time cleanup that deletes files a renamed type wrote. Two such migrations had accumulated (a `FilesystemModule::migrateRenamedConfigs()` deleting `LayoutGroup.json`/`DriverGroup.json`, and a `lsRead(key, legacyKey, default)` localStorage fallback), and a review proposed a third (fall back to a legacy `colour` key after the US-spelling rename).

Migration code is **absence-narration in code form**: it exists only to describe a past state a present-tense reader never sees, which fights CLAUDE.md's *Present tense only* principle. It also accretes — each rename tempts one more fallback, and they become permanent dead weight because no one remembers to remove them. A *patching framework* (version-stamp the persisted file, run an ordered chain of patch functions) was considered as the disciplined alternative, but it is still migration code — just centralized — and building the framework before there are several real migrations to hold is premature abstraction (*Concrete first, abstract later*; *Core grows slower than the domain*).

The realization that settles it: **the persistence layer is already robust to schema drift**, so migrations are rarely needed at all. On load, an **absent key** keeps the control's `defineControls()` default (the `hasKey` guard); a **stale/out-of-range value** snaps to the new bounds via `ApplyPolicy::Clamp`; an **unknown key** is ignored. The only genuinely lossy case is a renamed *value* key, and pre-1.0 a lost setting simply re-populates on next use (a cached device re-confirms on its next live packet; a UI selection resets to the first module).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Keep the ADR narrative in present tense.

These lines narrate prior actions (“had accumulated”, “were removed”), contrary to the documentation rule. Describe the current repository state instead.

As per coding guidelines, “docs/**/*.md: Write code, comments, and documentation in present tense.”

Also applies to: 21-21

🤖 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/adr/0013-no-migration-code-robust-persistence-plus-documented-breaks.md`
around lines 7 - 11, The ADR narrative uses past-tense descriptions of migration
code and repository history. Update the affected sentences to describe the
current repository state in present tense, including the statements about the
two migrations and their removal, while preserving the existing meaning and
surrounding rationale.

Source: Coding guidelines

Comment thread docs/backlog/backlog-core.md Outdated
Comment thread docs/backlog/backlog-light.md Outdated
Comment thread docs/backlog/multicore-analysis-top-down.md Outdated
Comment thread src/light/drivers/ParallelLedDriver.h Outdated
Comment thread src/platform/esp32/platform_config.h
Comment thread src/platform/esp32/platform_esp32_lcd.cpp Outdated
Comment thread test/unit/light/unit_Correction.cpp Outdated
Comment thread web-installer/deviceModels.json Outdated
Ships the Step 1.5 async double-buffer (asyncTransmit, default on) that overlaps the WS2812 wire wait to lift the P4 whole-board rate 48→76 fps, and fixes a pre-existing crash where a multi-channel preset (a moving-head/DMX fixture, or RGBCCT at 5 channels) overran a fixed 4-byte per-light scratch and bootlooped the device — LED drivers now drive any channel count, limited only by memory. Also folds in the earlier session's IR, pins, and MoonDeck port-identity work.

KPI: 16384lights | Desktop:666KB | tick:123/91/120/15/2/280/61/18/21/159/117/16/1/36us(FPS:8130/10989/8333/66666/500000/3571/16393/55555/47619/6289/8547/62500/1000000/27777) | ESP32:1400KB | src:192(38809) | test:130(20925) | lizard:141w

Core:
- platform.h + platform_esp32_lcd/parlio.cpp: async double-buffer seam — buf[2] + per-buffer done-semaphore, an in-order 2-slot completion FIFO routing each done-callback to its buffer, and a new wireUs KPI (DMA transfer duration, start→done) reporting the pure WS2812 output floor (live P4 7474µs/133fps). Second buffer allocated only when asyncTransmit is on, PSRAM-first with an internal fallback gated on HEAP_RESERVE so it degrades rather than starving the WiFi/HTTP reserve.
- platform_esp32_ir.cpp: NEC glitch filter signal_range_min_ns 200→1250 so edge ringing can't split the lead burst (IR receive was dead); truthful irChannelReady status.
- IrService.h: report the real RX-channel bind state, not just "pin set".
- PinsModule.h: skip hidden (eth) pin controls in the ownership map so a disabled Ethernet no longer shows a phantom pin conflict.

Light domain:
- ParallelLedDriver/RmtLedDriver: per-light correction scratch is now a heap buffer sized to outChannels (kMaxLanes × outCh for the parallel path), allocated off the hot path and reused every tick, with the encoder's lane stride taken from the channel count instead of a hard-coded 4 — a light of any channel count (RGB/RGBW/RGBCCT/N-channel fixture) lays out without overrun. Fixes the multi-channel-preset bootloop (a fixed 4-byte stride overflowed and heap-corrupted).
- ParallelLedDriver: deferred-wait tick split into two explicit paths — tickSync (the original encode→transmit→wait, 0 added latency, provable non-regression) and tickAsync (double-buffer); asyncTransmit control (default on) drives the allocation and rebuilds on toggle; drain-both-buffers before any reinit/release.
- LcdSlots.h: encoder lane stride parameterised to the channel count.
- DriverBase.h: driving-status now also reports total channels (lights × channels) for a multi-channel fixture — the DMX-universe footprint (live SE16: "driving 1305 of 1305 lights (19575 channels)").
- LcdLedDriver/ParlioLedDriver: CRTP wrappers thread the buffer index + wire-time accessor.

UI:
- app.js / index.html: MoonDeck port watcher (start/stop, flap detection, 60s auto-stop) and per-port board identity (port → chip type → specific board).

Scripts / MoonDeck:
- moondeck.py: USB-port identity — chip probe, registry/firmware mapping, swap-correction, non-ESP devices by product name; /api/identify-ports path allowlist.

Tests:
- unit_ParallelLedDriver_doublebuffer: MockDriver pins buffer-alternation, deferred-wait, single-buffer degrade, asyncTransmit toggle-allocation, drain-on-reinit, wireUs, and the any-channel-count fixture case (drives, does not overflow, status shows total channels).
- unit_LcdLedEncoder: encoder golden cases updated to the channels-stride layout.
- unit_LcdLedDriver / unit_RmtLedDriver_pins / unit_Correction: assertions updated for the new defaults + kWindowAll.

Docs / CI:
- lessons.md: two lessons — tickTimeUs is CPU-in-the-call not frame time (the measurement trap that nearly buried the 48→76 fps win, plus the git-worktree baseline method), and a fixed-stride per-light scratch silently caps channel count (the bootloop root cause + no-maxed-caps fix).
- Step 1.5 plan marked shipped with measured results; multicore top-down/bottom-up + performance.md updated; backlog-light RS-485/DMX item fleshed out (multi-channel encode now unblocks the channel-mapping half; RS-485-transceiver/XLR note); ADR 0013 (migration removal); mkdocs nav adds the MHC-WLED P4 shield.
- deviceModels.json: SE16/LC16 clock/dc pin fixes.

Reviews:
- 👾 HEAP_RESERVE bypass on the default-on second buffer — fixed (internal fallback now gated on the reserve).
- 👾 critical-section wrapped a blocking tx_color (would panic) — removed the critical section entirely; the completion FIFO is safe via slot-disjointness (push writes head, ISR reads tail, head≠tail while in flight).
- 👾 nine "defaults OFF" comments vs the shipped default-ON, stale busWaitIfBusy comment, present-tense in IrService/performance.md — fixed.
- 👾 LCD/Parlio FIFO duplication + degraded-board rebuild-every-sweep — accepted as advisory (dup is inherent to platform-specific files; noted for the shared-extraction trigger).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

♻️ Duplicate comments (1)
src/light/drivers/DriverBase.h (1)

270-272: ⚠️ Potential issue | 🟠 Major

Use a non-colliding “all lights” sentinel.

This repeats the previous finding: kWindowAll == 65535 is a valid explicit uint16_t count, but windowSlice() interprets it as “all.” On buffers larger than 65535 lights, setWindow(start, 65535) therefore drives the entire remainder instead of exactly 65535 lights. Preserve the existing 0 = all convention or use a wider/non-colliding representation.

Proposed fix
-    static constexpr uint16_t kWindowAll = 65535;
+    static constexpr uint16_t kWindowAll = 0;

-    uint16_t count_ = kWindowAll;
+    uint16_t count_ = kWindowAll;

-        outLen = (count_ == 0 || count_ == kWindowAll || count_ > avail)
+        outLen = (count_ == kWindowAll || count_ > avail)
                      ? avail
                      : static_cast<nrOfLightsType>(count_);

Also applies to: 297-305

🤖 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/DriverBase.h` around lines 270 - 272, Replace the colliding
kWindowAll sentinel used by DriverBase::count_ with the existing non-colliding 0
= all convention, and update windowSlice() plus setWindow() handling
accordingly. Preserve default behavior of driving all lights while ensuring an
explicit count of 65535 remains exactly 65535, including when buffers exceed
that size.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/backlog/backlog-light.md`:
- Line 13: Update the deferred backlog entries for PSRAM-first DMA buffering and
async transmit/double-buffering to reflect that the work has shipped, removing
them from future implementation scope or deleting the obsolete draft entries.
Keep the shipped status and references consistent with the existing 16-lane
implementation and design record.

In `@docs/history/lessons.md`:
- Line 290: In the lesson text around the wireUs KPI, replace the British
spelling “optimisation” with the American spelling “optimization”; leave the
surrounding explanation unchanged.

In `@src/core/IrService.h`:
- Around line 220-223: Update IrService::reportReady() to release any previously
active IR channel via platform::irStop() before handling the current pin or
attempting platform::irChannelReady(). Ensure this runs for both valid-to--1
transitions and valid-to-valid pin changes, while preserving the existing status
reporting and readiness checks.

In `@test/unit/light/unit_ParallelLedDriver_doublebuffer.cpp`:
- Around line 17-22: Update the comments near the double-buffer and
single-buffer behavior description to use the American spelling “behavior”
instead of “behaviour,” without changing the documented semantics.

---

Duplicate comments:
In `@src/light/drivers/DriverBase.h`:
- Around line 270-272: Replace the colliding kWindowAll sentinel used by
DriverBase::count_ with the existing non-colliding 0 = all convention, and
update windowSlice() plus setWindow() handling accordingly. Preserve default
behavior of driving all lights while ensuring an explicit count of 65535 remains
exactly 65535, including when buffers exceed that size.
🪄 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: 0b4f98dd-5d85-4a59-bb1e-927ca8e580af

📥 Commits

Reviewing files that changed from the base of the PR and between fe8fd0d and 3422f60.

📒 Files selected for processing (34)
  • docs/adr/0013-no-migration-code-robust-persistence-plus-documented-breaks.md
  • docs/backlog/backlog-core.md
  • docs/backlog/backlog-light.md
  • docs/backlog/multicore-analysis-bottom-up.md
  • docs/backlog/multicore-analysis-top-down.md
  • docs/history/lessons.md
  • docs/history/plans/Plan-20260712 - 16-lane parallel LED output (shipped).md
  • docs/history/plans/Plan-20260712 - Step 1.5 async transmit double-buffer (shipped).md
  • docs/performance.md
  • mkdocs.yml
  • moondeck/moondeck.py
  • moondeck/moondeck_ui/app.js
  • moondeck/moondeck_ui/index.html
  • src/core/IrService.h
  • src/core/PinsModule.h
  • src/light/drivers/DriverBase.h
  • src/light/drivers/LcdLedDriver.h
  • src/light/drivers/LcdSlots.h
  • src/light/drivers/ParallelLedDriver.h
  • src/light/drivers/ParlioLedDriver.h
  • src/light/drivers/RmtLedDriver.h
  • src/platform/desktop/platform_desktop.cpp
  • src/platform/esp32/platform_esp32_ir.cpp
  • src/platform/esp32/platform_esp32_lcd.cpp
  • src/platform/esp32/platform_esp32_parlio.cpp
  • src/platform/platform.h
  • test/CMakeLists.txt
  • test/scenarios/light/scenario_perf_full.json
  • test/unit/light/unit_Correction.cpp
  • test/unit/light/unit_LcdLedDriver.cpp
  • test/unit/light/unit_LcdLedEncoder.cpp
  • test/unit/light/unit_ParallelLedDriver_doublebuffer.cpp
  • test/unit/light/unit_RmtLedDriver_pins.cpp
  • web-installer/deviceModels.json

Comment thread docs/backlog/backlog-light.md Outdated
### 16-lane parallel output — SHIPPED (2026-07-12)

`LcdLedDriver` requires **all 8** i80 data lanes (`kExactLaneCount = true`, `LcdLedDriver.h`): the ESP-IDF `esp_lcd` i80 bus configures every data line of the bus width and rejects a partial set, so even a few WS2812 strands claim 8 GPIOs. That's why **S3 boards default to `RmtLedDriver`** in `deviceModels.json` (RMT runs one channel per pin, 1..N) rather than LCD — a board with fewer than 8 strips can't sensibly use the LCD driver, and the 8-lane LCD bench wiring (`1,2,4,5,6,7,8,9`) collides with common peripheral pins (e.g. the mic on 4/5/6). A **1..8-pin LCD mode** (drive only the lanes named in `pins`, leave the rest unclaimed — matching Parlio's flexibility) would let the parallel S3 path run any lane count, at which point an S3 board entry could choose LCD vs RMT by intent. Parlio already does this (`kExactLaneCount = false`, 1..8 lanes), so the P4 default *is* the parallel driver. Until LCD gains the same flexibility, S3 stays on RMT by default. Low priority — RMT covers the few-strip S3 case today.
The LCD_CAM (S3/P4) and Parlio (P4) drivers drive **up to 16 lanes**, with the bus width DERIVED from the pin count: ≤8 pins → an 8-bit bus (uint8 slots, the original path), 9..16 pins → a 16-bit bus (uint16 slots). LCD_CAM accepts exactly 8 or 16 real pins (IDF caps `bus_width` to those, and rejects an NC data line — a sub-16 board parks unused lanes on the WR "ghost pin"); Parlio accepts any 1..16 (unused lanes idle NC). The 16-lane transpose is the two-pass `transposeLanes16x8` (two 8×8 SWAR passes → a uint16 plane, low byte = lanes 0..7, high = 8..15) in `LcdSlots.h`; `frameBytesFor` and the loopback thread a `slotBytes` factor. The DMA buffer is PSRAM-first (the 16-bit frame doubles the footprint). **Consumers:** SE 16 V1 + LightCrafter 16 (both S3/LCD_CAM, wired to `LcdLedDriver` in the catalog). **Verified live on the LightCrafter 16:** the 16-bit i80 bus inits, the PSRAM DMA buffer allocates, and the 16×8 transpose + 16-bit encode runs (`LcdLed:19348 µs`). Design record: `docs/history/plans/Plan-20260712 - 16-lane parallel LED output (shipped).md`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Remove or update the shipped work from the deferred backlog.

Line 13 marks PSRAM-first DMA buffering as shipped, while the later PSRAM item still describes it as future work. Line 167 likewise keeps async transmit and double-buffering deferred despite the shipped plan and PR objective. Synchronize these entries to avoid directing duplicate implementation.

As per coding guidelines, “Use backlog documents for forward-looking work and delete a draft when the module ships.”

Also applies to: 167-167

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/backlog/backlog-light.md` at line 13, Update the deferred backlog
entries for PSRAM-first DMA buffering and async transmit/double-buffering to
reflect that the work has shipped, removing them from future implementation
scope or deleting the obsolete draft entries. Keep the shipped status and
references consistent with the existing 16-lane implementation and design
record.

Source: Coding guidelines

Comment thread docs/history/lessons.md Outdated
- **A module's `tickTimeUs` is CPU time spent inside its `tick()` call, which includes a *blocking* wait but excludes a *deferred* one — so it is NOT the output frame time.** Synchronous (async OFF) the ParlioLed tick was ~10,800 µs = encode (~2,800) **+ blocking on the WS2812 wire (~8,000)**; the CPU sits in `busWait`, so the wire time is *in* the tick, and `1e6/10800 ≈ 92` reads like a real rate. Async ON the tick was ~3,790 µs — encode only, because `busTransmit` returns immediately and the wait, deferred to the next tick, finds the DMA already finished (it drained during the other modules' ~9 ms of ticks). `1e6/3790 = 264` — which is **physically impossible**: 256 lights × ~29 µs/light = ~7,474 µs is a hard 133 fps wire ceiling. That impossibility is the tell that `tickTimeUs` had stopped measuring frame time and started measuring only CPU. The **real** frame rate is the *system* tick (the whole render loop): 48 fps sync → **76 fps async**. The driver's "264" is CPU headroom, not fps.
- **The first "no gain" A/B compared two async-ON states.** The initial P4 firmware *defaulted* async ON, so flipping the runtime toggle never cleanly reached the synchronous 10.8 ms path — both sides of the "A/B" were ~3,790 µs, so it looked flat. The clean A/B needs the two paths genuinely distinct: toggle OFF must run the *literal original* encode→transmit→wait (which is why the shipped `tick()` has two explicit branches, `tickSync`/`tickAsync`, not one path with a stale flag — the sync branch is provably the pre-double-buffer timing, no regression).
- **The rigorous baseline was a git worktree at the previous commit, flashed to the same board+config.** The doubt ("we started at 93, now 76, now 48 — only lower numbers?!") was three *different quantities* wearing the label "fps": 93 = driver-only sync fps (`1e6/10.8ms`), 76 = system fps async, 48 = system fps sync. To kill it, `git worktree add <tmp> <prev-commit>`, build, flash, measure — same meters, same board. Result: the previous commit was **always** 48 fps overall (never 76 or 93 as a whole-system number), and today's async-OFF reproduced it to the microsecond (10,820 µs vs 10,787). No regression ever existed; the confusion was entirely meter-mixing. General: when a number looks like a regression, measure the prior commit on the identical target before touching anything — a worktree does it without disturbing the working tree.
- **The fix that makes the trap un-fall-into-able: measure the wire explicitly.** Added a `wireUs` read-only KPI = the DMA transfer duration timed start-of-`busTransmit` → done-callback (an in-order 2-slot completion FIFO in the platform pairs each done with its start stamp; `esp_timer_get_time()` is ISR-safe). It reads the *pure* WS2812 output floor independent of render load — live on the P4 at 16×256: **"7474 µs (133 fps max)"**. Now the ceiling is measured, not assumed, the "impossible 264" can't masquerade as fps, and it doubles as the overclock instrument (raise the slot rate → `wireUs` drops directly). General: when a derived metric (fps-from-tick) can silently change *what it measures* under an optimisation, add a metric for the underlying physical quantity so the derived one can't lie.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use American English spelling: "optimisation" → "optimization".

As per coding guidelines, docs/**/*.{md,markdown} must use American English spelling.

🤖 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/lessons.md` at line 290, In the lesson text around the wireUs
KPI, replace the British spelling “optimisation” with the American spelling
“optimization”; leave the surrounding explanation unchanged.

Source: Coding guidelines

Comment thread src/core/IrService.h
Comment on lines 220 to +223
void reportReady() {
if (pin_ < 0) setStatus("set pin to receive", Severity::Warning);
else setStatus("ready");
if (pin_ < 0) { setStatus("set pin to receive", Severity::Warning); return; }
if (platform::irChannelReady(static_cast<uint16_t>(pin_))) setStatus("ready");
else setStatus("IR channel failed to open — pin busy or invalid?", Severity::Error);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

Release the previous IR channel before recomputing readiness.

When a valid pin changes to -1, Line 221 returns without calling platform::irStop(), leaving the old RX channel active while the ownership map can treat that GPIO as free. Valid-to-valid pin changes also need the previous binding released before opening the new one.

Proposed fix
 void reportReady() {
+    platform::irStop();
     if (pin_ < 0) { setStatus("set pin to receive", Severity::Warning); return; }

As per coding guidelines, “Apply configuration changes live on the next render tick; configuration changes must not require a reboot or restart.”

📝 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.

Suggested change
void reportReady() {
if (pin_ < 0) setStatus("set pin to receive", Severity::Warning);
else setStatus("ready");
if (pin_ < 0) { setStatus("set pin to receive", Severity::Warning); return; }
if (platform::irChannelReady(static_cast<uint16_t>(pin_))) setStatus("ready");
else setStatus("IR channel failed to open — pin busy or invalid?", Severity::Error);
void reportReady() {
platform::irStop();
if (pin_ < 0) { setStatus("set pin to receive", Severity::Warning); return; }
if (platform::irChannelReady(static_cast<uint16_t>(pin_))) setStatus("ready");
else setStatus("IR channel failed to open — pin busy or invalid?", 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/core/IrService.h` around lines 220 - 223, Update IrService::reportReady()
to release any previously active IR channel via platform::irStop() before
handling the current pin or attempting platform::irChannelReady(). Ensure this
runs for both valid-to--1 transitions and valid-to-valid pin changes, while
preserving the existing status reporting and readiness checks.

Source: Coding guidelines

Comment thread test/unit/light/unit_ParallelLedDriver_doublebuffer.cpp Outdated
… guard

The classic ESP32 now drives 8/16 parallel WS2812 lanes over the I2S peripheral in i80 mode — the same I80LedDriver the S3/P4 use for LCD_CAM, since IDF's esp_lcd i80 API is one interface routed to the right backend per chip. Any invalid or over-memory pin config now degrades with a clear status instead of ever crashing the device. Bench-verified on the ESP32-WROVER (classic I2S) and SE16 (S3 LCD_CAM); the async double-buffer makes the SE16 16-lane path 30–56% faster than the previous commit.

KPI: 16384lights | Desktop:666KB | tick:124/91/118/15/2/281/59/18/21/160/117/17/1/38us(FPS:8064/10989/8474/66666/500000/3558/16949/55555/47619/6250/8547/58823/1000000/26315) | ESP32:1431KB | src:192(38919) | test:130(20940)

Core:
- PinList.h: parsePinList now rejects any pin > MM_MAX_GPIO with "pin out of range for this chip" — an out-of-range GPIO (e.g. 999) parsed as a valid integer but reached IDF's gpio_func_sel and hard-faulted the classic ESP32; the guard sits in the one core primitive all three LED drivers share, so RMT/Parlio/i80 are all protected. HW-verified: the full hostile-input gauntlet degrades cleanly, no reset.
- PinsModule.h: removed the now-dead per-pin skip (parsePinList guarantees every returned pin is in range); a malformed lane list now yields no claims.
- IrService.h: reportReady() releases the RX channel on pin-unset (valid→-1) so it doesn't leak armed on a just-cleared pin; the valid→valid change was already handled in ensureChannel.
- platform_esp32_i80.cpp (was platform_esp32_lcd.cpp): the seam now serves all three i80-capable chips — classic ESP32 via I2S, S3/P4 via LCD_CAM. Classic-I2S-backend guards under #if SOC_LCDCAM_I80_LCD_SUPPORTED: internal-only draw buffer (I2S can't DMA from PSRAM), an 8-bit command phase (lcd_cmd_bits=8 / kI80Cmd=0) so the backend's unconditional busy-wait completes instead of hanging to a watchdog reset, and an IRAM_ATTR done callback.
- platform.h / platform_config.h: i2sLanes constant (classic-only gate); lanesAvailable() = lcdLanes + i2sLanes; the i80 seam symbols renamed lcdWs2812*→i80Ws2812*, LcdState→I80State, kLcdCmd→kI80Cmd, lcdDoneCb→i80DoneCb.

Light domain:
- LcdLedDriver.h → I80LedDriver.h: one driver over IDF's i80 bus, backend picked by chip. Registered under CONFIG_SOC_LCD_I80_SUPPORTED (classic + S3 + P4). Status string "i80 bus needs exactly 8 or 16 pins".
- LcdSlots.h → ParallelSlots.h, encodeWs2812LcdSlots → encodeWs2812ParallelSlots: the shared parallel-bus slot encoder, used by BOTH ParlioLedDriver and I80LedDriver via the ParallelLedDriver base (a Parlio bus byte and an i80 bus byte are identical), so the name is backend-neutral. lcdLanes kept — it is the genuine LCD_CAM lane-count constant, sibling to i2sLanes.

Tests:
- unit_RmtLedDriver_pins: pins the out-of-range crash guard (63 valid, 64/999 rejected, whole-list rejection).
- unit_PinsModule: updated to the reject-whole-malformed-list contract (asserts no claims + the 300&0xFF=44 must-never-appear negative).
- unit_I80LedDriver / unit_ParallelSlots: renamed with the driver + encoder; status-string assertion updated to "i80 bus".

Docs / CI:
- performance.md: classic I2S-i80 row (WROVER, ~7.6µs/light, ~4096-light internal-RAM ceiling); SE16 LCD_CAM 16-lane row re-measured with async ON (16384 → 49916µs/~34fps, up from ~13fps; async OFF reproduces the old numbers within 3%, so the rename is behavior-neutral and the speedup is Step 1.5).
- gpio-usage.md: WROVER i80 pin recommendation (8-lane set + WR/DC, the 16-lane strap caveat, the 4096 ceiling).
- backlog-light.md: classic-I2S item rewritten as SHIPPED (the chunk-streaming-ring/nbDmaBuffer design was superseded by esp_lcd whole-frame chained DMA); async double-buffer (Step 1.5) removed from deferred; PSRAM-first narrowed to Parlio-only.
- drivers.md: #i80led anchor + card describing both backends; moxygen link retargeted.
- esp32/main/CMakeLists.txt + test/CMakeLists.txt: renamed source entries.

WROVER variant:
- esp32-wrover firmware (classic ESP32 + quad PSRAM), sdkconfig.defaults.wrover, deviceModels.json entry + image, psramType platform seam + SystemModule control, firmwares.json regenerated.

Reviews:
- 🐇 optimisation→optimization (lessons.md) and behaviour→behavior (double-buffer test) — fixed (American spelling).
- 🐇 IrService leaked the RX channel on pin-unset — fixed (irStop() in the pin_<0 branch).
- 🐇 backlog listed shipped async double-buffer + PSRAM-first as deferred — fixed (async removed, PSRAM narrowed to Parlio).
- 🐇 DriverBase kWindowAll "collides" with an explicit 65535 count — accepted as-is: not a real collision, the code already treats 0 and 65535 as "all" by design (65535 is the uint16 max, so an explicit 65535 on a >65535 buffer correctly means all), documented at the site.
- 👾 three stale "LCD" strings the rename hadn't reached (platform.h section header, the pin-count status string, parlio comment cross-refs) — fixed; genuine LCD_CAM peripheral references kept.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (9)
docs/backlog/multicore-analysis-top-down.md (3)

35-35: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Mark classic ESP32 I2S/i80 as shipped.

Both references still describe parallel I2S as forthcoming, contradicting the shipped driver record in backlog-light.md.

Also applies to: 124-124

🤖 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/multicore-analysis-top-down.md` at line 35, Update the classic
ESP32 I2S/i80 references in this backlog entry to mark the parallel-I2S driver
as shipped rather than forthcoming, matching the shipped driver status recorded
in backlog-light.md. Preserve the existing investigation and fix details.

90-92: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Resolve the contradictory multicore gate.

Line 90 says measured network starvation has opened the gate, but Line 92 says not to build until LED glitches are independently reproduced. Decide whether network starvation alone is an accepted trigger and state one gate.

🤖 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/multicore-analysis-top-down.md` around lines 90 - 92, Resolve
the contradiction in the multicore gate text by choosing one explicit acceptance
criterion: either make the measured network starvation sufficient to open the
gate and remove the LED-glitch prerequisite, or require the sigrok LED-output
glitch confirmation before implementation. Update the adjacent statements
consistently so they describe a single gate and next action.

75-84: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Recalculate Step 2 from the post-async measurements.

This section still uses encode ≈ 22 ms, DMA wait ≈ 0, and a 45-fps pipeline floor, while Lines 26–27 and 65–67 establish the current effect ≈ 7.3 ms, driver ≈ 3.8 ms, async-overlapped model. The table and conclusion therefore no longer support the proposed pipeline.

🤖 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/multicore-analysis-top-down.md` around lines 75 - 84,
Recalculate the “Why the pipeline, not split-encode” section using the
post-async measurements established in the surrounding document, including
effect ≈ 7.3 ms and driver ≈ 3.8 ms. Replace the outdated encode ≈ 22 ms,
DMA-wait ≈ 0, table values, and 45-fps pipeline conclusion with timings and
conclusions derived from the current async-overlapped model, while preserving
the section’s comparison of pipeline versus hybrid approaches.
docs/backlog/backlog-light.md (1)

169-169: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Remove the stale “coming parallel-I2S driver” statement.

Lines 21–25 record classic ESP32 I2S/i80 as shipped, so its behavior is no longer TBD.

As per coding guidelines, backlog files contain forward-looking specifications and completed items are removed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/backlog/backlog-light.md` at line 169, Update the Parlio chunking
backlog entry to remove the stale statement that the parallel-I2S driver’s
behavior is TBD, since the driver is now shipped. Keep the remaining
Parlio-specific chunking details and completed-item guidance unchanged.

Source: Coding guidelines

docs/backlog/multicore-analysis-bottom-up.md (1)

7-24: 📐 Maintainability & Code Quality | 🟠 Major | 🏗️ Heavy lift

Update or archive this analysis; its core model is now obsolete.

It still claims 8-lane LCD/Parlio limits, negligible blocking transmit, single-buffer operation, a forced Parlio ring, and forthcoming classic I2S. The supplied implementation records 16-lane support, shipped whole-frame async double buffering, and shipped classic I2S/i80. These contradictions also invalidate the derived ceilings and build order.

As per coding guidelines, shared facts must live in one authoritative location, and backlog documents must remain forward-looking.

Also applies to: 31-40, 59-70, 72-129

🤖 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/multicore-analysis-bottom-up.md` around lines 7 - 24, The
analysis is obsolete and contradicts shipped driver capabilities and buffering
behavior. Update or archive this document rather than preserving its current
conclusions; remove or replace claims about 8-lane limits, negligible blocking
transmit, single-buffer operation, forced Parlio ring behavior, and forthcoming
classic I2S/i80, and revise derived ceilings and build ordering to reflect
16-lane support, whole-frame asynchronous double buffering, and shipped classic
I2S/i80. Keep shared facts aligned with their authoritative documentation and
ensure any remaining backlog content is explicitly forward-looking.

Source: Coding guidelines

docs/moonmodules/light/drivers.md (1)

14-14: 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win

Document the preset reference as a stable ID, not a name.

The preset is editable and may be renamed; persistence survives because drivers store its stable ID.

Proposed correction
-- `preset` — the [light preset](supporting.md) this driver applies per light (channel order / RGBW synthesis), referenced by name so it survives a reboot.
+- `preset` — the [light preset](supporting.md) this driver applies per light (channel order / RGBW synthesis), referenced by stable ID so renaming it does not break persisted driver configuration.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/moonmodules/light/drivers.md` at line 14, Update the `preset` field
documentation in the light driver reference to state that it stores the preset’s
stable ID rather than its name, while retaining that it identifies the light
preset used for channel order and RGBW synthesis and persists across reboots.
src/light/drivers/ParlioLedDriver.h (1)

36-39: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Update the class rationale to the same 1..16 lane limit.

Line 36 now permits 16 lanes, but the class comment above still says 1..8 lanes. Keep the documented contract consistent.

As per coding guidelines, “Do not duplicate logic or facts; centralize shared behavior and link to the single authoritative documentation location.”

🤖 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/ParlioLedDriver.h` around lines 36 - 39, Update the
class-level rationale associated with ParlioLedDriver to document the supported
1–16 lane range instead of 1–8, keeping it consistent with kExactLaneCount.
Avoid duplicating the lane-limit fact elsewhere; reference the authoritative
documentation if one already exists.

Source: Coding guidelines

docs/reference/gpio-usage.md (1)

22-22: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Correct the P4 usable-pin count.

The listed ranges contain 20 pins, not approximately 24: 20–27 (8), 32–33 (2), and 39–48 (10).

-| **ESP32-P4 (P4-NANO)** | 20, 21, 22, 23, 24, 25, 26, 27, 32, 33, 39-48 | ~24 |
+| **ESP32-P4 (P4-NANO)** | 20, 21, 22, 23, 24, 25, 26, 27, 32, 33, 39-48 | 20 |
🤖 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/reference/gpio-usage.md` at line 22, Update the ESP32-P4 (P4-NANO) row
in the GPIO usage table so its usable-pin count is 20 instead of approximately
24, leaving the listed pin ranges and board-specific notes unchanged.
test/scenarios/light/scenario_perf_full.json (1)

838-848: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Exercise every supported i80 backend or explicitly exclude it.

The platform contract now supports classic ESP32, S3, and P4, but this scenario still says “S3 only.” Because the step is optional, classic/P4 can silently record measure-i80 without an active I80 driver. Add target-valid configurations or explicitly gate unsupported scenario targets.

As per path instructions, “Verify tests cover edge cases and match the specifications in docs/moonmodules/.”

🤖 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_perf_full.json` around lines 838 - 848, Update
the add-i80-driver scenario step to exercise every supported I80 backend:
provide target-valid configurations for classic ESP32, S3, and P4, or explicitly
gate the step so unsupported targets cannot record measure-i80 without an active
I80LedDriver. Keep the existing S3 pin and LED configuration valid, and verify
coverage against docs/moonmodules/ specifications.

Source: Path instructions

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@docs/adr/0013-no-migration-code-robust-persistence-plus-documented-breaks.md`:
- Line 26: Update the ADR text describing the Hue bridge light count to use
American spelling: retain the legacy persisted key `colour`, but change the
surrounding “colour-capable” wording to “color-capable.”

In `@docs/backlog/backlog-light.md`:
- Line 23: Correct the classic ESP32 capacity statement in the backlog entry:
distinguish 8×256 as 2,048 lights from the RGB channel count of 6,144, and
update the hardware verification wording to identify which metric is being
recorded. Preserve the separate ~4096-light ceiling claim only if it is
accurately labeled as lights.

In `@docs/backlog/multicore-analysis-top-down.md`:
- Line 69: Resolve the contradictory Parlio chunking status between the
shipped-design statement and the later requirements section. Update the affected
documentation so it consistently either defers ring/chunking indefinitely or
identifies it as required for scaling from roughly 4K to 16K lights, while
preserving the chosen implementation scope throughout the document.

In `@docs/performance.md`:
- Line 247: Correct the 16384-light LCD_CAM performance entry to report
approximately 20 fps based on the 49,916 µs encode tick, while retaining the
separate wireUs measurement and async-overlap details. Update only the incorrect
frame-rate calculation in the LCD_CAM 16-lane row.

In `@src/light/drivers/I80LedDriver.h`:
- Around line 9-19: Update the documentation comment above the I80 LED driver to
reference the actual ESP-IDF symbols esp_lcd_new_i80_bus and
esp_lcd_panel_io_tx_color, and mention esp_lcd_panel_io_i80.c where the backend
implementation is described. Do not change the driver implementation or the
explanation of chip-specific peripherals.

In `@src/light/drivers/PinList.h`:
- Line 12: Update the documentation comment describing I80LedDriver to call the
resource an “i80 data lane” instead of an “LCD_CAM lane,” while preserving the
existing explanation that each pin uses a separate lane.

In `@src/light/drivers/RmtLedDriver.h`:
- Line 191: Centralize the grow-only wire scratch-buffer allocation and cleanup
in shared protected helpers on DriverBase, including wire_ and wireCap_. Update
RmtLedDriver::resizeSymbols(), release(), and readiness checks, plus
ParallelLedDriver::prepareWire() and teardown paths, to use the shared
ensureWire/freeWire implementation while preserving allocation-failure and
capacity behavior.

In `@src/platform/esp32/platform_esp32_parlio.cpp`:
- Line 82: Update the transfer timing logic around lastTransmitUs and txStartUs
so each buffer’s wire-time measurement begins when hardware starts transmitting
it, not when it is merely enqueued. Record the next transfer’s start timestamp
from the preceding completion, or use the available hardware-start event, and
ensure queued buffer 1 excludes buffer 0’s remaining transmission time while
preserving the existing completion measurement.
- Around line 258-263: The parlioWs2812Wait path must report whether the
semaphore was actually acquired and prevent buffer reuse after a timeout. Update
parlioWs2812Wait and ParallelLedDriver::busWaitIfBusy to propagate completion
status, only clear inFlight_ after successful completion, and abort/reset the TX
unit before allowing reuse or teardown when the wait times out.

---

Outside diff comments:
In `@docs/backlog/backlog-light.md`:
- Line 169: Update the Parlio chunking backlog entry to remove the stale
statement that the parallel-I2S driver’s behavior is TBD, since the driver is
now shipped. Keep the remaining Parlio-specific chunking details and
completed-item guidance unchanged.

In `@docs/backlog/multicore-analysis-bottom-up.md`:
- Around line 7-24: The analysis is obsolete and contradicts shipped driver
capabilities and buffering behavior. Update or archive this document rather than
preserving its current conclusions; remove or replace claims about 8-lane
limits, negligible blocking transmit, single-buffer operation, forced Parlio
ring behavior, and forthcoming classic I2S/i80, and revise derived ceilings and
build ordering to reflect 16-lane support, whole-frame asynchronous double
buffering, and shipped classic I2S/i80. Keep shared facts aligned with their
authoritative documentation and ensure any remaining backlog content is
explicitly forward-looking.

In `@docs/backlog/multicore-analysis-top-down.md`:
- Line 35: Update the classic ESP32 I2S/i80 references in this backlog entry to
mark the parallel-I2S driver as shipped rather than forthcoming, matching the
shipped driver status recorded in backlog-light.md. Preserve the existing
investigation and fix details.
- Around line 90-92: Resolve the contradiction in the multicore gate text by
choosing one explicit acceptance criterion: either make the measured network
starvation sufficient to open the gate and remove the LED-glitch prerequisite,
or require the sigrok LED-output glitch confirmation before implementation.
Update the adjacent statements consistently so they describe a single gate and
next action.
- Around line 75-84: Recalculate the “Why the pipeline, not split-encode”
section using the post-async measurements established in the surrounding
document, including effect ≈ 7.3 ms and driver ≈ 3.8 ms. Replace the outdated
encode ≈ 22 ms, DMA-wait ≈ 0, table values, and 45-fps pipeline conclusion with
timings and conclusions derived from the current async-overlapped model, while
preserving the section’s comparison of pipeline versus hybrid approaches.

In `@docs/moonmodules/light/drivers.md`:
- Line 14: Update the `preset` field documentation in the light driver reference
to state that it stores the preset’s stable ID rather than its name, while
retaining that it identifies the light preset used for channel order and RGBW
synthesis and persists across reboots.

In `@docs/reference/gpio-usage.md`:
- Line 22: Update the ESP32-P4 (P4-NANO) row in the GPIO usage table so its
usable-pin count is 20 instead of approximately 24, leaving the listed pin
ranges and board-specific notes unchanged.

In `@src/light/drivers/ParlioLedDriver.h`:
- Around line 36-39: Update the class-level rationale associated with
ParlioLedDriver to document the supported 1–16 lane range instead of 1–8,
keeping it consistent with kExactLaneCount. Avoid duplicating the lane-limit
fact elsewhere; reference the authoritative documentation if one already exists.

In `@test/scenarios/light/scenario_perf_full.json`:
- Around line 838-848: Update the add-i80-driver scenario step to exercise every
supported I80 backend: provide target-valid configurations for classic ESP32,
S3, and P4, or explicitly gate the step so unsupported targets cannot record
measure-i80 without an active I80LedDriver. Keep the existing S3 pin and LED
configuration valid, and verify coverage against docs/moonmodules/
specifications.
🪄 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: 4c1e01c7-3af9-479f-aa02-9e6d32d06079

📥 Commits

Reviewing files that changed from the base of the PR and between fe8fd0d and f96cf22.

⛔ Files ignored due to path filters (3)
  • docs/assets/deviceModels/esp32-d0-wrover.jpg is excluded by !**/*.jpg
  • docs/assets/deviceModels/esp32-wrover.jpg is excluded by !**/*.jpg
  • moondeck/build/build_esp32.py is excluded by !**/build/**
📒 Files selected for processing (52)
  • docs/adr/0013-no-migration-code-robust-persistence-plus-documented-breaks.md
  • docs/backlog/backlog-core.md
  • docs/backlog/backlog-light.md
  • docs/backlog/multicore-analysis-bottom-up.md
  • docs/backlog/multicore-analysis-top-down.md
  • docs/history/lessons.md
  • docs/history/plans/Plan-20260712 - 16-lane parallel LED output (shipped).md
  • docs/history/plans/Plan-20260712 - Step 1.5 async transmit double-buffer (shipped).md
  • docs/moonmodules/light/drivers.md
  • docs/performance.md
  • docs/reference/gpio-usage.md
  • esp32/main/CMakeLists.txt
  • esp32/sdkconfig.defaults.wrover
  • mkdocs.yml
  • moondeck/moondeck.py
  • moondeck/moondeck_ui/app.js
  • moondeck/moondeck_ui/index.html
  • src/core/AudioLevel.h
  • src/core/IrService.h
  • src/core/PinList.h
  • src/core/PinsModule.h
  • src/core/SystemModule.h
  • src/light/drivers/DriverBase.h
  • src/light/drivers/I80LedDriver.h
  • src/light/drivers/ParallelLedDriver.h
  • src/light/drivers/ParallelSlots.h
  • src/light/drivers/ParlioLedDriver.h
  • src/light/drivers/PinList.h
  • src/light/drivers/RmtLedDriver.h
  • src/main.cpp
  • src/platform/desktop/platform_config.h
  • src/platform/desktop/platform_desktop.cpp
  • src/platform/esp32/platform_config.h
  • src/platform/esp32/platform_esp32.cpp
  • src/platform/esp32/platform_esp32_i80.cpp
  • src/platform/esp32/platform_esp32_ir.cpp
  • src/platform/esp32/platform_esp32_lcd.cpp
  • src/platform/esp32/platform_esp32_parlio.cpp
  • src/platform/esp32/platform_esp32_rmt.cpp
  • src/platform/platform.h
  • test/CMakeLists.txt
  • test/scenarios/light/scenario_Driver_mutation.json
  • test/scenarios/light/scenario_perf_full.json
  • test/unit/core/unit_PinsModule.cpp
  • test/unit/light/unit_Correction.cpp
  • test/unit/light/unit_I80LedDriver.cpp
  • test/unit/light/unit_ParallelLedDriver_doublebuffer.cpp
  • test/unit/light/unit_ParallelSlots.cpp
  • test/unit/light/unit_ParlioLedDriver.cpp
  • test/unit/light/unit_RmtLedDriver_pins.cpp
  • web-installer/deviceModels.json
  • web-installer/firmwares.json
💤 Files with no reviewable changes (1)
  • src/platform/esp32/platform_esp32_lcd.cpp

Comment thread docs/adr/0013-no-migration-code-robust-persistence-plus-documented-breaks.md Outdated
Comment thread docs/backlog/backlog-light.md Outdated

**The reframed bottleneck (this is what Step 2 now attacks).** With the wire wait overlapped, the P4 tick decomposes as **effect render ~7.3 ms + driver ~3.8 ms**, serial on one core → ~76 fps. The effect (a DistortionWaves at 128×128) is now the dominant cost, not the driver. The measured wire floor is **`wireUs` = ~7,474 µs (133 fps)** on the P4 — a new read-only KPI (the DMA transfer duration, start→done) that makes the ceiling explicit and tracks an overclocked slot rate directly.

**The design as shipped** (differs from the plan below in three ways): (1) `tick()` is two explicit branches — `tickSync` (the literal original encode→transmit→wait, provably no regression) and `tickAsync` (deferred-wait) — so OFF is byte-for-byte the old timing; (2) allocation follows the flag — OFF allocates ONE DMA buffer, ON requests the second and degrades to sync if it won't fit — so OFF costs nothing; (3) Parlio uses the **same whole-frame double-buffer as LCD**, not a ring: the KPI 4096-light frame fits one transfer, so the ring (Step 4) is unneeded for the fps goal and, per the "beyond ~65K lights is a network-distribution problem, not a single-chip one" call, is deferred indefinitely. **RMT stayed deferred** (its shared per-pin symbol buffer isn't a small double-buffer delta). Latency opt-out: OFF is the sound-reactive path (0 added latency; ON adds ~1 frame). Full record: [Plan-20260712 - Step 1.5 async transmit double-buffer (shipped)](../history/plans/Plan-20260712%20-%20Step%201.5%20async%20transmit%20double-buffer%20(shipped).md).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Choose one status for Parlio chunking.

Line 69 defers the ring/chunking work indefinitely, while Line 123 calls chunking the required path from roughly 4K to 16K lights. These are mutually exclusive implementation decisions.

Also applies to: 123-123

🤖 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/multicore-analysis-top-down.md` at line 69, Resolve the
contradictory Parlio chunking status between the shipped-design statement and
the later requirements section. Update the affected documentation so it
consistently either defers ring/chunking indefinitely or identifies it as
required for scaling from roughly 4K to 16K lights, while preserving the chosen
implementation scope throughout the document.

Comment thread docs/performance.md Outdated
Comment on lines +9 to +19
/// Output driver: parallel 8-or-16-lane WS2812B over the ESP-IDF [esp_lcd i80 bus](https://docs.espressif.com/projects/esp-idf/en/stable/esp32s3/api-reference/peripherals/lcd/index.html)
/// — the parallel scale path on **all three i80-capable ESP32 families**. RMT gives a chip 4-8
/// channels; this gives 8-16 lanes for the wall time of one. The magic is that ESP-IDF exposes ONE
/// public i80 API (`esp_i80_new_i80_bus` / `esp_i80_panel_io_tx_color`) and routes it to whichever
/// peripheral the silicon has — so this single driver serves every chip:
/// - **ESP32-S3 / -P4:** backed by the dedicated **LCD_CAM** peripheral.
/// - **classic ESP32:** backed by the **I2S peripheral in i80/LCD mode** (the classic has no LCD_CAM;
/// I2S-i80 is its only >8-lane route). IDF's own CMake picks the backend by chip; the two are
/// mutually exclusive per silicon, so `lanesAvailable()` reads whichever lane-count constant is
/// non-zero. Named for the **i80 bus** (the shared API), not a peripheral, since it isn't one
/// peripheral — same reason its siblings are named `Rmt`/`Parlio` (their APIs).

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

🧩 Analysis chain

🌐 Web query:

ESP-IDF esp_lcd i80 bus API function name esp_lcd_new_i80_bus

💡 Result:

The function esp_lcd_new_i80_bus is part of the ESP-IDF esp_lcd component and is used to create an Intel 8080 (I80) parallel bus handle for LCD interfacing [1][2]. Function Signature: esp_err_t esp_lcd_new_i80_bus(const esp_lcd_i80_bus_config_t *bus_config, esp_lcd_i80_bus_handle_t *ret_bus); Key Details: - Purpose: It initializes the I80 bus, which is required before creating an LCD panel IO device [1][3]. - Configuration: It accepts a pointer to an esp_lcd_i80_bus_config_t structure, where you define parameters such as the data bus width (8 or 16 bits), GPIO assignments (DC, WR, and data lines), the clock source, and maximum transfer size [1][2]. - Return Value: It returns an esp_err_t (e.g., ESP_OK on success, or error codes like ESP_ERR_INVALID_ARG or ESP_ERR_NO_MEM) [2]. The resulting bus handle is stored in the variable provided via ret_bus [2]. To use this function, ensure your component's CMakeLists.txt lists esp_lcd as a dependency [1][4]. For more information, refer to the official ESP-IDF Programming Guide documentation under the "I80 Interfaced LCD" section [1][5][4].

Citations:


🏁 Script executed:

sed -n '1,80p' src/light/drivers/I80LedDriver.h

Repository: MoonModules/projectMM

Length of output: 1965


Fix the ESP-IDF symbol names in the doc comment. The /// block should reference esp_lcd_new_i80_bus / esp_lcd_panel_io_tx_color (and esp_lcd_panel_io_i80.c) instead of esp_i80_*; the current names mislead readers grepping for the real API.

🤖 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/I80LedDriver.h` around lines 9 - 19, Update the
documentation comment above the I80 LED driver to reference the actual ESP-IDF
symbols esp_lcd_new_i80_bus and esp_lcd_panel_io_tx_color, and mention
esp_lcd_panel_io_i80.c where the backend implementation is described. Do not
change the driver implementation or the explanation of chip-specific
peripherals.

Comment thread src/light/drivers/PinList.h Outdated
Comment thread src/light/drivers/RmtLedDriver.h Outdated
Comment thread src/platform/esp32/platform_esp32_parlio.cpp Outdated
Comment thread src/platform/esp32/platform_esp32_parlio.cpp Outdated
The output stage moves to the second core: while core 0 renders the next frame and serves the network, a core-1 task runs every driver's per-frame work (the LED encode, the ArtNet build, the preview build). Measured on the SE16 at 4096 lights: whole-board fps 32 → 46 (+44%), with 85% of the output stage off the render core. Reviewing the change surfaced a use-after-free — deleting a driver from the UI while the second core was encoding through it could crash the device — which is fixed in core, so any container that offloads work is safe against a live tree mutation.

KPI: 16384lights | Desktop:702KB | tick:136/107/10/135/23/3/287/67/18/23/173/129/22/7/44us(FPS:7352/9345/100000/7407/43478/333333/3484/14925/55555/43478/5780/7751/45454/142857/22727) | tick:3847us(FPS:259) | heap:33187KB | src:193(39529) | test:132(21372) | lizard:142w

Core:
- platform: new pinned-worker seam (spawnPinnedTask / notifyTask / waitNotify / stopPinnedTask) — xTaskCreatePinnedToCore + a direct-to-task notification on ESP32, std::thread + condvar on desktop, so the handoff invariants are host-testable. The async-ArtNet item wants the same primitive.
- platform: TryLock/LockGuard — a try-only mutual-exclusion latch on std::atomic_flag (one atomic RMW, no RTOS call, no init/destroy, header-only, no platform backing). Try-only by design: a caller that loses the race skips its slot rather than blocking a render/encode thread, which is exactly what makes the cheap primitive sufficient.
- MoonModule: new virtual quiesce(), called by core before every child-array mutation (addChild / removeChild / replaceChildAt). A module that hands work to another thread parks that worker first, so a structural change can't free memory the worker is walking. The control path already quiesced via applyState/prepareTree; this extends the same rule to the sibling structural path, in core, so no HTTP handler has to remember it.
- MoonModule: tickChildren takes a role filter, so a container that splits its children across threads selects a side instead of re-implementing the enabled-gate + timing loop per side.
- BinaryBroadcaster: tryAcquireSend/releaseSend + a SendLease RAII bracket — the WebSocket sender has two producers on two cores once the split engages, so a producer brackets a whole message rather than letting another core's write land mid-frame.
- HttpServerModule: takes the sender lease around its preview drain; PinList: an out-of-range GPIO is rejected before it reaches the IDF (it hard-faulted the classic ESP32).

Light domain:
- Drivers: owns the split behind one `multicore` control (default on) — no per-driver opt-out, the container owns the mechanism. Handoff is the single output buffer plus an atomic frame boundary; allocate-and-degrade, so a board that can't claim the buffer runs every driver inline exactly as before and re-engages by itself when the memory is there. A read-only `stall` KPI reports core 0's wait at the boundary, shown only while multicore is on.
- ParallelLedDriver: a DMA wait that times out now skips the frame instead of reusing a buffer still in flight; wire-time is stamped from the completion ISR (a P4 frame read 11,977 µs when the true wire time was 7,663).
- DriverBase: the per-driver scratch buffer is centralized (ensureWire/freeWire), replacing two hand-rolled copies.

Tests:
- unit_platform_worker: the worker seam + the TryLock latch (including that a busy LockGuard must not release a latch it never held).
- unit_Drivers_rendersplit: the split's invariants, and a driver deleted WHILE the worker is provably inside its tick() — the use-after-free above, pinned red/green (it fails without the fix).

Docs:
- architecture: the render↔output split as built, plus the two cross-core contracts (quiesce, the sender lease).
- drivers.md: the classic i80 ceiling is 2048 lights (measured), not the ~4096 previously documented.

Reviews:
- 👾 CONFIRMED use-after-free: a UI delete/replace/clearChildren of a driver raced the core-1 encode worker (quiesce ran only after the free) — fixed in core via MoonModule::quiesce, hardware-verified (8 add/configure/delete-mid-encode cycles on the P4, uptime climbing).
- 👾 CONFIRMED cross-core WebSocket race: PreviewDriver on core 1 wrote client sockets and the shared sender state concurrently with core 0's drain — fixed via the BinaryBroadcaster sender lease.
- 👾 CONFIRMED data race: a `volatile bool` stop flag is not a thread primitive — now std::atomic<bool> with acquire/release, matching what the worker seam already did.
- 👾 The enabled-gate + timing loop was re-implemented twice in Drivers — lifted back into core's tickChildren via a role filter (a net deletion).
- 🐇 A DMA wait that timed out reused a buffer still in flight; wire-time was measured across the wrong buffer — both fixed and hardware-verified.

Known: local macOS ASan/TSan hang on any threaded test (an environment limit, not this code), so the canonical race gate for the new concurrency is CI's Linux sanitizer run. Three unit tests (unit_NetworkReceiveEffect*, unit_SineEffect) are intermittently flaky on a loaded machine; verified pre-existing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 13

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (7)
src/core/HttpServerModule.cpp (1)

2122-2136: 🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win

Use cancelBufferedSend() before accepting the new client. previewSend_.active = false; skips the cleanup path that frees an owned full-state body. If a resync is mid-drain, the old JSON buffer gets orphaned and the next send overwrites previewSend_.body. Replace the direct flag write with cancelBufferedSend().

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/HttpServerModule.cpp` around lines 2122 - 2136, Replace the direct
previewSend_.active assignment in the new-client slot logic with a call to
cancelBufferedSend() before incrementing the generation and requesting resync.
Preserve the existing client initialization and full-resync behavior while
ensuring any owned in-flight buffer is cleaned up.
src/light/drivers/PinList.h (1)

83-85: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicated maxPerPin clamp loop between the broadcast and list branches.

Both branches run the identical "if counts[i] > maxPerPin, clamp + set *warn" loop. Extracting it into a small local helper (e.g. clampToMax(counts, nPins, maxPerPin, warn)) would remove the duplication and keep future changes (e.g. adjusting the warning condition) to one place.

♻️ Proposed refactor
+static void clampToMax(nrOfLightsType* counts, uint8_t nPins, nrOfLightsType maxPerPin, const char** warn) {
+    if (maxPerPin == 0) return;
+    for (uint8_t i = 0; i < nPins; i++)
+        if (counts[i] > maxPerPin) { counts[i] = maxPerPin; if (warn) *warn = kClampedWarning; }
+}
+
 inline const char* assignCounts(...) {
     ...
-                if (maxPerPin > 0)
-                    for (uint8_t i = 0; i < nPins; i++)
-                        if (counts[i] > maxPerPin) { counts[i] = maxPerPin; if (warn) *warn = kClampedWarning; }
+                clampToMax(counts, nPins, maxPerPin, warn);
                 return nullptr;
     ...
-    if (maxPerPin > 0)
-        for (uint8_t i = 0; i < nPins; i++)
-            if (counts[i] > maxPerPin) {
-                counts[i] = maxPerPin;
-                if (warn) *warn = kClampedWarning;
-            }
+    clampToMax(counts, nPins, maxPerPin, warn);
     return nullptr;
 }

Based on learnings: "Refactor hacks, workarounds, duplicated blocks, and bespoke one-offs when discovered; do not leave them as the destination."

Also applies to: 116-121

🤖 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/PinList.h` around lines 83 - 85, Extract the duplicated
maxPerPin clamping loop from the broadcast and list branches into a shared local
helper, such as clampToMax, that accepts counts, nPins, maxPerPin, and warn.
Replace both inline loops with calls to this helper while preserving the
existing positive-limit check, clamping behavior, and kClampedWarning
assignment.

Source: Learnings

docs/reference/gpio-usage.md (1)

40-45: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

16-lane strap-pin count looks short by two.

13 non-strap output pins (per this doc's own table) + WR/DC (2 pins) leaves 11 non-strap pins for data; reaching 16 data lanes needs 5 more from the strap set. The classic-ESP32 boot-strap set is 0, 2, 5, 12, 15 (also per this doc, line 35) — 5 pins — but the text only names (0, 12, 15) as the ones a 16-lane config would borrow, omitting 2 and 5. Since this section exists specifically to flag boot-brick risk (GPIO 12), the omitted straps should be named too, or the sentence should say "all 5 straps" rather than enumerate an incomplete subset.

🤖 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/reference/gpio-usage.md` around lines 40 - 45, The 16-lane WROVER
description incompletely lists the strap pins required for the configuration.
Update the 16-lane bullet to identify all five strap pins—GPIO 0, 2, 5, 12, and
15—or state that all five straps must be borrowed, while retaining the explicit
GPIO 12 boot-voltage warning and recommendation to use an S3/P4.
src/platform/platform.h (1)

571-583: 🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Make rmtWs2812Wait report timeout status

RmtLedDriver::tick() re-encodes the same symbols_ buffer every frame, but this wait returns void, so a timeout gives the caller no way to know the previous transfer may still be in flight. That breaks the buffer-valid-until-wait-completes contract; return bool like the i80/Parlio waits or add a separate guard before reusing 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/platform/platform.h` around lines 571 - 583, Change rmtWs2812Wait to
return a bool timeout/completion status, matching the existing i80/Parlio wait
APIs, and update its implementations and callers such as RmtLedDriver::tick() to
handle the result before reusing symbols_. Preserve the
buffer-valid-until-wait-completes contract and ensure timed-out transfers are
not treated as safely complete.
src/platform/esp32/platform_esp32_parlio.cpp (1)

213-227: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Allow the primary frame buffer to fit in PSRAM.

Line 223 rejects initialization whenever internal DMA memory is low, even when the PSRAM allocation at Line 156 can succeed. Check both regions, as the i80 path does.

Proposed fix
-    if (heap_caps_get_free_size(MALLOC_CAP_DMA | MALLOC_CAP_INTERNAL)
-        < bufferBytes + HEAP_RESERVE) {
-        return false;
-    }
+    const bool fitsInternal =
+        heap_caps_get_free_size(MALLOC_CAP_DMA | MALLOC_CAP_INTERNAL)
+            >= bufferBytes + HEAP_RESERVE;
+    const bool fitsPsram =
+        heap_caps_get_free_size(MALLOC_CAP_DMA | MALLOC_CAP_SPIRAM) >= bufferBytes;
+    if (!fitsInternal && !fitsPsram) return false;

As per coding guidelines, large ESP32 buffers use PSRAM via heap_caps_malloc(..., MALLOC_CAP_SPIRAM).

🤖 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 213 - 227, Update
parlioWs2812Init’s memory-availability check to account for PSRAM, matching the
i80 path: permit initialization when either the internal DMA-capable region or
PSRAM can provide bufferBytes while preserving HEAP_RESERVE requirements for
internal memory. Keep the existing transfer-size and invalid-argument checks
unchanged, and ensure the primary buffer allocation in createState continues
using the appropriate PSRAM capability.

Source: Coding guidelines

src/light/drivers/DriverBase.h (1)

146-150: 📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

Move freeWire() into DriverBase::release(). DriverBase already owns the scratch allocation lifecycle, so the shared teardown belongs there; then drop the duplicate freeWire() calls from ParallelLedDriver::release() and RmtLedDriver::release().

🤖 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/DriverBase.h` around lines 146 - 150, Move the shared
scratch cleanup call freeWire() into DriverBase::release(), alongside the
existing teardown operations. Remove the duplicate freeWire() calls from
ParallelLedDriver::release() and RmtLedDriver::release(), leaving their
remaining release behavior unchanged; apply these changes in
src/light/drivers/DriverBase.h (lines 146-150 and 162-185) and
src/light/drivers/ParallelLedDriver.h (lines 184-187).

Sources: Path instructions, Learnings

src/light/drivers/RmtLedDriver.h (1)

41-44: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Use “colors” in the constructor documentation.

Proposed fix
-    /// preset by default (a strip attached to a freshly-flashed board shows correct colours). The
+    /// preset by default (a strip attached to a freshly-flashed board shows correct colors). The

As per coding guidelines, “Use American English spelling, including identifiers, JSON and wire keys, comments, and documentation.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/light/drivers/RmtLedDriver.h` around lines 41 - 44, Update the
constructor documentation above RmtLedDriver() to use the American English
spelling “colors” instead of “colours”; leave the preset behavior and remaining
documentation unchanged.

Source: Coding guidelines

♻️ Duplicate comments (1)
src/light/drivers/DriverBase.h (1)

295-330: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Use 0 as the non-colliding all-lights sentinel.

65535 is also a valid explicit count. On a larger buffer, setWindow(start, 65535) incorrectly drives every remaining light instead of 65535.

Proposed fix
-    static constexpr uint16_t kWindowAll = 65535;
+    static constexpr uint16_t kWindowAll = 0;
     uint16_t start_ = 0;
     uint16_t count_ = kWindowAll;
...
-        outLen = (count_ == 0 || count_ == kWindowAll || count_ > avail)
+        outLen = (count_ == kWindowAll || count_ > avail)
🤖 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/DriverBase.h` around lines 295 - 330, Update the window
sentinel in DriverBase’s addWindowControls/windowSlice implementation so 0, not
kWindowAll (65535), represents all remaining lights. Preserve explicit count
values including 65535, and remove the kWindowAll-based default or comparisons
so setWindow(start, 65535) limits output to exactly 65535 lights.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/architecture.md`:
- Line 149: Fix the reference in the MoonModule::quiesce() documentation so its
link fragment targets an existing visible heading, using that heading’s actual
Markdown slug. Preserve the surrounding explanation and link text.

In `@docs/backlog/backlog-light.md`:
- Line 13: The ghost-pin description should distinguish the WR and DC control
pins. Update the LCD_CAM unused-lane guidance so unused data lanes may overlap
WR or DC only when no strand uses that lane, while explicitly preserving the
requirement that WR and DC remain separate.

In `@docs/backlog/multicore-analysis-bottom-up.md`:
- Around line 3-17: Update the adjacent blockquotes at the document header:
replace the bare blank line between the dated correction blockquote and the
forward-looking research blockquote with a quoted blank line or a short
separating paragraph, preserving both blockquotes as distinct sections and
satisfying markdownlint MD028.

In `@docs/history/plans/Plan-20260713` - Multicore Step 2 render-encode
pipeline.md:
- Around line 68-69: Mark the Step 2 plan as shipped and update its scope guard:
revise the “Offload only pure encode+transmit drivers” language and the
corresponding guard near the later scope section so it no longer incorrectly
excludes Preview and other output-stage work now assigned to core 1, or
explicitly reference the follow-up amendment that changes this split.

In `@src/light/drivers/Drivers.h`:
- Around line 326-329: Update the render-split startup and failure handling
around renderSplitActive_ and startEncodeTask() so a failed worker creation
deallocates or resets the split-only outputBuffer_. Ensure passBufferToDrivers()
falls back to the original inline source when renderSplitActive_ becomes false,
including the corresponding paths around the other referenced split-start logic.
- Around line 360-368: The render-path handoff in the tick method must not call
blocking quiesceEncode(). Replace it with a one-shot, non-blocking
readiness/try-lock check; when core 1 remains busy, skip or drop this output
handoff and record the bounded outcome without waiting. Keep blocking join
behavior confined to lifecycle teardown, including the corresponding logic
around the additional reported location.

In `@src/light/drivers/ParallelLedDriver.h`:
- Around line 289-310: Update drainInFlight() and the reinit/release teardown
paths so a timed-out busWait never clears inFlight_ or frees DMA buffers while
transfers or callbacks may still be active. Either keep retrying until both
buffers complete or use an abort-and-quiesce primitive that guarantees DMA and
callbacks are finished before deinit; preserve the rule that timed-out buffers
cannot be re-encoded or retransmitted until a later successful wait, and add
regression coverage for timeout during reinit/release.

In `@src/platform/desktop/platform_desktop.cpp`:
- Around line 263-267: Update notifyTask to avoid blocking on w->mtx: use the
platform’s established nonblocking handoff or try-lock pattern, setting
w->pending and notifying only when the lock is acquired. Preserve the
null-worker guard and ensure the render-side call never waits on worker or
shutdown synchronization.
- Around line 254-260: Update spawnPinnedTask so std::thread construction
failure returns false without leaving stale state or leaking DesktopWorker.
Start the thread before assigning t.impl, or catch construction exceptions,
delete w, and preserve the existing successful startup behavior.

In `@src/platform/esp32/platform_esp32_worker.cpp`:
- Around line 42-55: Update spawnPinnedTask to assign t.impl to the newly
allocated EspWorker before calling xTaskCreatePinnedToCore, so workerTrampoline
cannot observe a null implementation during startup. If task creation fails,
clear t.impl before deleting the worker and returning false; preserve the
existing success and inline-degrade behavior.

In `@src/platform/platform.h`:
- Around line 121-140: Move the platform-independent TryLock and LockGuard
classes out of platform.h into the appropriate src/core header, preserving their
APIs and RAII behavior. Update HttpServerModule.h and any other consumers to
include the new core header, and remove the duplicated definitions from
platform.h.

In `@test/unit/light/unit_Drivers_rendersplit.cpp`:
- Around line 146-152: The regression comments in
test/unit/light/unit_Drivers_rendersplit.cpp:146-152 and
test/unit/light/unit_ParallelLedDriver_doublebuffer.cpp:318-322 must use
present-tense documentation. Update the rendersplit comment to state the current
quiescence invariant and the use-after-free that occurs if it is violated;
update the double-buffer comment to state the current timeout ownership rule,
removing obsolete “used to” and reviewer-history narration.
- Around line 42-52: The SlowDriver test uses timing sleeps and an unbounded
spin, making it flaky and potentially hanging forever. Replace the fixed sleeps
and polling around SlowDriver::tick and the affected test sections with explicit
condition-variable or latch handshakes: signal when tick starts and completes,
wait with bounded timeouts, and retain the existing assertions and
use-after-free coverage.

---

Outside diff comments:
In `@docs/reference/gpio-usage.md`:
- Around line 40-45: The 16-lane WROVER description incompletely lists the strap
pins required for the configuration. Update the 16-lane bullet to identify all
five strap pins—GPIO 0, 2, 5, 12, and 15—or state that all five straps must be
borrowed, while retaining the explicit GPIO 12 boot-voltage warning and
recommendation to use an S3/P4.

In `@src/core/HttpServerModule.cpp`:
- Around line 2122-2136: Replace the direct previewSend_.active assignment in
the new-client slot logic with a call to cancelBufferedSend() before
incrementing the generation and requesting resync. Preserve the existing client
initialization and full-resync behavior while ensuring any owned in-flight
buffer is cleaned up.

In `@src/light/drivers/DriverBase.h`:
- Around line 146-150: Move the shared scratch cleanup call freeWire() into
DriverBase::release(), alongside the existing teardown operations. Remove the
duplicate freeWire() calls from ParallelLedDriver::release() and
RmtLedDriver::release(), leaving their remaining release behavior unchanged;
apply these changes in src/light/drivers/DriverBase.h (lines 146-150 and
162-185) and src/light/drivers/ParallelLedDriver.h (lines 184-187).

In `@src/light/drivers/PinList.h`:
- Around line 83-85: Extract the duplicated maxPerPin clamping loop from the
broadcast and list branches into a shared local helper, such as clampToMax, that
accepts counts, nPins, maxPerPin, and warn. Replace both inline loops with calls
to this helper while preserving the existing positive-limit check, clamping
behavior, and kClampedWarning assignment.

In `@src/light/drivers/RmtLedDriver.h`:
- Around line 41-44: Update the constructor documentation above RmtLedDriver()
to use the American English spelling “colors” instead of “colours”; leave the
preset behavior and remaining documentation unchanged.

In `@src/platform/esp32/platform_esp32_parlio.cpp`:
- Around line 213-227: Update parlioWs2812Init’s memory-availability check to
account for PSRAM, matching the i80 path: permit initialization when either the
internal DMA-capable region or PSRAM can provide bufferBytes while preserving
HEAP_RESERVE requirements for internal memory. Keep the existing transfer-size
and invalid-argument checks unchanged, and ensure the primary buffer allocation
in createState continues using the appropriate PSRAM capability.

In `@src/platform/platform.h`:
- Around line 571-583: Change rmtWs2812Wait to return a bool timeout/completion
status, matching the existing i80/Parlio wait APIs, and update its
implementations and callers such as RmtLedDriver::tick() to handle the result
before reusing symbols_. Preserve the buffer-valid-until-wait-completes contract
and ensure timed-out transfers are not treated as safely complete.

---

Duplicate comments:
In `@src/light/drivers/DriverBase.h`:
- Around line 295-330: Update the window sentinel in DriverBase’s
addWindowControls/windowSlice implementation so 0, not kWindowAll (65535),
represents all remaining lights. Preserve explicit count values including 65535,
and remove the kWindowAll-based default or comparisons so setWindow(start,
65535) limits output to exactly 65535 lights.
🪄 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: 08c8c42b-dc61-4748-beb7-37cdb3a374e4

📥 Commits

Reviewing files that changed from the base of the PR and between f96cf22 and 0ba46fb.

📒 Files selected for processing (40)
  • docs/adr/0013-no-migration-code-robust-persistence-plus-documented-breaks.md
  • docs/architecture.md
  • docs/backlog/README.md
  • docs/backlog/backlog-light.md
  • docs/backlog/multicore-analysis-bottom-up.md
  • docs/backlog/multicore-analysis-top-down.md
  • docs/backlog/nrf-zephyr-target-analysis.md
  • docs/history/plans/Plan-20260713 - Multicore Step 2 render-encode pipeline.md
  • docs/moonmodules/light/drivers.md
  • docs/moonmodules/light/supporting.md
  • docs/performance.md
  • docs/reference/gpio-usage.md
  • esp32/main/CMakeLists.txt
  • src/core/BinaryBroadcaster.h
  • src/core/HttpServerModule.cpp
  • src/core/HttpServerModule.h
  • src/core/MoonModule.h
  • src/light/drivers/DriverBase.h
  • src/light/drivers/Drivers.h
  • src/light/drivers/I80LedDriver.h
  • src/light/drivers/ParallelLedDriver.h
  • src/light/drivers/ParlioLedDriver.h
  • src/light/drivers/PinList.h
  • src/light/drivers/PreviewDriver.h
  • src/light/drivers/RmtLedDriver.h
  • src/main.cpp
  • src/platform/desktop/platform_desktop.cpp
  • src/platform/esp32/platform_esp32_i80.cpp
  • src/platform/esp32/platform_esp32_parlio.cpp
  • src/platform/esp32/platform_esp32_worker.cpp
  • src/platform/platform.h
  • test/CMakeLists.txt
  • test/scenarios/light/scenario_MoonLiveEffect_livescript.json
  • test/scenarios/light/scenario_perf_full.json
  • test/unit/core/unit_platform_worker.cpp
  • test/unit/light/unit_Drivers_rendersplit.cpp
  • test/unit/light/unit_Layers_container.cpp
  • test/unit/light/unit_Layouts_toggle_cycle.cpp
  • test/unit/light/unit_ParallelLedDriver_doublebuffer.cpp
  • test/unit/light/unit_PreviewDriver.cpp

Comment thread docs/architecture.md Outdated
### 16-lane parallel output — SHIPPED (2026-07-12)

`LcdLedDriver` requires **all 8** i80 data lanes (`kExactLaneCount = true`, `LcdLedDriver.h`): the ESP-IDF `esp_lcd` i80 bus configures every data line of the bus width and rejects a partial set, so even a few WS2812 strands claim 8 GPIOs. That's why **S3 boards default to `RmtLedDriver`** in `deviceModels.json` (RMT runs one channel per pin, 1..N) rather than LCD — a board with fewer than 8 strips can't sensibly use the LCD driver, and the 8-lane LCD bench wiring (`1,2,4,5,6,7,8,9`) collides with common peripheral pins (e.g. the mic on 4/5/6). A **1..8-pin LCD mode** (drive only the lanes named in `pins`, leave the rest unclaimed — matching Parlio's flexibility) would let the parallel S3 path run any lane count, at which point an S3 board entry could choose LCD vs RMT by intent. Parlio already does this (`kExactLaneCount = false`, 1..8 lanes), so the P4 default *is* the parallel driver. Until LCD gains the same flexibility, S3 stays on RMT by default. Low priority — RMT covers the few-strip S3 case today.
The LCD_CAM (S3/P4) and Parlio (P4) drivers drive **up to 16 lanes**, with the bus width DERIVED from the pin count: ≤8 pins → an 8-bit bus (uint8 slots, the original path), 9..16 pins → a 16-bit bus (uint16 slots). LCD_CAM accepts exactly 8 or 16 real pins (IDF caps `bus_width` to those, and rejects an NC data line — a sub-16 board parks unused lanes on the WR "ghost pin"); Parlio accepts any 1..16 (unused lanes idle NC). The 16-lane transpose is the two-pass `transposeLanes16x8` (two 8×8 SWAR passes → a uint16 plane, low byte = lanes 0..7, high = 8..15) in `ParallelSlots.h`; `frameBytesFor` and the loopback thread a `slotBytes` factor. The DMA buffer is PSRAM-first (the 16-bit frame doubles the footprint). **Consumers:** SE 16 V1 + LightCrafter 16 (both S3/LCD_CAM, wired to `I80LedDriver` in the catalog). **Verified live on the LightCrafter 16:** the 16-bit i80 bus inits, the PSRAM DMA buffer allocates, and the 16×8 transpose + 16-bit encode runs (`LcdLed:19348 µs`). Design record: `docs/history/plans/Plan-20260712 - 16-lane parallel LED output (shipped).md`.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Keep WR and DC distinct in the ghost-pin description.

Line 13 says WR, DC, and unused lanes share one GPIO, but the driver rejects clockPin == dcPin. State that unused data lanes may overlap WR or DC only when no strand uses that lane; WR and DC must remain separate.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/backlog/backlog-light.md` at line 13, The ghost-pin description should
distinguish the WR and DC control pins. Update the LCD_CAM unused-lane guidance
so unused data lanes may overlap WR or DC only when no strand uses that lane,
while explicitly preserving the requirement that WR and DC remain separate.

Comment on lines +3 to +17
> **⚠️ DATED SURVEY (2026-07-12) — several conclusions have since been SUPERSEDED by shipped work. Read this header before trusting any number below.**
>
> What still holds: the Parlio 65535-byte/lane single-transfer ceiling and its bytes-per-light math; the two driver bugs found + fixed; MoonLight's model as read; the frame-pacing decision (Appendix A). What has been overturned:
>
> | This doc says | Reality (shipped since) |
> |---|---|
> | "the DMA transmit-wait is ~0 µs — the driver does **not** block on the transmit" | **False.** The transmit *did* block. Step 1.5 (async double-buffer, `asyncTransmit`) shipped to overlap it, worth P4 48 → 76 fps. The measurement below missed it because the instrumented build sampled the wait in the wrong place. |
> | 8 lanes; `LcdLedDriver` is S3-only | **16 lanes** shipped for both parallel drivers; the driver is `I80LedDriver` and runs on **classic ESP32 (I2S) + S3/P4 (LCD_CAM)**. |
> | a parallel-I2S driver is future work | **Shipped** (classic ESP32, I2S i80). Its ceiling is internal-RAM, not a transfer cap: 2048 lights at 8 lanes. |
> | "multicore last, gated on need" | **Step 2a shipped** — the whole output stage runs on core 1 (`multicore` on Drivers): +44 % fps, 85 % of the output off the render core. |
> | Parlio must use a refill ring | It uses the **same whole-frame double-buffer** as i80; the ring is unneeded and deferred indefinitely. |
>
> Current, present-tense numbers live in [performance.md § Multi-pin LED driving](../performance.md#multi-pin-led-driving-all-three-peripherals-128128-grid) and [§ Multicore](../performance.md#multicore-the-whole-output-stage-on-core-1-multicore-step-2). The build plan and its outcome are in the [top-down](multicore-analysis-top-down.md). This document is kept as the **design-intent record of how we got there** — including the wrong turn, which is the point.

> **Forward-looking research document — exception to CLAUDE.md present-tense rule.** A Stage-1 bottom-up survey of *scaling the render pipeline* for projectMM: how large a display each driver can drive, where the time actually goes at scale, and whether a second core (à la MoonLight) earns its place. It combines a read of **MoonLight's documented dual-core architecture** ([moonmodules.org/MoonLight/develop/architecture](https://moonmodules.org/MoonLight/develop/architecture/), read **2026-07-12**) with **hardware measurement on the ESP32-P4 at 128×128 = 16384 lights** the same day, and the [frame-pacing decision](#appendix-a--frame-pacing-decided-against) settled with the product owner. Per *[Industry standards, our own code](../../CLAUDE.md#principles)*: study the reference, measure our own, write our own recommendation. Consolidates the multicore threads scattered across the backlog (see [§ Existing backlog](#existing-backlog-this-consolidates)). Citations use `file:line` against projectMM `HEAD`; the ESP-IDF ceilings cite the IDF HAL; MoonLight claims cite the architecture page.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Blockquote separated by a bare blank line (MD028).

The correction blockquote (lines 3-15) is followed by a plain blank line (16) then a second, unrelated blockquote (17) — markdownlint's MD028 flags this because some renderers merge two blockquotes separated only by a blank, non-quoted line. Insert a > on the blank line (or a short paragraph) to make the boundary unambiguous.

🧰 Tools
🪛 markdownlint-cli2 (0.23.0)

[warning] 16-16: Blank line inside blockquote

(MD028, no-blanks-blockquote)

🤖 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/multicore-analysis-bottom-up.md` around lines 3 - 17, Update the
adjacent blockquotes at the document header: replace the bare blank line between
the dated correction blockquote and the forward-looking research blockquote with
a quoted blank line or a short separating paragraph, preserving both blockquotes
as distinct sections and satisfying markdownlint MD028.

Source: Linters/SAST tools

Comment on lines +68 to +69
- **Offload only pure encode+transmit drivers** (I80/Parlio). Keep Preview / NetworkSend / Hue (`main.cpp:204-205,438`) ticking inline on core 0 — they do socket/HTTP I/O; don't move network I/O across cores. Smaller, safer cut.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== File list ==\n'
git ls-files 'docs/history/plans/Plan-20260713 - Multicore Step 2 render-encode pipeline.md' \
             'src/core/BinaryBroadcaster.h' \
             'src/light/drivers/PreviewDriver.h' \
             'docs/architecture.md' \
             'docs/moonmodules/light/supporting.md' \
             'test/CMakeLists.txt'

printf '\n== Plan file outline ==\n'
wc -l 'docs/history/plans/Plan-20260713 - Multicore Step 2 render-encode pipeline.md'
sed -n '1,220p' 'docs/history/plans/Plan-20260713 - Multicore Step 2 render-encode pipeline.md'

printf '\n== Referenced code/docs snippets ==\n'
for f in \
  'src/core/BinaryBroadcaster.h' \
  'src/light/drivers/PreviewDriver.h' \
  'docs/architecture.md' \
  'docs/moonmodules/light/supporting.md' \
  'test/CMakeLists.txt'
do
  printf '\n--- %s ---\n' "$f"
  wc -l "$f"
done

printf '\n== Search for multicore/Preview/offload markers ==\n'
rg -n --no-heading 'multicore split|offloaded PreviewDriver|PreviewDriver|SendLease|tryAcquireSend|releaseSend|NOT offloading Preview|offload only pure encode|shipped|attempted, abandoned' \
  'src/core/BinaryBroadcaster.h' \
  'src/light/drivers/PreviewDriver.h' \
  'docs/architecture.md' \
  'docs/moonmodules/light/supporting.md' \
  'docs/history/plans/Plan-20260713 - Multicore Step 2 render-encode pipeline.md' \
  'test/CMakeLists.txt'

Repository: MoonModules/projectMM

Length of output: 23550


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read the most relevant slices with line numbers.
for f in \
  'src/core/BinaryBroadcaster.h' \
  'src/light/drivers/PreviewDriver.h' \
  'docs/architecture.md' \
  'docs/moonmodules/light/supporting.md' \
  'test/CMakeLists.txt'
do
  printf '\n== %s ==\n' "$f"
  nl -ba "$f" | sed -n '1,260p'
done

Repository: MoonModules/projectMM

Length of output: 236


🏁 Script executed:

#!/bin/bash
set -euo pipefail

printf '\n== Plan lines around the contested scope ==\n'
sed -n '60,115p' 'docs/history/plans/Plan-20260713 - Multicore Step 2 render-encode pipeline.md' | cat -n

printf '\n== BinaryBroadcaster lines around multicore split ==\n'
sed -n '58,90p' 'src/core/BinaryBroadcaster.h' | cat -n

printf '\n== PreviewDriver lines around core-1 note ==\n'
sed -n '92,110p' 'src/light/drivers/PreviewDriver.h' | cat -n

printf '\n== Architecture lines around preview offload ==\n'
sed -n '144,156p' 'docs/architecture.md' | cat -n

printf '\n== Supporting doc lines around preview offload ==\n'
sed -n '48,62p' 'docs/moonmodules/light/supporting.md' | cat -n

Repository: MoonModules/projectMM

Length of output: 16429


Mark this plan shipped and update the scope guard

  • docs/history/plans/Plan-20260713 - Multicore Step 2 render-encode pipeline.md (lines 68-69, 108-109) still says NOT offloading Preview/NetworkSend/Hue, but the multicore docs now place Preview and other output-stage work on core 1. Add the required (shipped) marker and either revise this guard or point it at the follow-up amendment that changes the split.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@docs/history/plans/Plan-20260713` - Multicore Step 2 render-encode
pipeline.md around lines 68 - 69, Mark the Step 2 plan as shipped and update its
scope guard: revise the “Offload only pure encode+transmit drivers” language and
the corresponding guard near the later scope section so it no longer incorrectly
excludes Preview and other output-stage work now assigned to core 1, or
explicitly reference the follow-up amendment that changes this split.

Source: Path instructions

Comment thread src/light/drivers/Drivers.h
Comment on lines +263 to +267
void notifyTask(WorkerTask& t) {
auto* w = static_cast<DesktopWorker*>(t.impl);
if (!w) return;
{ std::lock_guard<std::mutex> lk(w->mtx); w->pending = true; }
w->cv.notify_one();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟠 Major | 🏗️ Heavy lift

Keep render-side notification nonblocking.

notifyTask() takes a blocking std::mutex; contention with the worker or shutdown can pause the render tick. Use the platform’s nonblocking handoff/TryLock pattern.

As per path instructions, multicore hot paths “never block on peer synchronization; use try-lock semantics instead.”

🧰 Tools
🪛 Clang (14.0.6)

[warning] 263-263: parameter name 't' is too short, expected at least 3 characters

(readability-identifier-length)


[warning] 264-264: variable name 'w' is too short, expected at least 3 characters

(readability-identifier-length)


[warning] 265-265: implicit conversion 'mm::platform::(anonymous namespace)::DesktopWorker *' -> bool

(readability-implicit-bool-conversion)


[warning] 265-265: statement should be inside braces

(readability-braces-around-statements)


[warning] 266-266: variable name 'lk' is too short, expected at least 3 characters

(readability-identifier-length)

🪛 Cppcheck (2.21.0)

[style] 263-263: The function 'notifyTask' 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 `@src/platform/desktop/platform_desktop.cpp` around lines 263 - 267, Update
notifyTask to avoid blocking on w->mtx: use the platform’s established
nonblocking handoff or try-lock pattern, setting w->pending and notifying only
when the lock is acquired. Preserve the null-worker guard and ensure the
render-side call never waits on worker or shutdown synchronization.

Source: Path instructions

Comment on lines +42 to +55
bool spawnPinnedTask(WorkerTask& t, const char* name, WorkerFn fn, void* user,
size_t stackBytes, uint8_t priority, int core) {
auto* w = new (std::nothrow) EspWorker();
if (!w) return false;
w->fn = fn;
w->user = user;
const BaseType_t coreId = (core < 0) ? tskNO_AFFINITY : static_cast<BaseType_t>(core);
const BaseType_t ok = xTaskCreatePinnedToCore(
&workerTrampoline, name, static_cast<uint32_t>(stackBytes), w,
static_cast<UBaseType_t>(priority), &w->handle, coreId);
if (ok != pdPASS) { delete w; return false; } // caller runs inline (degrade)
t.impl = w;
return true;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟠 Major | ⚡ Quick win

t.impl is assigned after the task is already created — a startup race.

xTaskCreatePinnedToCore can start workerTrampoline (and thus the caller's fn) running before t.impl = w; on line 53 executes and is visible cross-core. If fn's first action is waitNotify(t, …), it reads t.impl as still nullptr and gets a spurious immediate false — harmless here only because callers are written to retry, but it's an avoidable unsynchronized write/read race on a plain pointer.

🔒 Proposed fix — set `t.impl` before creating the task
     auto* w = new (std::nothrow) EspWorker();
     if (!w) return false;
     w->fn = fn;
     w->user = user;
+    t.impl = w;   // visible before the task can possibly start
     const BaseType_t coreId = (core < 0) ? tskNO_AFFINITY : static_cast<BaseType_t>(core);
     const BaseType_t ok = xTaskCreatePinnedToCore(
         &workerTrampoline, name, static_cast<uint32_t>(stackBytes), w,
         static_cast<UBaseType_t>(priority), &w->handle, coreId);
-    if (ok != pdPASS) { delete w; return false; }   // caller runs inline (degrade)
-    t.impl = w;
+    if (ok != pdPASS) { t.impl = nullptr; delete w; return false; }   // caller runs inline (degrade)
     return true;
📝 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.

Suggested change
bool spawnPinnedTask(WorkerTask& t, const char* name, WorkerFn fn, void* user,
size_t stackBytes, uint8_t priority, int core) {
auto* w = new (std::nothrow) EspWorker();
if (!w) return false;
w->fn = fn;
w->user = user;
const BaseType_t coreId = (core < 0) ? tskNO_AFFINITY : static_cast<BaseType_t>(core);
const BaseType_t ok = xTaskCreatePinnedToCore(
&workerTrampoline, name, static_cast<uint32_t>(stackBytes), w,
static_cast<UBaseType_t>(priority), &w->handle, coreId);
if (ok != pdPASS) { delete w; return false; } // caller runs inline (degrade)
t.impl = w;
return true;
}
bool spawnPinnedTask(WorkerTask& t, const char* name, WorkerFn fn, void* user,
size_t stackBytes, uint8_t priority, int core) {
auto* w = new (std::nothrow) EspWorker();
if (!w) return false;
w->fn = fn;
w->user = user;
t.impl = w; // visible before the task can possibly start
const BaseType_t coreId = (core < 0) ? tskNO_AFFINITY : static_cast<BaseType_t>(core);
const BaseType_t ok = xTaskCreatePinnedToCore(
&workerTrampoline, name, static_cast<uint32_t>(stackBytes), w,
static_cast<UBaseType_t>(priority), &w->handle, coreId);
if (ok != pdPASS) { t.impl = nullptr; delete w; return false; } // caller runs inline (degrade)
return true;
}
🧰 Tools
🪛 Clang (14.0.6)

[warning] 42-42: use a trailing return type for this function

(modernize-use-trailing-return-type)


[warning] 42-42: parameter name 't' is too short, expected at least 3 characters

(readability-identifier-length)


[warning] 42-42: parameter name 'fn' is too short, expected at least 3 characters

(readability-identifier-length)


[warning] 43-43: 3 adjacent parameters of 'spawnPinnedTask' of convertible types are easily swapped by mistake

(bugprone-easily-swappable-parameters)


[note] 43-43: the first parameter in the range is 'stackBytes'

(clang)


[note] 43-43: the last parameter in the range is 'core'

(clang)


[note] 43-43:

(clang)


[note] 43-43: 'size_t' and 'uint8_t' may be implicitly converted: 'size_t' (as 'unsigned long') -> 'uint8_t' (as 'unsigned char'), 'uint8_t' (as 'unsigned char') -> 'size_t' (as 'unsigned long')

(clang)


[note] 43-43:

(clang)


[note] 43-43: 'size_t' and 'int' may be implicitly converted: 'size_t' (as 'unsigned long') -> 'int', 'int' -> 'size_t' (as 'unsigned long')

(clang)


[note] 43-43:

(clang)


[note] 43-43: 'uint8_t' and 'int' may be implicitly converted: 'uint8_t' (as 'unsigned char') -> 'int', 'int' -> 'uint8_t' (as 'unsigned char')

(clang)


[warning] 44-44: variable name 'w' is too short, expected at least 3 characters

(readability-identifier-length)


[warning] 45-45: implicit conversion 'mm::platform::(anonymous namespace)::EspWorker *' -> bool

(readability-implicit-bool-conversion)


[warning] 45-45: statement should be inside braces

(readability-braces-around-statements)


[warning] 48-48: variable 'coreId' is not initialized

(cppcoreguidelines-init-variables)


[warning] 49-49: variable 'ok' is not initialized

(cppcoreguidelines-init-variables)


[warning] 49-49: variable name 'ok' is too short, expected at least 3 characters

(readability-identifier-length)

🪛 Cppcheck (2.21.0)

[style] 42-42: The function 'spawnPinnedTask' 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 `@src/platform/esp32/platform_esp32_worker.cpp` around lines 42 - 55, Update
spawnPinnedTask to assign t.impl to the newly allocated EspWorker before calling
xTaskCreatePinnedToCore, so workerTrampoline cannot observe a null
implementation during startup. If task creation fails, clear t.impl before
deleting the worker and returning false; preserve the existing success and
inline-degrade behavior.

Comment thread src/platform/platform.h Outdated
Comment on lines +121 to +140
class TryLock {
public:
bool tryAcquire() { return !flag_.test_and_set(std::memory_order_acquire); }
void release() { flag_.clear(std::memory_order_release); }
private:
std::atomic_flag flag_ = ATOMIC_FLAG_INIT;
};
// RAII scope guard: `if (LockGuard g{lk}; g) { …exclusive… }` — releases on scope exit, no-ops when
// the latch was busy. The standard scoped_lock/unique_lock(try_to_lock) shape.
class LockGuard {
public:
explicit LockGuard(TryLock& l) : lock_(l), held_(l.tryAcquire()) {}
~LockGuard() { if (held_) lock_.release(); }
explicit operator bool() const { return held_; }
LockGuard(const LockGuard&) = delete;
LockGuard& operator=(const LockGuard&) = delete;
private:
TryLock& lock_;
bool held_;
};

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider src/core/ for TryLock/LockGuard — they're not actually a platform seam.

The comment on these classes explicitly states they have "no platform backing at all" and are "the same code on every platform," yet they live in platform.h, whose charter (per path instructions) is being "the ONLY place... hardware API calls are allowed." They're already consumed by a core file (HttpServerModule.h), reinforcing that this is a domain-neutral, reusable primitive rather than a platform abstraction. Moving it to src/core/ would better match where genuinely portable, reusable mechanisms belong.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/platform/platform.h` around lines 121 - 140, Move the
platform-independent TryLock and LockGuard classes out of platform.h into the
appropriate src/core header, preserving their APIs and RAII behavior. Update
HttpServerModule.h and any other consumers to include the new core header, and
remove the duplicated definitions from platform.h.

Source: Path instructions

Comment thread test/unit/light/unit_Drivers_rendersplit.cpp Outdated
Comment thread test/unit/light/unit_Drivers_rendersplit.cpp Outdated
A NetworkSend driver now feeds a whole row of receivers instead of one: type a range (192.168.1.70-74) or a list, give each its share of the lights, and every packet is unicast once to the tube that owns it. That is what the Art-Net spec asks for, and it keeps the stream off every other device on the LAN — broadcasting a large grid floods the network hard enough to stall an ESP32's web server, which is why an unconfigured driver now idles instead of defaulting to broadcast. The module cards also stop growing without limit: each top-level module shows its children as tabs you can switch between and drag to reorder.

KPI: 16384lights | Desktop:718KB | tick:179/141/14/179/25/4/431/93/27/33/244/178/27/11/65us(FPS:5586/7092/71428/5586/40000/250000/2320/10752/37037/30303/4098/5617/37037/90909/15384) | tick:3775us(FPS:264) | heap:33184KB | src:194(39744) | test:133(21551) | lizard:144w

Core:
- IpList: new domain-neutral parser for a human-typed destination list — a range (`192.168.1.70-74`, ends inclusive), a list (`192.168.1.60,61,62,65`), or a mix; a bare number continues the previous /24, a full address switches subnet. Sits beside PinList as the same kind of primitive: a hand-typed list of endpoints. Malformed input is rejected, never guessed at, and an over-cap range errors rather than silently dropping tubes.
- PinList: assignCounts is now shared by two controls, so its error text no longer names ledsPerPin specifically.

Light domain:
- NetworkSendDriver: `ip` (one address) becomes `ips` (a list) + `lightsPerIp` (the same broadcasting idiom as an LED driver's ledsPerPin — blank splits evenly, one number applies to each, a list assigns by position). Each destination gets a contiguous run of the window, unicast to itself, universes restarting per tube. The destination now defaults to UNSET: an unconfigured driver idles with a status instead of inheriting a broadcast address that punishes the whole network rather than this device.
- Parlio: the init memory gate checked internal RAM only while the allocator tries PSRAM first, so it refused frames PSRAM could hold; it now admits either. The internal fallback that the gate cannot see is guarded by the same heap reserve the second buffer already used — on the P4 that fallback is the normal path, so without the guard a large frame could quietly eat the reserve WiFi and HTTP depend on.
- Drivers: a worker task that fails to spawn no longer leaves the split's handoff buffer allocated with nothing writing into it (drivers would have rendered a frozen frame forever).
- DriverBase: owns the correction scratch teardown, so no driver re-frees it.

UI:
- Every top-level module renders its children as tabs — one visible at a time, derived from the children on each render, so adding a layer adds its tab. Tabs drag to reorder (the same insert semantics as the card drag), carry a fault dot so an error on a background tab is still visible, and remember which tab was open. The "+" adds a child straight away when only one type is possible, instead of opening a picker with one choice.

Tests:
- unit_IpList: the range/list grammar, the subnet-continuation shorthand, and every malformed form.
- unit_Drivers_rendersplit: the delete-mid-encode test now parks the worker with a condition variable instead of sleeping, so it opens the race window deliberately rather than by timing luck, and it asserts that removeChild actually waited (it failed to catch the bug otherwise).

Docs / CI:
- A sanitizer gate, which did not exist: `check_sanitizers.py` plus a CI job running the suite under ASan and TSan. The multicore split put a second thread beside code that was single-threaded for its whole life — hand-written tests prove the races we thought of; the sanitizers find the others. On macOS, Apple's ASan runtime hangs during its own startup (a hello-world never reaches main) while Homebrew's works, and TSan crashes under both — so the script probes each runtime first and reports SKIPPED rather than a misleading failure, and Linux CI is the only place TSan runs.
- The PSRAM refill-ring study is trimmed to the engineering content: the memory model, the arithmetic behind the 2048-light classic ceiling, why lifting it needs a second driver rather than a flag, and the trigger that would un-park it.

Reviews:
- 👾 Parlio's PSRAM gate could admit a config whose buffer then landed in internal RAM below the reserve, because the allocator's internal fallback was unguarded — fixed by mirroring the guard the second buffer already had.
- 👾 A bad lightsPerIp value reported "invalid ledsPerPin list", naming a control that does not exist on that driver — the shared helper's message is now control-neutral.
- 👾 A test @module tag and a British spelling — fixed.
- 👾 Rejected: making the render-side handoff non-blocking (the frame boundary IS the design — core 0 waiting for core 1 to finish reading the shared buffer is what prevents a torn frame), a try-lock in the desktop notify path (it would lose wakeups), and a claimed t.impl race in the ESP32 spawn (the trampoline takes the worker as its argument and never reads t.impl).

Known: the ip → ips rename drops a persisted NetworkSend driver on first boot after this change (no migration code, ADR-0013) — re-add it once per board. TSan has not yet run against the multicore code anywhere; the new CI job is its first exposure.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 10

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/NetworkSendDriver.h (1)

223-250: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Do not turn a zero-channel correction into raw passthrough.

outCh == 0 is explicitly a valid “Custom preset with no roles emits nothing” state, but this branch sends the uncorrected source. The removed nullable correction state now conflates “not wired” with “configured for zero output”; preserve an explicit wired/configured state and idle for the latter.

🤖 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/NetworkSendDriver.h` around lines 223 - 250, The correction
path in the NetworkSendDriver hot path currently treats correction_.outChannels
== 0 as passthrough, but zero channels is a valid configured correction that
must emit nothing. Preserve an explicit wired/configured state to distinguish it
from an unwired correction, and update the branch around correction_.outChannels
and corrected_ so configured zero-output corrections idle without sending raw
source data while genuinely unwired corrections retain passthrough behavior.
🤖 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 @.github/workflows/test.yml:
- Line 85: Update the actions/checkout step in the workflow to reference a
reviewed, immutable commit SHA instead of the mutable `@v4` tag, while preserving
the existing checkout behavior.

In `@docs/backlog/backlog-light.md`:
- Line 43: Update the backlog wording around the ArtPoll increment and the
“multi-destination unicast” reference to reflect that the prerequisite has
already landed, removing the misleading future “after ... lands” phrasing while
preserving the intended sequencing context.

In `@docs/backlog/led-driver-psram-ring-analysis.md`:
- Line 11: Reconcile the default ring-memory estimate across the documented
claims and formulas, especially the streaming refill ring discussion and the
references on lines 30, 53, and 77. Either include the four-times-size
terminator and descriptor overhead in the formula to substantiate the
approximately 1.6 KB figure, or update the estimate to approximately 1.1 KiB
when calculating only (nbDmaBuffer + 2) × 144 bytes.

In `@moondeck/check/check_sanitizers.py`:
- Around line 58-67: Update the sanitizer probe around the existing subprocess
calls to use tempfile.TemporaryDirectory for an isolated source and executable
location instead of predictable /tmp/_san_probe_* paths. Keep the compile/run
behavior and failure handling unchanged, and ensure temporary files are cleaned
up automatically.
- Around line 73-77: Update the sanitizer probe logic around runtime_works and
the both-mode aggregation so only the explicitly unsupported macOS TSan case is
treated as a successful skip; return failure for all other compiler or runtime
probe failures. Ensure both ASan and TSan probes execute before combining their
results, avoiding short-circuiting through all().

In `@src/light/drivers/NetworkSendDriver.h`:
- Around line 270-304: Move the per-destination packet loop in the
NetworkSendDriver tick path into a bounded sender task or resumable nonblocking
mechanism. Ensure tick() only enqueues frame work or advances a limited send
step, while packet serialization and socket_.sendToAddr calls execute outside
the render thread; preserve destination ordering and prevent unbounded queue
growth or render-thread blocking.
- Around line 166-184: Update the destination validation flow around parseIpList
and assignCounts to use a local destination count throughout validation. Do not
publish nDest_ until both parsing and assignCounts succeed; ensure all error
returns leave the existing destination state unchanged and only then copy the
validated count into nDest_.

In `@src/ui/app.js`:
- Around line 497-514: Update the tab interaction logic in renderChildTabs and
its tab click handler to avoid calling renderCards() for local child switching.
Re-render only the selected panel by clearing it and invoking renderModuleTree
for the chosen child, then update tab-active and aria-selected on the existing
tab buttons in place. Restore focus to the clicked tab, including the "+"
add-tab, so keyboard focus and ARIA tab behavior remain intact.

In `@test/unit/light/unit_NetworkSendDriver_packet.cpp`:
- Line 170: Replace the unbounded strcpy calls initializing d.ips at the
referenced test cases with bounded string-copy operations such as snprintf or
the project’s established safe-copy helper, preserving the existing literal
values and null termination.
- Around line 184-206: Add a test subcase to “NetworkSendDriver: lightsPerIp
follows the ledsPerPin idiom” covering a lightsPerIp list whose length differs
from destinationCount(), including the expected behavior for the mismatch.
Exercise at least one shorter or longer list and assert lightsAt() results for
the destination tubes according to the control’s intended handling.

---

Outside diff comments:
In `@src/light/drivers/NetworkSendDriver.h`:
- Around line 223-250: The correction path in the NetworkSendDriver hot path
currently treats correction_.outChannels == 0 as passthrough, but zero channels
is a valid configured correction that must emit nothing. Preserve an explicit
wired/configured state to distinguish it from an unwired correction, and update
the branch around correction_.outChannels and corrected_ so configured
zero-output corrections idle without sending raw source data while genuinely
unwired corrections retain passthrough behavior.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 373e74e8-97b0-4835-aecd-15bc61fd97e9

📥 Commits

Reviewing files that changed from the base of the PR and between 0ba46fb and 46abaa0.

📒 Files selected for processing (22)
  • .github/workflows/test.yml
  • docs/architecture.md
  • docs/backlog/backlog-core.md
  • docs/backlog/backlog-light.md
  • docs/backlog/led-driver-psram-ring-analysis.md
  • docs/moonmodules/light/drivers.md
  • moondeck/check/check_sanitizers.py
  • src/core/IpList.h
  • src/light/drivers/DriverBase.h
  • src/light/drivers/Drivers.h
  • src/light/drivers/NetworkSendDriver.h
  • src/light/drivers/ParallelLedDriver.h
  • src/light/drivers/PinList.h
  • src/light/drivers/RmtLedDriver.h
  • src/platform/esp32/platform_esp32_parlio.cpp
  • src/ui/app.js
  • src/ui/style.css
  • test/CMakeLists.txt
  • test/scenarios/light/scenario_MoonLiveEffect_livescript.json
  • test/unit/core/unit_IpList.cpp
  • test/unit/light/unit_Drivers_rendersplit.cpp
  • test/unit/light/unit_NetworkSendDriver_packet.cpp

matrix:
kind: [address, thread]
steps:
- uses: actions/checkout@v4

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Pin actions/checkout to a reviewed commit SHA.

The mutable @v4 tag allows upstream changes to alter this security-sensitive sanitizer gate without a repository change.

🧰 Tools
🪛 zizmor (1.26.1)

[error] 85-85: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy)

(unpinned-uses)

🤖 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 @.github/workflows/test.yml at line 85, Update the actions/checkout step in
the workflow to reference a reviewed, immutable commit SHA instead of the
mutable `@v4` tag, while preserving the existing checkout behavior.

Source: Linters/SAST tools

Comment thread docs/backlog/backlog-light.md Outdated
Comment thread docs/backlog/led-driver-psram-ring-analysis.md Outdated
Comment thread moondeck/check/check_sanitizers.py Outdated
Comment thread moondeck/check/check_sanitizers.py Outdated
Comment thread src/light/drivers/NetworkSendDriver.h Outdated
Comment on lines +270 to +304
size_t offset = 0; // byte cursor into `data`, walking destination by destination
for (uint8_t d = 0; d < nDest_ && offset < totalBytes; d++) {
// This destination's run: its light count × the wire stride, clipped to what's left.
size_t runBytes = static_cast<size_t>(destCounts_[d]) * bytesPerLight;
if (offset + runBytes > totalBytes) runBytes = totalBytes - offset;
if (runBytes == 0) continue; // a zero-count destination is configured but idle

uint16_t universe = universeStart; // restart per destination
size_t sent = 0;
while (sent < runBytes) {
const size_t n = std::min(runBytes - sent, chunk);
const uint8_t* src = data + offset + sent;
size_t packetLen;
switch (protocol) {
case 1:
packetLen = buildE131Packet(packet, universe, sequence_, cid_,
src, static_cast<uint16_t>(n));
break;
case 2:
// DDP is byte-addressed: offsets are relative to THIS destination's strip,
// which starts at 0 on its own controller.
packetLen = buildDdpPacket(packet, static_cast<uint32_t>(sent),
/*push=*/sent + n >= runBytes,
src, static_cast<uint16_t>(n));
break;
default:
packetLen = buildArtDmxPacket(packet, universe, sequence_,
src, static_cast<uint16_t>(n));
break;
}
// Explicit per-packet address — one socket, N destinations. A send that fails (a
// full tx buffer, an unreachable host) drops that packet and the loop continues:
// one dark tube must not stall the others, and UDP gives us no delivery signal to
// act on anyway. A never-answering host costs only lwIP's slow background ARP retry.
socket_.sendToAddr(dest_[d], port, packet, packetLen);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟠 Major | 🏗️ Heavy lift

Move multi-destination sends off the render tick.

This loop serializes every receiver's packet burst inside tick(), so render latency grows with destination count and network backpressure. Queue bounded frame work to a sender task or use a nonblocking resumable send mechanism.

As per path instructions, every periodic tick is render-thread hot-path work and must avoid blocking writes or unconditional expensive serialization.

🤖 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/NetworkSendDriver.h` around lines 270 - 304, Move the
per-destination packet loop in the NetworkSendDriver tick path into a bounded
sender task or resumable nonblocking mechanism. Ensure tick() only enqueues
frame work or advances a limited send step, while packet serialization and
socket_.sendToAddr calls execute outside the render thread; preserve destination
ordering and prevent unbounded queue growth or render-thread blocking.

Source: Path instructions

Comment thread src/ui/app.js
Comment on lines +497 to 514
if (!childrenEl || !mod.children || mod.children.length === 0) return;

// One rule: a TOP-LEVEL module shows its children one at a time behind a tab strip, so its card
// stays short however many children it has. Deeper levels stack as before. The tabs are derived
// from mod.children on every render, so adding a layer adds its tab — there is no tab registry
// to keep in sync, which is the whole of the "dynamic" requirement.
if (depth === 0) {
// The "+" tab takes over the add affordance, so hide the footer's duplicate button — but keep
// the footer element itself, because openTypePicker renders the picker into it.
const addBtn = card.querySelector(".card-footer > .add-btn");
if (addBtn) addBtn.style.display = "none";
renderChildTabs(mod, childrenEl, depth);
return;
}
for (const child of mod.children) {
renderModuleTree(child, childrenEl, depth + 1);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚀 Performance & Scalability | 🟡 Minor | ⚡ Quick win

Tab click triggers a full renderCards() rebuild and drops keyboard focus.

Clicking a tab (or the "+" add-tab) tears down and rebuilds the entire module tree via renderCards() just to switch the active child panel. Since the tabs carry role="tab"/aria-selected and support :focus-visible, this reads as deliberate ARIA-tab semantics — but the full rebuild destroys the clicked <button>, so keyboard focus falls back to the document body instead of staying on the newly-active tab. It's also more DOM churn than needed for a purely local UI-state change.

Consider re-rendering only panel (clear + renderModuleTree(child, panel, depth+1)) and toggling tab-active/aria-selected in place, then restoring focus to the clicked tab.

Also applies to: 576-633

🤖 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/ui/app.js` around lines 497 - 514, Update the tab interaction logic in
renderChildTabs and its tab click handler to avoid calling renderCards() for
local child switching. Re-render only the selected panel by clearing it and
invoking renderModuleTree for the chosen child, then update tab-active and
aria-selected on the existing tab buttons in place. Restore focus to the clicked
tab, including the "+" add-tab, so keyboard focus and ARIA tab behavior remain
intact.

Comment thread test/unit/light/unit_NetworkSendDriver_packet.cpp Outdated
Comment thread test/unit/light/unit_NetworkSendDriver_packet.cpp
@ewowi ewowi changed the title Add reusable LightPresets library + migrate MoonLight fixtures Faster, wider LED output: SWAR encode, async DMA, a second core, 16 lanes, and many-tube network send Jul 13, 2026
@ewowi

ewowi commented Jul 13, 2026

Copy link
Copy Markdown
Collaborator Author

Processing the open review threads. Verified each against current code; here are the ones I'm not taking, with reasons.

ParallelLedDriver.h — "don't free DMA buffers after a timed-out wait" (🔴 Critical): invalid. The teardown already quiesces before freeing. destroyState() on both backends disables the peripheral first, then frees:

  • Parlio: parlio_tx_unit_disable(st->unit)parlio_del_tx_unit()heap_caps_free(buf)
  • i80: esp_lcd_panel_io_del()esp_lcd_del_i80_bus()heap_caps_free(buf)

The "abort-and-quiesce primitive that guarantees DMA and callbacks are finished before deinit" is the IDF deinit call itself, and it is already ordered correctly. drainInFlight()'s comment ("deinit below cancels the wedged transfer") is accurate, not an assumption.

platform_desktop.cpp — "keep render-side notification nonblocking" (make notifyTask use a try-lock): invalid, and it would introduce a bug. That's a condition_variable; the lock is held for exactly one bool store, and the only other holders are the worker's wait (which releases it) and shutdown. A try-lock there would lose wakeups — the worker would miss the notify and stall a frame. This is the textbook condvar pattern; the suggestion trades a non-problem for a real one.

platform_esp32_worker.cpp — "t.impl assigned after task creation is a startup race": invalid. workerTrampoline receives the worker as its void* parameter (xTaskCreatePinnedToCore(..., w, ...)); it never reads t.impl. There is no cross-thread read of that pointer to race with.

IrService.h — "release the previous IR channel before recomputing readiness": stale. Already fixed — reportReady() calls platform::irStop() on the pin_ < 0 path, with a comment explaining exactly this case.

.github/workflows/test.yml — pin actions/checkout to a SHA: not in this PR. All three jobs use @v4; pinning only the new one would be inconsistent, and pinning all of them is a repo-wide supply-chain policy decision that deserves its own change.

test/unit/.../unit_NetworkSendDriver_packet.cpp — unbounded strcpy (CWE-120): accepted as-is. Test-only, string literals of known length into fixed buffers. Switched to snprintf anyway where I touched those lines.

Everything else in the batch is fixed — including two real bugs it caught: NetworkSendDriver::prepare() was publishing a partial destination list on a parse error (so a typo in tube 3 of 5 still sent packets to tubes 1–2), and check_sanitizers.py was turning every probe failure into a green skip, which would have silently un-guarded the sanitizer gate on Linux.

ewowi and others added 7 commits July 13, 2026 23:01
… sanitizer gate

Review pass over the whole branch before merging. Three real bugs, one of them a regression this branch introduced: a module that faulted stopped showing its status. Also makes the sanitizer CI job actually run — it was failing at configure because it never installed uv.

KPI: 16384lights | Desktop:718KB | tick:127/100/10/124/20/3/279/69/17/23/171/123/22/10/45us(FPS:7874/10000/100000/8064/50000/333333/3584/14492/58823/43478/5847/8130/45454/100000/22222) | tick:3772us(FPS:265) | heap:33183KB | src:195(39832) | test:133(21687) | lizard:144w

Core:
- HttpServerModule: a module's status and severity now ride the 1 Hz value-diff. They were left out when the periodic push became a diff, so a driver that faulted showed nothing until an unrelated resync — and the tabbed UI made it worse, since a fault on a collapsed tab was invisible entirely. The client needed no change: its header-field handler was already generic.
- TryLock moved from platform.h to core. It has no platform backing (it is an atomic_flag, the same code everywhere) and its only consumers were core files, so it failed the platform layer's charter of being where hardware APIs live.
- Control: removed `setStepper` — a control flag, a wire key, a schema-hash bit and a UI branch, with no callers anywhere.

Light domain:
- NetworkSendDriver: a malformed `ips` or `lightsPerIp` no longer leaves a partial destination list live. The parser fills its output as it goes, so a typo in the third of five tubes left two destinations published with stale counts, and tick() sent real packets to them despite the error on the card. prepare() now parses into locals and publishes only when everything validates; on any error the driver idles.
- Drivers: the frame-boundary wait is bounded. A wedged core-1 worker would have spun the render loop forever — a permanent wedge, which ranks below "degraded". It now gives up after 500 ms, disengages the split, and every driver falls back to ticking inline on core 0, with a status saying why. The structural-mutation hook keeps its blocking join, because there the caller is about to free the very driver the worker may be inside.
- Drivers: the encode task's start/stop are private. Nothing outside called them, and an outside caller could have desynced the split flag from the task's real state — which is exactly how the hang above becomes reachable.
- Parlio: the internal-RAM fallback for the primary DMA buffer is guarded by the same heap reserve the second buffer already used. On the P4 that fallback is the normal path, so an unguarded one could quietly take internal RAM below the reserve WiFi and HTTP depend on.

Platform:
- desktop: `yield()` actually yields. It was a no-op, so the multicore boundary wait busy-spun at full CPU and starved the very worker it was waiting for. ESP32 already did the right thing (vTaskDelay).

Tests:
- The status change now rides the patch (pinned), a malformed destination list idles the driver rather than publishing part of it, a mismatched lightsPerIp list still slices sanely, and a wedged worker degrades to single-core instead of hanging.

Docs / CI:
- The sanitizer job installs uv, which is why it was failing at CMake configure in 6 seconds. This is TSan's first exposure to the multicore code.
- check_sanitizers.py no longer reports success when a probe fails: it skips only the one runtime we have positively identified as broken (TSan on macOS/arm64) and fails anything else, so a broken runtime on Linux can't silently un-guard the gate. It also runs both sanitizers rather than short-circuiting.
- performance.md: the 16K frame rate said 34 fps, which is the wire ceiling; async already hides the wire, so the tick is the encode and the honest number is ~20 fps.
- The PSRAM ring study drops the FastLED/WLED comparison and keeps the engineering: the memory model, the arithmetic behind the 2048-light classic ceiling, and the trigger that would un-park it.

Reviews:
- 🐇 A partial destination list on a parse error, and a sanitizer gate that turned every probe failure into a green skip — both fixed above.
- 🐇 Rejected: "don't free DMA buffers after a timed-out wait" (both backends already disable the peripheral before freeing — the abort-and-quiesce primitive it asks for is the IDF deinit call, already correctly ordered); a try-lock in the desktop notify path (it is a condvar — a try-lock there loses wakeups); a t.impl startup race on ESP32 (the trampoline takes the worker as its argument and never reads t.impl); pinning actions/checkout to a SHA (all three jobs use @v4 — a repo-wide policy change, not this PR's).
- 👾 Whole-branch review: no architectural drift; the core additions (the worker seam, quiesce, the tickChildren role filter) are reusable primitives rather than one-offs. Its findings — the unbounded boundary wait, the public task lifecycle, and the dead setStepper — are all fixed here.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The new sanitizer CI job is the first thing to compile this tree with GCC — every local gate uses clang — so it surfaced three -Werror truncation warnings clang does not emit. All three are unreachable in practice, but the code did not say so, which is exactly what the warning is for.

Light domain:
- LightPresetsModule: the default preset name formatted a uint32 id into a 16-byte buffer, and "preset " + 10 digits + NUL does not fit. Ids never get near that (they are bounded by kMaxPresets), but the compiler cannot know it — print the id modulo 10^5, which provably fits.

Tests:
- unit_FilesystemModule_persistence: a strncpy that copied exactly 8 bytes and appended the NUL by hand is now a bounded snprintf, and a 256-byte path buffer GCC could not prove was large enough is 512.

Known: local gates compile with clang only, so this class of divergence (GCC's -Wstringop-truncation / -Wformat-truncation vs clang) is invisible until CI. A containerised GCC build matching CI's toolchain would close it — backlogged rather than adding a local Homebrew GCC, which ships a different major version than CI and so would not agree with it anyway.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`place()` sat outside `#if MM_MOONLIVE_HAS_HOST_JIT` while every one of its callers sat inside it. The host JIT is arm64-only, so on CI's x86_64 Linux the guard removes the callers and the helper becomes unused — which GCC rejects under -Werror while clang stays quiet. A helper that only guarded code uses belongs inside the guard.

Verified by building with the JIT gate forced off (simulating CI's x86_64): no other symbol is affected, so this is the last of that class.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CI compiles with GCC; every local gate compiles with clang. The two disagree — GCC emits truncation and unused-symbol warnings clang stays silent about, and clang leaks standard headers transitively where GCC does not — so with -Werror a hard rule, each divergence is a CI failure invisible locally. That cost four push-and-discover cycles. Installing a GCC and building once found all of them at a stroke.

Tests:
- unit_moonlive_ir, unit_ParallelLedDriver_doublebuffer: missing <cstdio> / <string>. Clang supplies them transitively through another header; GCC does not.
- unit_TextEffect: `snprintf(buf, n, "")` is a zero-length format, which GCC rejects. Clearing the buffer says the same thing plainly.

Scripts:
- build_desktop.py takes `--gcc`: builds with GCC in Debug — the exact compiler and build type CI uses — into its own build/gcc dir so it can't clobber the clang cache. Opt-in and inert by default: without the flag nothing changes, and when GCC is absent it prints how to install one rather than installing anything itself.

Docs:
- Backlogged the 17 further truncation warnings a GCC *Release* build surfaces in core (Release inlines more, so GCC proves more and gets stricter). CI is Debug, so they are not failures; every one inspected so far is a bound the code checks but cannot prove to the compiler. Worth clearing in one pass, but not as a tail on this merge.

Verified: GCC Debug builds the whole tree with zero warnings and passes all 778 tests; clang unchanged and equally clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The sanitizer job got far enough to RUN the suite, and 2 of 753 cases failed on Linux. Neither is a sanitizer finding — both are tests that quietly depended on macOS behaviour and have been wrong on Linux for as long as they existed. Nothing caught them because CI did not compile the C++ tests until this job.

Core:
- platform: new `setTestBindFails()` test hook, alongside the existing `setTestNowMs`. A test that needs a bind to FAIL cannot get there by holding the port with a second socket: on Linux, SO_REUSEADDR on a UDP socket bound to INADDR_ANY *permits* the overlapping bind, so the hog succeeds and the failure never happens. A privileged port is no better — modern macOS lets a non-root process bind port 80. The hook makes the premise a fact we control rather than OS behaviour we hope for. The bind() comment claiming "same observable outcome on both platforms" is now corrected: it is not.

Tests:
- unit_AudioService_sync: drives the bind-failure path through the new hook instead of a port hog.
- unit_RmtLedDriver_pins: compared a warning string by POINTER (`warn == kClampedWarning`). The literal is `inline constexpr`, so its address need not be unique across translation units — clang merges the copies, GCC does not. Compares contents now, which is what the test always meant.

Verified under both compilers: GCC (CI's) and clang each build the tree warning-free and pass all 778 tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…was inside it

TSan's first run against the multicore code found a real one — exactly what the gate is for.

The container ticks its Driver children on the core-1 worker, so destroying a driver while that worker is inside its tick() is a vptr race: the destructor rewrites the vtable pointer while the worker dereferences it. Undefined behaviour, and on an ESP32 a jump through a half-written vtable.

`~Drivers()` stops the worker, but that is too late when the driver is destroyed BEFORE its container — which is the normal case for stack objects, since they destruct in reverse declaration order. TSan caught it on a test's stack-declared driver with the worker mid-tickChildren, but nothing about it is test-specific: any owner that outlives its Drivers hits the same window.

Light domain:
- DriverBase gains a destructor that quiesces its parent before the vtable comes apart. It cannot live in ~MoonModule — by then this object's Driver-ness is already gone — and it costs nothing when the container has no worker, since quiesce() is a no-op there.

Note this is the argument for keeping the split on desktop rather than making it ESP32-only: the desktop thread is the ONLY place a sanitizer can see these invariants. The race exists on the ESP32 too; we simply have no TSan there. Disabling the split on desktop would have silenced the detector, not the bug.

Verified: GCC and clang both build warning-free and pass all 778 tests; ASan clean over the full suite. TSan runs on Linux CI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
My first attempt at this was wrong. Quiescing in ~DriverBase cannot work: C++ destroys derived-before-base, so by the time the base destructor runs the derived vptr is already rewritten — TSan flagged the same race again, now pointing at my own destructor.

The guarantee has to sit with the OWNER, and production already has it: the HTTP delete path calls release() (which stops the worker) before deleteTree, and removeChild() quiesces. The race was only reachable from a stack-declared driver that never releases — it simply fell out of scope while the core-1 worker was still ticking it.

Tests:
- unit_Layers_container: seven test cases declared `Drivers` before their `CaptureDriver`, so reverse-declaration order destroyed the driver FIRST, while the worker was live. Only one of the seven called release(). Declaring the driver before its container puts ~Drivers() — which stops the worker — ahead of the driver's destructor.
- The rendersplit tests were already safe: all seven call release(), so the worker is joined before any destructor runs. TSan agreed — it flagged only unit_Layers_container.

Light domain:
- DriverBase documents the contract at the class: destroy through release()/removeChild(), or declare the driver before its container. There is no base-class fix, and pretending otherwise (my first attempt) hides the requirement rather than meeting it.

Verified: GCC and clang both warning-free, 778 tests pass, ASan clean over the full suite. TSan runs on Linux CI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@ewowi
ewowi merged commit 242f678 into main Jul 13, 2026
5 checks passed
@ewowi
ewowi deleted the next-iteration branch July 13, 2026 21:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant