Add 10 stateless and stateful effects#3
Conversation
📝 WalkthroughWalkthroughThis PR extends the LED effect pipeline by adding 10 new visual effects (Plasma, Fire, Metaballs, Particles, Spiral, Checkerboard, Lava Lamp, Glow Particles, Ripples, PlasmaPalette), introducing integer-only math utilities for fast rendering, enabling dynamic effect reordering/addition/deletion via HTTP APIs and UI controls, and updating project documentation. ChangesEffect Pipeline Expansion
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
@coderabbitai, regarding
it says wait 56 min. but how much is it now? As I need a review from you |
🧩 Analysis chain🏁 Script executed: Length of output: 595
The window should be clear (or very close to it) by now — I'll kick off the review for you! 🐇 ✅ Actions performedReview triggered.
|
|
@coderabbitai, please review this pr and provide a prompt for all review comments with AI agents |
|
✅ Actions performedFull review triggered. |
There was a problem hiding this comment.
Actionable comments posted: 15
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/light/NoiseEffect.h (1)
10-15:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winUpdate the
scalerange comment to match the control bounds.Line 10 says
1-32, but Line 15 now exposes1-255.Suggested fix
- uint8_t scale = 4; // spatial frequency (1-32) + uint8_t scale = 4; // spatial frequency (1-255)🤖 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/NoiseEffect.h` around lines 10 - 15, The comment for the uint8_t field "scale" is out of sync with the control bounds: update the comment on the "scale" member (declared as uint8_t scale = 4) to reflect the actual range used by controls_.addUint8 in onBuildControls (1-255) so the inline documentation matches the control limits; locate the scale declaration and the onBuildControls method to change the comment from "spatial frequency (1-32)" to "spatial frequency (1-255)" (or a wording that indicates the 1–255 range).
🤖 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/assessment-2026-05.md`:
- Line 26: Update the assessment to match this PR’s post-merge state (or clearly
label it as a pre-PR baseline): replace the Effects entry that currently lists
only "Rainbow, Noise" with the full set added by layers 2–5 (Plasma, Metaballs,
Fire, Particles, Checkerboard, Spiral, PlasmaPalette, Ripples, GlowParticles,
LavaLamp), mark that RipplesEffect is implemented (layer 5), change the "UI type
picker + dynamic effect switching" missing note to present since layer 7
implements the UI and wiring for /api/types, and remove/adjust the claim that
GET /api/types does not exist because HttpServerModule (layer 6) adds that
endpoint; ensure references to RipplesEffect, HttpServerModule, GET /api/types,
and the layer numbers are updated or add a “pre-PR baseline” header so readers
know the snapshot’s intent.
In `@src/core/color.h`:
- Around line 65-78: atan2_8 produces wrong quadrant offsets: fix by computing
the 4 quadrant base (0,64,128,192) from the signs of x and y (use (x<0) and
(y<0)), keep the octant swap logic but make offset represent 0 or 32 (45°
octant), compute the intra-octant step b in 0..31 (use y*32/x, not *64/x) and
return quadrant_base + offset + b; update variables r, offset, and b in atan2_8
accordingly so final value maps 0..255 with +X≈0, +Y≈64, -X≈128, -Y≈192.
In `@src/core/HttpServerModule.h`:
- Around line 458-463: The loop emitting the /api/types JSON array uses the loop
index to decide comma placement but skips null names, which can produce a
leading comma; change the emission logic in the loop that iterates
ModuleFactory::typeCount() / calls ModuleFactory::typeName(i) to track a bool
first = true (outside the loop), and when emitting each non-null name write a
comma only if first is false, then set first = false after emitting; keep using
jsonBuf_, pos and JSON_BUF_SIZE and the existing snprintf/bounds checks when
appending.
- Around line 146-155: The current check using std::strstr(path + 13, "/move")
matches any occurrence of "/move" and can accept malformed paths; change the
condition to require "/move" to be an exact suffix (or followed only by
query/fragment) before calling handleMoveModule. Specifically, find the existing
strstr usage and replace it with a check that locates the "/move" token (e.g.,
via std::strstr or std::strcmp on the found pointer) and then assert that the
characters after "/move" are only end-of-string, '?' or '#' (reject
"/move/extra" etc.), then proceed to extract nameBuf and call
handleMoveModule(nameBuf, body).
In `@src/light/FireEffect.h`:
- Line 5: Remove the direct platform dependency from FireEffect.h by deleting
`#include` "platform/platform.h" and any direct calls to platform allocation APIs
inside the FireEffect class; instead inject or call an abstract allocator
interface (e.g., IMemoryAllocator) via FireEffect’s constructor or an initialize
method and replace platformAlloc/platformFree usages with
IMemoryAllocator::allocate/ free (or equivalent) calls; keep only the allocator
interface declaration visible to src/light (reference symbols: class FireEffect,
FireEffect::FireEffect(...), FireEffect::initialize(...), and any
platformAlloc/platformFree calls) and move the concrete platform include and
implementation of the allocator into the platform-specific module so FireEffect
only depends on the allocator abstraction.
In `@src/light/GlowParticlesEffect.h`:
- Around line 37-54: The first-frame dt can be huge because lastElapsed_ is
initialized to 0; change the delta calculation so that if lastElapsed_ is 0 (or
otherwise indicates “no prior sample”) you treat dt as 0 instead of
now-lastElapsed_. In practice, call elapsed() into now, then compute dt =
(lastElapsed_ == 0 ? 0 : now - lastElapsed_), then set lastElapsed_ = now; this
prevents the huge initial sx/sy updates in the particle update loop that touches
particles_ and uses p.vx/p.vy, speed, and dt.
In `@src/light/LavaLampEffect.h`:
- Around line 49-63: The radius is currently used as an absolute pixel value
when computing r2, causing saturation on small panels; adjust radius before
computing r2 by scaling it to the panel size (e.g., multiply radius by a factor
derived from w and h such as min(w,h)/reference_size or normalize to [0,1] then
multiply by min(w,h)) so that r2 = (scaledRadius * scaledRadius) uses the
panel-aware value; update the calculation that sets r2 (and any use of radius in
the inner loop that influences d2/field/scaled) so that NUM_BLOBS, bx/by,
intensity and palette_ logic remain unchanged but operate on the normalized
radius to restore spatial variation.
In `@src/light/ParticlesEffect.h`:
- Around line 70-95: The particle motion is frame-rate dependent because sx/sy
are computed as (p.vx * speed) >> 6 with no time factor; update ParticlesEffect
to use elapsed milliseconds (dt) like GlowParticlesEffect::loop(): add/maintain
a lastElapsed_ timestamp, compute dt = elapsed - lastElapsed_, multiply the
velocity term by dt (so displacement = p.vx * speed * dt * scaling_factor rather
than just shifting), use that to compute sx/sy and update p.x/p.y, and update
lastElapsed_ after the loop so the slider and motion are resolution-independent;
reference symbols: sx, sy, p.vx, speed, p.x/p.y, particles_, lastElapsed_, and
loop().
In `@src/ui/app.js`:
- Around line 230-252: addModule, deleteModule and moveModule currently ignore
fetch responses and errors so UI refreshes even when the server operation fails;
update each function to await the fetch response, check response.ok (and parse
error body when not ok), throw or handle errors (e.g., log/display) and only
call refreshAndRender if the request succeeded, and wrap the fetch in try/catch
to handle network errors and prevent silent failures.
In `@test/test_fire.cpp`:
- Around line 72-74: Replace the direct effect-level manipulation (setting
fire.enabled = false and calling fire.onAllocateMemory()) with the layer-level
flow: locate the Layer that owns the fire effect and call its
disable/setEnabled(false) API so the Layer handles reallocation, then trigger
the Layer's allocation/reallocation entry point (e.g., Layer::onAllocateMemory
or Layer::reallocateMemory) and assert fire.dynamicBytes() == 0; do not call
fire.onAllocateMemory() directly. Ensure you reference the fire.enabled and
fire.onAllocateMemory() occurrences to remove them and use the Layer methods
instead.
In `@test/test_metaballs.cpp`:
- Around line 51-55: The test dereferences the raw pointer returned by
layer.buffer().data() (variable data) without checking for null; add a null
precondition by inserting a REQUIRE(data != nullptr) immediately after obtaining
data (before the uint8_t r0 = ... line) so a failed allocation is reported by
doctest instead of crashing the test; ensure the check references the same
expression (data) used later in the comparisons.
In `@test/test_particles.cpp`:
- Around line 68-70: The test currently disables particles and calls
particles.onAllocateMemory() directly, which bypasses the Layer-level
disable/reallocation contract; instead, after setting particles.enabled = false
you should invoke the owning Layer's reallocation API (i.e., trigger the Layer
reallocation flow that the effect contract uses rather than calling
particles.onAllocateMemory() directly) so the Layer performs the proper
disable/reallocate behavior, then assert particles.dynamicBytes() == 0; replace
the direct onAllocateMemory() call with the Layer reallocation call that owns
this particle set.
In `@test/test_plasma.cpp`:
- Around line 52-56: The test directly indexes layer.buffer().data() and can
crash if the buffer is null or too small; before accessing data[0] and
data[lastIdx+2] add REQUIRE checks that the buffer pointer is non-null and that
layer.buffer().size() (or equivalent size accessor on layer.buffer()) is greater
than lastIdx+2 so the accesses are safe, then proceed with the existing uint8_t
r0/g0/b0, r1/g1/b1 and CHECK; reference the local variables data, lastIdx and
the CHECK assertion when adding these precondition REQUIREs.
In `@test/test_stateless_effects.cpp`:
- Around line 58-74: The tests generated by STATELESS_EFFECT_TEST must assert
the stateless contract by checking effect.dynamicBytes() == 0; add an assertion
after allocation/loop in both generated TEST_CASEs so that for each mm::EFFECT
created (inside Ctx ctx(...); mm::EFFECT effect; ctx.layer.addChild(&effect);
ctx.layer.onAllocateMemory(); ctx.layer.loop();) you call
CHECK(effect.dynamicBytes() == 0) to ensure heap-free behavior; update the macro
STATELESS_EFFECT_TEST (and the same pattern in the other tests covering lines
76-118) to include this assertion immediately after onAllocateMemory()/loop().
- Around line 92-94: The test currently only compares a single channel (data[0]
!= data[4 * 3]) which is brittle; change it to assert the full RGB triplet
differs by comparing the three color bytes for the two pixels retrieved from
ctx.layer.buffer().data() (the local variable data). Replace the single-channel
check with a full-pixel comparison (e.g., use std::memcmp(&data[0], &data[4 *
3], 3) != 0 or explicitly check data[0..2] vs data[4*3..4*3+2]) so the test
verifies RGB-level spatial variation as specified.
---
Outside diff comments:
In `@src/light/NoiseEffect.h`:
- Around line 10-15: The comment for the uint8_t field "scale" is out of sync
with the control bounds: update the comment on the "scale" member (declared as
uint8_t scale = 4) to reflect the actual range used by controls_.addUint8 in
onBuildControls (1-255) so the inline documentation matches the control limits;
locate the scale declaration and the onBuildControls method to change the
comment from "spatial frequency (1-32)" to "spatial frequency (1-255)" (or a
wording that indicates the 1–255 range).
🪄 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: 8327ded8-cca9-4eed-ab2f-eed2ca9b1261
📒 Files selected for processing (41)
docs/history/assessment-2026-05.mddocs/moonmodules/light/EffectBase.mddocs/moonmodules/light/effects/CheckerboardEffect.mddocs/moonmodules/light/effects/FireEffect.mddocs/moonmodules/light/effects/GlowParticlesEffect.mddocs/moonmodules/light/effects/LavaLampEffect.mddocs/moonmodules/light/effects/MetaballsEffect.mddocs/moonmodules/light/effects/ParticlesEffect.mddocs/moonmodules/light/effects/PlasmaEffect.mddocs/moonmodules/light/effects/PlasmaPaletteEffect.mddocs/moonmodules/light/effects/RipplesEffect.mddocs/moonmodules/light/effects/SpiralEffect.mddocs/testing.mdsrc/core/HttpServerModule.hsrc/core/MoonModule.hsrc/core/color.hsrc/light/ArtNetSendDriver.hsrc/light/CheckerboardEffect.hsrc/light/EffectBase.hsrc/light/FireEffect.hsrc/light/GlowParticlesEffect.hsrc/light/LavaLampEffect.hsrc/light/Layer.hsrc/light/MetaballsEffect.hsrc/light/NoiseEffect.hsrc/light/ParticlesEffect.hsrc/light/PlasmaEffect.hsrc/light/PlasmaPaletteEffect.hsrc/light/PreviewDriver.hsrc/light/RainbowEffect.hsrc/light/RipplesEffect.hsrc/light/SpiralEffect.hsrc/main.cppsrc/ui/app.jssrc/ui/style.csstest/CMakeLists.txttest/test_fire.cpptest/test_metaballs.cpptest/test_particles.cpptest/test_plasma.cpptest/test_stateless_effects.cpp
| | **MoonModule + controls + scheduler** | Complete, consistently used | | ||
| | **Pipeline** | LayoutGroup → GridLayout → Layer → effects → modifier → DriverGroup → ArtNet + Preview | | ||
| | **Mapping** | MappingLUT, MirrorModifier, BlendMap, 1:1 optimization without extra buffer | | ||
| | **Effects** | Rainbow, Noise | |
There was a problem hiding this comment.
Assessment is inconsistent with this PR's changes.
This assessment document describes features as missing that are actually being delivered in this PR:
- Line 26 lists only "Rainbow, Noise" effects, but this PR adds 10 new effects (Plasma, Metaballs, Fire, Particles, Checkerboard, Spiral, PlasmaPalette, Ripples, GlowParticles, LavaLamp) per layers 2–5.
- Line 44 claims "UI type picker + dynamic effect switching" is missing, but layer 7 implements exactly this (UI fetches
/api/types, adds picker card, wire add/delete/move actions). - Line 47 lists "Ripples" as missing, but
RipplesEffectis added in layer 5. - Line 102 states "
/api/typesdoes not exist yet," but layer 6 addsGET /api/typesinHttpServerModule.
Since this document is part of the PR and dated the same month, readers will assume it reflects the post-merge state, making these claims misleading.
Recommendation: Either update the assessment to reflect the state after this PR, or clearly label it as a "pre-PR baseline" snapshot for historical reference.
Also applies to: 44-44, 47-47, 102-102
🤖 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/assessment-2026-05.md` at line 26, Update the assessment to
match this PR’s post-merge state (or clearly label it as a pre-PR baseline):
replace the Effects entry that currently lists only "Rainbow, Noise" with the
full set added by layers 2–5 (Plasma, Metaballs, Fire, Particles, Checkerboard,
Spiral, PlasmaPalette, Ripples, GlowParticles, LavaLamp), mark that
RipplesEffect is implemented (layer 5), change the "UI type picker + dynamic
effect switching" missing note to present since layer 7 implements the UI and
wiring for /api/types, and remove/adjust the claim that GET /api/types does not
exist because HttpServerModule (layer 6) adds that endpoint; ensure references
to RipplesEffect, HttpServerModule, GET /api/types, and the layer numbers are
updated or add a “pre-PR baseline” header so readers know the snapshot’s intent.
| // Fast octant atan2: returns 0-255 for full circle (y, x) in -32768..32767 | ||
| constexpr uint8_t atan2_8(int16_t y, int16_t x) { | ||
| uint8_t r = 0; | ||
| if (y < 0) { y = static_cast<int16_t>(-y); r = 0x80; } | ||
| if (x < 0) { x = static_cast<int16_t>(-x); r = static_cast<uint8_t>(r | 0x40); } | ||
| uint8_t offset = (x > y) ? 0 : 32; | ||
| if (x < y) { | ||
| int16_t t = y; | ||
| y = x; | ||
| x = t; | ||
| } | ||
| uint8_t b = (x == 0) ? 0 : static_cast<uint8_t>((static_cast<uint16_t>(y) * 64) / static_cast<uint16_t>(x)); | ||
| return static_cast<uint8_t>(r + offset + b); | ||
| } |
There was a problem hiding this comment.
atan2_8 currently maps key directions to wrong angles.
Line 66-78 does not produce a correct 0..255 full-circle mapping (e.g., (y=1,x=0) should be ~64, (y=0,x=-1) should be ~128). This will skew angle-based effects.
Suggested fix
-constexpr uint8_t atan2_8(int16_t y, int16_t x) {
- uint8_t r = 0;
- if (y < 0) { y = static_cast<int16_t>(-y); r = 0x80; }
- if (x < 0) { x = static_cast<int16_t>(-x); r = static_cast<uint8_t>(r | 0x40); }
- uint8_t offset = (x > y) ? 0 : 32;
- if (x < y) {
- int16_t t = y;
- y = x;
- x = t;
- }
- uint8_t b = (x == 0) ? 0 : static_cast<uint8_t>((static_cast<uint16_t>(y) * 64) / static_cast<uint16_t>(x));
- return static_cast<uint8_t>(r + offset + b);
-}
+constexpr uint8_t atan2_8(int16_t y, int16_t x) {
+ if (x == 0 && y == 0) return 0;
+
+ const uint16_t ax = static_cast<uint16_t>(
+ x < 0 ? -static_cast<int32_t>(x) : static_cast<int32_t>(x));
+ const uint16_t ay = static_cast<uint16_t>(
+ y < 0 ? -static_cast<int32_t>(y) : static_cast<int32_t>(y));
+
+ uint8_t base;
+ if (ax >= ay) {
+ base = static_cast<uint8_t>((static_cast<uint32_t>(ay) * 32u) / (ax == 0 ? 1u : ax)); // 0..32
+ } else {
+ base = static_cast<uint8_t>(64u - (static_cast<uint32_t>(ax) * 32u) / ay); // 32..64
+ }
+
+ if (x >= 0) {
+ return (y >= 0) ? base : static_cast<uint8_t>(256u - base);
+ }
+ return (y >= 0) ? static_cast<uint8_t>(128u - base) : static_cast<uint8_t>(128u + base);
+}🤖 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/color.h` around lines 65 - 78, atan2_8 produces wrong quadrant
offsets: fix by computing the 4 quadrant base (0,64,128,192) from the signs of x
and y (use (x<0) and (y<0)), keep the octant swap logic but make offset
represent 0 or 32 (45° octant), compute the intra-octant step b in 0..31 (use
y*32/x, not *64/x) and return quadrant_base + offset + b; update variables r,
offset, and b in atan2_8 accordingly so final value maps 0..255 with +X≈0,
+Y≈64, -X≈128, -Y≈192.
| } else if (std::strncmp(path, "/api/modules/", 13) == 0 && | ||
| std::strstr(path + 13, "/move") != nullptr && body) { | ||
| // POST /api/modules/Name/move with body {"delta": -1|1} | ||
| char nameBuf[32] = {}; | ||
| const char* slash = std::strstr(path + 13, "/move"); | ||
| size_t nameLen = static_cast<size_t>(slash - (path + 13)); | ||
| if (nameLen >= sizeof(nameBuf)) nameLen = sizeof(nameBuf) - 1; | ||
| std::memcpy(nameBuf, path + 13, nameLen); | ||
| nameBuf[nameLen] = 0; | ||
| handleMoveModule(conn, nameBuf, body); |
There was a problem hiding this comment.
Tighten /move route matching to an exact suffix.
Current matching accepts any path containing "/move" after /api/modules/, so malformed paths can still trigger moves.
Suggested fix
- } else if (std::strncmp(path, "/api/modules/", 13) == 0 &&
- std::strstr(path + 13, "/move") != nullptr && body) {
+ } else if (std::strncmp(path, "/api/modules/", 13) == 0 && body) {
+ const char* suffix = "/move";
+ const char* tail = path + std::strlen(path) - std::strlen(suffix);
+ if (tail >= path + 13 && std::strcmp(tail, suffix) == 0) {
// POST /api/modules/Name/move with body {"delta": -1|1}
char nameBuf[32] = {};
- const char* slash = std::strstr(path + 13, "/move");
+ const char* slash = tail;
size_t nameLen = static_cast<size_t>(slash - (path + 13));
if (nameLen >= sizeof(nameBuf)) nameLen = sizeof(nameBuf) - 1;
std::memcpy(nameBuf, path + 13, nameLen);
nameBuf[nameLen] = 0;
handleMoveModule(conn, nameBuf, body);
+ } else {
+ sendResponse(conn, 404, "text/plain", "Not found");
+ }🤖 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 146 - 155, The current check using
std::strstr(path + 13, "/move") matches any occurrence of "/move" and can accept
malformed paths; change the condition to require "/move" to be an exact suffix
(or followed only by query/fragment) before calling handleMoveModule.
Specifically, find the existing strstr usage and replace it with a check that
locates the "/move" token (e.g., via std::strstr or std::strcmp on the found
pointer) and then assert that the characters after "/move" are only
end-of-string, '?' or '#' (reject "/move/extra" etc.), then proceed to extract
nameBuf and call handleMoveModule(nameBuf, body).
| for (uint8_t i = 0; i < ModuleFactory::typeCount(); i++) { | ||
| const char* name = ModuleFactory::typeName(i); | ||
| if (!name) continue; | ||
| int n = std::snprintf(jsonBuf_ + pos, JSON_BUF_SIZE - pos, | ||
| "%s\"%s\"", (i == 0 ? "" : ","), name); | ||
| if (n > 0 && static_cast<size_t>(pos + n) < JSON_BUF_SIZE) pos += n; |
There was a problem hiding this comment.
Use a first flag when emitting /api/types array entries.
Comma placement is tied to index, but null names are skipped. That can produce invalid JSON with a leading comma.
Suggested fix
void serveTypes(platform::TcpConnection& conn) {
int pos = std::snprintf(jsonBuf_, JSON_BUF_SIZE, "{\"types\":[");
+ bool first = true;
for (uint8_t i = 0; i < ModuleFactory::typeCount(); i++) {
const char* name = ModuleFactory::typeName(i);
if (!name) continue;
int n = std::snprintf(jsonBuf_ + pos, JSON_BUF_SIZE - pos,
- "%s\"%s\"", (i == 0 ? "" : ","), name);
+ "%s\"%s\"", first ? "" : ",", name);
if (n > 0 && static_cast<size_t>(pos + n) < JSON_BUF_SIZE) pos += n;
+ first = false;
}🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@src/core/HttpServerModule.h` around lines 458 - 463, The loop emitting the
/api/types JSON array uses the loop index to decide comma placement but skips
null names, which can produce a leading comma; change the emission logic in the
loop that iterates ModuleFactory::typeCount() / calls ModuleFactory::typeName(i)
to track a bool first = true (outside the loop), and when emitting each non-null
name write a comma only if first is false, then set first = false after
emitting; keep using jsonBuf_, pos and JSON_BUF_SIZE and the existing
snprintf/bounds checks when appending.
|
|
||
| #include "light/Layer.h" | ||
| #include "core/color.h" | ||
| #include "platform/platform.h" |
There was a problem hiding this comment.
Keep platform-specific includes and API calls out of src/light.
This effect directly includes platform headers and calls platform allocation APIs from the light layer. Please route memory allocation through a non-platform abstraction owned outside src/light so platform coupling remains confined to src/platform/.
As per coding guidelines, src/**/*.{h,hpp,cpp,cc}: All #ifdef, platform-specific #include``s, and hardware API calls must live exclusively in src/platform/`.
Also applies to: 29-30, 114-114
🤖 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/FireEffect.h` at line 5, Remove the direct platform dependency from
FireEffect.h by deleting `#include` "platform/platform.h" and any direct calls to
platform allocation APIs inside the FireEffect class; instead inject or call an
abstract allocator interface (e.g., IMemoryAllocator) via FireEffect’s
constructor or an initialize method and replace platformAlloc/platformFree
usages with IMemoryAllocator::allocate/ free (or equivalent) calls; keep only
the allocator interface declaration visible to src/light (reference symbols:
class FireEffect, FireEffect::FireEffect(...), FireEffect::initialize(...), and
any platformAlloc/platformFree calls) and move the concrete platform include and
implementation of the allocator into the platform-specific module so FireEffect
only depends on the allocator abstraction.
| auto* data = layer.buffer().data(); | ||
| uint8_t r0 = data[0], g0 = data[1], b0 = data[2]; | ||
| size_t lastIdx = (32 * 32 - 1) * 3; | ||
| uint8_t r1 = data[lastIdx], g1 = data[lastIdx + 1], b1 = data[lastIdx + 2]; | ||
| CHECK((r0 != r1 || g0 != g1 || b0 != b1)); |
There was a problem hiding this comment.
Add a null precondition before dereferencing the buffer pointer.
Line 52 dereferences data without guarding for allocation failure. Add a REQUIRE(data != nullptr) so failures report cleanly instead of crashing.
Proposed fix
auto* data = layer.buffer().data();
+ REQUIRE(data != nullptr);
uint8_t r0 = data[0], g0 = data[1], b0 = data[2];🧰 Tools
🪛 Cppcheck (2.20.0)
[style] 54-54: The function 'addText' 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_metaballs.cpp` around lines 51 - 55, The test dereferences the raw
pointer returned by layer.buffer().data() (variable data) without checking for
null; add a null precondition by inserting a REQUIRE(data != nullptr)
immediately after obtaining data (before the uint8_t r0 = ... line) so a failed
allocation is reported by doctest instead of crashing the test; ensure the check
references the same expression (data) used later in the comparisons.
| particles.enabled = false; | ||
| particles.onAllocateMemory(); | ||
| CHECK(particles.dynamicBytes() == 0); |
There was a problem hiding this comment.
Validate disable memory release via Layer reallocation flow.
Line 69 calls particles.onAllocateMemory() directly, which skips the layer-level disable/reallocation behavior required by the effect contract.
Proposed fix
particles.enabled = false;
- particles.onAllocateMemory();
+ layer.onAllocateMemory();
CHECK(particles.dynamicBytes() == 0);🧰 Tools
🪛 Cppcheck (2.20.0)
[style] 69-69: The function 'removeChild' is never used.
(unusedFunction)
[style] 70-70: The function 'physicalLightCount' 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_particles.cpp` around lines 68 - 70, The test currently disables
particles and calls particles.onAllocateMemory() directly, which bypasses the
Layer-level disable/reallocation contract; instead, after setting
particles.enabled = false you should invoke the owning Layer's reallocation API
(i.e., trigger the Layer reallocation flow that the effect contract uses rather
than calling particles.onAllocateMemory() directly) so the Layer performs the
proper disable/reallocate behavior, then assert particles.dynamicBytes() == 0;
replace the direct onAllocateMemory() call with the Layer reallocation call that
owns this particle set.
| auto* data = layer.buffer().data(); | ||
| uint8_t r0 = data[0], g0 = data[1], b0 = data[2]; | ||
| size_t lastIdx = (16 * 16 - 1) * 3; | ||
| uint8_t r1 = data[lastIdx], g1 = data[lastIdx + 1], b1 = data[lastIdx + 2]; | ||
| CHECK((r0 != r1 || g0 != g1 || b0 != b1)); |
There was a problem hiding this comment.
Guard buffer pointer/size before direct indexing.
This test can crash instead of failing cleanly if allocation/config regresses; add REQUIRE checks before data[0]/data[lastIdx] access.
Proposed fix
layer.onAllocateMemory();
layer.loop();
- auto* data = layer.buffer().data();
+ auto& buf = layer.buffer();
+ REQUIRE(buf.data() != nullptr);
+ REQUIRE(buf.bytes() >= (16u * 16u * 3u));
+ auto* data = buf.data();
uint8_t r0 = data[0], g0 = data[1], b0 = data[2];
size_t lastIdx = (16 * 16 - 1) * 3;
uint8_t r1 = data[lastIdx], g1 = data[lastIdx + 1], b1 = data[lastIdx + 2];As per coding guidelines, test/** requires edge-case-aware tests; crashing on null/short buffers bypasses that.
🧰 Tools
🪛 Cppcheck (2.20.0)
[style] 54-54: The function 'addText' 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_plasma.cpp` around lines 52 - 56, The test directly indexes
layer.buffer().data() and can crash if the buffer is null or too small; before
accessing data[0] and data[lastIdx+2] add REQUIRE checks that the buffer pointer
is non-null and that layer.buffer().size() (or equivalent size accessor on
layer.buffer()) is greater than lastIdx+2 so the accesses are safe, then proceed
with the existing uint8_t r0/g0/b0, r1/g1/b1 and CHECK; reference the local
variables data, lastIdx and the CHECK assertion when adding these precondition
REQUIREs.
| #define STATELESS_EFFECT_TEST(EFFECT) \ | ||
| TEST_CASE(#EFFECT " writes non-zero RGB") { \ | ||
| Ctx ctx(16, 16); \ | ||
| mm::EFFECT effect; \ | ||
| ctx.layer.addChild(&effect); \ | ||
| ctx.layer.onAllocateMemory(); \ | ||
| ctx.layer.loop(); \ | ||
| CHECK(ctx.hasNonZero()); \ | ||
| } \ | ||
| TEST_CASE(#EFFECT " spatial variation") { \ | ||
| Ctx ctx(32, 32); \ | ||
| mm::EFFECT effect; \ | ||
| ctx.layer.addChild(&effect); \ | ||
| ctx.layer.onAllocateMemory(); \ | ||
| ctx.layer.loop(); \ | ||
| CHECK(ctx.cornersDiffer()); \ | ||
| } |
There was a problem hiding this comment.
🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win
Add dynamicBytes()==0 assertions for stateless effects.
These tests currently miss a key documented contract (heap-free/stateless memory behavior). Please assert effect.dynamicBytes() == 0 after allocation/loop for each stateless effect case.
Proposed pattern
`#define` STATELESS_EFFECT_TEST(EFFECT) \
TEST_CASE(`#EFFECT` " writes non-zero RGB") { \
Ctx ctx(16, 16); \
mm::EFFECT effect; \
ctx.layer.addChild(&effect); \
ctx.layer.onAllocateMemory(); \
ctx.layer.loop(); \
+ CHECK(effect.dynamicBytes() == 0); \
CHECK(ctx.hasNonZero()); \
} \As per coding guidelines, tests in test/** must match docs/moonmodules/ specifications, and those effect docs explicitly require heap-free / dynamicBytes() = 0 behavior.
Also applies to: 76-118
🧰 Tools
🪛 Clang (14.0.6)
[warning] 58-58: function-like macro 'STATELESS_EFFECT_TEST' used; consider a 'constexpr' template function
(cppcoreguidelines-macro-usage)
🪛 Cppcheck (2.20.0)
[style] 69-69: The function 'removeChild' is never used.
(unusedFunction)
[style] 63-63: The function 'cos8' is never used.
(unusedFunction)
[style] 61-61: The function 'lut' is never used.
(unusedFunction)
[style] 70-70: The function 'physicalLightCount' is never used.
(unusedFunction)
[style] 74-74: The function 'lutSkipped' 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_stateless_effects.cpp` around lines 58 - 74, The tests generated by
STATELESS_EFFECT_TEST must assert the stateless contract by checking
effect.dynamicBytes() == 0; add an assertion after allocation/loop in both
generated TEST_CASEs so that for each mm::EFFECT created (inside Ctx ctx(...);
mm::EFFECT effect; ctx.layer.addChild(&effect); ctx.layer.onAllocateMemory();
ctx.layer.loop();) you call CHECK(effect.dynamicBytes() == 0) to ensure
heap-free behavior; update the macro STATELESS_EFFECT_TEST (and the same pattern
in the other tests covering lines 76-118) to include this assertion immediately
after onAllocateMemory()/loop().
| auto* data = ctx.layer.buffer().data(); | ||
| CHECK(data[0] != data[4 * 3]); | ||
| } |
There was a problem hiding this comment.
Use full RGB-triplet comparison for checkerboard variation.
Comparing only one channel is brittle; assert full-pixel difference to match the spec intent.
Proposed fix
ctx.layer.onAllocateMemory();
ctx.layer.loop();
auto* data = ctx.layer.buffer().data();
- CHECK(data[0] != data[4 * 3]);
+ const size_t a = 0;
+ const size_t b = 4 * 3;
+ CHECK(data[a] != data[b] || data[a + 1] != data[b + 1] || data[a + 2] != data[b + 2]);As per coding guidelines, tests under test/** should match specs in docs/moonmodules/, which define spatial variation at color/pixel level.
🧰 Tools
🪛 Cppcheck (2.20.0)
[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_stateless_effects.cpp` around lines 92 - 94, The test currently
only compares a single channel (data[0] != data[4 * 3]) which is brittle; change
it to assert the full RGB triplet differs by comparing the three color bytes for
the two pixels retrieved from ctx.layer.buffer().data() (the local variable
data). Replace the single-channel check with a full-pixel comparison (e.g., use
std::memcmp(&data[0], &data[4 * 3], 3) != 0 or explicitly check data[0..2] vs
data[4*3..4*3+2]) so the test verifies RGB-level spatial variation as specified.
Ten new effects, registered with ModuleFactory and available in the pipeline: - Stateless (zero-heap): Plasma, PlasmaPalette, Metaballs, Checkerboard, Spiral, Ripples, GlowParticles, LavaLamp - Stateful (own dynamic buffer, freed when disabled): Fire (heat grid), Particles (trail buffer) Each effect ships a module spec in docs/moonmodules/light/effects/ and a doctest case (test_fire, test_metaballs, test_particles, test_plasma, test_stateless_effects — 23 cases). color.h gains palette helpers shared by the palette-driven effects. main.cpp registers all ten; the startup pipeline keeps the single default Noise effect. This is the effects-only half of the more-effects work. Dynamic effect management (the /api/types + /api/modules/move endpoints and the add-effect picker UI) is intentionally left out — it lands separately. Effects use the existing MoonModule `enabled()` system control (Fire/Particles free their buffer when disabled); no parallel `enabled` field is introduced. Reduced pre-commit (effects + docs, no render-loop or platform change): desktop build clean, 104 unit-test cases pass (23 new), 8 scenarios pass, platform boundary + spec check (20/20) PASS. ESP32 build/flash skipped — no ESP32-specific change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
9e28ef8 to
43bba8c
Compare
David Jupijn authored docs/history/assessment-2026-05.md in commit 9e28ef8 on the feature/more-effects branch. That branch was later rewritten (reset + re-apply, to split the effects from the dynamic-effect-management work) and force-pushed; the rewritten single commit 43bba8c carried the file content but was authored by ewowi, so David's authorship of 9e28ef8 was lost when PR #3 merged. This empty commit re-establishes the attribution: the project assessment in docs/history/assessment-2026-05.md is David Jupijn's work. The file content already lives on main (via 43bba8c); only the credit is restored here.
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>
…ling, generated docs
Introduce a performance-contracts framework: every scenario step carries a
per-target `contract` block (tick ceiling + heap floor with set_by/reason)
and an auto-captured `observed` block (latest reading, updated on every
successful run). Renames `expected`/`measured` → `contract`/`observed` for
clarity. README gains a headline FPS/heap table across 4 grid sizes × 3
targets, sourced from scenario contracts.
KPI one-liner: n/a (no src/ changes)
Core
- (none — no src/ changes in this commit)
Tests
- test/scenario_runner.cpp: read `contract[<target>]` blocks per step; tick
is a ceiling, heap is a floor; default tolerances 20%/200µs floor on PC,
10%/5µs floor on ESP32; print "vs contract" / "margin" output.
- test/scenarios/{core,light}/*.json: 10 scenarios converted to the
contract+observed shape, with clean round contract values (set_by
2026-06-02, reason "initial contract"); 2 new scenarios added
(scenario_GridLayout_grid_sizes for the 4-grid sweep, scenario_Layer_buildup
for incremental pipeline build measurement); construct/mutate suffix
redundancy removed from descriptions.
Scripts / MoonDeck
- scripts/scenario/run_scenario.py: parse MEASURE lines from the C++ runner,
write observed.<host-target> back to each scenario JSON on every successful
run; `--update-contract --reason "..."` flag (replaces --update-expected)
renegotiates the contract with stamp.
- scripts/scenario/run_live_scenario.py: same on-device, target detected from
SystemModule.board; print `model=N` (sum of dynamicBytes across the live
module tree) alongside heap; 500ms post-set_control settle to avoid
transient "module not found" during buildState rebuild; pc_only dead code
removed.
- scripts/docs/_test_metadata.py: per-step fields now returned as dicts
carrying mode/live_only/bounds/contract/observed; pc_only field removed.
- scripts/docs/generate_test_docs.py: surface mode/also/live_only meta,
bounds, contract, observed per target with FPS conversion (FPS = 1e6 /
tick_us); non-measured prep steps collapse to a "Setup" bullet list under
the next measured step to control page length.
Docs / CI
- README.md: new "## Performance" section with FPS + free-heap tables across
16×16/32×32/64×64/128×128 grid sizes for Apple Silicon, esp32-eth-wifi,
esp32-eth. Sub-µs PC cells flagged "below host clock resolution".
- docs/testing.md: new "Performance contracts" + "Persistent observations"
subsections covering semantics, tolerance defaults (PC floor dominates
below ~1 ms), bounds-vs-contract coexistence, renegotiation workflow,
bespoke-convention note for the construct/mutate/fixture/reset trinity.
- docs/performance.md: slimmed (~150→100 lines), per-step tick tables moved
to scenario JSONs; structural sizeof / heap breakdown / firmware sizes
kept.
- docs/plan.md: logged NoiseEffect ~47ms/tick cost at 128×128 mirror as a
future investigation (P1 — reaching 18 FPS at 128×128 with NoiseEffect
needs ~40% effect cost reduction).
- docs/tests/scenario-tests.md: regenerated.
- CLAUDE.md: pre-commit gate #3 now runs scenarios via
\`uv run scripts/scenario/run_scenario.py\` (the wrapper persists observed
blocks); direct ./build/test/mm_scenarios still works for ad-hoc checks.
Reviews
- 👾 Reviewer (Opus, on-demand pre-commit): naming \`expected\`/\`measured\` →
\`contract\`/\`observed\` (fixed); tolerance floor swallows percentage on PC
for sub-ms ticks (documented honestly in testing.md); observed write-every-
run policy validated as deliberate (B3); pc_only dead code removed; doc
generator verbosity trimmed; PC 16×16 README cell changed from
"clock-noise" hand-wave to em-dash + footnote; bespoke-convention note
added to scenario_runner.cpp mode dispatcher + testing.md; bounds-vs-
contract coexistence paragraph added.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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>
…bit review Renames the audio module from MicModule to AudioModule (it does audio acquisition plus level/FFT analysis, and will gain line-in / USB sources beyond the I²S mic, so the name now reflects the module, not one source). Makes the primary-source references in the docs clickable (datasheets, Espressif API pages, vendor sites) per the spec-and-test principle. Processes the CodeRabbit review on the prior commit: six findings fixed, six skipped with a stated reason. KPI: 16384lights | PC:340KB | tick:121/89/119/122/56/2/37/21/117/342us(FPS:8264/11235/8403/8196/17857/500000/27027/47619/8547/2923) | tick:21177us(FPS:47) | heap:33066KB | src:87(16753) | test:53(8518) | lizard:68w Core: - MicModule.h → AudioModule.h (git mv, history preserved): class MicModule → AudioModule, registration string "MicModule" → "AudioModule", the latestFrame() static, the main.cpp wiring. The mic-source platform seam (hasI2sMic, audioMicRead, AudioMicHandle) is deliberately NOT renamed: it correctly names today's actual source; it broadens when line-in lands (concrete-first). Verified live on the S3: AudioModule registers and runs (AudioModule:523us in the tick breakdown), no boot-loop. Light domain: - AudioLevel.h: clamp the DcBlocker IIR output to the int32 range before narrowing (a settling transient could push the float past int32 bounds, which is UB on the cast). 🐇 #1. - AudioSpectrumEffect.h: fix a height-gradient off-by-one — the gradient divided by (h-1) but the spectrum spans specH rows; when the bottom row is the level meter (specH == h-1) the top spectrum row never reached full red. Now divides by (specH-1). 🐇 #4. - AudioVolumeEffect.h: defensively zero any per-light channels beyond RGB (buffers are RGB/cpl=3 today; this keeps the write correct for any cpl and leaves no stale bytes). 🐇 #3. Scripts / build: - idf_component.yml: pin esp_wifi_remote ~1.6.1 and esp_hosted ~2.12.9 (the P4-NANO-validated versions) instead of "*", so the P4 build can't silently drift to a new minor; also corrected the stale "esp_hosted bring-up prelude" comment (that prelude was removed). 🐇 #2. Verified the pins resolve and the P4 builds + boots. Docs / CI: - AudioModule.md (was MicModule.md): retitled; opening reframed as "acquires an audio source ... named for what it does, not one source" (present-tense, no claim line-in exists yet). Added primary-source links: INMP441 datasheet, esp-dsp, ESP-IDF I2S API, Hann window. First fully em-dash-free module spec. - RmtLedDriver.md / LcdLedDriver.md / ParlioLedDriver.md: linked WS2812B datasheet, ESP-IDF RMT v2 / esp_lcd / Parlio API pages, the v6.0 migration guide (legacy-RMT removal), ESP32-P4 product page. (Caught + fixed an agent-guessed Parlio URL that 404'd; all links verified live.) - SystemModule.md / building.md: linked ESP32-C6, esp_hosted, esp_wifi_remote, Waveshare ESP32-P4-NANO. - decisions.md: reworded the "designed fresh" audio lesson so it no longer reads as "don't look at prior art at all" (which fought the study-with-respect principle); now "reference proven behaviour, don't trace structure," with the flat-mic / no-correction-table call as the example. 🐇 #6. - AudioVolumeEffect.md: em-dashes removed (touched alongside the effect). 🐇 #7 (partial). - AudioSpectrumEffect.md / AudioFrame.h / test files: AudioModule rename propagated (@module annotations, references). Reviews — 🐇 CodeRabbit, prior commit (b79d54f): - Fixed #1 (DcBlocker int32 clamp), #2 (component version pins), #3 (AudioVolumeEffect extra-channel clear), #4 (AudioSpectrumEffect specH off-by-one), #6 (decisions.md prior-art wording), #7 (AudioVolumeEffect.md em-dashes). - Skipped #5 (architecture.md Buffer*/Correction* "pin at wiring time"): describes the identity-mapping fast path where the pointer is genuinely stable; the suggestion proposes a different architecture, not a doc fix; paragraph untouched this branch. - Skipped #7 (README.md / backlog.md bulk em-dash sweep): the rule is "as files are touched, not a single sweep"; a ~176-dash reformat is its own task. - Skipped #8 (AudioModule stale frame): already handled — latestFrame() returns the static silent frame when active_ is null (teardown nulls it); reinit keeps the same active mic and refreshes within one loop. - Skipped #9 (AudioModule platform boundary): not a violation — the boundary check passes and the neutral platform.h facade is included by ~10 core modules; the rule forbids platform-specific code outside src/platform/, not the abstract interface. An IAudioSource indirection is exactly the bespoke abstraction the architecture avoids. - Skipped #10 (desktop improv signature): decl and definition match exactly; the desktop build passes, which proves it. - Skipped #11/#12 (platform_esp32.cpp fcntl error-handling / include): pre-existing code not changed this branch; expanding into untouched code violates minimal-change scope. Hardware verified this commit: S3 boots clean at 225 FPS with AudioModule live; P4 boots clean at 60 FPS on Ethernet with the pinned components resolved and wifiCoproc reporting "not detected" (the known C6-slave-firmware blocker, unchanged). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… fixes Processes the Event-2 (pre-merge) Reviewer pass over the branch. The headline is a structural deduplication of the three LED drivers: the parallel WS2812 drivers (S3 LCD_CAM i80, P4 Parlio) were ~250 of ~370 lines byte-for-byte identical, now one shared CRTP base. Also fixes four stale doc/comment items, makes the AudioModule enabled-toggle actually stop its FFT cost, and removes two dead platform flags. No control names or output behaviour change; the LED hot path is unchanged (measured zero overhead, see below). KPI: 16384lights | PC:340KB | tick:116/95/118/115/56/1/37/20/113/332us(FPS:8620/10526/8474/8695/17857/1000000/27027/50000/8849/3012) | ESP32:1178KB | tick:17341us(FPS:57) | heap:33068KB | src:88(16543) | test:53(8518) | lizard:65w Light domain (the dedup, per the No-duplication rule): - ParallelLedDriver.h (new): a CRTP base ParallelLedDriver<Derived> holding the shared body of the parallel drivers — the pins/ledsPerPin/loopback controls, parseConfig, the per-ROW fused correct+encode loop(), reinit/deinit, the loopback self-test. Binding is CRTP (static polymorphism), NOT a virtual second hierarchy: the base calls derived()->busX() resolved at compile time, no vtable, no per-light indirection — so it stays inside the data-over-objects / hot-path rules and "the one deliberate class hierarchy is the module tree" rule. - LcdLedDriver.h: 378 → 92 lines, now a derived shell supplying only the i80-specific pieces (clockPin/dcPin, the exactly-8-pins rule via kExactLaneCount, the platform::lcdWs2812* calls). - ParlioLedDriver.h: 362 → 82 lines, the simpler shell (no clock/dc, kExactLaneCount=false, kClockHz). - Drivers.h (DriverBase): absorbs the status-string lifecycle (configErr_/failBuf_, setConfigErr/clearConfigErr/failBufEnsure/clearFailBuf) that was triplicated verbatim across all three drivers (RMT too). - RmtLedDriver.h: loses its status-lifecycle copy (now inherited); symbol-per-light model stays standalone (genuinely different from the parallel drivers — concrete-first boundary). Core: - AudioModule.h: removed respectsEnabled()=false so the Scheduler honours `enabled` — disabling the module now skips loop() entirely, stopping the FFT (the real per-tick cost). Verified on hardware: 524us enabled -> 0us disabled. The FFT is never gated while enabled (it is the capability audio effects consume, not an optional cost). - platform_config.h (esp32 + desktop): removed the dead isEsp32/isEsp32S3 family flags (no users anywhere); kept isEsp32P4 (drives ethPins + hasWifiCoprocessor). Desktop now mirrors with isEsp32P4=false. Docs / CI: - architecture.md: § Firmware vs board now lists the two P4 firmwares (esp32p4-eth, esp32p4-eth-wifi); § Drivers updated for the LCD_CAM/Parlio drivers and the four-driver shared Correction pointer. (👾 MUST-FIX 1, 2) - sdkconfig.defaults.esp32p4-eth-wifi: corrected the comment that described an esp_hosted bring-up prelude the code deliberately does NOT do (it was removed; esp_hosted self-inits at boot). Following the stale comment would reintroduce the bench-fixed SDIO teardown. (👾 MUST-FIX 3) - platform_esp32_lcd.cpp: the header + #else/#endif comments now name the correct SOC_LCDCAM_I80_LCD_SUPPORTED macro (they said SOC_LCD_I80_SUPPORTED — the exact macro confusion that caused the classic-ESP32 boot loop). (👾 MUST-FIX 4) - check_specs.py: skip CRTP template bases (template<...> class X : public DriverBase) — shared infrastructure, not a registered module; the concrete derived classes still carry the docs. - backlog.md: the LED-driver duplication item updated (driver-logic dedup landed via the CRTP base; the platform loopback capture+verify extraction remains as a follow-up). Reviews — 👾 Reviewer (Fable 5, over git diff main...HEAD; verdict "merge with noted fixes"): - Fixed all 4 MUST-FIX (stale docs/comments, above). - SHOULD-CONSIDER #1/#2 (Lcd/Parlio driver-logic + status duplication): FIXED via the CRTP base + DriverBase status lifecycle (chose to do it now per the No-duplication rule rather than backlog). The remaining platform loopback capture+verify duplication is the tracked follow-up. - SHOULD-CONSIDER #4 (AudioModule enabled toggle couldn't stop the FFT): FIXED (above); the FFT itself is must-have, only the disabled-still-runs gap was the bug. - SHOULD-CONSIDER #5 (unused isEsp32/isEsp32S3 flags): FIXED (removed). - SHOULD-CONSIDER #3 (audio headers in src/light/ giving a core->light arrow): accepted as-is — documented producer/consumer placement, boundary check passes. Verification: 56 driver/encoder unit tests pass, desktop build zero-warning, spec + platform-boundary clean. All three ESP32 targets (esp32, esp32s3-n16r8, esp32p4-eth-wifi) build clean under -Werror, including both CRTP instantiations. On hardware: S3 LcdLedDriver loopback bit-perfect over a 13->12 jumper (1536/1536 symbols captured, 0-bits 15..15 / 1-bits 30..30 ticks, zero jitter); P4 ParlioLedDriver renders the S3's audio spectrum live over the DDP path. Hot-path A/B (same S3, same config): pre-dedup ~3421-3442us/frame vs CRTP ~3425-3436us/frame — statistically identical, zero overhead (CRTP is static dispatch; the per-light loop is unchanged). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
Summary
Ten new effects for the light pipeline — the effects-only half of the more-effects work.
Each effect ships a module spec in
docs/moonmodules/light/effects/and a doctest case.color.hgains palette helpers shared by the palette-driven effects.main.cppregisters all ten viaModuleFactory; the startup pipeline keeps the single default Noise effect.Effects use the existing
MoonModule::enabled()system control — no parallelenabledfield. Fire/Particles free their buffer when disabled.Not included — dynamic effect management
The
/api/types+/api/modules/<n>/moveendpoints and the add-effect picker / reorder UI are intentionally excluded from this PR. They land separately as their own change.Test plan
-Werror)🤖 Generated with Claude Code