Skip to content

Paged MappingLUT + blendMap; Peripheral role + SystemModule child management#12

Merged
ewowi merged 3 commits into
mainfrom
next-iteration
Jun 6, 2026
Merged

Paged MappingLUT + blendMap; Peripheral role + SystemModule child management#12
ewowi merged 3 commits into
mainfrom
next-iteration

Conversation

@ewowi

@ewowi ewowi commented Jun 6, 2026

Copy link
Copy Markdown
Collaborator

Summary

Two commits on next-iteration:

  1. be505bb — Paged MappingLUT + blendMap overwrite path; docs reorg — fixes the two no-PSRAM-ESP32 blockers (mirror LUT degrading at 128×128, and the effect-cost regression it caused) by paging the mapping table's destinations so it fits a fragmented heap, and speeds the per-tick map by skipping the additive blend where each light is written once. Also reorganises forward-looking docs into docs/backlog/ and migrates the present-tense docs.
  2. 2631d7a — Peripheral module role + SystemModule child management — adds a domain-neutral ModuleRole::Peripheral for hardware/network bridges (sensors, actuators) the user adds/removes at runtime under SystemModule. The core infrastructure a gyro/IMU, mic, relay, or Home Assistant module attaches to. Plus a CodeRabbit review round.

1. Paged MappingLUT + blendMap (be505bb)

The blockers (no-PSRAM ESP32, 128×128): a mirror LUT needs ~64 KB of destinations, but the largest contiguous free block on the Olimex is ~18 KB — a fragmentation cliff that degraded the modifier to 1:1 (mirror invisible) and made the effect render the full grid (~4× cost).

Fix — three-tier destinations allocation: single contiguous block if it fits (PSRAM + small grids — unchanged hot path), else page into fixed 8 KB (4096-entry, power-of-two) blocks when total heap allows, else degrade to 1:1 + warning. forEachDestination keeps the flat loop for the single-block case and switches pages at each 4096 boundary otherwise — paged_ is one branch-predicted check per blend, not per destination.

blendMap overwrite fast-path: MappingLUT::overwrites() (default true — every current layout/modifier writes each light once) → plain copy instead of additive read-add-clamp (~4×). Additive blend stays available (setOverwrites(false)) for future multi-layer compositing; both paths tested.

ArtNet investigation (reverted): found that lwIP blocks UDP TX below the socket API — neither O_NONBLOCK nor MSG_DONTWAIT sheds it (verified on hardware, same ~35 ms, zero drops). Reverted; the real fix (async send task) is recorded in backlog as PSRAM-only.

Docs: plan.mdbacklog/backlog.md, moonmodules_draft/backlog/moonmodules_draft/; present-tense migration (facts pulled into specs, forward/backward pointers removed).

2. Peripheral role + SystemModule child management (2631d7a)

A peripheral is a domain-neutral MoonModule (ModuleRole::Peripheral) that bridges to hardware/network independently of the light pipeline. The defining line: consumes the light output buffer → driver; otherwise → peripheral (a DMX sender is a driver despite a UART transport). Read-vs-write is a per-module decision, not a role split — one role spans readers, writers, and both.

Peripherals are user-add/deletable children of SystemModule (the firmware is identical whether or not the device has the peripheral soldered on). Reuses the existing generic child add/replace/delete + persistence machinery. Lifecycle fix: SystemModule's overridden setup() and loop1s() now chain to MoonModule:: base so a peripheral child actually inits and polls (closing the latent "container children never tick" gap from decisions.md). BoardModule::userEditable() → false so the code-wired board identity can't be deleted from the now-editable child list.

Architecture documented (§ Peripherals, with the concrete sensor→effect data-exchange wiring). The Pi-sensor backlog entry updated to reflect the role now exists.

CodeRabbit round: BlendMap comment, a sparse-clear regression test, esp32 testMaxBlock made atomic + min(real,cap), NetworkModule.md/CLAUDE.md doc fixes. One finding declined (CLAUDE.md gate-list renumber — the 1-N-across-subheaders numbering is intentional).

Pre-merge review

  • 👾 Reviewer (Opus, whole branch): APPROVE-WITH-NITS — domain boundary clean, core-grows-slower bar passed (Peripheral role justified with a stated roster), hot path clean, paged-LUT correctness verified, docs reorg consistent. Findings addressed in-branch: carry-forward lesson recorded in decisions.md, two comment nits fixed.
  • 🐇 CodeRabbit round processed (see commit 2).

Test plan

  • Desktop build clean (-Werror, zero warnings)
  • ctest / mm_tests — 229 cases, 5017 assertions pass
  • mm_scenarios — 10/10 pass · boundary PASS · spec check 28/28
  • ESP32 builds — esp32, esp32-eth, esp32-eth-wifi, esp32s3-n16r8 all clean
  • KPI — tick:24707us(FPS:40) on esp32-eth (Olimex); LUT builds clean at maxBlock ~19 KB (no skip), mirror applied
  • Hardware-verified: paged LUT allocates where a single block can't fit; mirror applied; effect on the quadrant

🤖 Generated with Claude Code

Fixes the two no-PSRAM-ESP32 release blockers — the mirror LUT silently
degrading at 128x128 and the resulting effect-cost regression — by paging the
mapping table's destinations so it fits a fragmented heap, and speeds the
per-tick map by skipping the additive blend where each light is written once.
Also reorganises the forward-looking docs into a `backlog/` folder and strips
forward/backward cross-references out of the present-tense docs.

KPI: ESP32 tick:57397us(FPS:17) on esp32-eth (Olimex, 128x128 + Mirror +
Ripples + ArtNet). ArtNet send (~33ms) bounds this number — a transport
throughput limit, not the render path; the LUT + blendMap wins show when ArtNet
isn't the bottleneck. LUT builds clean at maxBlock ~19KB (no "skipped"
warning), mirror applied.

Light domain:
- MappingLUT: the destinations array now pages into fixed 8KB (4096-entry,
  power-of-two) blocks when a single contiguous allocation won't fit but total
  heap allows it (three-tier build: single block / paged / degrade-to-1:1).
  This lets a 128x128 mirror LUT (~64KB) allocate on a no-PSRAM ESP32 whose
  largest free block is ~18KB — the fragmentation cliff that made mirror
  silently degrade to 1:1 and Noise/Ripples render the full grid. PSRAM boards
  and small grids take the single-block path unchanged (paged_ branch-predicted
  away). Power-of-two page math is shift/mask (Xtensa has no HW divide).
- BlendMap: when the LUT writes each physical light at most once (every current
  layout/modifier — mirror, serpentine, sparse box->driver), blendMap
  plain-copies instead of additive read-add-clamp — ~4x faster per tick.
  MappingLUT gains an `overwrites()` flag (default true); additive blend stays
  available (setOverwrites(false)) for a future multi-layer composite.
- Layer::rebuildLUT: dropped the single-block canAllocate pre-check for the
  LUT; build() owns the tiered decision and degrades only on real exhaustion.

Core:
- platform: added setTestMaxAllocBlock test seam (desktop + esp32) so the paged
  fallback is exercisable without a real fragmented heap.

Tests:
- unit_MappingLUT: paged round-trip + page-boundary straddle + single-block
  path (isPaged assertions, forced via the maxAllocBlock cap).
- unit_BlendMap: single-alloc vs paged LUT produce byte-identical output (the
  end-to-end pin); overwrite path (last-write-wins) vs additive (clamp).

Docs / CI:
- Reorganised forward-looking docs into docs/backlog/: plan.md -> backlog.md,
  moonmodules_draft/ -> backlog/moonmodules_draft/. history/ stays separate
  (backward-looking). CLAUDE.md documents both as the non-present-tense,
  agents-skip pair; all path references updated (README, release.yml, scripts,
  source comments, spec cross-links).
- Present-tense migration: pulled the present-tense facts that lived behind
  "see backlog/history" pointers into the specs themselves and removed the
  pointers (BoardModule, NetworkModule, Layers, ArtNetSendDriver, performance,
  leddriver-analysis, README, BoardModule.h, Drivers.h, collect_kpi.py). Only
  prior-art lineage (README) and the rules that define the folders (CLAUDE.md)
  still reference them.
- backlog.md: removed the now-fixed "Mirror LUT degrades" regression entry and
  rewrote the stale "NoiseEffect 47ms" framing (that 47ms WAS the degraded-LUT
  bug); added the async-ArtNet option (PSRAM-only, with the Olimex memory math
  showing it can't fit a non-PSRAM board at the grid sizes where it would help).
- MappingLUT.md / ArtNetSendDriver.md: document paging and the synchronous-send
  throughput limit. Removed two implemented drafts (LinesEffect, RipplesEffect).
- Regenerated docs/tests/*.md (GameOfLifeEffect inventory + scenario wording).

Reviews:
- ArtNet non-blocking UDP send: investigated and REVERTED. A non-blocking
  socket (O_NONBLOCK and MSG_DONTWAIT both tried) does not shed lwIP UDP TX
  backpressure — the block is in the netif/driver layer below the socket API,
  verified on hardware (same ~35ms send, zero drops). The one transient "2ms"
  reading was unreproducible under steady load. The real fix is an async send
  task, captured in backlog.md as PSRAM-only.

KPI Details:

  Desktop:
    Lights: 16,384
    Binary: 281 KB
    [doctest] 229 cases, 229 passed (4999 assertions)
    tick: 353/99/53/1/102/99/43/35us (per scenario)
    === 10 scenario(s), 10 passed, 0 failed ===
    Platform boundary: PASS
    Specs: 28 modules, 28 ok
  ESP32 (esp32-eth on Olimex Gateway, 128x128 + Mirror + Ripples + ArtNet):
    tick: 57397us (FPS: 17)  heap free: 54776  maxBlock: 19456 (LUT paged, no skip)
    All 4 firmwares build clean (esp32s3-n16r8, esp32, esp32-eth, esp32-eth-wifi).
  Code:
    64 source files (11374 lines)
    42 test files (6104 lines)
    Lizard: 46 warnings

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 6, 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: 2c72d2ff-06cf-4dac-a221-889ca88667d4

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

Use the checkbox below for a quick retry:

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

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 22.22% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the two main changes: paged MappingLUT with blendMap overwrite optimization and the introduction of Peripheral role with SystemModule child management.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ 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: 4

Caution

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

⚠️ Outside diff range comments (2)
src/light/layers/BlendMap.h (1)

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

Update the function header to match the new overwrite fast path.

Lines 10-12 still describe blendMap() as additive-only, but the default behavior is now overwrite/copy when lut.overwrites() is true. Leaving that comment stale misdocuments the API right above the hot-path implementation.

✏️ Suggested update
-// Reads from logical buffer (src), writes to physical buffer (dst) via LUT.
-// Additive blending with clamping (for future multi-layer support).
+// Reads from logical buffer (src), writes to physical buffer (dst) via LUT.
+// Default behavior is overwrite/copy when each physical destination is unique;
+// additive clamped blending is used only when `lut.overwrites()` is false.

As per coding guidelines for **/*.{cpp,h,ts,tsx,js}, comments document intent and context, so factually wrong comments should be corrected.

Also applies to: 23-39

🤖 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/layers/BlendMap.h` around lines 10 - 12, Update the comment for the
blendMap function to reflect the new fast-path behavior: note that when
lut.overwrites() is true the function performs an overwrite/copy from src to dst
(fast path), and when it is false it performs additive blending with clamping
for multi-layer support; update any similar stale comments in the same block
(lines covering the rest of the function header and description) so the comment
correctly references MappingLUT::overwrites() and both behaviors (overwrite vs
additive) are documented.

Source: Coding guidelines

test/unit/light/unit_BlendMap.cpp (1)

75-167: 🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Add a sparse overwrite test that starts from dirty dst.

The new overwrite path depends on dst.clear() in src/light/layers/BlendMap.h Lines 18-20, but every added case here either fills all physical slots or writes into a fresh buffer. A regression removing that clear would still pass these tests while reintroducing stale pixels on sparse layouts.

🧪 Suggested test
+TEST_CASE("blendMap overwrite path clears unmapped physical cells") {
+    mm::Buffer src, dst;
+    src.allocate(1, 3);
+    dst.allocate(3, 3);
+    std::memset(dst.data(), 0x7F, dst.bytes());
+
+    src.data()[0] = 10;
+    src.data()[1] = 20;
+    src.data()[2] = 30;
+
+    mm::MappingLUT lut;
+    REQUIRE(lut.build(1, 1));
+    mm::nrOfLightsType map[] = {1};
+    lut.setMapping(0, map, 1);
+    lut.finalize();
+
+    mm::blendMap(src, dst, lut, 3);
+
+    CHECK(dst.data()[0] == 0);
+    CHECK(dst.data()[1] == 0);
+    CHECK(dst.data()[2] == 0);
+    CHECK(dst.data()[3] == 10);
+    CHECK(dst.data()[4] == 20);
+    CHECK(dst.data()[5] == 30);
+    CHECK(dst.data()[6] == 0);
+    CHECK(dst.data()[7] == 0);
+    CHECK(dst.data()[8] == 0);
+}

As per coding guidelines for test/**, tests should cover edge cases and match the specifications in docs/moonmodules/.

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

In `@test/unit/light/unit_BlendMap.cpp` around lines 75 - 167, Add a new unit test
that verifies the overwrite (overwrites=true) path clears destination pixels for
sparse mappings by starting dst filled with non-zero "dirty" values, applying a
MappingLUT that only writes to a subset of physical lights, calling
mm::blendMap(src, dst, lut, channels) and asserting that (a) written physical
pixels contain the expected last-writer values and (b) untouched physical pixels
were cleared (e.g., zero); reference mm::Buffer, mm::MappingLUT, and
mm::blendMap and ensure the test exercises the sparse layout so it would fail if
dst.clear() in src/light/layers/BlendMap.h were removed.

Source: Coding guidelines

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

Inline comments:
In `@CLAUDE.md`:
- Line 176: The fenced code block opening fence currently lacks a language tag;
update the opening fence (the triple-backtick block) to include a language
identifier such as "text" (e.g., change ``` to ```text) so markdownlint rule
MD040 is satisfied and the block is properly tagged.
- Line 167: The numbered list item labeled "7. **Principles audit** — sweep
`docs/`..." uses the wrong prefix and triggers markdownlint MD029; change the
numeric prefix from "7." to "3." so the ordered list sequence is correct (update
the line containing "Principles audit" in CLAUDE.md to start with "3."),
ensuring the list remains valid and passes MD029.

In `@docs/moonmodules/core/NetworkModule.md`:
- Line 109: Change the ambiguous statement about onBuildControls() to specify it
binds only the full set of controls applicable to the current build/target and
detected hardware (e.g., “full applicable set per build target”), and update the
contract so onBuildControls() binds controls only for interfaces that will be
initialized/supported (use platform::ethInit() and the WiFi STA/AP init return
values as the determiners); also note that absent-interface cards still expose
"no link"/"no IP" since there is no hardware-presence signal to the UI, and that
ssid/password and other WiFi controls must be omitted when WiFi init fails or
the build target is Ethernet-only.

In `@src/platform/esp32/platform_esp32.cpp`:
- Around line 106-111: The ESP32 backend currently uses a non-atomic global
testMaxBlock and entirely replaces the real value in maxAllocBlock(); change
testMaxBlock to an atomic<size_t> (like the desktop backend), update
setTestMaxAllocBlock to store into that atomic, and modify maxAllocBlock() to
read the real heap_caps_get_largest_free_block(MALLOC_CAP_8BIT) then return the
minimum of the real value and the atomic cap (treat 0 as no cap) to avoid races
and preserve the “cap” contract documented in platform.h.

---

Outside diff comments:
In `@src/light/layers/BlendMap.h`:
- Around line 10-12: Update the comment for the blendMap function to reflect the
new fast-path behavior: note that when lut.overwrites() is true the function
performs an overwrite/copy from src to dst (fast path), and when it is false it
performs additive blending with clamping for multi-layer support; update any
similar stale comments in the same block (lines covering the rest of the
function header and description) so the comment correctly references
MappingLUT::overwrites() and both behaviors (overwrite vs additive) are
documented.

In `@test/unit/light/unit_BlendMap.cpp`:
- Around line 75-167: Add a new unit test that verifies the overwrite
(overwrites=true) path clears destination pixels for sparse mappings by starting
dst filled with non-zero "dirty" values, applying a MappingLUT that only writes
to a subset of physical lights, calling mm::blendMap(src, dst, lut, channels)
and asserting that (a) written physical pixels contain the expected last-writer
values and (b) untouched physical pixels were cleared (e.g., zero); reference
mm::Buffer, mm::MappingLUT, and mm::blendMap and ensure the test exercises the
sparse layout so it would fail if dst.clear() in src/light/layers/BlendMap.h
were removed.
🪄 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: f7fa1aaf-8d7e-4089-aa72-a1fe54bd1b20

📥 Commits

Reviewing files that changed from the base of the PR and between a3d4611 and be505bb.

📒 Files selected for processing (35)
  • .github/workflows/release.yml
  • CLAUDE.md
  • README.md
  • docs/backlog/backlog.md
  • docs/backlog/moonmodules_draft/.gitkeep
  • docs/backlog/moonmodules_draft/core/ui.md
  • docs/backlog/moonmodules_draft/light/effects/ArtNetReceiveEffect.md
  • docs/backlog/moonmodules_draft/light/effects/DistortionWavesEffect.md
  • docs/backlog/moonmodules_draft/light/effects/SineEffect.md
  • docs/backlog/moonmodules_draft/light/layouts/WheelLayout.md
  • docs/backlog/moonmodules_draft/light/modifiers/RotateModifier.md
  • docs/moonmodules/core/BoardModule.md
  • docs/moonmodules/core/NetworkModule.md
  • docs/moonmodules/core/ui.md
  • docs/moonmodules/light/Layers.md
  • docs/moonmodules/light/MappingLUT.md
  • docs/moonmodules/light/drivers/ArtNetSendDriver.md
  • docs/moonmodules/light/leddriver-analysis-bottom-up.md
  • docs/moonmodules_draft/light/effects/LinesEffect.md
  • docs/moonmodules_draft/light/effects/RipplesEffect.md
  • docs/performance.md
  • docs/tests/scenario-tests.md
  • docs/tests/unit-tests.md
  • scripts/check/collect_kpi.py
  • src/core/BoardModule.h
  • src/light/drivers/Drivers.h
  • src/light/layers/BlendMap.h
  • src/light/layers/Layer.h
  • src/light/layers/MappingLUT.h
  • src/platform/desktop/platform_desktop.cpp
  • src/platform/esp32/platform_esp32.cpp
  • src/platform/platform.h
  • test/scenarios/light/scenario_Layouts_mutation.json
  • test/unit/core/unit_MappingLUT.cpp
  • test/unit/light/unit_BlendMap.cpp
💤 Files with no reviewable changes (3)
  • docs/moonmodules_draft/light/effects/LinesEffect.md
  • docs/moonmodules/core/ui.md
  • docs/moonmodules_draft/light/effects/RipplesEffect.md

Comment thread CLAUDE.md
5. **Changelog / release notes** — drafted in the GitHub release body. Skip only for unreleased pre-1.0 tags.
6. **Cross-platform smoke** — run scenarios on every supported platform (today: PC + ESP32; later: + Teensy, RPi) — if the release claims new platform support or the version bumps a major or minor.
7. **Principles audit** — sweep `docs/` (except `docs/plan.md` and `docs/history/`) and `src/` for forward-looking language ("roadmap", "will be", "planned", "in the future", "currently lacks", `TODO`, `FIXME`) and other violations of § Principles. Acceptable hits carry a one-line justification; the rest get rewritten present-tense or moved to `docs/plan.md` / `docs/history/`. The reviewer agent can run this end-to-end. Skip only for releases where the diff against the previous tag is doc-empty.
7. **Principles audit** — sweep `docs/` (except `docs/backlog/` and `docs/history/`) and `src/` for forward-looking language ("roadmap", "will be", "planned", "in the future", "currently lacks", `TODO`, `FIXME`) and other violations of § Principles. Acceptable hits carry a one-line justification; the rest get rewritten present-tense or moved to `docs/backlog/backlog.md` / `docs/history/`. The reviewer agent can run this end-to-end. Skip only for releases where the diff against the previous tag is doc-empty.

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 ordered-list numbering to satisfy markdownlint (MD029).

Line 167 should use the next numeric prefix in sequence (3.), not 7., to avoid list-prefix lint warnings.

🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 167-167: Ordered list item prefix
Expected: 3; Actual: 7; Style: 1/2/3

(MD029, ol-prefix)

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

In `@CLAUDE.md` at line 167, The numbered list item labeled "7. **Principles
audit** — sweep `docs/`..." uses the wrong prefix and triggers markdownlint
MD029; change the numeric prefix from "7." to "3." so the ordered list sequence
is correct (update the line containing "Principles audit" in CLAUDE.md to start
with "3."), ensuring the list remains valid and passes MD029.

Source: Linters/SAST tools

Comment thread CLAUDE.md Outdated
Comment thread docs/moonmodules/core/NetworkModule.md Outdated
Comment thread src/platform/esp32/platform_esp32.cpp Outdated
Introduces the Peripheral concept: a domain-neutral MoonModule role for
hardware/network bridges (sensors, actuators) that the user adds or removes at
runtime under SystemModule — the firmware is the same whether or not the device
has the peripheral soldered on. This is the core infrastructure a gyro/IMU,
microphone, relay, or Home Assistant push module attaches to. Also folds in a
round of CodeRabbit review fixes.

KPI: ESP32 tick:24707us(FPS:40) on esp32-eth (Olimex, mirror + Noise). This
change is core/UI with no render-hot-path impact; the tick is unchanged (the
40 FPS read is render-only — ArtNet's fps gate skipped sends in the capture).

Core:
- MoonModule: new ModuleRole::Peripheral + roleName "peripheral". Read-vs-write
  is NOT a role distinction — direction and core affinity are per-module
  decisions (a peripheral may read, write, or both), so one role spans the
  category. Comment names the roster (gyro, mic/line-in, relay, HA) that
  justifies the core enum growth.
- SystemModule: accepts user-added/removed Peripheral children
  (acceptsChildRoles "peripheral") via the existing generic add/replace/delete
  + persistence machinery. Fixed a latent lifecycle gap: SystemModule's
  overridden setup() and loop1s() now chain to MoonModule's base, so a
  peripheral child's setup()/loop1s() actually fire (without the chain a sensor
  would never init or poll — the children-miss-callbacks trap from
  decisions.md). loop20ms/loop/teardown already propagate via the base default.
- BoardModule: userEditable() == false — it's a code-wired System child and
  must not be deletable now that the user can edit System's children.
- ModuleFactory: Peripheral case in displayNameFor (peripherals name
  themselves freely — no forced role suffix).

UI:
- app.js: peripheral role emoji (🛰️) so the type picker renders the category.

Docs:
- architecture.md: new "## Peripherals" section in Core — the role, the
  data-relationship boundary (consumes the light output buffer → driver,
  otherwise → peripheral; a DMX sender is a driver despite a UART transport),
  user-managed-under-System rationale, and the concrete effect-reads-a-sensor
  wiring (peripheral owns a POD struct → const getter → effect holds a const
  pointer set in main.cpp). TOC + MoonModules intro updated.
- BoardModule.md: corrected the stale "a future PeripheralsModule will own
  runtime devices" prediction — peripherals are individual MoonModules under
  SystemModule, not a container module.
- backlog.md: the Pi-sensor entry now reflects that the Peripheral role
  exists; what remains is the producer-struct + audio-effect-consumption work.

Tests:
- unit_SystemModule: accepts-peripheral-children, the lifecycle-propagation
  regression (a peripheral child's setup/loop1s/loop20ms all fire through
  System), and the Peripheral roleName mapping.
- unit_BoardModule: BoardModule is not user-editable.

Reviews:
- 🐇 BlendMap.h header comment was stale (described only the additive path):
  rewritten to document both the overwrite-copy fast path (overwrites() true)
  and the additive-clamp path (false). Fixed.
- 🐇 blendMap sparse-clear coverage gap: added a test that pre-fills dst dirty,
  applies a sparse overwrite LUT, and asserts untouched physical cells are
  zeroed. Verified it fails if dst.clear() is removed (the regression target).
- 🐇 esp32 maxAllocBlock test seam: testMaxBlock made std::atomic (matching the
  desktop seam) and the cap now returns min(real, cap) instead of replacing —
  a cap can only lower the reported block, never claim more contiguous heap than
  the device has. Fixed.
- 🐇 NetworkModule.md "binds the full control set on every device": the doc was
  inaccurate — WiFi controls are compiled out of the Ethernet-only build via
  if constexpr (platform::hasWiFi). Corrected to the real compile-time gating
  (NOT the finding's proposed runtime ethInit()/init-return mechanism, which the
  code doesn't use).
- 🐇 CLAUDE.md code fence: added a "text" language tag (MD040). Fixed.
- 🐇 CLAUDE.md "renumber 7. to 3.": DECLINED — the Release-tag gate list is
  numbered 1-7 continuously across the Mandatory/Conditional sub-headers, the
  same intentional pattern as the Commit (1-8) and PR-merge (1-9) lists.
  Renumbering would break the sequence; MD029 misfires on the bold sub-headers.

KPI Details:

  Desktop:
    [doctest] 229 cases, 229 passed (5017 assertions)
    tick: 353/100/54/1/100/99/43/35us (per scenario)
    === 10 scenario(s), 10 passed, 0 failed ===
    Platform boundary: PASS
    Specs: 28 modules, 28 ok
  ESP32 (esp32-eth on Olimex Gateway, mirror + Noise):
    tick: 24707us (FPS: 40)  heap free: 54840  maxBlock: 19456 (LUT clean, no skip)
    All 4 firmwares build clean (esp32s3-n16r8, esp32, esp32-eth, esp32-eth-wifi).
  Code:
    64 source files (11420 lines)
    42 test files (6192 lines)
    Lizard: 46 warnings

Co-Authored-By: Claude Opus 4.8 (1M context) <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: 2

🤖 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 195-200: The paragraph that reads “Automatic detection — probing a
bus and adding the matching peripheral module — is a future convenience on top;
the manual path is the foundation.” uses future-tense and should be rewritten to
present-tense to comply with docs present-tense rules: change that sentence to
something like “Automatic detection — probing a bus and adding the matching
peripheral module — is out of scope here; the manual path is the foundation.”
Ensure any other forward-looking phrasing in the same paragraph (e.g., “a future
`platform::uart*`”) is likewise converted to present-tense or
scoped/out-of-scope phrasing, keeping the rest of the section (mentions of
SystemModule, Peripheral role, file conventions, loop20ms/loop1s,
check_specs.py) unchanged.

In `@docs/moonmodules/core/NetworkModule.md`:
- Line 109: The docs claim onBuildControls() always binds deviceName and mode
but the Controls section lacks deviceName (it's provided by SystemModule);
update the NetworkModule.md to remove deviceName from the module's own controls
or explicitly state that deviceName is read/provided by SystemModule, and keep
mode listed as bound by onBuildControls(); ensure the Controls section and the
narrative mention the same set of controls (reference onBuildControls(),
deviceName, mode, and SystemModule) so the spec is unambiguous for implementers.
🪄 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: f8ce799f-3cc6-431e-be6b-a7dfe9632858

📥 Commits

Reviewing files that changed from the base of the PR and between be505bb and 2631d7a.

📒 Files selected for processing (15)
  • CLAUDE.md
  • docs/architecture.md
  • docs/backlog/backlog.md
  • docs/moonmodules/core/BoardModule.md
  • docs/moonmodules/core/NetworkModule.md
  • src/core/BoardModule.h
  • src/core/ModuleFactory.h
  • src/core/MoonModule.h
  • src/core/SystemModule.h
  • src/light/layers/BlendMap.h
  • src/platform/esp32/platform_esp32.cpp
  • src/ui/app.js
  • test/unit/core/unit_BoardModule.cpp
  • test/unit/core/unit_SystemModule.cpp
  • test/unit/light/unit_BlendMap.cpp

Comment thread docs/architecture.md Outdated
Comment on lines +195 to +200
Peripherals are **user-add/deletable children of SystemModule**. The firmware is identical whether or not the device has the peripheral wired, so the user manages it at runtime — solder a gyro on, add the module; remove it later. This reuses the generic child add/replace/delete + persistence machinery; SystemModule simply declares `acceptsChildRoles("peripheral")`, and `SystemModule::userEditable()` defaults to allowing deletion (BoardModule, also a System child, opts out with `userEditable() == false` because the board identity is code-wired, not user-discretionary). Automatic detection — probing a bus and adding the matching peripheral module — is a future convenience on top; the manual path is the foundation.

**Reader vs writer is a per-module decision, not a role.** A peripheral may read (gyro), write (relay), or both (read a mic, drive a relay on a beat). Core affinity (which core a module's polling runs on, for load balancing) is likewise a per-module choice. Encoding direction in the role would force a false binary and split modules that do both, so one `Peripheral` role spans the category and direction lives in the module's behaviour.

**File shape and location.** A peripheral is core (domain-neutral), so it follows the *core* file convention — `.h` + `.cpp` under `src/core/` — not the light-domain header-only convention. Its hardware access goes through a domain-neutral platform primitive (`platform::i2c*`, a future `platform::uart*`), never a direct device call (the [platform boundary](#platform-abstraction) applies). It polls in `loop20ms` / `loop1s` (the render hot path is for the light pipeline only) and formats its readings into controls. Each peripheral gets a spec in `docs/moonmodules/core/` like any other core module (enforced by `check_specs.py`).

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

Keep architecture text strictly present-tense in non-backlog docs.

Line 196 uses forward-looking phrasing (“future convenience”), which conflicts with the present-tense-only rule for docs/ outside backlog/history. Reword this as current-state contract text (e.g., “Automatic detection is out of scope here; the manual path is the foundation.”).

As per coding guidelines: “docs/!(backlog|history)/**/*.md: All code and documentation must use present tense only; no changelogs, roadmaps, or future-looking language”.

🧰 Tools
🪛 LanguageTool

[style] ~195-~195: Consider shortening this phrase to just ‘whether’, unless you mean ‘regardless of whether’.
Context: ...stemModule**. The firmware is identical whether or not the device has the peripheral wired, so...

(WHETHER)

🤖 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 195 - 200, The paragraph that reads
“Automatic detection — probing a bus and adding the matching peripheral module —
is a future convenience on top; the manual path is the foundation.” uses
future-tense and should be rewritten to present-tense to comply with docs
present-tense rules: change that sentence to something like “Automatic detection
— probing a bus and adding the matching peripheral module — is out of scope
here; the manual path is the foundation.” Ensure any other forward-looking
phrasing in the same paragraph (e.g., “a future `platform::uart*`”) is likewise
converted to present-tense or scoped/out-of-scope phrasing, keeping the rest of
the section (mentions of SystemModule, Peripheral role, file conventions,
loop20ms/loop1s, check_specs.py) unchanged.

Source: Coding guidelines

Comment thread docs/moonmodules/core/NetworkModule.md Outdated
Today the cascade tries each interface unconditionally and relies on the platform init calls to fail fast when the hardware isn't present: `platform::ethInit()` returns false on boards without a PHY, and the WiFi STA/AP init paths return false on chips without WiFi. The `onBuildControls()` pass binds the full control set on every device — one firmware variant handles all combinations.

Surfacing hardware presence to the UI (so cards for absent interfaces hide rather than show as "no link" / "no IP") is deferred — see `docs/plan.md`.
The cascade tries each interface unconditionally and relies on the platform init calls to fail fast when the hardware isn't present: `platform::ethInit()` returns false on boards without a PHY, and the WiFi STA/AP init paths return false on chips without WiFi. `onBuildControls()` binds the controls applicable to the build target: `deviceName` / `mode` always, and the WiFi controls (`ssid`, `password`, `rssi`, `txPower`, the TX-power cap) only when `platform::hasWiFi` is true — they're compiled out of the Ethernet-only build. The gate is compile-time (the build target), not a runtime `ethInit()` result. Within a WiFi-capable build, `rssi` / `txPower` are bound but `hidden` while the radio is off (Ethernet / Idle), since the value would be stale. Cards for absent interfaces show as "no link" / "no IP" rather than hiding, because the device exposes no hardware-presence signal to the UI.

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

Resolve control-contract mismatch (deviceName vs listed controls).

This line says onBuildControls() always binds deviceName and mode, but this module’s Controls section does not define a deviceName control (it is described as read from SystemModule). Please align wording to avoid an ambiguous spec contract.

Based on learnings: “Module specs must be sufficient to implement from before writing code…”.

🤖 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/NetworkModule.md` at line 109, The docs claim
onBuildControls() always binds deviceName and mode but the Controls section
lacks deviceName (it's provided by SystemModule); update the NetworkModule.md to
remove deviceName from the module's own controls or explicitly state that
deviceName is read/provided by SystemModule, and keep mode listed as bound by
onBuildControls(); ensure the Controls section and the narrative mention the
same set of controls (reference onBuildControls(), deviceName, mode, and
SystemModule) so the spec is unambiguous for implementers.

Sources: Coding guidelines, Learnings

@ewowi ewowi changed the title Paged MappingLUT + blendMap overwrite path; docs reorg to backlog/ Paged MappingLUT + blendMap; Peripheral role + SystemModule child management Jun 6, 2026
Addresses the PR-merge gate findings for #12 — the 👾 Reviewer's carry-forward
lesson (gate 3) and two nits, plus the final 🐇 CodeRabbit round. Docs/comments
only (one scenario observation write-back).

Docs:
- decisions.md: closed the "container children never tick" lesson with its
  actual resolution — a child only misses a callback when the parent overrides
  a lifecycle method and forgets to chain to base; the fix is per-parent
  override-and-chain (SystemModule now does this for Peripheral children), not a
  scheduler refactor. Fixed the stale docs/plan.md pointer in that entry.
- architecture.md § Peripherals: rewrote two future-tense phrases to
  present/out-of-scope (bus auto-detection, the transport-primitive note) — the
  section describes shipped behaviour, so present-tense applies. [CodeRabbit]
- architecture.md § Web UI: the role emoji map is the ROLE_EMOJI table in
  app.js (single source of truth), not a table in ui.md. [Reviewer nit]
- NetworkModule.md: corrected the control-binding claim — onBuildControls()
  binds `mode` (not `deviceName`); `deviceName` is bound by SystemModule and
  NetworkModule only reads it for the AP SSID / mDNS hostname. [CodeRabbit]

Core:
- MappingLUT.h: fixed the kMaxPages comment math — 64 × 4096 = 256 K dests
  (512 KB), not "512 K dests". [Reviewer nit]

Tests:
- regenerated docs/tests/unit-tests.md so the inventory includes the new
  SystemModule / BoardModule / BlendMap cases (doc-sync gate).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@ewowi
ewowi merged commit b23664d into main Jun 6, 2026
1 check passed
@ewowi
ewowi deleted the next-iteration branch June 6, 2026 09:41
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>
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