Paged MappingLUT + blendMap; Peripheral role + SystemModule child management#12
Conversation
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>
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
🚥 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 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 |
There was a problem hiding this comment.
Actionable comments posted: 4
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
src/light/layers/BlendMap.h (1)
10-12:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winUpdate 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 whenlut.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 winAdd a sparse overwrite test that starts from dirty
dst.The new overwrite path depends on
dst.clear()insrc/light/layers/BlendMap.hLines 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 indocs/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
📒 Files selected for processing (35)
.github/workflows/release.ymlCLAUDE.mdREADME.mddocs/backlog/backlog.mddocs/backlog/moonmodules_draft/.gitkeepdocs/backlog/moonmodules_draft/core/ui.mddocs/backlog/moonmodules_draft/light/effects/ArtNetReceiveEffect.mddocs/backlog/moonmodules_draft/light/effects/DistortionWavesEffect.mddocs/backlog/moonmodules_draft/light/effects/SineEffect.mddocs/backlog/moonmodules_draft/light/layouts/WheelLayout.mddocs/backlog/moonmodules_draft/light/modifiers/RotateModifier.mddocs/moonmodules/core/BoardModule.mddocs/moonmodules/core/NetworkModule.mddocs/moonmodules/core/ui.mddocs/moonmodules/light/Layers.mddocs/moonmodules/light/MappingLUT.mddocs/moonmodules/light/drivers/ArtNetSendDriver.mddocs/moonmodules/light/leddriver-analysis-bottom-up.mddocs/moonmodules_draft/light/effects/LinesEffect.mddocs/moonmodules_draft/light/effects/RipplesEffect.mddocs/performance.mddocs/tests/scenario-tests.mddocs/tests/unit-tests.mdscripts/check/collect_kpi.pysrc/core/BoardModule.hsrc/light/drivers/Drivers.hsrc/light/layers/BlendMap.hsrc/light/layers/Layer.hsrc/light/layers/MappingLUT.hsrc/platform/desktop/platform_desktop.cppsrc/platform/esp32/platform_esp32.cppsrc/platform/platform.htest/scenarios/light/scenario_Layouts_mutation.jsontest/unit/core/unit_MappingLUT.cpptest/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
| 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. |
There was a problem hiding this comment.
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
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>
There was a problem hiding this comment.
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
📒 Files selected for processing (15)
CLAUDE.mddocs/architecture.mddocs/backlog/backlog.mddocs/moonmodules/core/BoardModule.mddocs/moonmodules/core/NetworkModule.mdsrc/core/BoardModule.hsrc/core/ModuleFactory.hsrc/core/MoonModule.hsrc/core/SystemModule.hsrc/light/layers/BlendMap.hsrc/platform/esp32/platform_esp32.cppsrc/ui/app.jstest/unit/core/unit_BoardModule.cpptest/unit/core/unit_SystemModule.cpptest/unit/light/unit_BlendMap.cpp
| 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`). | ||
|
|
There was a problem hiding this comment.
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
| 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. |
There was a problem hiding this comment.
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
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>
…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>
Summary
Two commits on
next-iteration: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 intodocs/backlog/and migrates the present-tense docs.2631d7a— Peripheral module role + SystemModule child management — adds a domain-neutralModuleRole::Peripheralfor 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
destinationsallocation: 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.forEachDestinationkeeps 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_NONBLOCKnorMSG_DONTWAITsheds 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.md→backlog/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()andloop1s()now chain toMoonModule::base so a peripheral child actually inits and polls (closing the latent "container children never tick" gap fromdecisions.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
testMaxBlockmade 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
decisions.md, two comment nits fixed.Test plan
-Werror, zero warnings)ctest/mm_tests— 229 cases, 5017 assertions passmm_scenarios— 10/10 pass · boundary PASS · spec check 28/28esp32,esp32-eth,esp32-eth-wifi,esp32s3-n16r8all cleantick:24707us(FPS:40)on esp32-eth (Olimex); LUT builds clean at maxBlock ~19 KB (no skip), mirror applied🤖 Generated with Claude Code