Skip to content

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 / LightsConsumerEffectsLayer / 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:

  • RipplesEffect ported 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.
  • LinesEffect ported from MoonLight: three BPM-synced axis planes (red YZ, green XZ, blue XY).
  • Full 3D z-axis: Channel.depth, ProducerLayer / ConsumerLayer extended to w×h×d, binary frame format updated to 0x02 with 7-byte header.
  • WebGL 3D point-cloud viewer with orbit camera.
  • ConsumerLayer blending 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_tcp and scheduler both on CPU 1. Fix: explicit FreeRTOS task with vTaskDelay(1) each iteration giving async_tcp a 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:

  • RipplesEffect algorithm 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_malloc on 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 into effects/, modifiers/, layers/, drivers/, system/.
  • Module::setup() / teardown() demoted from pure virtual to no-ops. A valid module now needs only name(), 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: 1fr column + 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) + PlatformIO inject_build_info.py; exposed as read-only controls in SystemStatusModule.
  • GET /api/test: live healthReport() for all owned modules; PC serves build-logs/test-results.json when present.
  • Frontend System health panel: polls /api/test every 30 s; badge shows pass/fail count.
  • MoonLight Node vs StatefulModule analysis: 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 in src/core/; no lights types.
  • LightsProducer subclasses both StatefulModule and ProducerModule; LightsConsumer subclasses both StatefulModule and ConsumerModule.
  • Full rename — no aliases. ProducerLayer.h / ConsumerLayer.h deleted everywhere including test files, fixture JSON, docs, and state/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 / ConsumerModule dropped entirely — added almost nothing once lights-specific members were stripped; extra inheritance layer without benefit.
  • LightsProducerEffectsLayer; LightsConsumerDriverLayer. Clean break, no aliases.
  • onChildrenReady() lifecycle hook: StatefulModule::runSetup() calls it after all children finish setup(). DriverLayer overrides it to walk layout children, sum extents, and allocate its buffer. Zero changes to existing modules.
  • EffectsLayer drops absolute width/height/depth in favour of start/end fractions (0..1) relative to a connected DriverLayer. Buffer extent computed from driver size × percentages.
  • Live resize on control change: GridLayout::onUpdate() calls layoutParent_->onChildrenReady() — WebGL preview resizes immediately without a restart.
  • docs/modules/ split into subdirectories matching src/modules/.
Metric Value
Tests 189 total, 700 assertions (+10 new)

Retrospective:

  • onChildrenReady() was the right generalisation — clean virtual on Module, no-op default, zero changes to existing modules.
  • Keeping EffectsLayer ignorant of DriverLayer's type (no cross-include) via onSizeChanged() 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; localStorage persists preference.
  • Auto-setup: if modulemanager.json has no EffectsLayer + DriverLayer pair, ModuleManager::setup() creates a minimal default pipeline (driver → grid → effects → ripples → preview).
  • Day/night theme: 🌙/☀ button; dark default; [data-theme="light"] block with !important keeps dark CSS untouched.
  • GET /api/system: top-level system metrics + per-module timing array; fillSystemJson() virtual hook on StatefulModule.
  • GridLayout width/height/depth persists via saveState/loadState. EffectsLayer start/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: loadState must write directly to member variables, not call setControl(). Controls are not registered until setup() runs; setControl() before setup() silently does nothing. Rule: loadState → member vars → setup() calls addControl(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 the core field in modulemanager.json and managed by ModuleManager and Scheduler. No new base classes.
  • Audit over removal: only two virtuals (tags() and dim()) 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 — POD struct { uint16_t x, y, z; } in src/core/; coords_to_index + index_to_coords with round-trip tests. Foundation for Sprint 6 layout mapping.
  • test_sprint9.cpptest_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 in setup(), rebuilt on onUpdate(). Returning const uint32_t* (flat array, owned by GridLayout) avoids any allocation at call time.
  • Serpentine prop + toggle control; unit tests verify 4×3 straight (identity) and serpentine (odd rows reversed).
  • DriverLayer blend loop rewritten as Coord3D-aware (SrcFrame struct + 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.
  • EffectsLayer saveState/loadState re-enabled for startX/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 before setup() runs; addControl(var, key, …) applies the stashed value to var at registration time; saveState() iterates registered descriptors automatically. Rule: register a control before you use its field in setup(). No module needs to override setProps, loadState, or saveState.
  • setInput is the only hook module authors still override — it carries a Module*, not a scalar, so the base stash mechanism cannot replace it.
  • CtrlType::FloatConst — stores the float value in the descriptor's defVal slot with no backing member. Used for static display-only hardware properties (cpuFreqMhz_, flashSizeMb_, etc.) — zero class-member cost. Overload takes float&& to avoid ambiguity with float& backing-field overload.
  • meta_ store on StatefulModule — opt-in JsonDocument* for non-control metadata set once in setup(), read in healthReport(). 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 called layer_->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 SystemStatusModule string fields removed (5 × char[] → ROM literals), WifiApModule::ssid_[32]meta_, WifiSta.h ssid_[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::FloatConst with float&& disambiguation is enforced by the type system with no casts at call sites — a clean zero-overhead abstraction.
  • Moving publish() to EffectsLayer::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 when publish() was removed from effects. Fix: explicit layer.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 in deploy/_lib.py — any field in devicelist.json is 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.md and docs/status/deploy-summary.md served by mkdocs — first-class docs, not throwaway log files.
  • all.py — one command, all devices, all steps, full summary.
  • All 10 .sh scripts deleted; deploy/ is Python-only.
  • deploy/flashfs.py --wifi — WiFi credentials injected via LittleFS from data/state/sta1.json; replaces flash-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.py passes 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.py reads logs with heuristic PASS/FAIL regex — fragile. Future: every script writes a structured result.json per step.
  • OTA flash (WiFi, no USB cable) not yet implemented — flash.py requires 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 esptool or /api/ota endpoint.