Skip to content

Module replace, nested cards, streaming JSON, emoji tags, UI polish#4

Merged
ewowi merged 10 commits into
mainfrom
next-iteration
May 24, 2026
Merged

Module replace, nested cards, streaming JSON, emoji tags, UI polish#4
ewowi merged 10 commits into
mainfrom
next-iteration

Conversation

@ewowi

@ewowi ewowi commented May 22, 2026

Copy link
Copy Markdown
Collaborator

Feature batch on next-iteration — six features plus a bug fix.

Features

  • Nested module cards (plan-13) — child cards render inside their parent card's box (.card-children wrapper), not as flat siblings. Drag-reorder gates on a shared container.
  • Module replace (plan-14) — POST /api/modules/<n>/replace swaps a child's type at the same position; ✎ button opens a same-role type picker.
  • Emoji tags — per-module curated tags() + a UI-derived role emoji, shown as a chip filter in the type picker (AND filtering).
  • Per-card stats — 🕒 timing + 🧠 static/dynamic memory; a help (?) link to each module's spec page on GitHub (engine-provided docPath).
  • Press-twice delete & reboot — shared armPressTwice, no confirm() popups. Up/down buttons removed (drag-reorder works on mobile too).
  • Networkplatform::hostIp(): the app and MoonDeck print the LAN IP; MoonDeck device-discovery lists the PC at its network address. MoonDeck also shows the app version.

Bug fix

  • Streaming JSON (plan-15) — /api/state, /api/types, /api/system stream through a JsonSink with no fixed-buffer ceiling. Fixes a 4 KB overflow that produced malformed JSON and broke the UI on larger module trees. The 4 KB static jsonBuf_ is removed.

Other

  • Preview canvas: full content width, square aspect-ratio capped at 50vh, scroll-shrink restored.
  • FilesystemModule exposes a lastSaved read-only control.
  • README reworked; .claude/settings.local.json gitignored.

Pre-commit

Desktop build clean (zero warnings), ctest 1/1, scenarios 8/8, platform boundary PASS, specs 21/21, ESP32 build clean, Reviewer agent PASS. ESP32 live tick not captured — board offline.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added HTTP API and WebSocket support for real-time module inspection and control.
    • Web UI now displays application version, module emoji tags, and clickable help links.
    • New press-twice confirmation UX for reboot and delete actions (auto-disarm on timeout/pointer leave).
    • Desktop app discovers and displays LAN IP address alongside localhost URL.
    • Module replacement feature allows swapping effect/modifier types in-place with clean defaults.
    • Added emoji-chip filtering in type picker for effect/modifier discovery.
    • Added 3D Plasma Effect and Rainbow Effect with dimension-aware rendering.
    • Effects and modifiers now advertise their supported dimensions (D1/D2/D3).
    • Statistics display shows memory usage and execution timing per module.
  • Bug Fixes

    • Fixed JSON response buffering issues for larger module trees via streaming sink.
    • Fixed module name collision handling on load with automatic deduplication.
    • Added Int16 control persistence with proper signed value round-tripping.
  • Documentation

    • Updated architecture guide to focus on system design and light pipeline.
    • Added building and coding standards documentation.

Review Change Stack

KPI: 16384lights | PC:284KB tick:50us(FPS:20000) | ESP32:901KB (build clean; tick not captured — board offline) | src:45(6720) | test:22(2597) | lizard:22w

Features and fixes on next-iteration:

- Nested module cards: child cards render inside their parent's box
  (.card-children wrapper), not as flat siblings. Drag-reorder gates on
  shared container. (plan-13)
- Module replace: POST /api/modules/<n>/replace swaps a child's type at
  the same position; ✎ button + same-role type picker. (plan-14)
- Streaming JSON: /api/state, /api/types, /api/system stream through a
  JsonSink with no fixed-buffer ceiling — fixes a 4 KB overflow that
  produced malformed JSON and broke the UI on large trees. The 4 KB
  static jsonBuf_ is removed. (plan-15)
- Emoji tags: per-module curated tags() + role emoji, shown as a chip
  filter in the type picker (AND filtering).
- Per-card stats: 🕒 timing + 🧠 static/dynamic memory; help (?) link
  to the module spec on GitHub (engine-provided docPath).
- Press-twice delete and reboot buttons (shared armPressTwice) — no
  confirm() popups. Up/down buttons removed (drag-reorder works on
  mobile too).
- Preview canvas: full content width, square aspect-ratio capped at
  50vh, scroll-shrink restored.
- FilesystemModule exposes a lastSaved read-only control.
- Network: platform::hostIp() — the app and MoonDeck print the LAN IP;
  MoonDeck discover lists the PC at its network address.
- MoonDeck shows the app version; README reworked.

Pre-commit: desktop build clean, ctest 1/1, scenarios 8/8, platform
boundary PASS, specs 21/21, ESP32 build clean, Reviewer agent PASS.

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

coderabbitai Bot commented May 22, 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: ab86c02e-099c-4d97-adf9-8b8be17f0c04

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
✨ 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 and usage tips.

@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: 8

Caution

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

⚠️ Outside diff range comments (2)
docs/moonmodules/core/ui.md (1)

12-275: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

This markdown update should be soft-wrapped, not hard-wrapped.

Please reflow the changed prose blocks into single-line paragraphs and keep only semantic line breaks (lists/tables/code blocks).

As per coding guidelines, **/*.md: No hard line wraps in markdown — let the editor soft-wrap.

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

In `@docs/moonmodules/core/ui.md` around lines 12 - 275, Summary: The updated
markdown contains hard-wrapped prose that must be soft-wrapped. Fix: in
docs/moonmodules/core/ui.md, locate the prose paragraphs under the major
sections (e.g., "Principles", "Layout", "Status bar", "Side navigation", "Module
card", "Control types", "Type picker", "Module hierarchy", "Communication",
"WebSocket", "REST API", "Static assets", "Styling", "3D preview", "State
updates — the no-rebuild contract", "localStorage keys", "Feature summary") and
reflow each changed prose block into single-line paragraphs (join lines into a
single sentence/paragraph), preserving existing semantic line breaks for lists,
tables, code blocks, headings, and verbatim blocks; ensure bullets, numbered
lists, code fences, inline code, tables, and example text remain untouched while
only removing hard line breaks inside normal narrative paragraphs.
docs/history/plan-14.md (1)

5-66: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Remove hard-wrapped paragraph lines in this markdown file.

Please reflow these sections so each paragraph is a single logical line and relies on editor soft-wrap.

As per coding guidelines, **/*.md: No hard line wraps in markdown — let the editor soft-wrap.

🤖 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/plan-14.md` around lines 5 - 66, The file
docs/history/plan-14.md contains hard-wrapped paragraphs; reflow each paragraph
so it is a single logical line (no hard line breaks inside paragraphs) and rely
on editor soft-wrap; ensure headings, lists, and code blocks keep their
intentional line breaks but all prose paragraphs are joined into single lines
throughout the document.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/history/plan-15.md`:
- Around line 5-41: Reflow the hard-wrapped paragraphs in this markdown
(sections like the opening summary, Decision, Implementation —
`src/core/HttpServerModule.h`, Verification, and Notes) into single-line
paragraphs (soft-wrap style) so there are no manual line breaks inside
paragraphs; leave headings and code identifiers (e.g., `JsonSink`, `serveState`,
`buildStateJson`, `pushStateToWebSockets`, `jsonBuf_`) unchanged and only adjust
the paragraph text wrapping.

In `@docs/testing.md`:
- Around line 34-41: The "Module tree mutation" section contains hard line
wraps; reflow it to use soft-wrapping by making each paragraph a single
unwrapped line (no forced line breaks) so editors handle wrapping, e.g.,
collapse the bulleted descriptions of moveChildTo, replaceChildAt, and the
Replace lifecycle (mentioning HttpServerModule::handleReplaceModule) into single
continuous lines and ensure file/test references like test/test_movechild.cpp
and test/test_replacechild.cpp remain intact.

In `@src/core/HttpServerModule.h`:
- Around line 266-271: The code currently copies a computed nameLen into nameBuf
(char nameBuf[32]) and silently truncates long module names, which can cause
wrong module matches; instead validate that nameLen fits the buffer and reject
the request when it's too long (do not memcpy or call handleReplaceModule). In
the block using nameLen, check if nameLen >= sizeof(nameBuf) and if so send a
client error (e.g., HTTP 400 / "module name too long") using the existing
connection response mechanism and return early; otherwise proceed to memcpy,
null-terminate nameBuf, and call handleReplaceModule(conn, nameBuf, body).
Ensure you reference path, pathLen, nameLen, nameBuf, and handleReplaceModule
when making the change.
- Around line 820-833: When replacing a module created via
ModuleFactory::create(typeName), preserve the original module name by copying it
from the existing child before swapping: fetch the current child (e.g. via
parent->childAt(index) or similar), call its getName(), then set that name on
the new module (fresh->setName(...)) prior to calling
parent->replaceChildAt(index, fresh); maintain the existing lifecycle calls on
fresh (onBuildControls, setup, onAllocateMemory) and ensure you still own and
handle the returned old module from replaceChildAt.
- Around line 55-62: The current appendf function can silently truncate output
when vsnprintf reports a required length >= FRAG_MAX, producing malformed JSON;
change appendf to detect vsnprintf's return (n) and if n < 0 return/error, if n
< sizeof(frag) keep the current path, but if n >= sizeof(frag) allocate a
temporary buffer of size (n+1), re-run vsnprintf using a va_copy of the args to
fill that buffer, then call append(...) with the complete string; ensure va_list
is correctly copied and both va_end calls are performed and do not append the
truncated frag when the full formatted length exceeds FRAG_MAX (update the
function appendf accordingly).

In `@src/ui/app.js`:
- Around line 479-481: The click handler on btn leaves `armed` true during/after
calling onConfirm(), allowing repeated destructive actions; update the handler
so that when armed is true you immediately set `armed = false` (and optionally
disable the button) before calling onConfirm() to ensure the action only runs
once per arming cycle; locate the event listener attached to `btn` that
references `armed` and `onConfirm()` and modify the flow to clear/disarm prior
to invoking `onConfirm()`.

In `@test/test_replacechild.cpp`:
- Around line 85-106: Add an assertion that the replacement preserves the
original module name: after calling f.parent.replaceChildAt(1, &fresh) (and
before old->teardown()), check that the new module's name matches the original
(e.g. CHECK(fresh.getName() == "B") or CHECK(f.parent.getChildAt(1)->getName()
== "B")). Use the existing symbols replaceChildAt, TracingModule/setName,
getName, fresh, old and f.parent to locate where to insert this assertion.

---

Outside diff comments:
In `@docs/history/plan-14.md`:
- Around line 5-66: The file docs/history/plan-14.md contains hard-wrapped
paragraphs; reflow each paragraph so it is a single logical line (no hard line
breaks inside paragraphs) and rely on editor soft-wrap; ensure headings, lists,
and code blocks keep their intentional line breaks but all prose paragraphs are
joined into single lines throughout the document.

In `@docs/moonmodules/core/ui.md`:
- Around line 12-275: Summary: The updated markdown contains hard-wrapped prose
that must be soft-wrapped. Fix: in docs/moonmodules/core/ui.md, locate the prose
paragraphs under the major sections (e.g., "Principles", "Layout", "Status bar",
"Side navigation", "Module card", "Control types", "Type picker", "Module
hierarchy", "Communication", "WebSocket", "REST API", "Static assets",
"Styling", "3D preview", "State updates — the no-rebuild contract",
"localStorage keys", "Feature summary") and reflow each changed prose block into
single-line paragraphs (join lines into a single sentence/paragraph), preserving
existing semantic line breaks for lists, tables, code blocks, headings, and
verbatim blocks; ensure bullets, numbered lists, code fences, inline code,
tables, and example text remain untouched while only removing hard line breaks
inside normal narrative paragraphs.
🪄 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: 06f3eb88-86c0-45f5-a78e-f3ad6ab83170

📥 Commits

Reviewing files that changed from the base of the PR and between ce5bd70 and 314c3ad.

📒 Files selected for processing (36)
  • .gitignore
  • README.md
  • docs/history/plan-14.md
  • docs/history/plan-15.md
  • docs/moonmodules/core/FilesystemModule.md
  • docs/moonmodules/core/ui.md
  • docs/moonmodules_draft/core/ui.md
  • docs/testing.md
  • scripts/moondeck.py
  • scripts/moondeck_ui/index.html
  • scripts/moondeck_ui/style.css
  • src/core/FilesystemModule.h
  • src/core/HttpServerModule.h
  • src/core/ModuleFactory.h
  • src/core/MoonModule.h
  • src/light/CheckerboardEffect.h
  • src/light/FireEffect.h
  • src/light/GlowParticlesEffect.h
  • src/light/LavaLampEffect.h
  • src/light/MetaballsEffect.h
  • src/light/MirrorModifier.h
  • src/light/NoiseEffect.h
  • src/light/ParticlesEffect.h
  • src/light/PlasmaEffect.h
  • src/light/PlasmaPaletteEffect.h
  • src/light/RainbowEffect.h
  • src/light/RipplesEffect.h
  • src/light/SpiralEffect.h
  • src/main.cpp
  • src/platform/desktop/platform_desktop.cpp
  • src/platform/esp32/platform_esp32.cpp
  • src/platform/platform.h
  • src/ui/app.js
  • src/ui/style.css
  • test/CMakeLists.txt
  • test/test_replacechild.cpp

Comment thread docs/history/plan-15.md
Comment on lines +5 to +41
Adding several effects to a Layer broke the whole web UI: it showed *"Error: The string did not match the expected pattern."* and rendered no module cards, even after a refresh.

Root cause: `HttpServerModule` built the entire `/api/state` JSON into a single fixed `char jsonBuf_[4096]`. With a larger module tree the JSON exceeded 4 KB; `snprintf`-based appends silently dropped individual fragments past the limit, leaving **malformed JSON** (`…value":0},}]},},},}]}]}]}` — dangling commas, broken nesting). The browser's `JSON.parse` failed and the UI never rendered. The WebSocket state push had the identical bug — a `char json[4096]` stack buffer.

This is exactly the failure the plan-12 spec note predicted: *"revisit only if the tree outgrows the JSON buffer (the documented fallback is then streaming JSON to the socket)."* This plan implements that fallback.

## Decision

Stream the state JSON with **no fixed-size ceiling**, rather than just enlarging the buffer (which only moves the cliff and costs ESP32 RAM). A `JsonSink` abstraction serves both consumers:

- **Socket mode** — a small (1 KB) staging buffer flushes to the `TcpConnection` as it fills; the whole response never lives in RAM at once. Used by `GET /api/state`.
- **Buffer mode** — bytes collect in a heap buffer that doubles on demand. Used by the WebSocket push, whose frame header needs the total length up front so it can't stream incrementally.

Either way a module tree of any size serializes correctly.

## Implementation — `src/core/HttpServerModule.h`

- New `JsonSink` class (before `HttpServerModule`): `append()` / `appendf()` write JSON; a `TcpConnection*` selects socket vs buffer mode. Socket mode auto-flushes the 1 KB stage; buffer mode grows a heap allocation (`platform::alloc`, doubling from 2 KB), freed in the destructor.
- `serveState` — writes the HTTP header directly (no `Content-Length`; `Connection: close` ends the body at EOF), then streams the tree through a socket-mode `JsonSink`.
- `buildStateJson`, `writeModuleJson`, `writeControls` — converted from `(char* buf, size_t bufSize, int& pos)` to a single `JsonSink&`. `appendf` replaces every `snprintf` + `pos`-bookkeeping pair, so the converted code is also shorter. The old "peek `buf[pos-1]` to decide a comma" trick became a `bool first` flag (streaming has no buffer to peek).
- `pushStateToWebSockets` — builds into a buffer-mode `JsonSink`, sends `sink.data()` / `sink.size()` via the unchanged `sendWsTextFrame`.
- `<cstdarg>` added for `appendf`'s varargs. The old `jsonBuf_` / `JSON_BUF_SIZE` stay — `/api/types` and `/api/system` still use them (smaller responses, not the overflow path).

## Verification

- Desktop build clean, zero warnings.
- Live: with the persisted large tree, `GET /api/state` returned **7000 bytes of valid JSON** (was truncated at 4095). Adding 10 more effects via the API pushed it to **~7 KB / 24 modules** — still valid, no truncation.
- Headless browser: the UI rendered all cards with no "pattern" error (the original symptom gone).
- WebSocket: `/ws` handshake returns `101 Switching Protocols` and pushes frames; the state push uses the same verified `buildStateJson`.
- `ctest` 1/1, `mm_scenarios` 8/8.

## Notes

- The fix removes the size ceiling entirely — there is no new larger limit to hit.
- ESP32 RAM: socket mode uses a 1 KB stage (down from the 4 KB static `jsonBuf_` for this path); buffer mode allocates transiently from PSRAM-preferred heap and frees immediately.
- Spec updated: `docs/moonmodules/core/ui.md` — `/api/state` REST entry, the WebSocket push description, and the per-root-filtering note now describe the streaming sink.
- Implemented on `next-iteration`. Pre-commit gates not run — the product owner's gate.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Reflow wrapped markdown paragraphs to soft-wrap style.

This section is manually hard-wrapped; convert paragraph text to single-line paragraphs.

As per coding guidelines, **/*.md: No hard line wraps in markdown — let the editor soft-wrap.

🧰 Tools
🪛 LanguageTool

[style] ~9-~9: Consider an alternative for the overused word “exactly”.
Context: ...char json[4096]` stack buffer. This is exactly the failure the plan-12 spec note predi...

(EXACTLY_PRECISELY)


[style] ~16-~16: ‘in a heap’ might be wordy. Consider a shorter alternative.
Context: ...ate`. - Buffer mode — bytes collect in a heap buffer that doubles on demand. Used by ...

(EN_WORDINESS_PREMIUM_IN_A_HEAP)

🤖 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/plan-15.md` around lines 5 - 41, Reflow the hard-wrapped
paragraphs in this markdown (sections like the opening summary, Decision,
Implementation — `src/core/HttpServerModule.h`, Verification, and Notes) into
single-line paragraphs (soft-wrap style) so there are no manual line breaks
inside paragraphs; leave headings and code identifiers (e.g., `JsonSink`,
`serveState`, `buildStateJson`, `pushStateToWebSockets`, `jsonBuf_`) unchanged
and only adjust the paragraph text wrapping.

Comment thread docs/testing.md
Comment thread src/core/HttpServerModule.h Outdated
Comment thread src/core/HttpServerModule.h Outdated
Comment thread src/core/HttpServerModule.h Outdated
Comment on lines +820 to +833
auto* fresh = ModuleFactory::create(typeName);
if (!fresh) {
sendResponse(conn, 400, "application/json", "{\"error\":\"unknown type\"}");
return;
}

// Swap in place; replaceChildAt returns the old module, which we own.
MoonModule* old = parent->replaceChildAt(index, fresh);

// Lifecycle on the fresh module — same phase order as the add path.
fresh->onBuildControls();
fresh->setup();
fresh->onAllocateMemory();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Replacement does not preserve the original module name.

fresh is created from type only, then swapped in-place; the previous name is not copied. This can break name-based addressing and produce duplicate/default names after replace.

Proposed fix
 auto* fresh = ModuleFactory::create(typeName);
 if (!fresh) {
     sendResponse(conn, 400, "application/json", "{\"error\":\"unknown type\"}");
     return;
 }
+if (mod->name()) fresh->setName(mod->name());
📝 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
auto* fresh = ModuleFactory::create(typeName);
if (!fresh) {
sendResponse(conn, 400, "application/json", "{\"error\":\"unknown type\"}");
return;
}
// Swap in place; replaceChildAt returns the old module, which we own.
MoonModule* old = parent->replaceChildAt(index, fresh);
// Lifecycle on the fresh module — same phase order as the add path.
fresh->onBuildControls();
fresh->setup();
fresh->onAllocateMemory();
auto* fresh = ModuleFactory::create(typeName);
if (!fresh) {
sendResponse(conn, 400, "application/json", "{\"error\":\"unknown type\"}");
return;
}
if (old->name()) fresh->setName(old->name());
// Swap in place; replaceChildAt returns the old module, which we own.
MoonModule* old = parent->replaceChildAt(index, fresh);
// Lifecycle on the fresh module — same phase order as the add path.
fresh->onBuildControls();
fresh->setup();
fresh->onAllocateMemory();
🤖 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 820 - 833, When replacing a module
created via ModuleFactory::create(typeName), preserve the original module name
by copying it from the existing child before swapping: fetch the current child
(e.g. via parent->childAt(index) or similar), call its getName(), then set that
name on the new module (fresh->setName(...)) prior to calling
parent->replaceChildAt(index, fresh); maintain the existing lifecycle calls on
fresh (onBuildControls, setup, onAllocateMemory) and ensure you still own and
handle the returned old module from replaceChildAt.

Comment thread src/core/HttpServerModule.h Outdated
Comment thread src/ui/app.js
Comment on lines +85 to +106
TEST_CASE("replace lifecycle: fresh module is built, set up, allocated in order") {
// Mirrors what HttpServerModule::handleReplaceModule does to the replacement:
// onBuildControls → setup → onAllocateMemory, then teardown on the old module.
Fixture f;
Trace trace;
f.b.trace = &trace;
TracingModule fresh;
fresh.setName("X");
fresh.trace = &trace;

mm::MoonModule* old = f.parent.replaceChildAt(1, &fresh);
fresh.onBuildControls();
fresh.setup();
fresh.onAllocateMemory();
old->teardown();

REQUIRE(trace.calls.size() == 4);
CHECK(trace.calls[0] == "X:build");
CHECK(trace.calls[1] == "X:setup");
CHECK(trace.calls[2] == "X:alloc");
CHECK(trace.calls[3] == "B:teardown");
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Add an assertion for name preservation during replace.

Current tests validate position/parenting/lifecycle but not name continuity. Add coverage that replacement keeps the original module name when API semantics require stable identity.

🧰 Tools
🪛 Cppcheck (2.20.0)

[style] 87-87: The function 'addProgress' is never used.

(unusedFunction)


[style] 93-93: The function 'count' is never used.

(unusedFunction)


[style] 99-99: The function 'setHidden' is never used.

(unusedFunction)


[style] 89-89: The function 'typeName' is never used.

(unusedFunction)


[style] 90-90: The function 'setTypeName' is never used.

(unusedFunction)


[style] 92-92: The function 'enabled' is never used.

(unusedFunction)


[style] 93-93: The function 'setEnabled' is never used.

(unusedFunction)

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

In `@test/test_replacechild.cpp` around lines 85 - 106, Add an assertion that the
replacement preserves the original module name: after calling
f.parent.replaceChildAt(1, &fresh) (and before old->teardown()), check that the
new module's name matches the original (e.g. CHECK(fresh.getName() == "B") or
CHECK(f.parent.getChildAt(1)->getName() == "B")). Use the existing symbols
replaceChildAt, TracingModule/setName, getName, fresh, old and f.parent to
locate where to insert this assertion.

@ewowi

ewowi commented May 22, 2026

Copy link
Copy Markdown
Collaborator Author

@coderabbitai, processed your review, will be added in next commit:

CodeRabbit review processed. Summary:

Fixed (3 valid findings — src/core/HttpServerModule.h, src/ui/app.js)
nameLen truncation — /replace (and /move, which had the identical bug CodeRabbit missed) now reject a too-long module name with 400 "module name too long" instead of silently truncating it. Verified: 40-char name → 400 on both routes.

appendf silent truncation — a formatted fragment exceeding the 256-byte stack buffer is now re-formatted into an exact heap buffer (va_copy + platform::alloc) — never truncated. The common short-fragment path is unchanged (no allocation).

armPressTwice re-arm — disarm() now runs before onConfirm(), so a stray repeat click can't fire a destructive action twice (e.g. two /api/reboot). Verified: armed → confirm → disarmed.

Skipped (2 findings — with reasons)
"Preserve the old module name on replace" — invalid: it contradicts the deliberate clean-swap design (plan-14, the spec). Replace gives the new module its factory typeName so a NoiseEffect→PlasmaEffect swap correctly relabels the card. Keeping the old name would mislabel it. The companion test-assertion ask was skipped for the same reason.

Markdown soft-wrap (5 files: plan-14, plan-15, testing.md, ui.md) — invalid: those files are already soft-wrapped (one long line per paragraph, per CLAUDE.md). CodeRabbit misread its own line-wrapped diff display as hard wraps in the source. Confirmed by measuring actual line lengths (99–480 chars/line).

@coderabbitai

coderabbitai Bot commented May 22, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

@ewowi — thanks for the detailed breakdown! All three fixes look well-reasoned:

  • nameLen truncation — good catch extending the fix to /move as well (I did miss that route).
  • appendf silent truncation — the va_copy + heap fallback approach is the right pattern; keeps the hot path allocation-free.
  • armPressTwice re-arm — correct ordering: disarm before firing the callback prevents any double-trigger window.

Your skips are both valid:

  • Preserve name on replace — you're right, the clean-swap semantics from plan-14 are intentional. Relabelling the card with the new type's factory name is correct behaviour; carrying the old name across would silently mislabel a replaced module.
  • Markdown soft-wrap — confirmed; CodeRabbit was misreading its own terminal-wrapped diff display as hard line breaks in the source. No action needed.

🐇 ✨


✏️ Learnings added
Learnt from: ewowi
URL: https://github.com/ewowi/projectMM/pull/4

Timestamp: 2026-05-22T20:59:36.846Z
Learning: In projectMM (ewowi/projectMM), the `POST /api/modules/<name>/replace` endpoint (plan-14) intentionally assigns the new module its factory `typeName` rather than preserving the old module's name. This is deliberate clean-swap design: a NoiseEffect→PlasmaEffect swap must relabel the card with the new type name; preserving the old name would mislabel it. Do not flag absence of name-preservation as a bug.

Learnt from: ewowi
URL: https://github.com/ewowi/projectMM/pull/4

Timestamp: 2026-05-22T20:59:36.846Z
Learning: In projectMM (ewowi/projectMM), Markdown documentation files (docs/) use soft-wrapping — one long line per paragraph — as mandated by CLAUDE.md. Terminal-wrapped diff display can make these look like hard-wrapped lines. Do not flag soft-wrapped long lines in these docs as a style issue.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


If you found this review helpful, would you consider giving us a shout-out on X?

Thank you for using CodeRabbit!

KPI: 16384lights | PC:283KB | tick:37/38/111/108us(FPS:27027/26315/9009/9259) | ESP32:913KB | tick:43892us(FPS:22) | heap:132KB | src:45(7036) | test:23(2922) | lizard:21w

Multiple cohesive threads landed together:

# Auto-extrusion & Dim system

* `mm::Dim` enum (D1/D2/D3) added to core/types.h — domain-neutral so
  both EffectBase and ModifierBase can refer to it without including
  each other.
* `EffectBase::dimensions()` defaults to D3 ("I iterate every axis the
  layer gives me"). D2/D1 are opt-in promises: declaring D2 tells the
  framework `Layer::extrude` may duplicate the z=0 slice across z;
  declaring D1 lets it fill y and z. Effects that don't honestly
  iterate every axis must declare a lower D.
* `ModifierBase::dimensions()` defaults to D3 (modifiers touching the
  LUT are 3D-capable by default; advisory chip in the UI).
* `Layer::extrude(effectDim)` runs after each effect's loop(). Hot-path
  cost is one comparison + branch for the D3 case; inner loops are
  guarded by `depth_ > 1` / `height_ > 1` so a D2 effect on a 2D layer
  pays nothing. Real memcpy work only when the effect declared fewer
  axes than the layer has.
* Effect declarations: NoiseEffect + PlasmaEffect = D3 (true z-aware
  math). RainbowEffect + the 9 effects added in the more-effects PR
  = D2 (loops iterate y,x only; extrude fills z). FireEffect and
  ParticlesEffect resized their dynamic buffers from full-3D to z=0
  plane (w*h*cpl) — saves depth× heap on 3D installations.
* New contract pinned in EffectBase.h: loop() must honour the layer's
  runtime dimensions; never hardcode bounds. A D3 effect may run on a
  D1 or D2 layer.

# /api/types: dim + displayName

* `ModuleFactory::registerType<T>` captures dim via if-constexpr in the
  single existing probe — no separate registerEffectType /
  registerModifierType wrappers (reviewer-driven: that was a no-op
  abstraction).
* HttpServerModule emits `"dim":N` and `"displayName":"X"` per type.
  displayName uses the factory's existing displayNameFor() (strips
  Effect/Modifier/Layout/Driver/Module suffix) — fixes the picker rows
  that still read "RainbowEffect" instead of "Rainbow".
* UI app.js adds DIM_EMOJI (1:📏, 2:🟦, 3:🧊) and merges into
  emojiTagsFor(). The picker row label now uses t.displayName.

# Creator emoji 🦅

* Ten effects authored by David Jupijn / Rising Step in PR #3 carry the
  🦅 chip in their `tags()` (appended to origin emoji). UI shows it on
  the card and in the picker chip filter.
* core/ui.md describes the chip mechanism domain-neutrally;
  architecture-light.md owns the LED-domain emoji-key assignments
  (role / dim / origin / creator / audio / moving-head).

# src/light/ restructure

* New subfolders: effects/, modifiers/, drivers/, layouts/. Layer.h,
  Buffer.h, MappingLUT.h, BlendMap.h stay at src/light/ root.
* 20 module headers moved (16 renames preserved by git's similarity
  heuristic; 3 effects came through as A+D because session edits put
  similarity below 50% — history follows via `git log --follow`).
* 37 #include paths rewritten across src/ + test/. No CMake list to
  update (headers are glob-included via the include path).
* EffectBase and ModifierBase live in their category folders for
  discoverability.

# UI ↔ light-domain spec split

* docs/moonmodules/core/ui.md is now domain-neutral. Describes the
  UI engine: tags string is opaque graphemes for the chip filter; the
  preview channel is a binary WS frame whose leading byte dispatches
  to a domain renderer; the module tree shape is whatever main.cpp
  pins.
* docs/architecture-light.md gains a "UI integration (light domain)"
  section: the fixed LayoutGroup→Layer→DriverGroup tree, the 3D
  preview frame format ([0x02][13-byte header][RGB…]), the emoji-key
  table for the light domain.

# Tests

* New test/test_extrude.cpp pins the dim contract: D2 effect on 3D grid
  (Rainbow), D1 stub on 3D grid, D3 effect on 2D and 1D layer (Noise
  and Plasma — protects against hardcoded depth bounds), D2 effect on
  3D layer for the three styles (Checkerboard stateless, Fire stateful
  with heat grid, Particles stateful with trail buffer).
* test_mirror.cpp gains a `dimensions() == D3` case to pin the
  ModifierBase default.
* 121 unit tests + 8 scenarios all pass.

# Pre-commit gates

1. Desktop build clean, zero warnings (-Wall -Wextra -Werror)
2. ctest: 121/121 unit tests pass (1286+ assertions)
3. Scenarios: 8/8 pass
4. Platform boundary: PASS
5. Spec check: 21/21 modules ok
6. ESP32 build clean: 1,040,785 B image, IRAM 67.74%, DRAM 20.27%
7. Reviewer agent (Opus) re-run: PASS, no findings (after addressing
   first review's two SHOULD-FIX items: wrappers collapsed; D2
   declarations made honest by labeling the 9 non-z-aware effects as
   D2 rather than reframing docs to match wrong code)
8. KPI: see one-liner above + details below
9. perf snapshot row added to docs/performance.md (Plasma on Olimex
   ~44ms / 22 FPS, well within run-to-run jitter band)
10. testing.md adds test_extrude entry; EffectBase.md and Layer.md
    gain Dimensions / extrude sections; cross-links to architecture-
    light.md and the test file

# KPI Details

Desktop (macOS Apple Silicon):
  Lights: 16,384
  Binary: 283 KB
  Test cases: 121 passed, 0 failed, 0 skipped
  tick: 37us, 38us, 111us, 108us (FPS: 27027, 26315, 9009, 9259) per scenario
  Scenarios: 8/8 passed
  Platform boundary: PASS
  Specs: 21 modules, 21 ok

ESP32 (Olimex Gateway Rev G, no PSRAM, 320KB internal):
  Image: 1,040,785 bytes (43% partition free)
  Flash (code+data): 913 KB
  DRAM: 36,676 / 180,736 (144,060 free)
  tick: 43892 us (FPS: 22), heap free: 135,200

Code:
  45 source files (7036 lines)
  23 test files (2922 lines)
  32 specs, 8 scenarios
  Lizard: 21 warnings (no new ones; existing complex functions unchanged)

Co-Authored-By: Claude Opus 4.7 <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: 5

Caution

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

⚠️ Outside diff range comments (1)
src/main.cpp (1)

28-58: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Handle factory failure paths before dereferencing created modules.

registerType() and create() can fail, but this path assumes success and immediately dereferences returned pointers. A single registration/allocation failure can crash startup.

Proposed fail-fast pattern
-static void registerModuleTypes() {
+static bool registerModuleTypes() {
+    bool ok = true;
     // Containers
-    mm::ModuleFactory::registerType<mm::LayoutGroup>("LayoutGroup", "light/LayoutGroup.md");
-    mm::ModuleFactory::registerType<mm::Layer>("Layer", "light/Layer.md");
-    mm::ModuleFactory::registerType<mm::DriverGroup>("DriverGroup", "light/DriverGroup.md");
+    ok &= mm::ModuleFactory::registerType<mm::LayoutGroup>("LayoutGroup", "light/LayoutGroup.md");
+    ok &= mm::ModuleFactory::registerType<mm::Layer>("Layer", "light/Layer.md");
+    ok &= mm::ModuleFactory::registerType<mm::DriverGroup>("DriverGroup", "light/DriverGroup.md");
     // ... same for remaining registerType calls ...
+    return ok;
 }
 
 void mm_main(volatile bool& keepRunning, mm::lengthType gridW, mm::lengthType gridH, uint16_t httpPort) {
-    registerModuleTypes();
+    if (!registerModuleTypes()) {
+        std::printf("Module registration failed\n");
+        return;
+    }
+
+    auto mustCreate = [](const char* type) -> mm::MoonModule* {
+        auto* mod = mm::ModuleFactory::create(type);
+        if (!mod) std::printf("Module creation failed: %s\n", type);
+        return mod;
+    };
 
-    auto* filesystemModule = static_cast<mm::FilesystemModule*>(mm::ModuleFactory::create("FilesystemModule"));
+    auto* filesystemModule = static_cast<mm::FilesystemModule*>(mustCreate("FilesystemModule"));
+    if (!filesystemModule) return;
     filesystemModule->setScheduler(&scheduler);

Also applies to: 91-137

🤖 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/main.cpp` around lines 28 - 58, The registerModuleTypes function
currently assumes mm::ModuleFactory::registerType (and any subsequent
mm::ModuleFactory::create calls elsewhere) always succeed and immediately
dereferences returned objects; change these call sites (notably
registerModuleTypes, and the similar block around the create() calls referenced
at lines 91-137) to validate results before use: for registerType, check the
returned status/handle (or catch exceptions) and log and fail-fast (or skip
registration) on error; for create(), check for nullptr or failed optional and
avoid dereferencing—return an error, exit startup, or handle cleanup when
creation fails so startup cannot crash. Ensure you reference and guard the
specific symbols mm::ModuleFactory::registerType, mm::ModuleFactory::create, and
the registerModuleTypes function while adding logging and
early-return/error-handling.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@scripts/run/run_desktop.py`:
- Around line 31-37: The Windows tasklist filter appends ".exe" blindly causing
duplicate extensions (e.g., "projectMM.exe.exe"); update _is_running() to
compute a single exe_name variable that ensures exactly one ".exe" (e.g., if
name.endswith(".exe") use name else append ".exe"), then pass that exe_name into
the tasklist filter (f"IMAGENAME eq {exe_name}") instead of using name directly;
keep the rest of the logic (subprocess.run call and checks) unchanged and still
reference EXECUTABLE for the non-Windows branch.

In `@src/light/effects/PlasmaEffect.h`:
- Around line 68-71: The hot path in PlasmaEffect.h does a per-pixel division by
5 when computing hue (variable hue using s1, s2_y, s3, s4, s5_z and hue_shift);
remove the costly `/ 5` and replace it with an integer multiply-by-reciprocal +
right-shift (or a small lookup) to approximate divide-by-5 without using `/`
(e.g. compute a uint16_t sum = s1 + s2_y + s3 + s4 + s5_z and use (sum *
RECIPROCAL_CONST) >> SHIFT to get sum/5, then add hue_shift; apply the same
reciprocal approach for the 5-term branch while keeping the existing >>2 for the
4-term branch). Ensure types stay uint16_t/uint8_t and use named symbols (hue,
s1, s2_y, s3, s4, s5_z, hue_shift) so the change is localized and avoids runtime
division inside the inner pixel loop.

In `@src/light/Layer.h`:
- Around line 64-93: Add a direct include of the C string header to ensure
std::memcpy is declared: update the header that defines the extrude(Dim
effectDim) method (Layer.h) to include <cstring> so the std::memcpy calls inside
extrude (and any other uses in this header) do not rely on transitive includes;
simply add the include at the top of the file near other includes.

In `@src/ui/app.js`:
- Around line 1421-1424: The pinch-zoom math in the touch handler can divide by
zero when the two touch points coincide (d === 0); update the block around
touchDistance(e.touches[0], e.touches[1]) so that when d is 0 (or extremely
close to 0) you skip updating camDist or treat ratio as 1 (or use a small
epsilon) to avoid non-finite values; change the logic in the pinch handling that
computes ratio = pinchDist / d and updates camDist and pinchDist so it first
checks d and only applies camDist = Math.max(0.5, Math.min(10, camDist * ratio))
when d is valid, otherwise preserve current camDist and set pinchDist
appropriately.
- Around line 369-373: The enabled click handler can be reverted by a stale
WebSocket patch because updateValues() uses dragTs to suppress WS writes but the
enabled click path does not update dragTs; modify the enabled click handler (the
listener on the enabled element that calls setEnabledUi and sendControl with
mod.name, "enabled", next) to also update the same dragTs timestamp used by
updateValues (e.g., set the element/module's dragTs to Date.now() or assign to
the timestamp variable/key for mod.name) immediately when clicked so incoming WS
patches older than this timestamp are ignored.

---

Outside diff comments:
In `@src/main.cpp`:
- Around line 28-58: The registerModuleTypes function currently assumes
mm::ModuleFactory::registerType (and any subsequent mm::ModuleFactory::create
calls elsewhere) always succeed and immediately dereferences returned objects;
change these call sites (notably registerModuleTypes, and the similar block
around the create() calls referenced at lines 91-137) to validate results before
use: for registerType, check the returned status/handle (or catch exceptions)
and log and fail-fast (or skip registration) on error; for create(), check for
nullptr or failed optional and avoid dereferencing—return an error, exit
startup, or handle cleanup when creation fails so startup cannot crash. Ensure
you reference and guard the specific symbols mm::ModuleFactory::registerType,
mm::ModuleFactory::create, and the registerModuleTypes function while adding
logging and early-return/error-handling.
🪄 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: 959fb578-becb-4575-844c-111f39e2b5c6

📥 Commits

Reviewing files that changed from the base of the PR and between 314c3ad and 70890cd.

📒 Files selected for processing (59)
  • docs/architecture-light.md
  • docs/moonmodules/core/ui.md
  • docs/moonmodules/light/EffectBase.md
  • docs/moonmodules/light/Layer.md
  • docs/moonmodules/light/effects/NoiseEffect.md
  • docs/moonmodules/light/effects/PlasmaEffect.md
  • docs/performance.md
  • docs/plan.md
  • docs/testing.md
  • scripts/MoonDeck.md
  • scripts/moondeck_ui/app.js
  • scripts/run/run_desktop.py
  • src/core/HttpServerModule.h
  • src/core/ModuleFactory.h
  • src/core/MoonModule.h
  • src/core/types.h
  • src/light/EffectBase.h
  • src/light/Layer.h
  • src/light/PlasmaEffect.h
  • src/light/RainbowEffect.h
  • src/light/drivers/ArtNetSendDriver.h
  • src/light/drivers/DriverGroup.h
  • src/light/drivers/PreviewDriver.h
  • src/light/effects/CheckerboardEffect.h
  • src/light/effects/EffectBase.h
  • src/light/effects/FireEffect.h
  • src/light/effects/GlowParticlesEffect.h
  • src/light/effects/LavaLampEffect.h
  • src/light/effects/MetaballsEffect.h
  • src/light/effects/NoiseEffect.h
  • src/light/effects/ParticlesEffect.h
  • src/light/effects/PlasmaEffect.h
  • src/light/effects/PlasmaPaletteEffect.h
  • src/light/effects/RainbowEffect.h
  • src/light/effects/RipplesEffect.h
  • src/light/effects/SpiralEffect.h
  • src/light/layouts/GridLayout.h
  • src/light/layouts/LayoutGroup.h
  • src/light/modifiers/MirrorModifier.h
  • src/light/modifiers/ModifierBase.h
  • src/main.cpp
  • src/ui/app.js
  • src/ui/index.html
  • src/ui/style.css
  • test/CMakeLists.txt
  • test/scenario_runner.cpp
  • test/test_artnet_packet.cpp
  • test/test_extrude.cpp
  • test/test_filesystem_persistence.cpp
  • test/test_fire.cpp
  • test/test_grid_layout.cpp
  • test/test_metaballs.cpp
  • test/test_mirror.cpp
  • test/test_noise.cpp
  • test/test_particles.cpp
  • test/test_plasma.cpp
  • test/test_preview_driver.cpp
  • test/test_rainbow.cpp
  • test/test_stateless_effects.cpp
💤 Files with no reviewable changes (4)
  • src/light/EffectBase.h
  • src/light/RainbowEffect.h
  • src/ui/index.html
  • src/light/PlasmaEffect.h

Comment thread scripts/run/run_desktop.py
Comment on lines +68 to +71
uint8_t hue = is3d
? static_cast<uint8_t>(
(static_cast<uint16_t>(s1 + s2_y + s3 + s4 + s5_z) / 5) + hue_shift)
: static_cast<uint8_t>(((s1 + s2_y + s3 + s4) >> 2) + hue_shift);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Remove per-pixel integer division from the hot path.

Line 70 performs / 5 in the inner pixel loop. On MCU targets this is avoidable overhead in a render hot path.

⚙️ Proposed fix
-                    uint8_t hue = is3d
-                        ? static_cast<uint8_t>(
-                              (static_cast<uint16_t>(s1 + s2_y + s3 + s4 + s5_z) / 5) + hue_shift)
+                    uint16_t hue_sum = static_cast<uint16_t>(s1) + s2_y + s3 + s4 + s5_z;
+                    uint8_t hue = is3d
+                        ? static_cast<uint8_t>(
+                              ((static_cast<uint32_t>(hue_sum) * 0xCCCDu) >> 18) + hue_shift)
                         : static_cast<uint8_t>(((s1 + s2_y + s3 + s4) >> 2) + hue_shift);

As per coding guidelines, docs/moonmodules/light/effects/PlasmaEffect.md requires to “avoid division/modulo in inner loop”.

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

In `@src/light/effects/PlasmaEffect.h` around lines 68 - 71, The hot path in
PlasmaEffect.h does a per-pixel division by 5 when computing hue (variable hue
using s1, s2_y, s3, s4, s5_z and hue_shift); remove the costly `/ 5` and replace
it with an integer multiply-by-reciprocal + right-shift (or a small lookup) to
approximate divide-by-5 without using `/` (e.g. compute a uint16_t sum = s1 +
s2_y + s3 + s4 + s5_z and use (sum * RECIPROCAL_CONST) >> SHIFT to get sum/5,
then add hue_shift; apply the same reciprocal approach for the 5-term branch
while keeping the existing >>2 for the 4-term branch). Ensure types stay
uint16_t/uint8_t and use named symbols (hue, s1, s2_y, s3, s4, s5_z, hue_shift)
so the change is localized and avoids runtime division inside the inner pixel
loop.

Comment thread src/light/layers/Layer.h
Comment thread src/ui/app.js
Comment thread src/ui/app.js Outdated
… regions

KPI: 16384lights | PC:299KB | tick:37/36/102/101us(FPS:27027/27777/9803/9900) | ESP32:916KB | image:1,044,433B | DRAM:36,676/180,736 | src:46(7235) | test:24(3057) | lizard:23w

ESP32 live tick capture skipped — the configured serial port was
unplugged during pre-commit. No hot-path code changed in this commit
(renames + shape pass-through + dim system + Int16 ControlType wiring),
so a tick regression is structurally unlikely; the previous capture
(43,892 µs / 22 FPS at 128×128) is the comparable baseline. Image size
grew ~2 KB (+0.2%) from the new Layers class + six Int16 wire branches.

Two cohesive threads landed together, plus reviewer-driven polish:

# Top-level shape: Layouts → Layers → Drivers

The light pipeline's top-level containers are now plural, honest about
holding multiple children of one type.

* `class LayoutGroup` → `class Layouts` (file + factory key + spec)
* `class DriverGroup` → `class Drivers` (file + factory key + spec)
* New `class Layers` in `src/light/Layers.h` (~60 lines): top-level
  container for one or more `Layer` children. `loop()` runs each
  enabled child Layer in order. With one child today it's a thin
  pass-through, byte-identical to the previous single-Layer pipeline.
  `activeLayer()` returns the first enabled child as a placeholder for
  Drivers' single-Layer wiring; replaced by composition in the
  follow-up.
* `ModuleRole::Layer` added to core; UI ROLE_EMOJI gains `layer:"🥞"`.
* `acceptsChildren` learns `"Layers"` accepts `"layer"` role.
* main.cpp wires `Layouts → Layers (one Layer with Noise + Mirror) →
  Drivers`. Boot is byte-identical to the previous commit.

# Layer start/end percentages with ControlType::Int16

* `Layer` gains six `lengthType` controls — `startX/Y/Z`, `endX/Y/Z` —
  representing **percentages of the physical extent** on each axis,
  not pixel coordinates. Resilient to layout resize: a `startX = 25`
  layer stays at 25% when a panel grows from 64x64 to 128x128.
  Sentinel: `end == 0` means "use full extent" (zero-width region
  would be meaningless). Negative values + values > 100 reserved for
  a future modifier that drags a layer in/out of the visible area.
* New `ControlType::Int16` in `src/core/Control.h`, with paired
  `case Int16` branches in five wire paths (FilesystemModule load +
  save, HttpServerModule state push, control set, type defaults).
  Replaces the previous `addInt16 → ControlType::Uint16` aliasing,
  which corrupted negative round-trips (a CodeRabbit-confirmed bug:
  `-1` defaults reached the wire as `65535`).
* UI renders `int16` controls as sliders with range -100..+200 (the
  band a percentage with modifier-shift headroom needs). Value
  displayed next to the slider, matching the existing uint8 UX.
* `rebuildLUT()` ignores the controls today — values are persistent
  state, not yet wired. Composition follow-up activates them. The
  rounding rule (floor for start, ceiling for end) is documented in
  the Layer spec.

# Migration + spec updates

* `FilesystemModule::migrateRenamedConfigs` deletes stale
  `.config/LayoutGroup.json` and `.config/DriverGroup.json` on boot
  with a warning. Verified live. Comment block explicitly cites
  CLAUDE.md's "domain-neutral core" rule + a removal trigger
  ("remove next time `next-iteration` is merged to `main`").
* All specs updated for the rename. `Layouts.md`, `Layers.md`,
  `Drivers.md`, `Layer.md` all carry a "Naming convention" callout:
  capital `Layers` is the class, lowercase "layers" is the English
  plural — capitalisation alone disambiguates "the Layers container"
  from "two layers stacked". `architecture-light.md` pipeline diagram
  and prose follow the convention; awkward "Layer children" / "Layer
  instances" phrasings replaced by natural English.

# Tests

* New `test/test_layers_container.cpp` — 3 cases pinning the pass-
  through (one-child byte-identical to bare Layer), two-child both-
  loops-run, and `activeLayer()` fallback semantics.
* Existing tests' `layoutGroup` local vars + scenario JSON keys
  updated to match the rename.
* Result: 124/124 unit cases pass (was 121; +3 new).

# CodeRabbit pre-commit fixes

* `scripts/run/run_desktop.py::_is_running` — tasklist filter
  appended `.exe` to a name that already ended in `.exe`, so on
  Windows it matched nothing. Fixed; rationale in the comment.
* `src/light/Layer.h` — added `#include <cstring>` for `std::memcpy`
  (was relying on transitive include).
* `src/ui/app.js` pinch-zoom — guarded `d > 0` before computing
  `ratio = pinchDist / d`. Catches the degenerate "two fingers at
  identical coords" case.
* `src/ui/app.js` enabled-toggle — added `dragTs[mod.name+":enabled"]`
  stamp before `sendControl()`. Stops a WS state push older than the
  click from reverting the toggle.
* PlasmaEffect `/5` — skipped with reason (Release `-O3` already
  lowers constant-divisor division to a magic-multiply sequence;
  hand-rolling would produce identical assembly with worse
  readability). One-line in-source comment explains the asymmetry
  with the sibling `>> 2`.
* main.cpp `create()` null checks — skipped with reason
  (startup-time allocations; panic-on-startup is the right failure
  mode; documented in a comment block at the top of `mm_main`).

# Pre-commit gates

1. Desktop build: clean, zero warnings (`-Wall -Wextra -Werror`)
2. ctest: 124/124 unit tests pass (2453 assertions)
3. Scenarios: 8/8 pass
4. Platform boundary: PASS
5. Spec check: 22/22 modules ok
6. ESP32 build: clean, 1,044,433 B image (~+2 KB vs prior)
7. Reviewer agent (Opus): PASS WITH NOTES, two notes addressed
   (architecture-light.md "Layout children" leak, PlasmaEffect
   in-source `/5` comment)
8. KPI captured (ESP32 live skipped — see note above)
9. Perf snapshot: no perf-relevant change, jitter band unchanged
10. Docs check: 22/22 specs, README + testing.md updated

Co-Authored-By: Claude Opus 4.7 <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

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

Inline comments:
In `@docs/history/plan-16.md`:
- Line 7: The fenced code blocks in the document are missing explicit language
tags and required blank lines which trigger MD040/MD031; update each fenced
block by adding the appropriate language identifier (e.g., use ```text for the
ASCII tree block and ```cpp for C++ snippets) and ensure there is a blank line
before and after every fenced block; specifically fix the blocks that show the
ASCII layout example (the one starting with "Layouts             ← was
LayoutGroup") and the C++ snippets containing "`#pragma` once" and the includes
(the cpp fences near the blocks showing src/light/Layers.h and the other two cpp
examples around the documented Line 124 and Line 140), making sure each fence is
like ```text or ```cpp and surrounded by a blank line above and below.

In `@docs/moonmodules_draft/core/ui.md`:
- Line 40: Update the stale table row text that currently reads
"System/Network/Layer/Drivers" and "up/down + drag" to reflect the standardized
naming and behavior: change the root modules sequence to
"Layouts/Layers/Drivers" and state that child reordering is "drag-only" (keep
the `/api/modules/reorder` note as-is). Locate and edit the same table row
string in the docs (the line containing "Drag-to-reorder root modules...") so
the row now mentions "Layouts/Layers/Drivers" and "drag-only child reorder".

In `@docs/moonmodules/light/Layouts.md`:
- Line 30: Update the broken architecture link in
docs/moonmodules/light/Layouts.md by replacing the target `[architecture.md §
Rebuild Propagation](../../architecture.md#rebuild-propagation)` with the
correct doc path and anchor (e.g., point to docs/architecture-light.md with the
proper anchor), ensure the anchor id matches the heading in
docs/architecture-light.md (or add an explicit anchor there), and verify the
updated markdown link resolves to the "Rebuild Propagation" section.

In `@src/core/FilesystemModule.h`:
- Around line 305-309: The Int16 case in the switch (ControlType::Int16) parses
an int via mm::json::parseInt and casts it directly to int16_t causing
wraparound for out-of-range values; change this to clamp the parsed int to
std::numeric_limits<int16_t>::min()/max() before assigning through c.ptr
(static_cast<int16_t*>), i.e. compute a bounded value from the parsed int and
then store that into *static_cast<int16_t*>(c.ptr) so persisted out-of-range
JSON values are safely clamped instead of wrapping.

In `@src/core/HttpServerModule.h`:
- Around line 596-600: The Int16 branch (ControlType::Int16) currently parses an
int via mm::json::parseInt and blindly casts to int16_t into
*static_cast<int16_t*>(c.ptr), which allows wraparound for out-of-range values;
change it to parse into a wider signed type (e.g., long or int64_t), validate
the parsed value against INT16_MIN/INT16_MAX, clamp or return an error on
overflow, and only then store the safely cast value into
*static_cast<int16_t*>(c.ptr) so out-of-range JSON integers cannot corrupt
program state.

In `@src/light/Layers.h`:
- Around line 25-29: The loop in Layers that does
static_cast<Layer*>(child(i))->setLayouts(layouts_) assumes every child is a
Layer and can UB if the invariant is broken; change it to guard the downcast by
checking the child's runtime type before casting (for example add/use a virtual
predicate like isLayer() or a type/kind() accessor on the base class, or use
dynamic_cast where RTTI is enabled), skip or log unexpected child types instead
of casting, and apply the same guarded pattern to the other occurrence that sets
activeLayer()/calls Layer methods (the block referenced at lines 47-53).

In `@src/main.cpp`:
- Around line 91-99: Replace the intentional null-deref behavior when
ModuleFactory::create returns nullptr with an explicit fail-fast check: add a
small helper (e.g. require_not_null or requireOrAbort) and call it immediately
after each ModuleFactory::create invocation in main.cpp so that when the factory
returns nullptr you log a clear diagnostic mentioning "ModuleFactory::create"
and the component/type name and then call std::abort() (or std::terminate())
instead of relying on UB; apply the same pattern to the other
ModuleFactory::create sites referenced around lines 103-117 so all startup
allocations fail with a deterministic, informative abort.

In `@src/ui/app.js`:
- Around line 709-718: Replace the hardcoded slider bounds by using
control-provided bounds: when creating the input range use ctrl.min and ctrl.max
(coerced to numbers or parsed) with -100 and 200 as fallbacks (e.g., input.min =
Number(ctrl.min ?? -100); input.max = Number(ctrl.max ?? 200)); also ensure the
initial input.value (currently set from ctrl.value) is clamped to the computed
min/max so you never set an out-of-range value. Reference the input element
creation and the existing ctrl.value usage to locate where to change min/max and
value clamping.

In `@test/scenario_runner.cpp`:
- Around line 188-197: The wiring block uses terse names `lg` and `l`; replace
them with semantic names like `layoutsModule` and `layerModule` where `lg` is
declared as auto* static_cast<mm::Layouts*>(modules[props["layouts"].str]) and
used in layer->setLayouts, and where `l` is declared as auto*
static_cast<mm::Layer*>(modules[props["layer"].str]) and used in
static_cast<mm::Drivers*>(mod)->setLayer; update both declarations and
subsequent uses to the new names to improve readability.
🪄 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: 2329a100-5f7a-4cef-a3a8-afdf89b9a4a9

📥 Commits

Reviewing files that changed from the base of the PR and between 70890cd and 28e0e1f.

📒 Files selected for processing (54)
  • README.md
  • docs/architecture-light.md
  • docs/history/plan-16.md
  • docs/moonmodules/core/FilesystemModule.md
  • docs/moonmodules/core/MoonModule.md
  • docs/moonmodules/core/NetworkModule.md
  • docs/moonmodules/light/BlendMap.md
  • docs/moonmodules/light/DriverGroup.md
  • docs/moonmodules/light/Drivers.md
  • docs/moonmodules/light/EffectBase.md
  • docs/moonmodules/light/Layer.md
  • docs/moonmodules/light/Layers.md
  • docs/moonmodules/light/LayoutGroup.md
  • docs/moonmodules/light/Layouts.md
  • docs/moonmodules/light/MappingLUT.md
  • docs/moonmodules/light/drivers/ArtNetSendDriver.md
  • docs/moonmodules/light/layouts/GridLayout.md
  • docs/moonmodules_draft/core/ui.md
  • docs/performance.md
  • docs/plan.md
  • docs/testing.md
  • scripts/run/run_desktop.py
  • src/core/Control.h
  • src/core/FilesystemModule.h
  • src/core/HttpServerModule.h
  • src/core/ModuleFactory.h
  • src/core/MoonModule.h
  • src/light/Layer.h
  • src/light/Layers.h
  • src/light/drivers/ArtNetSendDriver.h
  • src/light/drivers/Drivers.h
  • src/light/drivers/PreviewDriver.h
  • src/light/effects/PlasmaEffect.h
  • src/light/layouts/GridLayout.h
  • src/light/layouts/Layouts.h
  • src/main.cpp
  • src/ui/app.js
  • test/CMakeLists.txt
  • test/scenario_runner.cpp
  • test/scenarios/base-pipeline.json
  • test/scenarios/memory-1to1.json
  • test/scenarios/memory-lut.json
  • test/scenarios/mirror.json
  • test/test_extrude.cpp
  • test/test_fire.cpp
  • test/test_grid_layout.cpp
  • test/test_layers_container.cpp
  • test/test_metaballs.cpp
  • test/test_noise.cpp
  • test/test_particles.cpp
  • test/test_plasma.cpp
  • test/test_preview_driver.cpp
  • test/test_rainbow.cpp
  • test/test_stateless_effects.cpp
💤 Files with no reviewable changes (2)
  • docs/moonmodules/light/DriverGroup.md
  • docs/moonmodules/light/LayoutGroup.md

Comment thread docs/history/plan-16.md Outdated
Comment thread docs/moonmodules_draft/core/ui.md Outdated
Comment thread docs/moonmodules/light/Layouts.md Outdated
Comment thread src/core/HttpServerModule.h Outdated
Comment thread src/light/layers/Layers.h
Comment thread src/main.cpp
Comment on lines +91 to +99
// Note: ModuleFactory::create can in principle return nullptr (factory entry
// missing, OOM at probe construction). We deliberately do not null-check
// each result here — these are startup-time allocations and the right
// behaviour on failure is "device can't boot the light pipeline; crash with
// a clear stack trace so the operator can fix the build/config." On ESP32
// the panic handler reports a usable backtrace; on desktop the segfault
// surfaces under gdb/lldb just as cleanly. Wrapping every line in an
// if(!x) std::abort() pattern would add ~20 lines of boilerplate without
// improving the observed failure mode.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Avoid intentional null-deref as startup error handling.

The current strategy relies on undefined behavior for factory failures. Please fail fast with an explicit check (or helper wrapper) so diagnostics always point to the missing/OOM type directly.

Proposed pattern
+template <typename T>
+static T* requireModule(const char* typeName) {
+    auto* base = mm::ModuleFactory::create(typeName);
+    if (!base) {
+        std::fprintf(stderr, "Fatal: ModuleFactory::create(%s) failed\n", typeName);
+        std::abort();
+    }
+    return static_cast<T*>(base);
+}
...
-    auto* filesystemModule = static_cast<mm::FilesystemModule*>(mm::ModuleFactory::create("FilesystemModule"));
+    auto* filesystemModule = requireModule<mm::FilesystemModule>("FilesystemModule");

Also applies to: 103-117

🤖 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/main.cpp` around lines 91 - 99, Replace the intentional null-deref
behavior when ModuleFactory::create returns nullptr with an explicit fail-fast
check: add a small helper (e.g. require_not_null or requireOrAbort) and call it
immediately after each ModuleFactory::create invocation in main.cpp so that when
the factory returns nullptr you log a clear diagnostic mentioning
"ModuleFactory::create" and the component/type name and then call std::abort()
(or std::terminate()) instead of relying on UB; apply the same pattern to the
other ModuleFactory::create sites referenced around lines 103-117 so all startup
allocations fail with a deterministic, informative abort.

Comment thread src/ui/app.js Outdated
Comment thread test/scenario_runner.cpp Outdated
ewowi and others added 2 commits May 24, 2026 08:24
KPI: 16384lights | PC:300KB | tick:103/64/133/108us(FPS:9708/15625/7518/9259) | ESP32:917KB | image:1,045,177B | DRAM:36,676/180,736 | src:46(7341) | test:25(3240) | lizard:23w

ESP32 live tick capture skipped — serial port unplugged during pre-commit.
The previous capture (43,892 µs / 22 FPS at 128×128) is the comparable
baseline; nothing in this batch touches hot-path code. Image grew ~744 B
from the rabbit-driven defensive checks (Int16 clamps, Layers role guard,
name-uniqueness helper + tests).

Small follow-up batch on top of 28e0e1f. Three threads:

# Layer end defaults flipped 0 → 100

`Layer::endX/Y/Z` previously defaulted to `0` with a "0 means full extent"
sentinel. After product owner feedback ("end should default 100%"),
defaults are now `start = 0, end = 100` (the full layout, no sentinel).
Spec at `docs/moonmodules/light/Layer.md` and `docs/plan.md` updated.
Persisted state from 28e0e1f gets the values back as `0`, which today is
harmless because `rebuildLUT()` ignores start/end — composition follow-up
will need a one-time clamp at load if it matters.

# Tree-wide name uniqueness

User-reported bug: adding a second `Layer`, then adding a child effect to
it, landed the effect in the first Layer. Root cause: `displayNameFor`
strips role-noun suffixes but `Layer` has no suffix to strip, so two
Layer instances both got name `"Layer"`; `findModuleByName` did a
first-match DFS and `parent_id="Layer"` always resolved to Layer #0.

Fix landed in core:

* `Scheduler` gained `ensureUniqueName(MoonModule*)`, `firstByName`,
  and `deduplicateNamesInTree()`. First occurrence in tree-walk order
  keeps its name; later ones get " 2", " 3", … suffix.
* `Scheduler::setup` calls `deduplicateNamesInTree()` after persistence
  load (phase 2a in `setup`). This is the path my first fix attempt
  missed — persistence rebuilds the tree positionally and the freshly-
  created modules collide on factory display names every boot.
* `HttpServerModule::handleAddModule` calls
  `scheduler_->ensureUniqueName(mod)` after `addChild` instead of the
  previous local helper. Single source of truth.
* Helper buffer is sized to match `MoonModule::name_[16]` so we never
  truncate to a different result. Practical cap is "suffix 9 for 13-char
  bases, suffix 99 for ≤5-char bases" — documented in the comment, and
  pinned by a test that builds 10 "GlowParticles" siblings and asserts
  the 10th keeps the duplicate name rather than truncating.

# CodeRabbit findings on 28e0e1f

Eight valid, one skipped with reason.

* `FilesystemModule.h`, `HttpServerModule.h` — `ControlType::Int16`
  parsing now clamps to `INT16_MIN/MAX` before the narrowing cast.
  Persisted-or-hostile out-of-range JSON values no longer wrap
  (40000 → -25536). Added `#include <climits>` in both.
* `src/light/Layers.h` — `static_cast<Layer*>(child(i))` in `setLayouts`
  and `activeLayer` now guarded by a `role() == ModuleRole::Layer`
  check; non-Layer children are skipped rather than miscast. (Same
  unguarded `static_cast` pattern lives in `Layouts.h` / `Drivers.h` /
  `Layer.h` but the rabbit only flagged Layers — a project-wide sweep
  is a separate batch.)
* `src/ui/app.js` Int16 slider — was hardcoded `min=-100, max=200`. Now
  `Number(ctrl.min ?? -100)`, `Number(ctrl.max ?? 200)`, and the initial
  value is clamped to the computed range. Engine-supplied bounds (when
  later wired through) override the fallback.
* `test/scenario_runner.cpp` — terse `lg`/`l` local var names →
  `layoutsModule`/`layerModule`.
* `docs/history/plan-16.md` — fences gained `text`/`cpp` language tags
  and surrounding blank lines (MD040/MD031 lint).
* `docs/moonmodules_draft/core/ui.md` — stale table row
  "System/Network/Layer/Drivers" + "up/down + drag" → "Layouts/Layers/
  Drivers" + "Children reorder via drag" (drag-only since plan-12).
* `docs/moonmodules/light/Layouts.md` — broken link to
  `architecture.md § Rebuild Propagation` (anchor lives in
  `architecture-light.md`); corrected.
* `src/main.cpp` null checks — **skipped with reason**, same reasoning
  documented at main.cpp:91-99 that the previous Opus reviewer accepted
  on 28e0e1f. Startup-time allocations; panic-on-failure is the right
  mode; OS panic provides a usable backtrace.

# Tests

* New `test/test_scheduler_unique_names.cpp` — 6 cases pinning the
  uniqueness contract: name-already-unique no-op, second-occurrence
  suffix, "Layer 2" already taken → "Layer 3", whole-tree dedup walk
  (the persistence-load case), `firstByName` semantics, and the
  silent-no-op on over-long bases (reviewer-driven addition).
* `test/test_layers_container.cpp` gained `Layers::activeLayer returns
  nullptr when no child has role Layer` — pins the role-guard contract
  the reviewer flagged.
* Net: 131/131 unit tests pass (was 124; +7).

# Pre-commit gates (this commit)

1. Desktop build: clean, zero warnings
2. ctest: 131/131 unit tests pass (2470 assertions)
3. Scenarios: 8/8 pass
4. Platform boundary: PASS
5. Spec check: 22/22 modules ok
6. ESP32 build: clean, 1,045,177 B image (+744 B vs 28e0e1f, all
   from the new defensive code paths above)
7. Reviewer agent (Opus): PASS WITH NOTES — candidate-buffer comment
   sharpened, two tests added covering the gaps the reviewer flagged
8. KPI captured (ESP32 live skipped — port unplugged; see note above)
9. Perf snapshot: no hot-path change, jitter band unchanged
10. Docs check: 22/22 specs, broken Layouts.md link fixed

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
KPI: 16384lights | PC:301KB | tick:35/35/98/95us(FPS:28571/28571/10204/10526) | ESP32:917KB | src:50(7407) | test:25(3240) | lizard:23w

Split src/core/HttpServerModule from a single 1294-line header into a
117-line interface header + 1023-line .cpp, so edits to the HTTP body
no longer cascade-recompile every TU that includes the header. The
header forward-declares JsonSink/Scheduler/PreviewFrame; the .cpp pulls
in the real headers. mm_core becomes a STATIC library carrying
HttpServerModule.cpp and depends on ui_embed so ui_embedded.h is
generated before compile.

Three reusable inline helpers extracted out of the old header so the
.cpp's TU isn't the only place they can live:

  src/core/JsonSink.h  - JsonSink streaming JSON writer + jsonEscape()
  src/core/Sha1.h      - RFC 3174 SHA-1 (WebSocket handshake)
  src/core/Base64.h    - RFC 4648 base64 encoder (WS + Password)

All three stay header-only: small, inline, no platform deps.

ESP32 component build (esp32/main/CMakeLists.txt) updated to list the
new .cpp — without it the link failed on the HttpServerModule vtable.
main.cpp now includes PreviewFrame.h directly (was transitively pulled
through the old header).

CLAUDE.md:
- Pre-commit policy strengthened: "Never start the checklist on your
  own — always ask first." Reviewer + ESP32 build burn ~10min of tokens;
  agents must not initiate them.
- Old "MoonModules are single-file" bullet replaced with explicit rules:
  light-domain modules + MoonModule base stay header-only; core service
  modules use .h + .cpp; exceptions need a one-line reason at file top.
- New Documentation principle: module .md is end-user / API-integrator
  doc, not tech doc. Wire contracts, cross-domain wiring, prior art,
  one mention of every control name (spec-check). Do not repeat facts
  the .h already states.

docs/moonmodules/core/HttpServerModule.md trimmed under the new
principle: dropped domain-boundary essay, redundant controls list,
implementation prose. Kept REST URL table, WS frame layouts,
cross-domain wiring note, v1/v2 prior art.

docs/plan.md: lazy-migrate FilesystemModule, NetworkModule, Scheduler,
SystemModule from header-only to .h+.cpp when next touched. MoonModule,
ModuleFactory, small utilities stay header-only.

Behaviour-neutral: ESP32 image +4 bytes vs prior commit. All 131 unit
tests pass, 8/8 scenarios, 22/22 specs, platform boundary clean.
Incremental rebuild on .cpp edit ~1.25s vs full-tree ~2.1s on header
edit. ESP32 live tick not re-captured this commit (port unplugged);
PC tick is fresh, ESP32 figure is the last committed value.

Co-Authored-By: Claude Opus 4.7 <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: 11

🤖 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 `@CMakeLists.txt`:
- Around line 14-19: The mm_core target was created as a STATIC library
including non-platform .cpp (HttpServerModule.cpp) which violates the root CMake
contract; change the mm_core target to an INTERFACE library (remove
src/core/HttpServerModule.cpp from add_library) so mm_core remains header-only,
keep target_include_directories(mm_core PUBLIC src/) and
target_link_libraries(mm_core PUBLIC mm_platform) as appropriate, and ensure any
platform-specific .cpp (e.g., HttpServerModule.cpp) are moved into the
platform-specific targets or compiled under the platform-specific CMakeLists
rather than added to mm_core; also preserve the existing
add_dependencies(mm_core ui_embed) placement after ui_embed is defined.

In `@docs/plan.md`:
- Around line 14-15: The plan incorrectly instructs adding core service .cpp
files to mm_core's STATIC sources in CMakeLists.txt, which violates the root
CMake contract that mm_core is an INTERFACE (header-only) library and only
platform .cpp files should be compiled; update the migration plan text to
instead: leave core modules header-only under mm_core (keep .h and move
implementation into platform-specific .cpp only when appropriate), do NOT add
core .cpp files to mm_core's STATIC sources, and when creating platform
implementations add them to the platform targets' source lists per the root
CMake contract (referencing mm_core, CMakeLists.txt, INTERFACE, STATIC, .cpp, .h
to locate the relevant lines).

In `@src/core/Base64.h`:
- Line 16: Change the public helper signature inline void base64Encode(const
uint8_t* in, size_t inLen, char* out, size_t outMax) to take std::span for both
input and output (e.g., std::span<const uint8_t> input, std::span<char> output),
add `#include` <span> in Base64.h, update the function body to use
input.size()/data() and output.size()/data() instead of in/inLen/out/outMax, and
update all call sites to pass spans (or construct spans from buffers) so size
contracts are enforced by the span bounds; keep the function inline and preserve
existing semantics and error handling.
- Line 29: The code writes a null terminator with out[oi] = 0 which can overflow
when outMax == 0; modify the function containing out, oi and outMax (Base64.h)
to guard the write: either early-return/fail when outMax == 0 or only write the
terminator if oi < outMax (e.g., check outMax > 0 and/or oi < outMax before
doing out[oi] = 0), ensuring you keep the same return/error semantics and do not
change the buffer indexing elsewhere.

In `@src/core/FilesystemModule.h`:
- Around line 306-315: The Int16 case in the load path (ControlType::Int16 using
mm::json::parseInt and writing to c.ptr) currently only clamps to int16 limits
and ignores control-specific bounds c.min/c.max; update the handler to, after
clamping to INT16_MIN/INT16_MAX, also clamp the parsed value to the control's
configured bounds (use c.min and c.max analogous to the Uint8 branch) before
narrowing and assigning to *static_cast<int16_t*>(c.ptr) so persisted values
respect the control's min/max.

In `@src/core/HttpServerModule.cpp`:
- Around line 603-617: When parentId is empty the new module (mod) is left
unattached and still proceeds through onBuildControls/onSetup/onAllocateMemory,
causing an orphan; modify the creation flow so that if
findModuleByName(parentId) is not used (parentId[0] == 0) you explicitly attach
or register the module with scheduler_ (e.g., call the scheduler_ method that
owns/records modules such as scheduler_->addModule/registerModule or set
scheduler_ as the module's owner) before any build/setup/allocation steps, and
if that scheduler attachment fails undo/delete mod and return an error; apply
the same change to the similar block around the other creation path (the 631-636
region) to avoid unreachable runtime state for parentless modules.
- Around line 658-667: The current deletion path removes the module from its
parent but skips unlinking top-level modules from scheduler storage, risking
dangling pointers; in the block using parent(), removeChild, teardown(), and
delete mod you must handle the parent==nullptr case by first unlinking the
module from scheduler storage (e.g. call the scheduler's remove/unlink method
such as Scheduler::instance()->removeModule(mod) or mod->detachFromScheduler()),
ensure any scheduler pointers/flags on the module are cleared, and only then
call mod->teardown() and delete mod; keep the existing parent->removeChild(mod)
branch as-is and add the explicit scheduler-unlink branch for top-level modules.
- Around line 446-454: The code for ControlType::Uint8 and ControlType::Uint16
currently casts parsed ints directly into c.ptr which allows out-of-range values
to wrap; change the logic after mm::json::parseInt to validate the parsed int
against the control's bounds (use c.min and c.max) and if the value is outside
[c.min, c.max] do not assign—handle it by returning a proper error/HTTP 400
response (or otherwise signaling validation failure) instead of performing the
static_cast and assignment; ensure you apply the same bound-check logic to both
ControlType::Uint8 and ControlType::Uint16 before writing to
*static_cast<...*>(c.ptr).

In `@src/core/JsonSink.h`:
- Around line 32-43: The append(const char* s) path updates heapLen_ when conn_
is false but never writes the trailing null byte, violating the null-terminated
string contract; after each successful heap-mode append (i.e., after
heap_[heapLen_++] = *s++ within append) ensure heap_[heapLen_] = '\0' (and also
set the terminator if append exits early or when ensureHeap(...) fails) so that
heap_ is always null-terminated; update the append method and any similar
heap-mode write sites (referencing append, ensureHeap, heap_, heapLen_, conn_)
to maintain the terminating '\0' after modifications.
- Around line 118-125: The jsonEscape function can write a null terminator past
the end when outMax == 0; modify jsonEscape to first handle zero-sized
destinations (return immediately if outMax == 0) and ensure you only write the
terminator inside bounds (e.g., set out[min(oi, outMax-1)] = '\0' or explicitly
check outMax>0 before assigning out[oi] = 0). Update the function jsonEscape to
use the outMax guard and bounded termination so oi and out writes never exceed
the provided buffer.

In `@src/core/Scheduler.h`:
- Around line 233-239: The function firstInTree can dereference a null mod and
mod->name(); add a null check at the top of firstInTree (e.g., if (!mod) return
nullptr) and ensure the loop retrieves each child into a variable (auto* child =
mod->child(i)) and checks child != nullptr before calling firstInTree(child,
name); also guard mod->name() by checking it only after confirming mod is
non-null.
🪄 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: 7735cba1-9857-42ef-9901-0dc4a347f64c

📥 Commits

Reviewing files that changed from the base of the PR and between 28e0e1f and 7eee4b3.

📒 Files selected for processing (24)
  • CLAUDE.md
  • CMakeLists.txt
  • docs/history/plan-16.md
  • docs/moonmodules/core/HttpServerModule.md
  • docs/moonmodules/light/Layer.md
  • docs/moonmodules/light/Layouts.md
  • docs/moonmodules_draft/core/ui.md
  • docs/plan.md
  • esp32/main/CMakeLists.txt
  • src/core/Base64.h
  • src/core/FilesystemModule.h
  • src/core/HttpServerModule.cpp
  • src/core/HttpServerModule.h
  • src/core/JsonSink.h
  • src/core/Scheduler.h
  • src/core/Sha1.h
  • src/light/Layer.h
  • src/light/Layers.h
  • src/main.cpp
  • src/ui/app.js
  • test/CMakeLists.txt
  • test/scenario_runner.cpp
  • test/test_layers_container.cpp
  • test/test_scheduler_unique_names.cpp

Comment thread CMakeLists.txt
Comment on lines +14 to +19
add_library(mm_core STATIC
src/core/HttpServerModule.cpp
)
target_include_directories(mm_core PUBLIC src/)
target_link_libraries(mm_core PUBLIC mm_platform)
# `add_dependencies(mm_core ui_embed)` is below, after the ui_embed target is defined.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Restore mm_core as INTERFACE per root build contract.

mm_core is now a compiled STATIC target with non-platform .cpp sources, which breaks the repository’s root CMake rule for a header-only core and platform-only compilation units.

As per coding guidelines: “CMakeLists.txt: Root CMake build… Core library is INTERFACE (header-only). Only platform .cpp files need compilation.”

Also applies to: 45-45

🤖 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 `@CMakeLists.txt` around lines 14 - 19, The mm_core target was created as a
STATIC library including non-platform .cpp (HttpServerModule.cpp) which violates
the root CMake contract; change the mm_core target to an INTERFACE library
(remove src/core/HttpServerModule.cpp from add_library) so mm_core remains
header-only, keep target_include_directories(mm_core PUBLIC src/) and
target_link_libraries(mm_core PUBLIC mm_platform) as appropriate, and ensure any
platform-specific .cpp (e.g., HttpServerModule.cpp) are moved into the
platform-specific targets or compiled under the platform-specific CMakeLists
rather than added to mm_core; also preserve the existing
add_dependencies(mm_core ui_embed) placement after ui_embed is defined.

Comment thread docs/plan.md Outdated
Comment on lines +14 to +15
Split each one **lazily, when other work touches it**, not as a big-bang refactor commit. Each split: move bodies to `.cpp`, leave a forward-compatible `.h`, add to `CMakeLists.txt`'s `mm_core` STATIC sources list, no behavioural change. One commit per module, mechanical, reviewable.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Align this migration plan with the root CMake contract.

This text instructs adding core service .cpp files into mm_core STATIC sources, which conflicts with the project’s root build rule for an INTERFACE core and platform-only compiled .cpp files.

As per coding guidelines: “CMakeLists.txt: Root CMake build… Core library is INTERFACE (header-only). Only platform .cpp files need compilation.”

🤖 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/plan.md` around lines 14 - 15, The plan incorrectly instructs adding
core service .cpp files to mm_core's STATIC sources in CMakeLists.txt, which
violates the root CMake contract that mm_core is an INTERFACE (header-only)
library and only platform .cpp files should be compiled; update the migration
plan text to instead: leave core modules header-only under mm_core (keep .h and
move implementation into platform-specific .cpp only when appropriate), do NOT
add core .cpp files to mm_core's STATIC sources, and when creating platform
implementations add them to the platform targets' source lists per the root
CMake contract (referencing mm_core, CMakeLists.txt, INTERFACE, STATIC, .cpp, .h
to locate the relevant lines).

Comment thread src/core/Base64.h
// SHA-1 of `client_key + magic_GUID`), and `/api/state`'s Password
// serialization (XOR-then-base64 obfuscation, see HttpServerModule).
// Both are short payloads; the encoder is straightforward not optimised.
inline void base64Encode(const uint8_t* in, size_t inLen, char* out, size_t outMax) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Use std::span for input/output buffers instead of pointer+length.

The public helper API currently uses raw pointer + length pairs, which violates the project rule and makes size contracts easier to misuse.

As per coding guidelines: “**/*.{h,hpp,cpp,cc}: Use std::span over pointer + length”.

🤖 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/Base64.h` at line 16, Change the public helper signature inline void
base64Encode(const uint8_t* in, size_t inLen, char* out, size_t outMax) to take
std::span for both input and output (e.g., std::span<const uint8_t> input,
std::span<char> output), add `#include` <span> in Base64.h, update the function
body to use input.size()/data() and output.size()/data() instead of
in/inLen/out/outMax, and update all call sites to pass spans (or construct spans
from buffers) so size contracts are enforced by the span bounds; keep the
function inline and preserve existing semantics and error handling.

Comment thread src/core/Base64.h
Comment thread src/core/FilesystemModule.h Outdated
Comment on lines +306 to +315
case ControlType::Int16: {
int v = mm::json::parseInt(json, key);
// Clamp before narrowing — parseInt returns int (up to ±2^31).
// A persisted-or-corrupted JSON value outside int16 range
// would otherwise wrap (e.g. 40000 → -25536).
if (v < INT16_MIN) v = INT16_MIN;
if (v > INT16_MAX) v = INT16_MAX;
*static_cast<int16_t*>(c.ptr) = static_cast<int16_t>(v);
break;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Int16 load should also apply control-specific min/max bounds.

The type-limit clamping is good, but unlike the Uint8 case (lines 296-297), this doesn't apply c.min/c.max bounds. A persisted value of 150 would load into a control with max=100 without clamping.

Proposed fix
 case ControlType::Int16: {
     int v = mm::json::parseInt(json, key);
     // Clamp before narrowing — parseInt returns int (up to ±2^31).
     // A persisted-or-corrupted JSON value outside int16 range
     // would otherwise wrap (e.g. 40000 → -25536).
     if (v < INT16_MIN) v = INT16_MIN;
     if (v > INT16_MAX) v = INT16_MAX;
+    if (v < c.min) v = c.min;
+    if (v > c.max) v = c.max;
     *static_cast<int16_t*>(c.ptr) = static_cast<int16_t>(v);
     break;
 }
🤖 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/FilesystemModule.h` around lines 306 - 315, The Int16 case in the
load path (ControlType::Int16 using mm::json::parseInt and writing to c.ptr)
currently only clamps to int16 limits and ignores control-specific bounds
c.min/c.max; update the handler to, after clamping to INT16_MIN/INT16_MAX, also
clamp the parsed value to the control's configured bounds (use c.min and c.max
analogous to the Uint8 branch) before narrowing and assigning to
*static_cast<int16_t*>(c.ptr) so persisted values respect the control's min/max.

Comment on lines +603 to +617
// Find parent and add as child
if (parentId[0] != 0) {
auto* parent = findModuleByName(parentId);
if (!parent) {
delete mod;
sendResponse(conn, 404, "application/json", "{\"error\":\"parent not found\"}");
return;
}
if (!parent->addChild(mod)) {
delete mod;
sendResponse(conn, 400, "application/json", "{\"error\":\"parent rejected child\"}");
return;
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reject or attach parentless module creation to scheduler ownership.

At Line 603, when parent_id is missing, the new module is never attached to a parent and never added to scheduler_, but it still runs onBuildControls/setup/onAllocateMemory. This creates an orphan allocation and unreachable runtime state.

Proposed fix
-    // Find parent and add as child
-    if (parentId[0] != 0) {
+    // Find parent and add as child (top-level adds are not supported here)
+    if (parentId[0] != 0) {
         auto* parent = findModuleByName(parentId);
         if (!parent) {
             delete mod;
             sendResponse(conn, 404, "application/json", "{\"error\":\"parent not found\"}");
             return;
         }
         if (!parent->addChild(mod)) {
             delete mod;
             sendResponse(conn, 400, "application/json", "{\"error\":\"parent rejected child\"}");
             return;
         }
+    } else {
+        delete mod;
+        sendResponse(conn, 400, "application/json", "{\"error\":\"parent_id required\"}");
+        return;
     }

Also applies to: 631-636

🧰 Tools
🪛 Clang (14.0.6)

[warning] 606-606: implicit conversion 'mm::MoonModule *' -> bool

(readability-implicit-bool-conversion)


[warning] 608-608: escaped string literal can be written as a raw string literal

(modernize-raw-string-literal)


[warning] 613-613: escaped string literal can be written as a raw string literal

(modernize-raw-string-literal)

🤖 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 603 - 617, When parentId is empty
the new module (mod) is left unattached and still proceeds through
onBuildControls/onSetup/onAllocateMemory, causing an orphan; modify the creation
flow so that if findModuleByName(parentId) is not used (parentId[0] == 0) you
explicitly attach or register the module with scheduler_ (e.g., call the
scheduler_ method that owns/records modules such as
scheduler_->addModule/registerModule or set scheduler_ as the module's owner)
before any build/setup/allocation steps, and if that scheduler attachment fails
undo/delete mod and return an error; apply the same change to the similar block
around the other creation path (the 631-636 region) to avoid unreachable runtime
state for parentless modules.

Comment thread src/core/HttpServerModule.cpp Outdated
Comment thread src/core/JsonSink.h
Comment thread src/core/JsonSink.h
Comment thread src/core/Scheduler.h Outdated
ewowi and others added 3 commits May 24, 2026 11:50
KPI: 16384lights | PC:301KB | tick:35/35/98/96us(FPS:28571/28571/10204/10416) | ESP32:917KB | src:50(7441) | test:25(3240) | lizard:23w

Six narrow defensive fixes from a reviewer pass. No behaviour change for
any current caller; tightens contracts so future call sites can't trip
the latent bugs.

src/core/Base64.h
  Guard base64Encode against outMax == 0; the trailing `out[oi] = 0`
  would otherwise write to a zero-sized buffer. Both current call sites
  pass non-zero buffers, but the helper should be safe for any caller.

src/core/JsonSink.h
  - Heap-mode append now writes the trailing '\0' after each call so
    data() is a valid C-string even if a caller skips size(). The
    docstring already promised null-termination; this matches it.
  - jsonEscape gets the same outMax == 0 guard as base64Encode.

src/core/Scheduler.h
  firstInTree now null-checks `mod` before dereferencing — matches the
  pattern in walkAndEnsureUnique a few lines above. Today's callers
  feed it non-null modules; the guard makes the helper standalone-safe.

src/core/FilesystemModule.h
  Int16 load path now clamps to the control's c.min/c.max after the
  INT16_MIN/MAX range clamp. Matches the Uint8 branch above. Without
  this a persisted value outside the control's configured range loads
  intact and silently bypasses the live setter's validation.

src/core/HttpServerModule.cpp
  Two fixes in handleSetControl + handleDeleteModule:

  - Uint8 / Uint16 / Int16 setters now reject out-of-range with HTTP
    400, matching the existing Select branch. Previously a hostile or
    buggy client could static_cast-wrap an out-of-range int into the
    control (e.g. 40000 → -25536 for Int16, or 300 → 44 for Uint8).
    Int16 also picks up c.min/c.max bounds in addition to its
    pre-existing INT16_MIN/MAX clamp — symmetric with the load path.

  - handleDeleteModule rejects top-level deletes (parent == nullptr)
    with 400 "cannot delete top-level module". The top-level shape
    (Layouts/Layers/Drivers/Filesystem/System/Network/HttpServer) is
    fixed in main.cpp via Scheduler::addModule; deleting a top-level
    module would leave a dangling pointer in scheduler_.modules_[] and
    segfault on the next tick. The UI never sends this delete (× is
    only rendered for depth > 0 effect/modifier cards in app.js), so
    the hole was only reachable via hand-crafted curl.

Pre-commit gates: 1-6 + 8 ran clean (build, unit tests, scenarios,
platform boundary, specs, ESP32 build, KPI). Gates 7/9/10/11 skipped
intentionally: 7 (reviewer) — this batch *was* a reviewer pass, running
another reviewer is circular; 9 (perf analysis) — no hot-path changes,
deltas in error/edge paths and zero-byte guards only; 10 (docs sync) —
no new functionality, gate 5 (22/22 specs ok) covers spec drift;
11 (permission review) — advisory.

PC tick essentially flat (35/35/98/95 → 35/35/98/96 µs, within noise).
ESP32 flash unchanged at 917 KB. Source +34 lines (the new guards +
comments). Lizard warnings unchanged at 23.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…→ building → testing

Single top-down doc set, no duplication, conventional names recognisable to a
new contributor. Four design docs in docs/ plus the two root files.

- README.md — end-user first (multi-target: ESP32, Teensy, RPi, desktop),
  developer second. Doc-index table maps the new layout.
- docs/architecture.md — merged from the old architecture.md +
  architecture-light.md. Top to bottom: # Core (MoonModules, Controls,
  Persistence, Parallelism, Hot path discipline, Platform abstraction)
  then # Light domain (pipeline, 3D types, Layouts/Layers/Effects/
  Modifiers/Mapping/Drivers, Memory strategy, Rebuild propagation,
  Multi-device sync), then Web UI, then "What we leave undesigned." 355
  lines with a TOC.
- docs/coding-standards.md (new) — conventions, file shape (.h vs .cpp),
  casts, warnings, static checks, when checks run. 46 lines.
- docs/building.md (new) — MoonDeck (CLI + browser console), tooling
  overview, per-target build commands (desktop / RPi / ESP32 / Teensy),
  ESP-IDF version, build profiles, third-party library policy,
  pre-compilation steps. 129 lines.
- docs/testing.md — added a "Testing strategy" section absorbing what
  used to live in architecture.md § Testing. Existing per-test
  inventory unchanged.
- docs/architecture-light.md — deleted; content merged into
  architecture.md.

CLAUDE.md:
- Added "Common patterns first" as the first principle: this repo
  should be a recognisable example of good practice, not a Frankenstein
  of bespoke conventions. New patterns need an external anchor (a
  widely-used project / framework / canonical resource); a contributor
  with general C++/web experience should recognise the pattern in 30
  seconds; bespoke choices carry their reason at the introduction site.
- § Code Style trimmed to one pointer to coding-standards.md (was the
  full file-shape rules; those live in coding-standards.md now).
- § Build trimmed to a pointer to building.md.
- Documentation tree updated: dropped architecture-light.md, added
  coding-standards.md, building.md.
- § Hard Rules "Tests must pass" anchor updated to testing.md (the
  strategy moved out of architecture.md).
- "Always-read" list dropped architecture-light.md.

Cross-references swept: 12 broken architecture-light.md links across
moonmodules/ + moonmodules_draft/ + src/light/Layer.h fixed to point at
the new architecture.md anchors (or to architecture.md § Web UI where
the old UI-integration / 3D-preview-channel sections were folded in).
All anchors verified to resolve.

src/light/Layer.h: one-line comment edit — the Spec: pointer now reads
"architecture.md § Layers and Layer" instead of "architecture-light.md
§ Layer." Comment-only, no compiled-code change.

scripts/check/collect_kpi.py: one-line comment edit — the CLAUDE.md
back-reference now reads "Lifecycle Events, Event 1 gate 7 — KPI
collection" instead of "pre-commit step 8."

Pre-commit gates run: 1 (build) ✓, 2 (ctest 1/1) ✓, 3 (scenarios 8/8) ✓,
4 (platform boundary) ✓, 5 (spec check 22/22) ✓. Gates 6 (ESP32 build)
and 7 (KPI) skipped: the only src/ change is a one-line comment that
cannot affect compiled output, image size, or runtime performance — the
case the new conditional-gate policy is for.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…corruption

KPI: 16384lights | PC:301KB | tick:59/52/122/105us(FPS:16949/19230/8196/9523) | ESP32:917KB | tick:56476us(FPS:17) | heap:80KB | src:50(7496) | test:30(3715) | lizard:23w

Three bugs the user hit in one UI session, fixed together because they
share the "small thing goes wrong on the edge of the parameter space"
shape — and one rename pulling along the same axis.

1. Effect-animation freeze on desktop and small grids
-----------------------------------------------------

MetaballsEffect, CheckerboardEffect, LavaLampEffect, SpiralEffect all
computed per-tick phase as `dt * bpm * 256 / 60000`. With `dt = 0..1 ms`
on desktop (and small-grid ESP32) and `bpm < 234`, that integer division
rounds to 0 every tick — the animation freezes solid regardless of bpm.

Fix: keep the raw `dt * bpm` numerator in the accumulator and divide
only at the read site (same pattern NoiseEffect / RainbowEffect already
use, which is why they weren't affected):

    phase_num_ += static_cast<uint64_t>(dt) * bpm;
    uint8_t t = static_cast<uint8_t>((phase_num_ * 256) / 60000);

Pinned by test/test_phase_animation.cpp — animates the four effects
over a 100ms window plus a runtime-replace path. Documented the rule in
architecture.md § Effects (must-animate-at-every-tick-rate) and pointed
to it from CLAUDE.md § Hard Rules.

Audit while we're here: ran every shipped effect on a 0×0×0 grid (modifiers
can shrink the logical grid to nothing) via test/test_effects_zero_grid.cpp
— all 12 effects survive cleanly. Documented as a sibling rule in arch.md
§ Effects.

2. Layouts container enable/disable lifecycle
----------------------------------------------

Three layered bugs:

- The Layouts container's `enabled` flag was a UI no-op — Layouts has no
  loop() (its work is in totalLightCount()/forEachCoord() cold paths),
  so Scheduler::tick's top-level gate didn't apply. Gated those two
  methods directly (src/light/layouts/Layouts.h).

- Disabling a layout child (or the whole container) zeroed the logical
  light count, and Layer::onAllocateMemory early-returned without
  dropping its stale LUT. Drivers then reallocated its output buffer to
  0 bytes while keeping a LUT-sized blendMap loop — first frame after
  disable segfaulted in Drivers::loop. Layer::onAllocateMemory now
  explicitly tears down (dims = 0, LUT free, buffer free) on empty.

- Children disabled inside Layouts now shift indices down to close the
  gap (universal-enable-everywhere). Pinned in
  test/test_layouts_container.cpp + test/test_layout_toggle_cycle.cpp.

3. Int16 persistence zero-corruption
------------------------------------

The Int16 c.min/c.max clamp I added in 58c5257 was wrong:
ControlDescriptor.min/max are uint8_t (0..255), so applying them to
Int16 controls clamped every value into [0..0] on load. After a save
cycle and reboot, every Int16 control (GridLayout width/height/depth,
Layer start/end) loaded as 0 — pipeline allocated nothing, "Layouts
cannot be activated" with a blank preview.

Fix: removed the c.min/c.max clamp from the Int16 load path
(FilesystemModule.h:316). Same shape for the live HTTP setter's
Uint16/Int16 paths (HttpServerModule.cpp) — kept Uint8 (real uint8 bounds)
but removed Uint16/Int16 (storage can't bound those wider types).
Added comments on Control.h::addInt16 / addUint16 documenting why
c.min/c.max stays 0,0.

Pinned by a new round-trip test that hand-writes a Layer.json with
non-zero startX/endY and asserts the values reach the live members
(test/test_filesystem_persistence.cpp).

4. Renames + minor sweeps
-------------------------

- MoonModule::onOnOff → onEnabled(bool) — name now matches the
  enabled() / setEnabled() pair. One definition, one internal call,
  three doc mentions updated.

- Drivers::loop() now gates child driver loop() calls on enabled() —
  matches the gate Layer::loop() and Layers::loop() already had.
  Without this, toggling ArtNet/Preview in the UI was a no-op. Pinned
  in test/test_drivers_container.cpp.

- CLAUDE.md § Principles: added "Common patterns first" — this repo
  should be a recognisable example of good practice, not a Frankenstein
  of bespoke conventions. Reviewer agent now flags new bespoke
  patterns (Event 2 gate 3 scope updated).

- docs/moonmodules/light/Layouts.md: new section on disable semantics
  (indices shift, disable driver if you want stable ArtNet mapping).

Verification
------------

Pre-commit gates (per CLAUDE.md § Lifecycle Events):
 1 build (desktop)     ✓ zero warnings
 2 ctest              ✓ 153 cases / 2552 assertions
 3 scenarios          ✓ 8/8
 4 platform boundary  ✓
 5 spec check         ✓ 22/22
 6 ESP32 build        ✓ both profiles
                        - eth-only: 526 KB image, free heap 131 KB
                        - default:  917 KB image, free heap  80 KB
 7 KPI                ✓ tick:56476µs (17 FPS) ESP32 default profile
                        — within performance.md variance band
                        - eth-only steady-state was 53912µs (18 FPS)

Live ESP32 scenarios (Olimex eth-only + default): 3/8 pass under both
profiles. The 5 failures (base-pipeline, memory-1to1, memory-lut,
mirror, preview-detail on default; base-pipeline, memory-1to1, mirror
on eth-only) all share a structural cause: each scenario does
`add_module` on an ArtNetSendDriver on top of the device's already-running
ArtNet driver, so the live device runs two ArtNet drivers and ArtNet
cost roughly doubles (per-tick ArtNetSend ~22 ms on eth-only → ~44 ms
total → ~11 FPS vs 18 FPS baseline). Not a regression from this commit
— scenario-harness behaviour. Bare-pipeline ticks (Drivers + one
ArtNet) hold the 17–18 FPS baseline.

Co-Authored-By: Claude Opus 4.7 <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: 6

Caution

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

⚠️ Outside diff range comments (2)
src/core/Base64.h (1)

30-30: ⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Terminator write can still overflow when buffer fills.

The guard on line 17 prevents the crash when outMax == 0, but the terminator write out[oi] = 0 on line 30 can still write out of bounds when the loop fills the buffer. After the loop exits with oi + 4 >= outMax, the final iteration can leave oi == outMax, making out[oi] an out-of-bounds write.

🛡️ Proposed fix
     }
-    out[oi] = 0;
+    if (oi < outMax) out[oi] = 0;
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/core/Base64.h` at line 30, The null terminator write out[oi] = 0 can
overflow when the encoding loop fills the buffer; update the logic so the
terminator is only written when there is space (e.g., ensure the loop uses
outMax-1 as the maximum writable bytes or check oi < outMax before writing the
terminator). Locate the variables/out access in Base64 code (symbols: out, oi,
outMax, the loop that emits 4 bytes per input block) and either adjust the loop
boundary to reserve one byte for the terminator or guard the final write with a
conditional that writes into out[oi] only if oi < outMax (otherwise write the
terminator into out[outMax-1] or skip it as appropriate).
src/core/JsonSink.h (1)

3-3: 🛠️ Refactor suggestion | 🟠 Major | 🏗️ Heavy lift

Keep core infrastructure free of platform includes.

src/core/JsonSink.h directly depends on platform::TcpConnection, which breaks the core/platform separation contract.

As per coding guidelines "src/core/**: Core infrastructure ... Must be platform-independent — no platform includes."

Also applies to: 22-22

🤖 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/JsonSink.h` at line 3, JsonSink.h must not include
platform/platform.h or depend on platform::TcpConnection directly; remove the
include and the direct reference to platform::TcpConnection in the JsonSink
class and replace it with a platform-agnostic, core-facing abstraction (e.g., a
forward-declared interface such as class IConnection or Connection) used via
pointer/reference in JsonSink, then implement an adapter in the platform layer
that wraps platform::TcpConnection and implements that core interface so
JsonSink only depends on the abstract symbol (JsonSink, the
Connection/IConnection type) and not platform::TcpConnection.
♻️ Duplicate comments (1)
src/core/HttpServerModule.cpp (1)

615-629: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Reject parentless creates or attach them to scheduler ownership.

When parent_id is empty, the module is still initialized (onBuildControls/setup/onAllocateMemory) but never attached to a parent or scheduler-owned root, creating unreachable allocated state.

Proposed fix
-    // Find parent and add as child
-    if (parentId[0] != 0) {
+    // Find parent and add as child (top-level creation is not supported here)
+    if (parentId[0] != 0) {
         auto* parent = findModuleByName(parentId);
         if (!parent) {
             delete mod;
             sendResponse(conn, 404, "application/json", "{\"error\":\"parent not found\"}");
             return;
         }
         if (!parent->addChild(mod)) {
             delete mod;
             sendResponse(conn, 400, "application/json", "{\"error\":\"parent rejected child\"}");
             return;
         }
+    } else {
+        delete mod;
+        sendResponse(conn, 400, "application/json", "{\"error\":\"parent_id required\"}");
+        return;
     }

Also applies to: 643-646

🤖 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 615 - 629, The code currently
initializes a module (mod) even when parentId is empty, leaving it unattached
and leaking state; update the create/attach logic in the block around
findModuleByName/addChild so that when parentId is empty you either reject the
request (delete mod; call sendResponse with a 400/422 and an error JSON) or
explicitly attach mod to the scheduler-owned root (call the scheduler/rootOwner
addChild API instead of skipping attachment); apply the same change to the
similar block at the other occurrence (lines referenced in the review) and
ensure cleanup (delete mod) and an appropriate sendResponse are used on failure
or when rejecting parentless creates.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/architecture.md`:
- Around line 144-164: The fenced code block containing the ASCII architecture
diagram should include an explicit language to satisfy MD040; change the opening
triple backticks from ``` to ```text (or another explicit language like
```ascii) so the block becomes ```text and keep the same closing ```; update the
block surrounding the diagram that references Layouts, GridLayout, WheelLayout,
Layers (Layer A/Layer B/Layer C), Blend+Map and Drivers (WS2812Driver,
ArtNetDriver, PreviewDriver) accordingly.

In `@docs/building.md`:
- Around line 28-44: The fenced code block in docs/building.md is missing a
language tag (causing MD040); update the opening fence from ``` to ```text so
the block becomes a labeled text block and the markdown linter passes—locate the
directory tree block (the triple-backtick fenced block containing
CMakeLists.txt, src/, platform/, esp32/, sdkconfig.defaults) and change only the
opening fence to include the "text" language tag.

In `@src/core/HttpServerModule.cpp`:
- Around line 683-686: The current delete sequence calls mod->teardown(); delete
mod; which leaks descendant modules for container modules; update the delete
handler to mirror the replace flow by invoking teardown() then recursively
freeing the entire subtree (e.g., traverse mod's children, call the same
recursive delete used in the replace logic for each child, then delete the node
itself) so all descendants are deleted instead of only the root module; locate
the delete site that calls mod->teardown() and delete mod and replace it with
the teardown + recursive descendant deletion pattern used in the replace flow.

In `@test/test_phase_animation.cpp`:
- Around line 46-50: The test currently uses real wall-clock sleeps via
mm::platform::delayMs inside the loop which makes tests non-deterministic;
replace those calls with a deterministic advance of a virtual/test clock or stub
mm::platform::delayMs to a no-op and instead simulate elapsed time by calling
layer.loop() the needed number of iterations (or invoke a provided tick/advance
method) and compare layer.buffer() to baseline; update both occurrences around
the loop that calls layer.loop() and the similar block later (where baseline is
compared) so tests no longer depend on real time and use the injected/mock clock
or simple loop iterations to emulate passage of time.
- Around line 92-94: The test calls the lifecycle methods in the wrong order;
change the sequence in the replace test so the runtime-add semantics invoke
fresh->setup() first, then fresh->onBuildControls(), and finally
fresh->onAllocateMemory(); update the call order around
fresh->onBuildControls(), fresh->setup(), fresh->onAllocateMemory() to match
setup() → onBuildControls() → onAllocateMemory() as required by MoonModule
runtime-add rules.

In `@test/test_stateless_effects.cpp`:
- Around line 125-129: Test is flaky due to real-time sleep; remove dependency
on mm::platform::delayMs and make the check deterministic by iterating a fixed
number of loops and asserting based on those iterations. Replace the
sleep-backed loop that calls ctx.layer.loop() and checks
ctx.hasTwoDistinctColors() (and sets varied) with a tight loop of N
deterministic iterations (e.g., 10 or configurable constant) without calling
mm::platform::delayMs, or, if available, advance a mock/virtual clock before
each ctx.layer.loop() call; ensure you reference the same ctx.layer.loop(),
ctx.hasTwoDistinctColors(), and the varied variable so the test no longer relies
on real time.

---

Outside diff comments:
In `@src/core/Base64.h`:
- Line 30: The null terminator write out[oi] = 0 can overflow when the encoding
loop fills the buffer; update the logic so the terminator is only written when
there is space (e.g., ensure the loop uses outMax-1 as the maximum writable
bytes or check oi < outMax before writing the terminator). Locate the
variables/out access in Base64 code (symbols: out, oi, outMax, the loop that
emits 4 bytes per input block) and either adjust the loop boundary to reserve
one byte for the terminator or guard the final write with a conditional that
writes into out[oi] only if oi < outMax (otherwise write the terminator into
out[outMax-1] or skip it as appropriate).

In `@src/core/JsonSink.h`:
- Line 3: JsonSink.h must not include platform/platform.h or depend on
platform::TcpConnection directly; remove the include and the direct reference to
platform::TcpConnection in the JsonSink class and replace it with a
platform-agnostic, core-facing abstraction (e.g., a forward-declared interface
such as class IConnection or Connection) used via pointer/reference in JsonSink,
then implement an adapter in the platform layer that wraps
platform::TcpConnection and implements that core interface so JsonSink only
depends on the abstract symbol (JsonSink, the Connection/IConnection type) and
not platform::TcpConnection.

---

Duplicate comments:
In `@src/core/HttpServerModule.cpp`:
- Around line 615-629: The code currently initializes a module (mod) even when
parentId is empty, leaving it unattached and leaking state; update the
create/attach logic in the block around findModuleByName/addChild so that when
parentId is empty you either reject the request (delete mod; call sendResponse
with a 400/422 and an error JSON) or explicitly attach mod to the
scheduler-owned root (call the scheduler/rootOwner addChild API instead of
skipping attachment); apply the same change to the similar block at the other
occurrence (lines referenced in the review) and ensure cleanup (delete mod) and
an appropriate sendResponse are used on failure or when rejecting parentless
creates.
🪄 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: 8a32d3a1-b0c7-421c-bd6d-2e16052234fc

📥 Commits

Reviewing files that changed from the base of the PR and between 7eee4b3 and 67579f5.

📒 Files selected for processing (39)
  • CLAUDE.md
  • README.md
  • docs/architecture-light.md
  • docs/architecture.md
  • docs/building.md
  • docs/coding-standards.md
  • docs/moonmodules/core/HttpServerModule.md
  • docs/moonmodules/core/MoonModule.md
  • docs/moonmodules/core/ui.md
  • docs/moonmodules/light/Drivers.md
  • docs/moonmodules/light/EffectBase.md
  • docs/moonmodules/light/Layer.md
  • docs/moonmodules/light/Layouts.md
  • docs/moonmodules/light/MappingLUT.md
  • docs/moonmodules_draft/core/ui.md
  • docs/testing.md
  • scripts/check/collect_kpi.py
  • src/core/Base64.h
  • src/core/Control.h
  • src/core/FilesystemModule.h
  • src/core/HttpServerModule.cpp
  • src/core/JsonSink.h
  • src/core/MoonModule.h
  • src/core/Scheduler.h
  • src/light/Layer.h
  • src/light/drivers/Drivers.h
  • src/light/effects/CheckerboardEffect.h
  • src/light/effects/LavaLampEffect.h
  • src/light/effects/MetaballsEffect.h
  • src/light/effects/SpiralEffect.h
  • src/light/layouts/Layouts.h
  • test/CMakeLists.txt
  • test/test_drivers_container.cpp
  • test/test_effects_zero_grid.cpp
  • test/test_filesystem_persistence.cpp
  • test/test_layout_toggle_cycle.cpp
  • test/test_layouts_container.cpp
  • test/test_phase_animation.cpp
  • test/test_stateless_effects.cpp
💤 Files with no reviewable changes (1)
  • docs/architecture-light.md

Comment thread docs/architecture.md Outdated
Comment on lines +144 to +164
```
Layouts (shared by every Layer in Layers)
├── GridLayout ──→ coordinate iterator
└── WheelLayout ──→ coordinate iterator
Layers
┌───────┼───────┐
▼ ▼ ▼
Layer A Layer B Layer C
Effect(s) Effect(s) Effect(s)
Modifier(s) Modifier(s) Modifier(s)
Buffer(own) Buffer(own) Buffer(own)
LUT(own) LUT(own) LUT(own)
│ │ │
└── Blend+Map ──┘
Drivers
├── WS2812Driver (consumer buffer / DMA)
├── ArtNetDriver (UDP packets)
└── PreviewDriver (WebSocket)
```

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add a language to the fenced code block to satisfy markdown lint.

The block opens with plain and triggers MD040; set it totext (or another explicit language) to keep docs lint-clean.

🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 144-144: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

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

In `@docs/architecture.md` around lines 144 - 164, The fenced code block
containing the ASCII architecture diagram should include an explicit language to
satisfy MD040; change the opening triple backticks from ``` to ```text (or
another explicit language like ```ascii) so the block becomes ```text and keep
the same closing ```; update the block surrounding the diagram that references
Layouts, GridLayout, WheelLayout, Layers (Layer A/Layer B/Layer C), Blend+Map
and Drivers (WS2812Driver, ArtNetDriver, PreviewDriver) accordingly.

Comment thread docs/building.md Outdated
Comment on lines +28 to +44
```
CMakeLists.txt ← standard CMake: desktop / RPi + tests
src/
main.cpp ← shared pipeline wiring (mm_main), platform-neutral
platform/
desktop/
main_desktop.cpp ← desktop entry point: int main() + SIGINT
platform_config.h ← desktop platform constants
esp32/
platform_config.h ← ESP32 platform constants (reads sdkconfig)
esp32/
CMakeLists.txt ← ESP-IDF project root (thin wrapper)
main/
CMakeLists.txt ← idf_component_register() pointing at src/
main.cpp ← ESP32 entry point: app_main() + Ethernet init
sdkconfig.defaults ← board-specific defaults
```

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add a language tag to the fenced code block.

Line 28 triggers MD040; adding a language keeps docs lint-clean.

Proposed fix
-```
+```text
 CMakeLists.txt                          ← standard CMake: desktop / RPi + tests
 src/
   main.cpp                              ← shared pipeline wiring (mm_main), platform-neutral
@@
   sdkconfig.defaults                    ← board-specific defaults
-```
+```
🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 28-28: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

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

In `@docs/building.md` around lines 28 - 44, The fenced code block in
docs/building.md is missing a language tag (causing MD040); update the opening
fence from ``` to ```text so the block becomes a labeled text block and the
markdown linter passes—locate the directory tree block (the triple-backtick
fenced block containing CMakeLists.txt, src/, platform/, esp32/,
sdkconfig.defaults) and change only the opening fence to include the "text"
language tag.

Comment on lines +683 to +686
// Teardown and delete
mod->teardown();
delete mod;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Delete handler should free the full subtree, not only the root node.

delete mod after teardown() can leak descendants for container modules. Use the same teardown + recursive delete pattern already used in replace flow.

Proposed fix
-    // Teardown and delete
+    // Teardown and delete full subtree
     mod->teardown();
-    delete mod;
+    Scheduler::deleteTree(mod);
🤖 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 683 - 686, The current delete
sequence calls mod->teardown(); delete mod; which leaks descendant modules for
container modules; update the delete handler to mirror the replace flow by
invoking teardown() then recursively freeing the entire subtree (e.g., traverse
mod's children, call the same recursive delete used in the replace logic for
each child, then delete the node itself) so all descendants are deleted instead
of only the root module; locate the delete site that calls mod->teardown() and
delete mod and replace it with the teardown + recursive descendant deletion
pattern used in the replace flow.

Comment on lines +46 to +50
for (int elapsed = 0; elapsed < total_ms; elapsed += step_ms) {
mm::platform::delayMs(step_ms);
layer.loop();
std::vector<uint8_t> now(layer.buffer().data(), layer.buffer().data() + layer.buffer().bytes());
if (now != baseline) 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.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Avoid wall-clock delays in unit tests.

Line 47 and Line 100 make these tests depend on real elapsed time, which introduces CI flakiness and breaks the test contract for deterministic tests.

As per coding guidelines "test/**: Tests should not depend on timing or network."

Also applies to: 100-103

🧰 Tools
🪛 Clang (14.0.6)

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

(readability-braces-around-statements)

🪛 Cppcheck (2.20.0)

[style] 50-50: The function 'activeLayer' is never used.

(unusedFunction)

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

In `@test/test_phase_animation.cpp` around lines 46 - 50, The test currently uses
real wall-clock sleeps via mm::platform::delayMs inside the loop which makes
tests non-deterministic; replace those calls with a deterministic advance of a
virtual/test clock or stub mm::platform::delayMs to a no-op and instead simulate
elapsed time by calling layer.loop() the needed number of iterations (or invoke
a provided tick/advance method) and compare layer.buffer() to baseline; update
both occurrences around the loop that calls layer.loop() and the similar block
later (where baseline is compared) so tests no longer depend on real time and
use the injected/mock clock or simple loop iterations to emulate passage of
time.

Comment on lines +92 to +94
fresh->onBuildControls();
fresh->setup();
fresh->onAllocateMemory();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Fix runtime-add lifecycle order in the replace test.

Line 92-94 uses onBuildControls() -> setup() -> onAllocateMemory(), but runtime add semantics are setup() -> onBuildControls() -> onAllocateMemory().

Proposed fix
-    fresh->onBuildControls();
-    fresh->setup();
+    fresh->setup();
+    fresh->onBuildControls();
     fresh->onAllocateMemory();

As per coding guidelines "test/**: Verify tests cover edge cases and match the specifications in docs/moonmodules/." and docs/moonmodules/core/MoonModule.md states runtime add must call setup() → onBuildControls() → onAllocateMemory().

📝 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
fresh->onBuildControls();
fresh->setup();
fresh->onAllocateMemory();
fresh->setup();
fresh->onBuildControls();
fresh->onAllocateMemory();
🧰 Tools
🪛 Cppcheck (2.20.0)

[style] 94-94: The function 'addSelect' is never used.

(unusedFunction)


[style] 94-94: The function 'setEnabled' is never used.

(unusedFunction)


[style] 94-94: The function 'forEachDestination' is never used.

(unusedFunction)

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

In `@test/test_phase_animation.cpp` around lines 92 - 94, The test calls the
lifecycle methods in the wrong order; change the sequence in the replace test so
the runtime-add semantics invoke fresh->setup() first, then
fresh->onBuildControls(), and finally fresh->onAllocateMemory(); update the call
order around fresh->onBuildControls(), fresh->setup(), fresh->onAllocateMemory()
to match setup() → onBuildControls() → onAllocateMemory() as required by
MoonModule runtime-add rules.

Comment on lines +125 to +129
for (int i = 0; i < 10 && !varied; i++) {
ctx.layer.loop();
if (ctx.hasTwoDistinctColors()) varied = true;
mm::platform::delayMs(50);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Make LavaLamp spatial-variation test deterministic.

Line 128 depends on real-time sleep, so this test can be flaky across environments.

As per coding guidelines "test/**: Tests should not depend on timing or network."

🧰 Tools
🪛 Clang (14.0.6)

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

(readability-braces-around-statements)

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

In `@test/test_stateless_effects.cpp` around lines 125 - 129, Test is flaky due to
real-time sleep; remove dependency on mm::platform::delayMs and make the check
deterministic by iterating a fixed number of loops and asserting based on those
iterations. Replace the sleep-backed loop that calls ctx.layer.loop() and checks
ctx.hasTwoDistinctColors() (and sets varied) with a tight loop of N
deterministic iterations (e.g., 10 or configurable constant) without calling
mm::platform::delayMs, or, if available, advance a mock/virtual clock before
each ctx.layer.loop() call; ensure you reference the same ctx.layer.loop(),
ctx.hasTwoDistinctColors(), and the varied variable so the test no longer relies
on real time.

@ewowi

ewowi commented May 24, 2026

Copy link
Copy Markdown
Collaborator Author

@coderabbitai, feedback on your review (will be added in next commit(

  • 2 fixes applied: architecture.md:144 → text, building.md:28 → text. Pure doc, lint-only.
  • 7 findings skipped with reasons given above. Notably:
    • The delete-leaks-descendants finding cites the wrong line range (early-return at 683-686 vs actual recursive Scheduler::deleteTree at 691-700).
    • The lifecycle-order finding is backwards (current order matches Scheduler::setup phases).
    • The wall-clock-sleep findings would break the tests they "fix" (elapsed() wouldn't advance).
    • The platform-dependency finding misreads where the platform boundary lives.

@coderabbitai

coderabbitai Bot commented May 24, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

@ewowi — thanks for the clear breakdown.

Fixes acknowledged:
Both architecture.md:144 and building.md:28 code-fence corrections (→ \``text`) are valid lint-only doc improvements.

Skips accepted — notable corrections noted:

  • delete-leaks-descendants — you're right that I cited the wrong line range. The early-return guard at 683–686 is unrelated to the actual recursive Scheduler::deleteTree at 691–700. That's a misread on my part; the finding was anchored to the wrong code.
  • lifecycle-order — acknowledged: the finding was backwards. The current teardown/setup ordering already matches the Scheduler::setup phase contract; flagging it as wrong was incorrect.
  • wall-clock-sleep — a fair counter: replacing platform::micros()-based elapsed tracking with real wall-clock sleeps in tests would break elapsed-time-dependent assertions, since elapsed() wouldn't advance. The existing approach is correct for the test harness.
  • platform-dependency — acknowledged: I misidentified where the platform boundary lives in this codebase. The actual boundary enforcement is in src/platform/, not where I flagged it.

🐇 📝


✏️ Learnings added
Learnt from: ewowi
URL: https://github.com/ewowi/projectMM/pull/4

Timestamp: 2026-05-24T18:27:24.031Z
Learning: In projectMM (ewowi/projectMM), `Scheduler::deleteTree` is the recursive descendant-cleanup function located around line 691-700 of `src/core/Scheduler.h` (or HttpServerModule.cpp). The early-return guard at lines 683-686 is a separate top-level-module rejection check and must not be confused with the actual deletion logic. Do not cite the early-return guard as evidence of a descendant-leak.

Learnt from: ewowi
URL: https://github.com/ewowi/projectMM/pull/4

Timestamp: 2026-05-24T18:27:24.031Z
Learning: In projectMM (ewowi/projectMM), the module lifecycle teardown/setup ordering in HttpServerModule is intentional and already matches the Scheduler::setup phase contract. Do not flag the lifecycle ordering as incorrect — it is correct by design.

Learnt from: ewowi
URL: https://github.com/ewowi/projectMM/pull/4

Timestamp: 2026-05-24T18:27:24.031Z
Learning: In projectMM (ewowi/projectMM), test files use `platform::micros()`-based elapsed time rather than real wall-clock sleeps. Replacing this with actual sleeps would break elapsed()-dependent assertions because `elapsed()` would not advance. Do not suggest switching to wall-clock sleeps in the test harness.

Learnt from: ewowi
URL: https://github.com/ewowi/projectMM/pull/4

Timestamp: 2026-05-24T18:27:24.031Z
Learning: In projectMM (ewowi/projectMM), the platform boundary is enforced via the `src/platform/` directory. Platform-specific `#ifdef` guards and hardware API calls are restricted to files under `src/platform/`. Do not flag cross-platform usage at other call sites as a platform-boundary violation unless the actual hardware/platform API is being called outside `src/platform/`.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

ewowi and others added 2 commits May 24, 2026 20:43
…+Scheduler split

KPI: 16384lights | PC:304KB | tick:48/51/148/147us(FPS:20833/19607/6756/6802) | ESP32:920KB | tick:54004us(FPS:18) | heap:185KB | src:52(7630) | test:30(3715) | lizard:24w

Bundled UI work, structural refactors, and the per-module status mechanism.

Status / severity slot on MoonModule
-------------------------------------

New `MoonModule::status() + severity()` slot. A short message any module
attaches when it wants the user to see something — Layer writes "buffer
reduced — not enough memory" on memory degradation, NetworkModule
writes "Eth: 192.168.1.210" or "No network". Severity is
Status / Warning / Error → UI picks ℹ️ / ⚠️ / ❌. Wire cost: +9 bytes
per module (pointer + enum), zero allocation (callers pass flash
literals or own a static buffer).

JSON wire (only emitted when set):
  "status":"Eth: 192.168.1.210","severity":"status"

NetworkModule drops its `status` ReadOnly control and routes through
setStatus() per state transition — same string, no separate control row.
Spec updated (NetworkModule.md).

src/light/layers/ subfolder
---------------------------

Moved Layer.h, Layers.h, Buffer.h, BlendMap.h, MappingLUT.h into
src/light/layers/. Symmetric to drivers/, effects/, layouts/,
modifiers/. ~34 includes updated mechanically.

FilesystemModule and Scheduler split to .h + .cpp
-------------------------------------------------

Two more core service modules migrated to .h + .cpp per
coding-standards.md. Added to mm_core's STATIC sources in both
CMakeLists.txt files. Removes the "lazy migration" entry from plan.md
— SystemModule and NetworkModule stay header-only.

UI: collapsible controls, drag rework
-------------------------------------

- Container modules wrap their own controls in a `<details>` element
  (default collapsed). Native HTML5 disclosure, no JS state.
- Drag rework: the whole card body is the drag source, controls
  (`.control-row`, `.card-controls-collapse > summary`) excluded.
  Drag handle (`☰`) glyph removed. Header buttons stay clickable
  (click vs drag is timing-based, not source-based).

  Implementation note: HTML5 `dragstart`'s `e.target` is always the
  draggable element (the card), not the deepest element under the
  mouse — `closest(".control-row")` never matches there. The reliable
  signal is the *mousedown* target, so we toggle `card.draggable` on
  mousedown based on where the grab landed. Touchstart mirror gives
  the same behaviour on mobile.

Two doc fence labels added (architecture.md, building.md — MD040).

Reviewer agent pass
-------------------

Opus reviewer ran over the staged diff (Event 1 gate 8, new policy
this commit). Findings addressed:

- NetworkModule.h stale comments (2 sites) — fixed: the
  pointer-bound-ReadOnly justification was rewritten to name the new
  MoonModule::status_ pointer it actually refers to.
- HttpServerModule duplicated `status/severity` emit block at two
  sites — extracted to a private static `writeStatus(sink, mod)`
  helper, shared by writeModuleJson (/api/state) and
  writeModuleMetricsJson (/api/system) so the two endpoints stay in
  sync.
- Reviewer flagged MirrorModifier::mirrorZ default change (false →
  true) as "buried in a refactor commit." Product owner confirmed the
  change is intended (3D layouts should mirror all three axes by
  default); calling it out explicitly here so it's not a surprise.

Findings accepted with reasons:

- JSON severity value "status" reads oddly next to the field name
  "status" (e.g. `"severity":"status"`). Kept — value mirrors the C++
  enum name (Status / Warning / Error → lowercased), HttpServerModule.md
  documents it; renaming to "info" would mean a 4-file cascade for a
  cosmetic improvement. Worth revisiting if a real reader is confused.
- Layer.md doesn't document the new status messages. Skipped — module
  specs are end-user wire contracts (per the principle in CLAUDE.md
  § Documentation), and these strings are visible in the UI chip
  already; not a control name so check_specs.py doesn't enforce.

Pre-commit gates: 1-7 all green (build / ctest / scenarios /
platform-boundary / specs / ESP32-default-build / KPI). The Reviewer
agent ran on the staged diff under the now-superseded "reviewer at
commit" policy that this commit *also* removes (the reviewer now runs
at PR-merge over the whole branch diff; mid-branch it's an on-demand
safety valve). Reviewer findings addressed above.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Three findings from the merge-gate Reviewer pass (CLAUDE.md Event 2
gate 5). Doc / comment edits only — no compiled code change, so
KPI/perf are unchanged from the previous commit.

- README intro reshaped to lead with the supported targets (product
  owner edit).
- docs/moonmodules/core/MoonModule.md: dropped the `onChildrenReady()`
  paragraph (the function no longer exists in v3 — child setup
  ordering is handled by Scheduler's 4-phase boot). Added a Status
  slot section pointing at HttpServerModule.md for the wire format
  (`status()/severity()/setStatus()` are wire-contract surface and
  the check_specs.py enforcement only catches control names, so this
  drifted silently). The v2 prior-art bullet now notes the hook was
  not carried into v3.
- src/ui/app.js: four stale comments referenced removed up/down
  buttons or a removed `↑/↓` mobile fallback path. Updated to reflect
  the actual implementation: HTML5 DnD on desktop, touchstart-gated
  on mobile, no dedicated drag handle. Behaviour-neutral.

Reviewer findings 1 (unguarded static_cast in Layouts/Drivers vs
guarded in Layers), 4 (NetworkModule snprintf+setStatus repetition),
and 5 (Severity::Status name collision with status() method)
accepted with reasons. (1) is already a known follow-up per commit
9e5bf8f. (4) keeps the explicit call-by-call form so the timing
relationship between snprintf and setStatus stays visible at each
site. (5) is documented at the introduction site (MoonModule.h:207
and the original commit body).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@ewowi
ewowi merged commit 92eea4d into main May 24, 2026
1 check passed
@ewowi
ewowi deleted the next-iteration branch May 24, 2026 18:57
ewowi added a commit that referenced this pull request Jun 3, 2026
Moondeck.json restructures from a flat device list + single port into named
networks, each holding its own devices, last-used serial port, and WiFi
credentials. Header network bar auto-selects from host's current subnet on
load; manual override pins. Improv WiFi reads the active network's creds —
wifi_credentials.json retired. Existing moondeck.json migrates in place.

KPI: n/a (no functional src/ changes — only comments in scenario_runner.cpp)

Tests
- scenario_runner.cpp: KEEP IN SYNC comment pointing at run_live_scenario.py tolerance defaults.
- unit_ArtNetSendDriver_no_alloc_in_loop.cpp: present-tense ("would need" → "needs", "grow happens" → "grow runs"). [CodeRabbit]
- unit_FilesystemModule_persistence.cpp: present-tense ("would have caught" → "catches"). [CodeRabbit]
- scenario_PreviewDriver_detail.json: present-tense descriptions ("must still hit" → "still hits", "must not affect" → "does not affect"). [CodeRabbit]
- All scenario JSONs: routine observed-range widenings from gate sweeps.

Scripts / MoonDeck
- moondeck.py: load_state() detects legacy flat shape and runs _migrate_to_networks (buckets by /24 subnet, largest bucket "Home"); save_state() strips volatile per-device fields including conditionally board when it equals the deduced value; new _active_network / _auto_select_network / _deduce_board helpers; /api/state GET auto-selects on host subnet, /api/discover attributes found devices to matching network, /api/refresh refreshes single network's devices; flash-event breadcrumb still links last_port. NAMING COLLISION breadcrumb added to _deduce_board docstring pointing at docs/plan.md. KEEP IN SYNC note between _deduce_board and the JS board picker list. contextlib.suppress for three inline try/except sites. [CodeRabbit #5]
- moondeck_ui/app.js: getActiveNetwork() helper; renderNetworkBar() + setupNetworkBar() build the dropdown, Rename/Add buttons, WiFi panel; applyNetworkBarVisibility() hides the bar on PC tab; every state.devices / state.port site re-routed through the active network; cross-ref comment at the hardware-board picker pointing at moondeck.py::_deduce_board.
- moondeck_ui/index.html: network-bar div above the per-tab content in the sidebar.
- moondeck_ui/style.css: .network-bar + .network-wifi styling; .device-board picker layout fix.
- build/host_wifi.py: primary credentials source is the active network's wifi block in moondeck.json; OS auto-detect fallback stays.
- check/collect_kpi.py: 18 FPS banner → derived from MIN_ESP32_FPS_LED_PRODUCT (currently 10). [CodeRabbit #3]
- scenario/run_live_scenario.py: max_alloc_block 0 fails when contract demands >0 (was silently passing). [CodeRabbit #6]; KEEP IN SYNC comment about tolerance defaults mirroring scenario_runner.cpp.
- scenario/_observed.py: shared widen-only range update for observed.<target> blocks (factored from both runners).
- docs/screenshot_modules.py: --extras-only flag + _ExtrasOnlyDone sentinel; lets MoonDeck UI re-shoots run without projectMM.
- build/flash_esp32.py: writes scripts/.last_flash.json breadcrumb on success; MoonDeck consumes it to attribute last_port.

Deleted
- scripts/build/wifi_credentials.example.json — the live source moved into moondeck.json's network records.
- .gitignore entry for scripts/build/wifi_credentials.json.

Docs
- testing.md: new Live tab screenshot; observed-range model wording.
- building.md: MoonDeck PC tab + ESP32 tab screenshots placed in their sections.
- MoonDeck.md: Network bar UI Features bullet; improv_provision section updated for the new active-network credentials flow.
- docs/plan.md: "Board vs firmware separation, runtime board presets (multi-commit, started)" roadmap.
- docs/tests/unit-tests.md + scenario-tests.md: regenerated (esp32 row now included in PreviewDriver tables from stale-doc fix). [CodeRabbit #1]
- assets/screenshots/moondeck_{pc,esp32,live}.png: re-captured with the network bar.

Reviews
- 🐰 CodeRabbit: 6 fixed (#1 esp32 row from stale doc → regen; #2 unit-test tense; #3 18 FPS → 10 FPS; #5 contextlib.suppress; #6 max_alloc_block 0 fails; #7 scenario descriptions present-tense). 1 skipped: #4 (show tolerated contract values in perf table) — conflates contract (promise) with tolerance (measurement noise); reviewer agent agreed.
- 👾 Reviewer (Opus, branch diff): ship with changes; 3 architectural calls accepted (sync comments for tolerance defaults, board naming-collision breadcrumb, hardware-board catalog cross-ref) + 1 minor refactor (FPS helper docstring extended to name shared core).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ewowi added a commit that referenced this pull request Jun 4, 2026
Adds the LOLIN S3 N16R8 board entry to the catalog and the full chain
of fixes it needed: a writable WiFi-TX-power cap to prevent the
on-module LDO brown-out, USB-Serial-JTAG support in the Improv
listener so the device responds on native-USB S3 boards, a CORS
preflight handler so cross-origin POSTs from the web installer
actually reach `/api/control`, and a generic per-board controls
fan-out across MoonDeck and the installer paths so future per-board
fields land without code changes. Web installer also gains a
Monitor button + retry affordance, and `maxBlock` now reports
internal-RAM block (useful) instead of PSRAM block (always ~8MB).

KPI: tick:153831us(FPS:6) on esp32s3-n16r8 with txPower capped to
8 dBm (LOLIN brown-out fix). Slower than full-power ArtNet because
8 dBm cuts radio TX margin — known hardware trade-off, not a code
regression.

Core:
- NetworkModule: added writable `txPowerSetting` (0..21 dBm, 0=no
  override) re-applied on change via syncTxPower(). Setting back to 0
  actively pushes 80 quarter-dBm to undo any prior cap (no IDF
  "reset to default" API exists; sticky-cap was the previous behavior).
- BoardModule: ASCII-printable validation rationale documented at
  setBoard() (JSON encoding, UI rendering, C-string round-trip).
- HttpServerModule: added OPTIONS preflight handler returning 204 +
  CORS headers. Root cause of every "cross-origin POST silently
  blocked" symptom — the orchestrator's HTTP inject from preview now
  lands properly. Also documented path-agnostic preflight choice.
- SystemModule: maxBlock display switched to maxInternalAllocBlock;
  added comment explaining how PSRAM is detected (heap-size derivation,
  not a flag).

Platform:
- platform.h / platform_esp32.cpp / platform_desktop.cpp: new
  `maxInternalAllocBlock()` API alongside existing `maxAllocBlock`.
  Internal-only block is the memory-pressure KPI; the all-memory
  variant reports ~8 MB on PSRAM boards and tells you nothing.
  Layer::canAllocate keeps the all-memory call (light buffers may
  live in PSRAM); every diagnostic site (SystemModule, main.cpp tick
  log, HttpServerModule /api/state, scenario_runner) now uses the
  internal-only variant.
- platform.h / platform_esp32.cpp / platform_desktop.cpp: new
  `wifiSetTxPower(int8_t quarterDbm)` setter wrapping
  esp_wifi_set_max_tx_power. Clamps into ESP-IDF's 8..84 range.
- platform_esp32_improv.cpp: USB-Serial-JTAG read+write path added
  alongside UART0 (guarded by SOC_USB_SERIAL_JTAG_SUPPORTED). LOLIN
  S3 N16R8 and other native-USB S3 boards expose USB-C through
  USB-Serial-JTAG, not UART0 — without this the Improv task was deaf
  to host writes on those boards. Both transports drained
  symmetrically each loop with a single 10 ms yield when both are
  empty; ESP_LOGW for driver-install warnings + compound
  `"listening (uart unavailable)"` status so partial-transport
  failures stay visible.

UI:
- src/ui/install-picker.js: added `installRowExtras` slot so the
  installer page can slot the Erase-chip checkbox between firmware
  dropdown and Install button without the on-device OTA UI inheriting
  installer-specific affordances.
- src/ui/app.js: sendControl() now logs non-ok HTTP + network errors
  to console.warn (no retry — design intent for the single-shot
  `?board=` consume path).

Scripts / MoonDeck:
- MoonDeck `_push_board_to_device` now fans out the full
  `controls.<Module>.<control>` block from boards.json on every push
  (was Board.board only). Falls back to bare-board for catalog-
  missing names; no-op on empty board. Generic — any future per-board
  control in boards.json lands via MoonDeck without code changes.
- docs/install/boards.json: new "LOLIN S3 N16R8" entry injecting
  `Network.txPowerSetting: 8` (8 dBm — well below the ~13 dBm
  threshold ESPHome documents for the brown-out symptom).
- docs/install/index.html: rich installer-UI changes — Monitor
  button + serial-monitor modal (Reset/Clear/Close, autoscroll,
  UTF-8 decode, RTS-pulse reset), Retry button on the "Improv not
  detected" dialog with stale-port probe + 250 ms post-close wait
  for SDK lock release, lazy-render WiFi-creds form (avoids macOS
  iCloud Passwords prompt on page load), Skip-creds path exits
  cleanly via onSuccess({url:""}) rather than failing in provision(),
  HTTP injection runs on the Improv-success path too (was needsIp-
  only — fixes the "txPower not applied after Improv install"
  symptom), 2 s → 3 s post-flash port-reopen wait (boot race against
  Improv task init on S3), monitor button auto-disabled during
  install (Web Serial port-mutex enforcement), pendingBoard fallback
  now covers Improv-success HTTP fail.
- docs/install/install-orchestrator.js: stale-prePickedPort probe
  with fallback to requestPort() in both start() and eraseOnly();
  isTransientImprovError helper extracted next to isImprovNotDetected
  so SDK error-string drift has one update site; uiShowNeedsIpRetrying
  contract reconciled with actual behavior.
- docs/install/devices.js: pendingBoard explicitly cleared on
  successful inject (was leaking across re-installs); erase confirm
  text rewritten to reflect the orchestrator-driven flow (EWT
  branding removed).

Docs / CI:
- docs/install/README.md, scripts/MoonDeck.md: replaced "ESP Web
  Tools installer page" branding with the orchestrator-driven
  description (present tense, no backward-looking change-log talk).
- docs/moonmodules/core/BoardModule.md: MoonDeck section updated to
  reflect full controls fan-out; "Pre-WiFi limitation" paragraph
  trimmed to a one-line pointer; full timing-constraint rationale
  moved to docs/history/decisions.md as the lesson worth carrying
  forward.
- docs/moonmodules/core/NetworkModule.md: txPowerSetting bullet
  added (trimmed to wire contract + intended use).
- docs/history/decisions.md: new "Board-injection pipeline timing
  constraint" entry — names the controls that wouldn't tolerate the
  HTTP-after-WiFi fan-out (country code, antenna selector, pre-
  association TX-power) and the two escape hatches (new vendor RPC,
  sdkconfig bake). Warns against extending SET_BOARD's wire format.

Tests:
- test/scenario_runner.cpp: switched to maxInternalAllocBlock for
  the regression KPI; updated rationale to explain why (PSRAM
  boards previously masked internal-heap fragmentation).
- test/unit/core/unit_BoardModule.cpp: added c.readonly assertion
  on the board control — regression guard against accidentally
  making the BoardModule.board field user-editable.

Reviews:
- 👾 Reviewer (on-demand pre-commit): 0 blockers, 10 should-fix, 12
  nits — all 22 addressed in this commit. Highlights: #1
  syncTxPower=0 now actively lifts the cap (was sticky until reboot);
  #2 pendingBoard fallback covers the Improv-success HTTP-fail path
  the LOLIN feature most needs to defend; #4 Monitor button now
  actually disabled during install (matched the documented mutex);
  #5 Improv driver-install warnings now logged via ESP_LOGW and
  carried in compound status (were silently overwritten by
  "listening"); #7 JTAG TX timeout 50 ms → 0 (truly non-blocking);
  #8 UART/JTAG polling made symmetric. The remaining items are
  comment trims, sentinel-vs-tagged-union docstring clarifications,
  and the 32-vs-34-quarter-dBm example fix in platform.h.
- 🐇 CodeRabbit (prior round): 10 findings processed earlier in the
  branch; 2 rejected with reason (HttpServerModule single-line `if
  (c.readonly)` brace fix — no clang-tidy config in the repo, and
  the codebase has many single-line ifs; consumePendingBoardParam
  retry-token — contradicts the documented "no retry" single-shot
  design).

KPI Details:

  Desktop:
    Lights: 16,384
    Binary: 375 KB
    [doctest] test cases: 207 | 207 passed | 0 failed | 0 skipped
    tick: 352us, 99us, 304us, 100us, 99us, 100us, 45us, 35us, 35us
    (FPS: 2840, 10101, 3289, 10000, 10101, 10000, 22222, 28571, 28571)
    === 10 scenario(s), 10 passed, 0 failed ===
    Platform boundary: PASS
    Specs: 26 modules, 26 ok, 0 missing, 0 outdated
  ESP32 (esp32s3-n16r8 on LOLIN S3 N16R8 at txPower=8 dBm):
    Image: 1,307,517 bytes (69% partition free)
    Flash (code+data): 1171 KB
    tick: 153831us (FPS: 6)
    free heap: 8354995  maxBlock (internal): 163840 (160 KB)
    ArtNetSend: ~105 ms/tick (LOLIN at 8 dBm — known trade-off)
  Code:
    62 source files (10491 lines)
    38 test files (5410 lines)
    39 specs, 10 scenarios

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ewowi added a commit that referenced this pull request Jun 4, 2026
Four CodeRabbit findings folded in (three with leaner shapes than
proposed), one deferred with reason. Most user-visible: syncTxPower()
now runs immediately after every successful wifiStaInit / wifiApInit,
so the LOLIN brown-out cap lands BEFORE the first association burst
rather than up to 1 s after — closes the window the cap exists to
defend. Also: Improv replies now go only on the transport that asked,
the needs-ip dialog catches whitespace-only input via the browser's
own validation tooltip, and the BoardModule.h ASCII-validation
asymmetry between SET_BOARD-over-Improv (validates) and HTTP
/api/control (doesn't) is documented explicitly. Plus an unrelated
docs addition: the NanoDLA v1.3 logic analyzer is named as the
project's reference fx2lafw device with wiring + capture instructions
for measuring ESP32 WS2812 output.

KPI: tick:138506us(FPS:7) on esp32s3-n16r8 at txPower=8 dBm. Within
noise of last commit's 141ms/FPS:7; the syncTxPower-on-init change
doesn't measurably affect the tick.

Core:
- NetworkModule: syncTxPower() called immediately after each of the
  5 wifiStaInit / wifiApInit success sites (setWifiCredentials, setup,
  the two ethernet-cascade-to-WiFi paths in loop1s, and startAP). The
  comment at setWifiCredentials's call is the canonical one; the rest
  point back to it. Closes the up-to-1 s gap between WiFi-up and the
  next loop1s tick during which the radio was at full power.
- BoardModule: documented the HTTP-write-bypasses-ASCII-validation
  asymmetry on setBoard()'s comment. The HTTP path uses the generic
  Text-control write at applyControlValue (Control.cpp) which does
  no printable-ASCII check. Acceptable today because HTTP-write
  callers (MoonDeck, web installer's HTTP inject) source values from
  boards.json (project-controlled). If the threat model grows, the
  right fix is a per-control validator hook on ControlDescriptor —
  named in the comment for the next reader.

Platform:
- platform_esp32_improv.cpp: per-transport reply routing. New
  static `g_replySource` set at the top of improvFeedByte's
  FrameReady branch and read inside improvSend; replies go ONLY on
  the transport that received the request rather than broadcast to
  both. The Improv task is single-threaded so a single static is
  sufficient; resets to ImprovSource::Both after dispatch so any
  unsolicited send (status broadcasts) keeps the prior shape.
  Lighter than threading an enum through every signature — same
  correctness outcome.

UI:
- docs/install/index.html: uiWaitForIp's submit handler now rejects
  whitespace-only input via setCustomValidity + reportValidity.
  Surfaces the browser's existing field-validation tooltip on the
  same field rather than letting normalizeDeviceUrl() return empty
  and the orchestrator silently re-prompt. The `required` attribute
  catches empty submits but not "   " — trim first, then flag.

Docs / CI:
- docs/moonmodules/light/leddriver-analysis-top-down.md: named the
  NanoDLA v1.3 as the project's reference fx2lafw-class logic
  analyzer (replacing the generic "$10-15 fx2lafw clone" mention in
  the TL;DR and recommended-suite table) and added a new §5.3.1
  "Wiring the NanoDLA v1.3 to an ESP32 and capturing one frame" —
  common-ground / signal-tap / power-isolation / strand-power
  wiring, voltage-level reasoning (3.3→3.3 direct, no shifter
  needed), sample-rate math for typical strands, copy-pasteable
  sigrok-cli capture + decode sequence, and common gotchas in the
  order they'll appear.

Tests:
- test/scenarios/light/scenario_PreviewDriver_detail.json: drift-
  tracking observed[pc-macos].tick_us[0] updated from 297→296.
  Normal CI noise persisted by scripts/scenario/run_scenario.py
  per the docs/testing.md drift-visibility contract.

Reviews:
- 🐇 CodeRabbit round 2: 4 findings; 3 fixed, 1 deferred. (a) #1
  empty-IP validation: chose host-side setCustomValidity instead of
  proposed onValidationError callback (smaller, equivalent UX, no
  new API surface). (b) Outside #improv-routing: chose static-
  g_replySource instead of proposed signature-threading (same
  correctness, ~30 fewer touched call sites). (c) #4 syncTxPower-
  after-wifi-init: implemented as-proposed. (d) #2 unified
  validation between HTTP and Improv board-name paths: deferred —
  both clean fixes are architectural (per-control validator hook on
  ControlDescriptor, or per-module HTTP dispatch exception); the
  asymmetry is documented with the right fix-shape named, to be
  picked up when the threat model warrants it.

KPI Details:

  Desktop:
    Lights: 16,384
    Binary: 375 KB
    [doctest] test cases: 207 | 207 passed | 0 failed | 0 skipped
    tick: 354us, 100us, 303us, 99us, 99us, 98us, 45us, 35us, 34us
    (FPS: 2824, 10000, 3300, 10101, 10101, 10204, 22222, 28571, 29411)
    === 10 scenario(s), 10 passed, 0 failed ===
    Platform boundary: PASS
    Specs: 26 modules, 26 ok, 0 missing, 0 outdated
  ESP32 (esp32s3-n16r8 on LOLIN S3 N16R8 at txPower=8 dBm):
    Image: 1,307,661 bytes (69% partition free)
    Flash (code+data): 1171 KB
    tick: 138506us (FPS: 7)
    free heap: 8358927  maxBlock (internal): 163840 (160 KB)
    ArtNetSend: ~93 ms/tick (LOLIN at 8 dBm — known trade-off)
  Code:
    62 source files (10592 lines)
    38 test files (5410 lines)
    39 specs, 10 scenarios

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ewowi added a commit that referenced this pull request Jun 4, 2026
PR-merge review pass turned up 12 items; 5 fixed pre-merge per the
Reviewer's "quick wins worth landing before merge" recommendation,
2 deferred to docs/plan.md as concrete follow-ups, 5 deferred as
nits. Plus the gate-3 (carry-forward lessons) and gate-7 (live perf
snapshot to docs/performance.md) outputs that the PR-merge checklist
mandates whenever the branch touches tick-path code.

KPI: tick:138506us(FPS:7) on esp32s3-n16r8 — unchanged from commit
6212fc0 (this round's only src/ change is a desktop stub return-
value tweak, no ESP32 behavioral delta).

Core:
- platform_desktop.cpp: wifiSetTxPower(0) now returns true to match
  the documented "0 = no-override = success" contract — same shape
  the MM_NO_WIFI ESP32 stub already uses. Non-zero still returns
  false (no radio to set). Without this, NetworkModule's
  syncTxPower on desktop never updated appliedTxPowerSetting_ and
  retried on every loop1s tick — harmless but inconsistent.

Docs / CI:
- docs/install/index.html: user-visible browser-warning text
  "ESP Web Tools needs Web Serial" → "Web Serial is required".
  EWT is gone; the warning lives on past it.
- docs/install/README.md: same string at the bottom of the local-
  preview docs.
- docs/moonmodules/core/BoardModule.md: rewrote the past-tense "This
  commit replaced ESP Web Tools' install button..." paragraph as a
  present-tense statement about what the orchestrator does today.
  Per § Principles, module spec pages describe the system as-is;
  changelog-style narrative lives in history.
- docs/moonmodules/core/NetworkModule.md: "Board-specific GPIO config
  ... will be handled by a future IO module" → present-tense ("is
  hardcoded per board via compile-time defines in
  sdkconfig.defaults.<board>"). Forward-looking promises belong in
  plan.md.
- docs/history/decisions.md: three new "Lessons from the LOLIN S3
  N16R8 enablement branch" entries — USB-Serial-JTAG vs UART0 on
  S3 (the dual-stdio mirror that made the wrong inference cheap),
  CORS preflight silent-failure (the root cause behind every "inject
  silently fails" symptom), and cache-staleness on hardware-event
  invalidation (the appliedTxPowerSetting_ pattern). Plus the
  ESP-Web-Tools replacement rationale moved out of BoardModule.md.
- docs/plan.md: two new open-follow-up entries under § Board injection
  — per-control validator hook on ControlDescriptor (the right fix
  for the HTTP-vs-Improv board-name validation asymmetry), and
  shared JS helpers across device-UI and web-installer (defer until
  a second cross-context helper earns the 5-file build-glue cost).
- docs/performance.md: new "ESP32-S3 — LOLIN S3 N16R8" section
  covering tick budget at txPower=8 dBm (~138 ms / FPS 7), the
  ArtNet-slow-at-low-TX explanation, memory layout (Layer in PSRAM,
  maxBlock as internal-RAM-only KPI), and the use-case framing
  (LOLIN fits "lots of PSRAM, accept WiFi compromise"; Ethernet
  boards win on FPS).

Reviews:
- 👾 PR-merge Reviewer (whole branch diff, 7 commits): 0 blockers,
  7 should-fix, 5 nits. (a) #1-3 (stale EWT copy, past-tense spec,
  forward-looking spec) → fixed above. (b) #10 (desktop stub
  contract) → fixed above. (c) #4 (validation hook) + #6 (shared
  helpers) → documented as follow-ups in plan.md. (d) #5, #7-9,
  #11-12 → deferred per the Reviewer's own "future-work, not merge
  blocker / low priority / tiny / minor / nit" framing.

KPI Details:

  Desktop:
    Lights: 16,384
    Binary: 375 KB
    [doctest] test cases: 207 | 207 passed | 0 failed | 0 skipped
    tick: 354us, 100us, 303us, 99us, 99us, 98us, 45us, 35us, 34us
    (FPS: 2824, 10000, 3300, 10101, 10101, 10204, 22222, 28571, 29411)
    === 10 scenario(s), 10 passed, 0 failed ===
    Platform boundary: PASS
    Specs: 26 modules, 26 ok, 0 missing, 0 outdated
  ESP32 (esp32s3-n16r8 on LOLIN S3 N16R8 at txPower=8 dBm):
    tick: 138506us (FPS: 7)   [unchanged from 6212fc0 — desktop-
    only src/ change this round, no ESP32 behavioral delta]
  Code:
    62 source files (10591 lines)
    38 test files (5410 lines)
    39 specs, 10 scenarios

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ewowi added a commit that referenced this pull request Jun 12, 2026
…bit review

Renames the audio module from MicModule to AudioModule (it does audio acquisition plus level/FFT analysis, and will gain line-in / USB sources beyond the I²S mic, so the name now reflects the module, not one source). Makes the primary-source references in the docs clickable (datasheets, Espressif API pages, vendor sites) per the spec-and-test principle. Processes the CodeRabbit review on the prior commit: six findings fixed, six skipped with a stated reason.

KPI: 16384lights | PC:340KB | tick:121/89/119/122/56/2/37/21/117/342us(FPS:8264/11235/8403/8196/17857/500000/27027/47619/8547/2923) | tick:21177us(FPS:47) | heap:33066KB | src:87(16753) | test:53(8518) | lizard:68w

Core:
- MicModule.h → AudioModule.h (git mv, history preserved): class MicModule → AudioModule, registration string "MicModule" → "AudioModule", the latestFrame() static, the main.cpp wiring. The mic-source platform seam (hasI2sMic, audioMicRead, AudioMicHandle) is deliberately NOT renamed: it correctly names today's actual source; it broadens when line-in lands (concrete-first). Verified live on the S3: AudioModule registers and runs (AudioModule:523us in the tick breakdown), no boot-loop.

Light domain:
- AudioLevel.h: clamp the DcBlocker IIR output to the int32 range before narrowing (a settling transient could push the float past int32 bounds, which is UB on the cast). 🐇 #1.
- AudioSpectrumEffect.h: fix a height-gradient off-by-one — the gradient divided by (h-1) but the spectrum spans specH rows; when the bottom row is the level meter (specH == h-1) the top spectrum row never reached full red. Now divides by (specH-1). 🐇 #4.
- AudioVolumeEffect.h: defensively zero any per-light channels beyond RGB (buffers are RGB/cpl=3 today; this keeps the write correct for any cpl and leaves no stale bytes). 🐇 #3.

Scripts / build:
- idf_component.yml: pin esp_wifi_remote ~1.6.1 and esp_hosted ~2.12.9 (the P4-NANO-validated versions) instead of "*", so the P4 build can't silently drift to a new minor; also corrected the stale "esp_hosted bring-up prelude" comment (that prelude was removed). 🐇 #2. Verified the pins resolve and the P4 builds + boots.

Docs / CI:
- AudioModule.md (was MicModule.md): retitled; opening reframed as "acquires an audio source ... named for what it does, not one source" (present-tense, no claim line-in exists yet). Added primary-source links: INMP441 datasheet, esp-dsp, ESP-IDF I2S API, Hann window. First fully em-dash-free module spec.
- RmtLedDriver.md / LcdLedDriver.md / ParlioLedDriver.md: linked WS2812B datasheet, ESP-IDF RMT v2 / esp_lcd / Parlio API pages, the v6.0 migration guide (legacy-RMT removal), ESP32-P4 product page. (Caught + fixed an agent-guessed Parlio URL that 404'd; all links verified live.)
- SystemModule.md / building.md: linked ESP32-C6, esp_hosted, esp_wifi_remote, Waveshare ESP32-P4-NANO.
- decisions.md: reworded the "designed fresh" audio lesson so it no longer reads as "don't look at prior art at all" (which fought the study-with-respect principle); now "reference proven behaviour, don't trace structure," with the flat-mic / no-correction-table call as the example. 🐇 #6.
- AudioVolumeEffect.md: em-dashes removed (touched alongside the effect). 🐇 #7 (partial).
- AudioSpectrumEffect.md / AudioFrame.h / test files: AudioModule rename propagated (@module annotations, references).

Reviews — 🐇 CodeRabbit, prior commit (b79d54f):
- Fixed #1 (DcBlocker int32 clamp), #2 (component version pins), #3 (AudioVolumeEffect extra-channel clear), #4 (AudioSpectrumEffect specH off-by-one), #6 (decisions.md prior-art wording), #7 (AudioVolumeEffect.md em-dashes).
- Skipped #5 (architecture.md Buffer*/Correction* "pin at wiring time"): describes the identity-mapping fast path where the pointer is genuinely stable; the suggestion proposes a different architecture, not a doc fix; paragraph untouched this branch.
- Skipped #7 (README.md / backlog.md bulk em-dash sweep): the rule is "as files are touched, not a single sweep"; a ~176-dash reformat is its own task.
- Skipped #8 (AudioModule stale frame): already handled — latestFrame() returns the static silent frame when active_ is null (teardown nulls it); reinit keeps the same active mic and refreshes within one loop.
- Skipped #9 (AudioModule platform boundary): not a violation — the boundary check passes and the neutral platform.h facade is included by ~10 core modules; the rule forbids platform-specific code outside src/platform/, not the abstract interface. An IAudioSource indirection is exactly the bespoke abstraction the architecture avoids.
- Skipped #10 (desktop improv signature): decl and definition match exactly; the desktop build passes, which proves it.
- Skipped #11/#12 (platform_esp32.cpp fcntl error-handling / include): pre-existing code not changed this branch; expanding into untouched code violates minimal-change scope.

Hardware verified this commit: S3 boots clean at 225 FPS with AudioModule live; P4 boots clean at 60 FPS on Ethernet with the pinned components resolved and wifiCoproc reporting "not detected" (the known C6-slave-firmware blocker, unchanged).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ewowi added a commit that referenced this pull request Jun 12, 2026
… fixes

Processes the Event-2 (pre-merge) Reviewer pass over the branch. The headline is a structural deduplication of the three LED drivers: the parallel WS2812 drivers (S3 LCD_CAM i80, P4 Parlio) were ~250 of ~370 lines byte-for-byte identical, now one shared CRTP base. Also fixes four stale doc/comment items, makes the AudioModule enabled-toggle actually stop its FFT cost, and removes two dead platform flags. No control names or output behaviour change; the LED hot path is unchanged (measured zero overhead, see below).

KPI: 16384lights | PC:340KB | tick:116/95/118/115/56/1/37/20/113/332us(FPS:8620/10526/8474/8695/17857/1000000/27027/50000/8849/3012) | ESP32:1178KB | tick:17341us(FPS:57) | heap:33068KB | src:88(16543) | test:53(8518) | lizard:65w

Light domain (the dedup, per the No-duplication rule):
- ParallelLedDriver.h (new): a CRTP base ParallelLedDriver<Derived> holding the shared body of the parallel drivers — the pins/ledsPerPin/loopback controls, parseConfig, the per-ROW fused correct+encode loop(), reinit/deinit, the loopback self-test. Binding is CRTP (static polymorphism), NOT a virtual second hierarchy: the base calls derived()->busX() resolved at compile time, no vtable, no per-light indirection — so it stays inside the data-over-objects / hot-path rules and "the one deliberate class hierarchy is the module tree" rule.
- LcdLedDriver.h: 378 → 92 lines, now a derived shell supplying only the i80-specific pieces (clockPin/dcPin, the exactly-8-pins rule via kExactLaneCount, the platform::lcdWs2812* calls).
- ParlioLedDriver.h: 362 → 82 lines, the simpler shell (no clock/dc, kExactLaneCount=false, kClockHz).
- Drivers.h (DriverBase): absorbs the status-string lifecycle (configErr_/failBuf_, setConfigErr/clearConfigErr/failBufEnsure/clearFailBuf) that was triplicated verbatim across all three drivers (RMT too).
- RmtLedDriver.h: loses its status-lifecycle copy (now inherited); symbol-per-light model stays standalone (genuinely different from the parallel drivers — concrete-first boundary).

Core:
- AudioModule.h: removed respectsEnabled()=false so the Scheduler honours `enabled` — disabling the module now skips loop() entirely, stopping the FFT (the real per-tick cost). Verified on hardware: 524us enabled -> 0us disabled. The FFT is never gated while enabled (it is the capability audio effects consume, not an optional cost).
- platform_config.h (esp32 + desktop): removed the dead isEsp32/isEsp32S3 family flags (no users anywhere); kept isEsp32P4 (drives ethPins + hasWifiCoprocessor). Desktop now mirrors with isEsp32P4=false.

Docs / CI:
- architecture.md: § Firmware vs board now lists the two P4 firmwares (esp32p4-eth, esp32p4-eth-wifi); § Drivers updated for the LCD_CAM/Parlio drivers and the four-driver shared Correction pointer. (👾 MUST-FIX 1, 2)
- sdkconfig.defaults.esp32p4-eth-wifi: corrected the comment that described an esp_hosted bring-up prelude the code deliberately does NOT do (it was removed; esp_hosted self-inits at boot). Following the stale comment would reintroduce the bench-fixed SDIO teardown. (👾 MUST-FIX 3)
- platform_esp32_lcd.cpp: the header + #else/#endif comments now name the correct SOC_LCDCAM_I80_LCD_SUPPORTED macro (they said SOC_LCD_I80_SUPPORTED — the exact macro confusion that caused the classic-ESP32 boot loop). (👾 MUST-FIX 4)
- check_specs.py: skip CRTP template bases (template<...> class X : public DriverBase) — shared infrastructure, not a registered module; the concrete derived classes still carry the docs.
- backlog.md: the LED-driver duplication item updated (driver-logic dedup landed via the CRTP base; the platform loopback capture+verify extraction remains as a follow-up).

Reviews — 👾 Reviewer (Fable 5, over git diff main...HEAD; verdict "merge with noted fixes"):
- Fixed all 4 MUST-FIX (stale docs/comments, above).
- SHOULD-CONSIDER #1/#2 (Lcd/Parlio driver-logic + status duplication): FIXED via the CRTP base + DriverBase status lifecycle (chose to do it now per the No-duplication rule rather than backlog). The remaining platform loopback capture+verify duplication is the tracked follow-up.
- SHOULD-CONSIDER #4 (AudioModule enabled toggle couldn't stop the FFT): FIXED (above); the FFT itself is must-have, only the disabled-still-runs gap was the bug.
- SHOULD-CONSIDER #5 (unused isEsp32/isEsp32S3 flags): FIXED (removed).
- SHOULD-CONSIDER #3 (audio headers in src/light/ giving a core->light arrow): accepted as-is — documented producer/consumer placement, boundary check passes.

Verification: 56 driver/encoder unit tests pass, desktop build zero-warning, spec + platform-boundary clean. All three ESP32 targets (esp32, esp32s3-n16r8, esp32p4-eth-wifi) build clean under -Werror, including both CRTP instantiations. On hardware: S3 LcdLedDriver loopback bit-perfect over a 13->12 jumper (1536/1536 symbols captured, 0-bits 15..15 / 1-bits 30..30 ticks, zero jitter); P4 ParlioLedDriver renders the S3's audio spectrum live over the DDP path. Hot-path A/B (same S3, same config): pre-dedup ~3421-3442us/frame vs CRTP ~3425-3436us/frame — statistically identical, zero overhead (CRTP is static dispatch; the per-light loop is unchanged).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ewowi added a commit that referenced this pull request Jun 21, 2026
Follow-up to the "Improv = REST over serial" work on this branch: processes the CodeRabbit review of the prior commit and adds the host-side + device-side test coverage that makes config-push over serial provable without hardware. The Improv frame format (built three times — device C++, Python, installer JS) and the device's APPLY_OP chunk reassembly + sequence guard now have automated tests; the installer's frame builders were extracted into a shared, dependency-free module so the JS is testable in node at all. Also restores 18 historical plans under the corrected Plan-YYYYMMDD (ISO-8601) naming.

KPI: 16384lights | PC:365KB | tick:111/87/110/9/1/312/37/15/19/111/11us(FPS:9009/11494/9090/111111/1000000/3205/27027/66666/52631/9009/90909) | ESP32:1249KB | tick:5155us(FPS:193) | heap:8335KB | src:97(19754) | test:68(10118) | lizard:77w

(ESP32 tick 5155us is a fresh live capture from the S3 running the full testbench config — Grid + AudioSpectrum + RandomMap + RmtLed + NetworkSend broadcasting Art-Net. Higher than the old idle-ish 3.6ms because NetworkSend is active; this change touches no render-path code, so the tick is unchanged for a like-for-like config.)

Core:
- ImprovOpReassembler (new): extracted the APPLY_OP chunk-reassembly + out-of-order/duplicate sequence guard out of the ESP32 handler into a pure, header-only core state machine (the ImprovFrame.h precedent — algorithm in core, UART in platform). Returns a typed Continue/Ready/Error. This makes the heart of "REST over serial" desktop-unit-testable and shrinks the platform handler (removed two g_improv fields).
- HttpServerModule: split OpResult::NotFound into ModuleNotFound + ControlNotFound so the HTTP /api/control handler reports the two distinct 404 bodies again (CodeRabbit #1); added OpResult::AlreadyExists so an idempotent add reports {"ok":true,"note":"already exists"} again (#5); documented the serial "parent" vs HTTP "parent_id" key difference at the applyOp site (#6).
- ImprovProvisioningModule: a non-Ok APPLY_OP result is now surfaced — logged over serial and parked in provision_status — so a silently-misconfigured device is visible on a monitor and via /api/state instead of looking like a clean install (#2). Ok/AlreadyExists are both treated as success.

Platform:
- platform_esp32_improv.cpp: improvHandleApplyOp now delegates chunk merge + seq guard to mm::ImprovOpReassembler, keeping only the serial I/O (busy guard, ack/error sends). No behaviour change; the logic is now the unit-tested core path.

UI:
- improv-frame.js (new): the pure Improv frame builders (buildImprovFrame, encodeApplyOpFrames, the command IDs) extracted from install-orchestrator.js so node:test can import them without the orchestrator's top-level unpkg imports. The orchestrator imports them back — no behaviour change.
- install-orchestrator.js: APPLY_OP inter-op pacing 30ms -> 120ms to cover the worst-case single-buffer consume window with headroom (CodeRabbit #3; the closed-loop read-back-ack upgrade is backlogged, ops are idempotent).

Tests:
- unit_ImprovOpReassembler (new, 12 cases): in-order multi-chunk reassembly + NUL-termination, duplicate-chunk rejection, out-of-order/skipped-seq rejection, overflow at the buffer-minus-NUL boundary, exact-fit boundary, mid-stream seq-0 reset, empty final chunk, recovery after every error.
- test/js/improv-frame.test.mjs + test/python/test_improv_frame.py (new): assert the SAME golden frame (buildImprovFrame(0x03,[0x01]) == 49 4d 50 52 4f 56 01 03 01 01 e3) so the device C++, Python, and JS frame builders provably agree; the JS suite also pins APPLY_OP chunking (seq/last, the 125-byte boundary). Encode (JS) + reassemble (C++) prove APPLY_OP end to end without hardware.
- unit_HttpServerModule_apply: updated for the ModuleNotFound/ControlNotFound/AlreadyExists split.

Scripts / CI:
- .github/workflows/test.yml (new): PR-triggered pytest (test/python) + node --test (test/js) — the first PR-level test gate (none existed). Python tests carry deps in a PEP-723 inline block (repo convention, no central pyproject).

Docs / CI:
- testing.md: new "Host-side tests (Python + JS)" tier with a per-test inventory of exactly what the Python/JS frame suites pin, plus the C++ reassembler coverage. test/python + test/js added to the file layout.
- CLAUDE.md: added commit gate 10 (host-side Python/JS unit tests); corrected the plan naming convention to Plan-YYYYMMDD (ISO-8601, sorts chronologically) and clarified plans are product-owner reference (agents write, don't auto-read).
- ImprovProvisioningModule.md: documented the serial-vs-HTTP parent key difference.
- docs/history/plans: restored 18 historical plans (plan-01..18, deleted in an old merge) under Plan-YYYYMMDD names; renamed the Improv-as-REST plan to the corrected ISO date.
- backlog: removed the shipped "board injection / general data injector" item (it IS APPLY_OP now); added the closed-loop-pacing follow-up; renamed stale boards.json -> deviceModels.json references.

Reviews:
- 🐇 #1 module-vs-control 404 specificity — fixed (OpResult split).
- 🐇 #2 dropped APPLY_OP result — fixed (logged + provision_status).
- 🐇 #3 open-loop 30ms pacing — fixed (bumped to 120ms; ack-loop backlogged).
- 🐇 #4 perf-bound "regression" — accepted: the cited tick_us are observed.* telemetry (0 bounds, 21 observed), which only widens on a slow run; no render-path change, not a regression.
- 🐇 #5 dropped "already exists" note — fixed (AlreadyExists OpResult).
- 🐇 #6 parent vs parent_id — fixed (documented at code site + spec).
- 🐇 #7 NUL-guard off-by-one — no action (CodeRabbit confirmed correct).
- 🐇 #8 plan date format — fixed (YYYYDDMM -> ISO YYYYMMDD, all plans renamed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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