Release 3 — Pixel Pipeline, MoonLight Effects, and Continuous Integration¶
Theme: Rebuilt the pixel pipeline from the ground up on MoonLight principles, ported the first three MoonLight effects (Sine, Ripples, Lines), added a 3D WebGL preview, hardened the module interface (StatefulModule audit, ProducerModule/ConsumerModule decision, layout mapping), and replaced ad-hoc shell scripts with a unified Python CI pipeline: unit tests (doctest, 205 cases), live integration tests (REST against PC and physical ESP32 boards), build/flash automation, and pass/fail status pages published alongside the docs.
Release Overview¶
What was delivered¶
| Area | Highlights |
|---|---|
| MoonLight pixel pipeline | LightsProducer / LightsConsumer → EffectsLayer / DriverLayer; domain-agnostic base; full 3D w×h×d pixel buffer |
| MoonLight effects | SineEffect, RipplesEffect, LinesEffect ported; BrightnessModifier; registered and tested |
| 3D WebGL preview | Orbit-camera point-cloud viewer replaces 2D texture renderer; PreviewModule streams binary frames |
| Module interface | StatefulModule audit; ProducerModule/ConsumerModule base classes; setProps/setInput/loadState/saveState rationalised; classSize() footprint tracking |
| Layout system | GridLayout physical coordinate mapping + serpentine; DriverLayer Coord3D blend loop; EffectsLayer start/end persistence |
| Frontend UX | Card layout with reordering; fps/ms toggle; auto-setup default pipeline; day/night theme; ? doc links per module type |
| Runtime observability | GET /api/system JSON; SystemStatus module (fps, heap, PSRAM, FS, temp, MAC, build info, reboot button) |
| CI pipeline | Unit tests (doctest, 205 cases); live integration tests (REST against PC + ESP32); deploy/ Python scripts (build.py, flash.py, flashfs.py, run.py, livetest.py, unittest.py, all.py, summarise.py); devicelist.json device registry; pass/fail status pages in docs/status/ |
| WiFi / network | ESP32 AP+STA stability (Lolin S3 fix, task watchdog, CPU affinity); WiFi credentials in data/state/sta1.json |
Starting point — what Release 2 left open¶
| Problem | Resolution in Release 3 |
|---|---|
Flat src/modules/ folder |
Organised into effects/, modifiers/, layers/, drivers/, system/, network/ |
| Producer/Consumer model informal | Rebuilt as EffectsLayer / DriverLayer with domain-agnostic ProducerModule / ConsumerModule base |
| Module base class had too many optional overrides | StatefulModule audit: mandatory vs optional clearly separated; Sprint 7 rationalised dual-storage |
| Deploy was shell scripts per-board | Replaced by Python CI pipeline: unit tests, live integration tests, build/flash, device registry, all.py one-command orchestrator |
Sprint 1 — RipplesEffect + WiFi Lolin Fix¶
Goal: close out two concrete in-progress items — a working RipplesEffectModule visible on 2D displays, and a reliable WiFi fix for the Lolin S3 board.
Key deliverables:
RipplesEffectported from MoonLight: one pixel per (x,z) column set at wave-height y derived from 2D distance to centre. Produces "dancing sine waves" not concentric rings.LinesEffectported from MoonLight: three BPM-synced axis planes (red YZ, green XZ, blue XY).- Full 3D z-axis:
Channel.depth,ProducerLayer/ConsumerLayerextended tow×h×d, binary frame format updated to0x02with 7-byte header. - WebGL 3D point-cloud viewer with orbit camera.
ConsumerLayerblending corrected from pixel-average to saturating ADD — pixel-average was silently halving sparse effects to near-zero when blended. Saturating ADD is what every LED framework (FastLED, MoonLight) uses.- WiFi fix:
WiFi.mode(WIFI_STA)→WiFi.enableSTA(true)(ORs in the STA bit, preserves AP mode).WiFi.disconnect(true)→WiFi.disconnect(false)(radio stays on). - Task watchdog fix:
async_tcpand scheduler both on CPU 1. Fix: explicit FreeRTOS task withvTaskDelay(1)each iteration givingasync_tcpa guaranteed 1ms window per tick.
| Metric | PC | esp32dev | esp32s3_n16r8 |
|---|---|---|---|
| Tests | 179 total, 634 assertions | — | — |
| Scheduler fps (Network only) | ~258 K | ~165 K | ~267 K |
| Scheduler fps (full pipeline, 10×10×10) | — | — | ~2 755 |
| RAM runtime | — | 41.1% (133.4 KB / 324.6 KB) | 33.5% (116.3 KB / 346.7 KB) |
| Flash | — | 86.6% (1 135 KB / 1 280 KB) | 26.9% (1 099 KB / 4 096 KB) |
Retrospective:
RipplesEffectalgorithm insight: the correct MoonLight algorithm iterates only the XZ floor, computes a 2D distance → wave height y, and sets exactly that one pixel per column. First attempt filled every pixel — visually wrong. This distinction is the entire visual character of the effect.- 16×16×16 OOM on esp32dev: at 4 096 pixels the pixel buffers (double buffer + consumer snapshot) total ~73 KB — right at the headroom limit. Workaround: 10×10×10 (1 000 pixels, ~18 KB). To reach 4 096: single-buffer option + PSRAM allocation (
ps_mallocon S3). Deferred. - esp32dev Flash at 86.6% — real LED drivers will push this close to the limit. S3 is the primary target.
Sprint 2 — StatefulModule Refactor + Frontend Polish + Build Info¶
Goal: minimise the module base class surface; improve the frontend UX; expose build date; add health panel.
Key deliverables:
- Folder reorganisation:
src/modules/split intoeffects/,modifiers/,layers/,drivers/,system/. Module::setup()/teardown()demoted from pure virtual to no-ops. A valid module now needs onlyname(),category(),loop().- MoonLight-parity metadata hooks added:
tags(),dim(),onSizeChanged()— all no-op defaults, opt-in overrides. - ↺ reset-to-default button on every slider: dimmed at default, active on drift, restores on click.
- Module card layout fixed:
1frcolumn +max-width: 500px; margin: auto. Root cause of old bug:repeat(auto-fill, minmax(300px, 1fr))with one card creates N columns — card was stranded at 300 px regardless of viewport. - Drag-and-drop reordering: nav sidebar + child cards; order persisted in
localStorage. - Build date/time: CMake
string(TIMESTAMP)+ PlatformIOinject_build_info.py; exposed as read-only controls inSystemStatusModule. GET /api/test: livehealthReport()for all owned modules; PC servesbuild-logs/test-results.jsonwhen present.- Frontend System health panel: polls
/api/testevery 30 s; badge shows pass/fail count. - MoonLight
NodevsStatefulModuleanalysis: projectMM is ahead on typed controls (no JSON roundtrip), hierarchical modules, explicit teardown, and sensitive-control flag. MoonLight is ahead on coordinate abstraction (VirtualLayer+Coord3D), modifier/layout/driver hooks, and static metadata — all gaps addressed in Sprints 3–6.
| Metric | PC | esp32dev | esp32s3_n16r8 |
|---|---|---|---|
| Tests | 179 total, 635 assertions | — | — |
| Scheduler fps (full pipeline) | — | ~1 506 | ~1 589 |
| RAM runtime | — | 49.1% (159.5 KB) | 40.2% (139.2 KB) |
| Flash | — | 87.4% ↑0.8% | 27.1% ↑0.2% |
Retrospective:
- The health panel pivot: the live device endpoint (
GET /api/test) is more useful than static CI badges — always responds on any device without a file upload step. - esp32dev at 87.4% flash — 16 KB headroom. S3 at 27.1% with 73% headroom.
Sprint 3 — Generic Producer/Consumer + Lights Subclasses¶
Goal: establish a clean producer/consumer split that is domain-agnostic at the base level, with lights-specific behaviour in subclasses.
Key decisions:
ProducerModule/ConsumerModule— abstract base classes insrc/core/; no lights types.LightsProducersubclasses bothStatefulModuleandProducerModule;LightsConsumersubclasses bothStatefulModuleandConsumerModule.- Full rename — no aliases.
ProducerLayer.h/ConsumerLayer.hdeleted everywhere including test files, fixture JSON, docs, andstate/modulemanager.json. Aliases would leave two names for the same thing — do the full sweep in one sprint, making the codebase consistent from here forward. - PSRAM allocation added to both:
ps_malloc()when PSRAM available,malloc()elsewhere.
| Metric | Value |
|---|---|
| Tests | 179 total / 179 pass |
Sprint 3b — Rethink Producers/Consumers: EffectsLayer + DriverLayer¶
Goal: redesign the producer/consumer model to be genuinely domain-agnostic at core level, with layer logic matching MoonLight's two-layer model, and driver size derived from child layout modules.
Key decisions:
ProducerModule/ConsumerModuledropped entirely — added almost nothing once lights-specific members were stripped; extra inheritance layer without benefit.LightsProducer→EffectsLayer;LightsConsumer→DriverLayer. Clean break, no aliases.onChildrenReady()lifecycle hook:StatefulModule::runSetup()calls it after all children finishsetup().DriverLayeroverrides it to walk layout children, sum extents, and allocate its buffer. Zero changes to existing modules.EffectsLayerdrops absolutewidth/height/depthin favour ofstart/endfractions (0..1) relative to a connectedDriverLayer. Buffer extent computed from driver size × percentages.- Live resize on control change:
GridLayout::onUpdate()callslayoutParent_->onChildrenReady()— WebGL preview resizes immediately without a restart. docs/modules/split into subdirectories matchingsrc/modules/.
| Metric | Value |
|---|---|
| Tests | 189 total, 700 assertions (+10 new) |
Retrospective:
onChildrenReady()was the right generalisation — clean virtual onModule, no-op default, zero changes to existing modules.- Keeping
EffectsLayerignorant ofDriverLayer's type (no cross-include) viaonSizeChanged()kept the layer graph acyclic.
Sprint 4 — UX + Runtime Observability¶
Goal: richer performance display, self-healing default pipeline, day/night theme, runtime diagnostics without serial cable.
Key deliverables:
- fps/ms toggle:
"perf"UI control type; click switches display;localStoragepersists preference. - Auto-setup: if
modulemanager.jsonhas noEffectsLayer + DriverLayerpair,ModuleManager::setup()creates a minimal default pipeline (driver → grid → effects → ripples → preview). - Day/night theme: 🌙/☀ button; dark default;
[data-theme="light"]block with!importantkeeps dark CSS untouched. GET /api/system: top-level system metrics + per-module timing array;fillSystemJson()virtual hook onStatefulModule.GridLayoutwidth/height/depth persists viasaveState/loadState.EffectsLayerstart/end intentionally NOT persisted — blend loop reads by flat index with no bounds check; restoring non-full extents causes OOB read → UB. Deferred to Sprint 6.- Persistence ordering fix:
loadStatemust write directly to member variables, not callsetControl(). Controls are not registered untilsetup()runs;setControl()beforesetup()silently does nothing. Rule:loadState→ member vars →setup()callsaddControl(var, …)which reads the already-restored value.
| Metric | Value |
|---|---|
| Tests | 192 total, 715 assertions, 0 failed |
Sprint 5 — Module Interface Cleanup + Test Coverage¶
Goal: settle the ProducerModule/ConsumerModule question, introduce Coord3D, make test coverage visible per module.
Key decisions:
ProducerModule/ConsumerModule— Option A (keep dropped). Core affinity is set via thecorefield inmodulemanager.jsonand managed byModuleManagerandScheduler. No new base classes.- Audit over removal: only two virtuals (
tags()anddim()) have no concrete overrides, and both are load-bearing for the add-module picker (Release 4+). Right call was documenting the audit, not removing. Coord3D— PODstruct { uint16_t x, y, z; }insrc/core/;coords_to_index+index_to_coordswith round-trip tests. Foundation for Sprint 6 layout mapping.test_sprint9.cpp→test_stateful_module.cpp: removes a sprint-number label that ages badly.scripts/gen-test-coverage.py: groups test cases by component using prefix matching; picks up new cases automatically as long as they follow"ComponentName - description"naming.
| Metric | Value |
|---|---|
| Tests | 199 total, 953 assertions (+7 new: 6 Coord3D + 1 EffectsLayer heapSize) |
Sprint 6 — Layout Mapping + Blend Loop Fix¶
Goal: wire Coord3D into GridLayout's physical mapping API; fix the DriverLayer blend loop OOB issue blocking EffectsLayer start/end persistence since Sprint 4.
Key deliverables:
GridLayout::requestMappings()/mappingCount(): table built insetup(), rebuilt ononUpdate(). Returningconst uint32_t*(flat array, owned byGridLayout) avoids any allocation at call time.- Serpentine prop + toggle control; unit tests verify 4×3 straight (identity) and serpentine (odd rows reversed).
DriverLayerblend loop rewritten asCoord3D-aware (SrcFramestruct +index_to_coords+ bounds check). No OOB read when source is smaller than driver. For 10×10×10 the overhead is negligible; precomputed table is a straightforward follow-up if profiling ever shows it hot.EffectsLayersaveState/loadStatere-enabled forstartX/Y/Z,endX/Y/Z;pixelOffsetX/Y/Z()accessors added. Re-enabling was a one-liner — the code was already written in Sprint 3b and intentionally removed in Sprint 4.
| Metric | Value |
|---|---|
| Tests | 204 total, 999 assertions (+5 new) |
Sprint 7 — StatefulModule Memory and Interface Audit¶
Goal: eliminate per-module repetition of setProps/loadState/saveState; address dual-storage waste.
Key insight: every module had to declare fields in four places — setup() (addControl), setProps(), loadState(), and saveState(). This was an ordering artifact, not an inherent requirement.
Key deliverables:
addControl()now stashes and applies pending props/state. Base class stashes pending props and saved state beforesetup()runs;addControl(var, key, …)applies the stashed value tovarat registration time;saveState()iterates registered descriptors automatically. Rule: register a control before you use its field insetup(). No module needs to overridesetProps,loadState, orsaveState.setInputis the only hook module authors still override — it carries aModule*, not a scalar, so the base stash mechanism cannot replace it.CtrlType::FloatConst— stores the float value in the descriptor'sdefValslot with no backing member. Used for static display-only hardware properties (cpuFreqMhz_,flashSizeMb_, etc.) — zero class-member cost. Overload takesfloat&&to avoid ambiguity withfloat&backing-field overload.meta_store onStatefulModule— opt-inJsonDocument*for non-control metadata set once insetup(), read inhealthReport(). Lazily allocated; modules that never use it pay only 4–8 bytes (null pointer).EffectsLayer::runLoop()publishes once after all children have looped. Previously each effect calledlayer_->publish()— with multiple effects on one layer, the second effect wrote into a fresh empty back-buffer (after the first flip), silently discarding the first effect's pixels.- Memory savings: 321 B per-instance across
SystemStatusModulestring fields removed (5 ×char[]→ ROM literals),WifiApModule::ssid_[32]→meta_,WifiSta.hssid_[64]→ssid_[32],apPassword_[64]removed (gated#define WIFIAP_PASSWORD).
| Metric | Value |
|---|---|
| Tests | 207 total, 1 004 assertions |
| Memory savings | 321 B per-instance (target was ≥ 300 B) |
Retrospective:
CtrlType::FloatConstwithfloat&&disambiguation is enforced by the type system with no casts at call sites — a clean zero-overhead abstraction.- Moving
publish()toEffectsLayer::runLoop()fixed a latent multi-effect pixel-corruption bug. Effects are now pure pixel writers with no footgun. - Tests that call
effect.loop()directly (bypassing hierarchy) silently broke whenpublish()was removed from effects. Fix: explicitlayer.publish()in tests. Required updating eight test files.
Sprint 8 — Deploy System (last sprint of Release 3)¶
Goal: Replace ad-hoc scripts/ shell scripts with a clean, cross-platform deploy/ Python system. Every operational step is one script with a consistent device-selection interface.
Key deliverables:
deploy/devicelist.json— JSON array (not object) of devices; fields:type,device_name,ip,port,mac,env,version,test,ssid.-<field> <value>selection convention unified indeploy/_lib.py— any field indevicelist.jsonis a filter; MAC is the preferred stable identifier. Multiple flags AND together.- Scripts:
unittest.py,build.py,devicelist.py,flash.py,flashfs.py,run.py,livetest.py,live_suite.py,summarise.py,wifi.py,all.py. All cross-platform Python; PlatformIO bundled Python used for ESP32. - Separating build (per-env) from flash/test (per-device) eliminated duplication endemic to per-device shell scripts.
docs/status/test-results.mdanddocs/status/deploy-summary.mdserved by mkdocs — first-class docs, not throwaway log files.all.py— one command, all devices, all steps, full summary.- All 10
.shscripts deleted;deploy/is Python-only. deploy/flashfs.py --wifi— WiFi credentials injected via LittleFS fromdata/state/sta1.json; replacesflash-and-monitor.sh.
| Metric | Value |
|---|---|
| Unit tests | 205/205 passed |
| PC live tests | 4/4 (38 checks) |
| ESP32 live tests | 4/4 (38 checks) per device — esp32dev + esp32s3_n16r8 |
| Scripts delivered | 11 |
Deploy summary (2026-04-20, all devices connected):
| Device | Type | Build | Flash | Run | Live |
|---|---|---|---|---|---|
| PC | pc | ✅ | — | ✅ | ✅ |
| MM-70BC | esp32 | ✅ | ✅ | ✅ | ✅ |
| MM-ESP32 | esp32 | ✅ | ✅ | ✅ | ✅ |
Retrospective:
- Moving from shell scripts to Python removed the heredoc/stdin conflict entirely;
livetest.pypasses structured data as a normal Python dict. - Stable markdown anchor headings (no counts or emoji in section titles) mean cross-doc links survive as test counts change.
summarise.pyreads logs with heuristic PASS/FAIL regex — fragile. Future: every script writes a structuredresult.jsonper step.- OTA flash (WiFi, no USB cable) not yet implemented —
flash.pyrequires a serial port.
Seeds for Release 4:
- Hardware heap measurement on both boards to confirm the 321 B calculated saving from Sprint 7.
- Art-NET / DDP / E1.31 output drivers — the driver layer is ready; protocol implementations are next.
- OTA upload via
esptoolor/api/otaendpoint.