Skip to content

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:

  1. 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.
  2. 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 returning true lets unit tests pass, but end-to-end verification requires a physical device.
  3. File picker UI. <input type="file"> + XHR upload with progress events was not in the frontend. Adding it is straightforward but was not prioritised.
  4. 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:

  1. Reads Content-Length to call pal::ota_begin(total).
  2. Streams the body in chunks, calling pal::ota_write(chunk, len) per chunk and updating progress_ in the module state.
  3. On completion calls pal::ota_end().
  4. Returns 200 OK with {"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:

  1. Build firmware for esp32s3_n16r8, noting the firmware version.
  2. Increment APP_VERSION, build a new .bin.
  3. Open the web UI on the device, add FirmwareUpdateModule, select the .bin file.
  4. Confirm the device reboots, SystemStatusModule.firmware_version shows the new version.
  5. Capture the serial log showing [pal] OTA complete before reboot.

Definition of Done:

  • pal::ota_begin/write/end/error compile on Arduino + PC (IDF stub compiles)
  • POST /api/firmware accepts a binary stream; returns {"status":"ok"} on PC dry-run
  • FirmwareUpdateModule registered, loadable, schema has progress + status controls
  • Frontend renders file picker for firmware_upload sentinel or standalone /update page
  • 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 green
  • NetworkModule degrades 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_blend and pal::ppa_fill compile and link on esp32s3_n16r8 and PC
  • DriverLayer blend output is pixel-identical to the previous software loop (unit test)
  • On ESP32-S3-N16R8: DriverLayer healthReport() reports ppa=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.h with winsock2.h + ws2tcpip.h
  • close(fd) with closesocket(fd)
  • fcntl(fd, F_SETFL, O_NONBLOCK) with ioctlsocket(fd, FIONBIO, &mode)
  • recv / send signatures are identical on Windows (same names, different headers)
  • Call WSAStartup once in WsServer::begin() and WSACleanup in 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-latest job to ci.yml (CMake configure, build, test) mirroring the existing macOS job.
  • Add build-pc-windows job to release.yml that produces projectMM-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 build succeeds on a Windows machine or windows-latest CI runner
  • All unit tests pass on Windows
  • projectMM.exe starts, serves the UI on port 80, and responds to GET /api/modules
  • CI windows-latest job green
  • release.yml uploads projectMM-pc-windows.zip on 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 in src/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 in src/core/ that reads from a ProducerModule. 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 of ProducerModule and ConsumerModule respectively — 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:

  1. src/core/CoreRegistrations.cpp — infrastructure-only: NetworkModule, SystemStatusModule, DeviceDiscoveryModule. Always compiled into the library.
  2. src/modules/ModuleRegistrations.cpp — LED domain modules (effects, layers, drivers). Excluded from the library build via library.json srcFilter. 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:

  • WaveRainbow2DEffect extends ProducerModule; calls declareBuffer(flm_leds, FLM_NUM_LEDS, sizeof(CRGB)) in setup.
  • FastLEDDriverModule extends ConsumerModule; reads producer_->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 / ConsumerModule API.
  • 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:

  • ProducerModule and ConsumerModule in src/core/; EffectsLayer + DriverLayer subclass them; all existing tests pass
  • library.json excludes ModuleRegistrations.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.md updated 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() handles type === 'table'; renders an HTML table with header row
  • WebSocket updates re-render the table in place without full page reload
  • TasksModule tasks control is "table" type; columns: Name, Core, Pri, Stack
  • FileManagerModule files control is "table" type; columns: Filename, Size
  • DeviceDiscoveryModule peer 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_picture at boot (same pattern as PPA handles). Prerequisite for image-loading effects.
  • Palette system. PaletteModule with 7 FastLED presets. Pick up alongside modifiers.