Release 5 — OTA, Platform Reach, and Library Packaging¶
Theme: Release 5 completes what Release 4 deferred: OTA firmware update, Windows native build, PPA hardware acceleration, ESP32-P4 support, and projectMM as an importable library for FastLED and other domain consumers.
Release Overview¶
What was delivered in Release 4 — build on this¶
| Strength | Notes |
|---|---|
| PAL v1 | Zero #ifdef ARDUINO in modules; all platform calls route through pal:: |
| PSRAM pixel buffers | Full 16×16+ grids without exhausting internal RAM |
| Dual-core dispatch | Effects on Core 0, drivers on Core 1 via binary semaphore |
| Art-Net in/out | Two-device live test passing; broadcast delivery ratio measured |
| Optional system modules | TasksModule and FileManagerModule loadable at runtime |
| Test classification | smoke / format / behavioral / integration badges in test-results.md |
library.json |
PlatformIO can resolve projectMM as a dependency (Sprint 3) |
What Release 5 addresses¶
| Problem | Sprint |
|---|---|
| No OTA update path; firmware update requires USB cable | Sprint 1 (FirmwareUpdateModule) |
| Rough ideas unexplored; no ESP32-P4 support | Sprint 2 (Rough ideas + P4) |
| Software blend loop bottleneck on S3/P4 | Sprint 3 (PPA accelerated blending) |
| No Windows build or CI; macOS-only release binary | Sprint 4 (Windows build) |
| No clean library entry point; no FastLED adapter | Sprint 5 (Library packaging) |
| Tasks, files, and devices show as raw text; no table rendering | Sprint 6 (Table control type) |
Sprints¶
| Sprint | Goal |
|---|---|
| Sprint 1 | OTA firmware update: PAL OTA functions + FirmwareUpdateModule + file-picker UI |
| Sprint 2 | Rough ideas + ESP32-P4 target |
| Sprint 3 | PPA accelerated blending (ESP32-S3/P4) |
| Sprint 4 | Windows build + CI + release binary |
| Sprint 5 | projectMM as a domain-independent library, moved to R4S11 |
| Sprint 6 | Table control type: structured display for tasks, files, and devices |
Sprint 1 — FirmwareUpdateModule (OTA)¶
Scope¶
Goal: let an operator upload new firmware to an ESP32 through the web UI without a USB cable. This was Part A of Release 4 Sprint 7; it was deferred because binary HTTP upload, Update.h, and hardware-in-the-loop verification were not yet in place. This sprint addresses all three.
Why it was deferred from Release 4¶
The prerequisites that were missing:
- Binary HTTP POST. The HTTP server (
cpp-httplib) can handle raw body uploads, but the route handler and size limits were not wired. This is a one-time addition to the server setup. Update.h/ ESP-IDF OTA. The Arduino OTA API requires running on real ESP32 hardware; there is no PC equivalent. CI cannot verify OTA without a hardware-in-the-loop runner. A PC stub returningtruelets unit tests pass, but end-to-end verification requires a physical device.- File picker UI.
<input type="file">+ XHR upload with progress events was not in the frontend. Adding it is straightforward but was not prioritised. - Progress reporting. The module needs to push upload progress (0-100%) to the frontend; WebSocket push is the natural path, but the upload handler and the WebSocket path run in different threads, requiring careful synchronisation.
Release 5 Sprint 1 addresses all four. OTA is less complex here than in Release 4 because the binary HTTP handler pattern is now better understood (see Sprint 4 Windows work which exercises multipart handling), and the WebSocket thread model is stable.
Part A — PAL OTA functions¶
Add to src/pal/Pal.h:
| Function | Arduino | IDF stub | PC |
|---|---|---|---|
pal::ota_begin(size_t total) |
Update.begin(total) |
stub returns false | stub returns true (dry-run) |
pal::ota_write(const uint8_t* buf, size_t len) |
Update.write(buf, len) |
stub | memcpy to /dev/null |
pal::ota_end() |
Update.end() + esp_restart() |
stub | stub |
pal::ota_error() |
Update.getError() as string |
"" |
"" |
All four functions follow the existing three-way platform switch in Pal.h. On PC, ota_begin / ota_end return true (allowing dry-run unit tests); ota_write silently discards bytes.
Part B — HTTP upload endpoint¶
Add POST /api/firmware to the server. The handler:
- Reads
Content-Lengthto callpal::ota_begin(total). - Streams the body in chunks, calling
pal::ota_write(chunk, len)per chunk and updatingprogress_in the module state. - On completion calls
pal::ota_end(). - Returns
200 OKwith{"status":"ok"}or{"error":"..."}.
The handler runs in the HTTP thread (Core 0 on ESP32). It updates progress_ atomically — FirmwareUpdateModule::loop1s() reads it and pushes a WebSocket update each second.
Part C — FirmwareUpdateModule¶
New module at src/modules/system/FirmwareUpdateModule.h:
| Control | Type | Description |
|---|---|---|
progress |
progress (0-100) |
Upload progress percentage |
status |
display |
Current status: idle, uploading, rebooting, or error: <msg> |
upload_size_kb |
display |
Total firmware size of the current/last upload (KB) |
No user-facing buttons — the upload is triggered entirely from the file picker. The module exposes state only.
Part D — Frontend file picker¶
Add to the module card rendering when ctrl.type === 'firmware_upload' (a new virtual control type the module advertises via a sentinel in its schema). The frontend renders:
<input type="file" accept=".bin">
<button>Upload</button>
<progress value="0" max="100"></progress>
On button click: read the file, POST to /api/firmware with Content-Type: application/octet-stream, track xhr.upload.onprogress, update the progress bar. On completion, poll GET /api/modules/<id> until status changes from uploading.
If this virtual control type adds too much complexity to the frontend, fall back to a standalone /update HTML page (same approach as ESPAsyncWebServer's built-in OTA page).
Part E — Hardware verification¶
The sprint is not complete until a real ESP32 has been OTA-flashed through the UI:
- Build firmware for
esp32s3_n16r8, noting the firmware version. - Increment
APP_VERSION, build a new.bin. - Open the web UI on the device, add
FirmwareUpdateModule, select the.binfile. - Confirm the device reboots,
SystemStatusModule.firmware_versionshows the new version. - Capture the serial log showing
[pal] OTA completebefore reboot.
Definition of Done:
pal::ota_begin/write/end/errorcompile on Arduino + PC (IDF stub compiles)POST /api/firmwareaccepts a binary stream; returns{"status":"ok"}on PC dry-runFirmwareUpdateModuleregistered, loadable, schema hasprogress+statuscontrols- Frontend renders file picker for
firmware_uploadsentinel or standalone/updatepage - Hardware test: ESP32-S3-N16R8 OTA-flashed and rebooted; firmware version confirmed updated
- Unit test: dry-run on PC —
ota_begin(1024)+ 16 ×ota_write(64B)+ota_end()returns true, no crash
Result¶
To be completed after implementation.
Retrospective¶
To be completed after implementation.
Sprint 2 — Rough Ideas + ESP32-P4¶
Moved from Release 4 Sprint 10.
Scope¶
Goal: explore two rough ideas and land ESP32-P4 as a third supported hardware target. With PAL v1 in place (R4 Sprint 1), adding P4 is mostly a CI job addition.
Rough Idea A — Per-sprint progress indicator. Each sprint gets a visual summary of visible (user-facing) vs. invisible (refactor/test) improvements. Evaluate whether this adds navigational value in the release docs; implement if yes.
Rough Idea B — Per-module WebGL window. Each module card could embed a miniature WebGL preview showing only that module's pixel output before compositing. Investigate feasibility (canvas per card vs. shared canvas with module selector); prototype if feasible.
ESP32-P4. Add [env:esp32p4] to platformio.ini. Key difference from S3: no onboard WiFi — NetworkModule shows "not available" rather than crashing (PAL isolates the capability check). Add esp32p4 CI compile job.
Definition of Done:
- Each rough idea has a written feasibility note; at least one ships as a feature or is promoted to backlog with rationale
[env:esp32p4]compiles cleanly; CI job greenNetworkModuledegrades gracefully on P4
Result¶
To be completed after implementation.
Retrospective¶
To be completed after implementation.
Sprint 3 — PPA Accelerated Blending¶
Moved from Release 4 Sprint 11.
Scope¶
Goal: replace the software saturating-ADD blend loop in DriverLayer with the ESP32-S3/P4 Pixel Processing Accelerator (PPA), and lay the PAL foundation so future PPA operations (fill, SRM) follow the same pattern.
Background (from hardware experience): The PPA exposes three hardware operations: Fill (2D-aware DMA memset), SRM (Scale/Rotate/Mirror), and Blend (alpha-compositing). Each operation requires a registered client handle. The key performance rules are: register handles once at boot in a global and never deregister them (teardown/re-register on every frame causes measurable slowdown and potential memory fragmentation). Each FreeRTOS task should register its own client. On PC and on esp32dev (no PPA hardware) all calls are no-ops or software fallbacks.
Part A — PAL PPA foundation. Add pal::ppa_blend_handle and pal::ppa_fill_handle as process-global handles. On ESP32-S3/P4: registered with ppa_register_client() in pal::init(), never deregistered. On PC/esp32dev: empty stubs. Add pal::ppa_blend(src_a, src_b, dst, w, h) and pal::ppa_fill(dst, color, w, h) helpers that dispatch to hardware or fall back to software.
Part B — DriverLayer uses PPA Blend. Replace the manual saturating-ADD pixel loop in DriverLayer::loop() with pal::ppa_blend(). For more than two sources, chain calls (blend source 1+2 into a scratch buffer, then blend result with source 3, etc.). The PPA Blend operation requires source and destination pointers to be different when blending two distinct inputs; the PAL wrapper enforces this. Gate the scratch buffer allocation in setup() when more than one source is wired.
Part C — Blocking vs. non-blocking strategy. For the current dual-core model (DriverLayer on Core 1 behind a binary semaphore), use blocking PPA calls: the driver task already waits for Core 0 effects, so a synchronous blend is safe and avoids adding a second semaphore. Document the non-blocking option (queue commands, block only on the final gather) in the PAL header for future use when a dedicated PPA task is introduced.
Definition of Done:
pal::ppa_blendandpal::ppa_fillcompile and link on esp32s3_n16r8 and PCDriverLayerblend output is pixel-identical to the previous software loop (unit test)- On ESP32-S3-N16R8: DriverLayer
healthReport()reportsppa=1; frame time measured at 16x16x1 vs previous sprint - Handles registered once at boot; no
ppa_unregister_client()calls in normal operation
Result¶
To be completed after implementation.
Retrospective¶
To be completed after implementation.
Sprint 4 — Windows Build + CI + Release Binary¶
Moved from Release 4 Sprint 12.
Scope¶
Goal: make projectMM build and run natively on Windows, add a Windows CI job, and publish a Windows binary alongside the macOS one on every release.
Background. cpp-httplib (HTTP server) already supports Windows via Winsock2. The two remaining POSIX blockers are WsServer.h (raw TCP sockets: sys/socket.h, unistd.h, fcntl) and Pal.h (UDP sockets: same headers). Both need #ifdef _WIN32 guards that swap in winsock2.h equivalents. CMake already works on Windows; it just needs ws2_32 linked on Windows.
Part A — Winsock2 in WsServer.h. Add #ifdef _WIN32 guards replacing:
sys/socket.h/netinet/in.h/arpa/inet.hwithwinsock2.h+ws2tcpip.hclose(fd)withclosesocket(fd)fcntl(fd, F_SETFL, O_NONBLOCK)withioctlsocket(fd, FIONBIO, &mode)recv/sendsignatures are identical on Windows (same names, different headers)- Call
WSAStartuponce inWsServer::begin()andWSACleanupin the destructor
Part B — Winsock2 in Pal.h (UDP). Same guard pattern for the PC UDP socket path: replace POSIX includes with Winsock2, close() with closesocket(), fcntl non-blocking with ioctlsocket. WSAStartup is called once per process so coordinate with Part A (call it in a shared PAL init helper).
Part C — CMakeLists.txt. Add Windows socket library link:
if(WIN32)
target_link_libraries(core PUBLIC ws2_32)
target_link_libraries(projectMM PRIVATE ws2_32)
endif()
Part D — Python deploy scripts. deploy/build.py, deploy/unittest.py, and deploy/run.py reference binary paths without .exe. Add a helper to deploy/_lib.py:
import sys
def exe(name): return name + (".exe" if sys.platform == "win32" else "")
Use it wherever a built binary is invoked.
Part E — CI and release workflow.
- Add
windows-latestjob toci.yml(CMake configure, build, test) mirroring the existing macOS job. - Add
build-pc-windowsjob torelease.ymlthat producesprojectMM-pc-windows.zip(zipped.exe) and uploads it alongside the macOS and ESP32 artifacts.
Definition of Done:
cmake -B build -DCMAKE_BUILD_TYPE=Release && cmake --build buildsucceeds on a Windows machine orwindows-latestCI runner- All unit tests pass on Windows
projectMM.exestarts, serves the UI on port 80, and responds toGET /api/modules- CI
windows-latestjob green release.ymluploadsprojectMM-pc-windows.zipon tag push
Result¶
To be completed after implementation.
Retrospective¶
To be completed after implementation.
Sprint 5 — projectMM as a Domain-Independent Library¶
Pulled forward to Release 4 Sprint 11. The full scope, design, and Definition of Done live there. This entry is kept as a cross-reference only.
Scope¶
Goal: make projectMM a clean, domain-independent library that downstream projects can import without pulling in LED-specific logic. The primary use case is FastLED-MM, which already exists (R4S10) but currently imports the full projectMM including EffectsLayer, DriverLayer, and all built-in effects.
Revised design (after R4S10 experience):
The key insight from the FastLED-MM bootstrap is that a FastLED consumer does not want or need EffectsLayer, DriverLayer, or the Channel pixel buffer. They write to CRGB leds[] directly. projectMM should not impose its internal pipeline on a domain that already has its own. The right abstraction is leaner:
ProducerModule— a thin base class insrc/core/that owns or references a pixel buffer. Subclass it to define what "a frame of pixel data" means in your domain (CRGB array, ArtNet buffer, audio samples, servo positions).ConsumerModule— a thin base class insrc/core/that reads from aProducerModule.setInput("producer", &p)wires them; core assignment routes each to the right FreeRTOS task.- EffectsLayer / DriverLayer remain in
src/modules/layers/as the built-in LED pipeline. They become concrete subclasses ofProducerModuleandConsumerModulerespectively — not required by the core.
This reopens the decision documented in architecture.md: ProducerModule/ConsumerModule, with the re-open condition (library packaging sprint) now met.
Part A — ProducerModule and ConsumerModule base classes.
Add to src/core/:
// src/core/ProducerModule.h
class ProducerModule : public StatefulModule {
public:
// Subclasses call this in setup() to declare their pixel buffer type.
// buf: pointer to the pixel data, len: number of elements, elemSize: sizeof one element.
void declareBuffer(void* buf, size_t len, size_t elemSize);
void* bufferPtr() const { return buf_; }
size_t bufferLen() const { return len_; }
private:
void* buf_ = nullptr;
size_t len_ = 0;
};
// src/core/ConsumerModule.h
class ConsumerModule : public StatefulModule {
public:
void setInput(const char* key, Module* m) override {
if (strcmp(key, "producer") == 0)
producer_ = static_cast<ProducerModule*>(m);
}
protected:
ProducerModule* producer_ = nullptr;
};
EffectsLayer and DriverLayer are updated to subclass these. No other module logic changes.
Part B — Replaceable module registrations.
Split src/modules/ModuleRegistrations.cpp into:
src/core/CoreRegistrations.cpp— infrastructure-only:NetworkModule,SystemStatusModule,DeviceDiscoveryModule. Always compiled into the library.src/modules/ModuleRegistrations.cpp— LED domain modules (effects, layers, drivers). Excluded from the library build vialibrary.jsonsrcFilter. Consumers provide their own.
FastLED-MM's main.cpp then has:
REGISTER_MODULE(WaveRainbow2DEffect)
REGISTER_MODULE(FastLEDDriverModule)
// No SineEffect, EffectsLayer, DriverLayer, GridLayout pulled in.
Part C — embeddedSetup() helper (prerequisite: R4S10 Part B).
If R4S10 Part B landed, pal::embeddedSetup() is already available. This part validates it works from a library consumer's context and documents it in library.md.
If R4S10 Part B did not land, scope it here.
Part D — library.json and CMakeLists.txt updates.
Update library.json to exclude both main.cpp and ModuleRegistrations.cpp:
"build": {
"srcDir": "src",
"srcFilter": ["+<**/*>", "-<main.cpp>", "-<modules/ModuleRegistrations.cpp>"]
}
Add a projectMMLib CMake target that excludes the same files for PC/Raspberry Pi consumers.
Part E — FastLED-MM integration test.
In the FastLED-MM repo, update to use ProducerModule / ConsumerModule:
WaveRainbow2DEffectextendsProducerModule; callsdeclareBuffer(flm_leds, FLM_NUM_LEDS, sizeof(CRGB))in setup.FastLEDDriverModuleextendsConsumerModule; readsproducer_->bufferPtr()in loop if needed (or keeps the shared array as-is for simplicity).
Verify the FastLED-MM project builds cleanly after the projectMM changes.
Part F — Documentation.
Update docs/developer-guide/library.md:
ProducerModule/ConsumerModuleAPI.- How to provide a custom
ModuleRegistrations.cpp. - FastLED-MM as the worked example.
- Domain-independence principle:
src/core/is stable public API;src/modules/is the built-in LED domain, which consumers can replace.
Definition of Done:
ProducerModuleandConsumerModuleinsrc/core/;EffectsLayer+DriverLayersubclass them; all existing tests passlibrary.jsonexcludesModuleRegistrations.cpp; a consumer providing their own compiles without duplicate-symbol errors- FastLED-MM builds with the updated projectMM and the two new base classes
docs/developer-guide/library.mdupdated with ProducerModule/ConsumerModule API- No LED-specific types in
src/core/
Backlog seeded by this sprint¶
| Item | Where |
|---|---|
Audio module: StatefulModule that reads microphone data and publishes float values to KvStore; assignable to its own FreeRTOS task |
R6 or FastLED-MM repo |
FastLED Channels API support in FastLEDDriverModule (multi-strip, per-strip GPIO via new FastLED fl::Channel) |
FastLED-MM repo |
| FastLED-MM WiFi STA: connect to home network instead of AP-only | FastLED-MM repo |
| MIDI adapter module: ConsumerModule that maps CRGB pixel data to MIDI note velocities | Future release |
Result¶
To be completed after implementation.
Retrospective¶
To be completed after implementation.
Sprint 6 — Table Control Type¶
Scope¶
Goal: replace the flat-string display in TasksModule, FileManagerModule, and DeviceDiscoveryModule with a proper HTML table rendered by the frontend. Currently all three write a newline-separated formatted string into a display control, which the UI renders as a single text block. Users cannot scan columns, sort rows, or distinguish fields at a glance.
Why a new control type rather than a frontend workaround¶
Parsing a display string client-side (splitting on \n and whitespace) is fragile and ties the frontend to the exact output format of the PAL functions. A dedicated "table" control type keeps the schema clean: the module declares column names and the frontend owns all rendering decisions (alignment, sorting, row highlighting).
Part A — "table" control type in the frontend¶
Add a new branch to buildControl() in src/frontend/index.html:
} else if (ctrl.type === 'table') {
const tbl = document.createElement('table');
tbl.className = 'ctrl-table';
tbl.dataset.mid = moduleId;
tbl.dataset.key = ctrl.key;
// ctrl.value is a JSON array-of-arrays: first row is headers.
const rows = JSON.parse(ctrl.value || '[]');
if (rows.length > 0) {
const head = tbl.createTHead();
const hr = head.insertRow();
for (const h of rows[0]) { const th = document.createElement('th'); th.textContent = h; hr.appendChild(th); }
const body = tbl.createTBody();
for (let i = 1; i < rows.length; i++) {
const tr = body.insertRow();
for (const cell of rows[i]) { const td = tr.insertCell(); td.textContent = cell; }
}
}
row.appendChild(tbl);
}
WebSocket updates for table controls: the existing applyUpdate() path reads ctrl.value — re-parse and re-render the table in place by replacing tbl.innerHTML.
Add minimal CSS for .ctrl-table: border-collapse: collapse, alternating row background, monospace font for the task/file data.
Part B — addTableControl() in StatefulModule¶
Add an overload that stores a JSON array-of-arrays in a char[] buffer:
// buf must remain valid for the module's lifetime (member variable).
// maxLen is the buffer capacity including the null terminator.
// Schema emits type:"table"; value is a JSON string: [["col1","col2"],[row...],...]
void addControl(char* buf, size_t maxLen, const char* key, const char* uiType);
This already exists as the EditStr overload ("text" / "password" types); "table" reuses the same CtrlType::EditStr storage path but is treated as read-only in setControl() (table values are not writable from outside). No new CtrlType is needed — the uiType string "table" is sufficient for the frontend to distinguish it.
Part C — Update TasksModule¶
Replace the flat task_list_ char buffer with a JSON-formatted version. task_list() still writes the raw string internally; TasksModule::loop1s() parses it into JSON:
// loop1s() fragment
char raw[1024];
pal::task_list(raw, sizeof(raw));
// Parse raw lines into JSON array-of-arrays
JsonDocument doc;
JsonArray arr = doc.to<JsonArray>();
arr.add(JsonArray{}); // header row
arr[0].add("Name"); arr[0].add("Core"); arr[0].add("Pri"); arr[0].add("Stack");
// ... split raw on '\n', split each line on whitespace, append rows
serializeJson(doc, taskTable_, sizeof(taskTable_));
Control registration changes from addControl(taskList_, "tasks", "display") to addControl(taskTable_, sizeof(taskTable_), "tasks", "table"). The task_count_ display control remains unchanged.
Part D — Update FileManagerModule¶
Same treatment: loop1s() parses the fs_list() output into JSON rows with columns ["Filename", "Size"], stores in a char[] buffer, registered as "table". The filename text input and delete button are unaffected.
Part E — Update DeviceDiscoveryModule¶
DeviceDiscoveryModule currently exposes discovered peers as a display string (or similar flat control). Switch it to a "table" control with columns ["Name", "IP", "MAC", "Version"], populated from the discovery result list in loop1s().
Definition of Done:
buildControl()handlestype === 'table'; renders an HTML table with header row- WebSocket updates re-render the table in place without full page reload
TasksModuletaskscontrol is"table"type; columns: Name, Core, Pri, StackFileManagerModulefilescontrol is"table"type; columns: Filename, SizeDeviceDiscoveryModulepeer list is"table"type; columns: Name, IP, MAC, Version- All three modules still pass their existing tests (format tests updated for new type)
- One new behavioral test per module: verifies JSON array structure in the table control value
Result¶
To be completed after implementation.
Retrospective¶
To be completed after implementation.
Release 5 Backlog¶
- PPA SRM (Scale/Rotate/Mirror). Hardware-accelerated transform for EffectsLayer outputs. Pick up after Sprint 3 PPA foundation lands.
- PPA dual-task post-processing. Dedicated PPA core for frame post-processing. Requires Sprint 3 and dual-core concurrency analysis.
- JPEG decoder handle. Register
esp_jpeg_decode_one_pictureat boot (same pattern as PPA handles). Prerequisite for image-loading effects. - Palette system.
PaletteModulewith 7 FastLED presets. Pick up alongside modifiers.