Improv = REST over serial: push device-model config (incl. eth-only)#23
Conversation
Reworks how the web installer applies a device-model's catalog defaults. The old push (installer POSTs to /api/control) fails on the deployed HTTPS site (a browser blocks an HTTPS page from POSTing to an http:// device — mixed-content), and the pull/handoff workaround that replaced it only ran if the user opened the exact ?deviceModel= link. Now the installer pushes the whole config over the serial port it already owns during provisioning, as REST operations: SET_DEVICE_MODEL then APPLY_OP (one /api/modules or /api/control op per Improv frame), applied on the device by the SAME core the HTTP API uses. Works identically on the deployed installer and local preview, and on WiFi AND Ethernet-only firmware. The device-side catalog fetch + the whole ?deviceModel= handoff are deleted. KPI: 16384lights | PC:365KB | tick:123/88/112/9/1/319/38/16/20/116/11us(FPS:8130/11363/8928/111111/1000000/3134/26315/62500/50000/8620/90909) | ESP32:1248KB | src:96(19638) | test:67(9996) | lizard:77w Core: - HttpServerModule: extracted the add/set/clear-children apply logic out of the TcpConnection handlers into transport-free applyAddModule / applySetControl / applyClearChildren + an applyOp(opJson) dispatcher (returns an OpResult enum). The HTTP handlers are now thin wrappers mapping OpResult to a status code. One apply-core both transports share — HTTP and Improv-serial execute identical code. - ImprovProvisioningModule: consumes an APPLY_OP op pushed over serial (pendingOp_ buffer + atomic, same producer/consumer dance as credentials/deviceModel) and applies it on the main loop via the HttpServerModule apply-core — never on the Improv task. Polled per-tick so a burst of ops applies promptly. - main.cpp: wire the ImprovProvisioningModule to the HttpServerModule apply-core once httpServer exists. Platform: - platform_esp32_improv.cpp: new APPLY_OP vendor RPC (0xFC) — one REST op as JSON per frame, chunked + reassembled for a rare long value, single-buffered (refuses a new op while the previous is unapplied), acked. The listener now runs on EVERY ESP32 target including Ethernet-only (MM_NO_WIFI): the WiFi-provisioning RPCs (WIFI_SETTINGS, GET_WIFI_NETWORKS, esp_wifi_*) are #ifdef-guarded out, the vendor RPCs stay, and GET_CURRENT_STATE reports from the Ethernet link on eth builds. So an eth device gets serial config injection too (proven live on the P4). Removed the old "no Improv on eth" stub. - platform_config.h: hasImprov is now true on every ESP32 target (it's the serial transport, not WiFi). Desktop stays false. - platform.h + platform_desktop.cpp: improvProvisioningInit gains opOut/opOutLen/opReady (desktop stub no-ops them). UI: - install-orchestrator.js: pushDefaultsOverSerial (SET_DEVICE_MODEL + walk the deviceModels.json entry into APPLY_OP ops) runs on BOTH the WiFi-provision path AND the already-online/eth path (an eth device is online at boot, so it took a branch that previously skipped the push entirely — that was the bug). Deleted the HTTP fan-out (tryHttpInjectBoard/deviceFetch/clearDeviceChildren/canFetchHttp) and the handoff plumbing. The TX-power cap is still sent before provisioning (weak-power boards need it before the first association). - index.html: the success screen reports honestly from defaultsApplied (did the serial push actually run?) not applyDefaults (the checkbox intent) — so it can't falsely claim "Applied". Removed the auto-open + apply-defaults button + ?deviceModel= link decoration. - app.js: deleted the device-side handoff (consumePendingDeviceModelParam, clearModuleChildren, the cross-origin catalog fetch). The device no longer reaches out to Pages. - devices.js: deleted the Inject button + its handoff helpers; renamed the "Your devices" param + state field board -> deviceModel (reads the old `board` field as a fallback so existing bookmarks keep their label, no migration). Tests: - unit_HttpServerModule_apply (new): host-tests the extracted apply-core directly (no socket) — add/set/clearChildren/applyOp, plus the robustness cases (unknown module/type, malformed op return a typed result, never crash). Docs / CI: - ImprovProvisioningModule.md + SystemModule.md + install/README.md + architecture.md: document the APPLY_OP wire op as "a REST operation in an Improv frame", the serial config-push flow, and the eth-only listener; remove the handoff docs. - CLAUDE.md: process rule — every approved plan is saved to docs/history/plans/ named Plan-YYYYDDMM - <title>. - docs/history/plans/Plan-20262006 - Improv-as-REST...: the saved plan for this change. - backlog: removed the shipped "board injection + Improv as a general data injector" item (the general injector IS APPLY_OP); renamed stale boards.json -> deviceModels.json and Board.board -> System.deviceModel references. Co-Authored-By: Claude Opus 4.8 <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: 1
🤖 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 `@src/platform/esp32/platform_esp32_improv.cpp`:
- Around line 397-461: The improvHandleApplyOp function does not validate that
chunks arrive in the expected sequence order, which can cause data corruption
from retried or out-of-order chunks. Add a field to track the expected next
sequence number in the g_improv structure, then validate that the incoming seq
matches the expected value before processing; if it doesn't match, reset the
reassembly buffer and send an error. Additionally, add braces around the
single-statement if block where opLen is reset when seq equals zero to satisfy
static analysis requirements.
🪄 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: 5dfffaaf-24f4-4638-89b8-e8cd76411963
📒 Files selected for processing (26)
CLAUDE.mddocs/architecture.mddocs/backlog/backlog.mddocs/history/plans/Plan-20262006 - Improv-as-REST: push device-model config over serial.mddocs/install/README.mddocs/install/devices.jsdocs/install/index.htmldocs/install/install-orchestrator.jsdocs/moonmodules/core/ImprovProvisioningModule.mddocs/moonmodules/core/SystemModule.mdsrc/core/HttpServerModule.cppsrc/core/HttpServerModule.hsrc/core/ImprovProvisioningModule.hsrc/main.cppsrc/platform/desktop/platform_desktop.cppsrc/platform/esp32/platform_config.hsrc/platform/esp32/platform_esp32_improv.cppsrc/platform/platform.hsrc/ui/app.jssrc/ui/style.csstest/CMakeLists.txttest/scenarios/light/scenario_Driver_mutation.jsontest/scenarios/light/scenario_modifier_swap.jsontest/scenarios/light/scenario_perf_full.jsontest/scenarios/light/scenario_perf_light.jsontest/unit/core/unit_HttpServerModule_apply.cpp
|
@coderabbitai, can you do a thorough analysis of commit b375d59 and report findings in a prompt for agents |
|
Tip For best results, initiate chat on the files or code changes. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Fetch commit details
gh api repos/MoonModules/projectMM/commits/b375d59dca72e9364df0a90364f048830ea4ed14 \
--jq '{sha: .sha, message: .commit.message, author: .commit.author.name, date: .commit.author.date, files: [.files[] | {filename: .filename, status: .status, additions: .additions, deletions: .deletions, patch: .patch}]}'Length of output: 190315
Agent Prompt: Code-review findings for commit
|
| Step | Old upper tick_us |
New upper tick_us |
Change |
|---|---|---|---|
measure-light-64 |
4 | 5 | +25% |
measure-light-128 |
17 | 41 | +141% |
measure-heavy-32 |
16 | 59 | +269% |
measure-heavy-64 |
71 | 342 | +382% |
measure-heavy-128 |
343 | 420 | +22% |
And in scenario_modifier_swap.json: upper tick_us 13 → 20 (+54%).
These are recorded macOS benchmark samples, likely reflecting measurement variance on a loaded CI/dev machine rather than a genuine regression introduced by this commit (the C++ changes touch module management paths, not the render hot path). However the measure-heavy-64 jump (71 → 342 µs, 4.8×) is large enough to warrant confirmation.
Suggested action: Confirm these updated bounds represent a noisy re-run on the same machine rather than a code-induced slowdown. If any benchmark is run on dedicated hardware, re-run measure-heavy-64 in isolation. If it is indeed noise, add a comment in the scenario JSON explaining how upper bounds are updated.
Finding 5 — applyAddModule idempotent response body changed (minor)
File: src/core/HttpServerModule.cpp, handleAddModule
Before: Returned {"ok":true,"note":"already exists"} when the module id already existed.
After: Returns {"ok":true} (no note field).
Any client checking for that note to distinguish "created now" vs "already there" will silently stop seeing the distinction. This is unlikely to matter in practice but is a subtle API behavior change. Worth documenting in the API changelog or a comment if MoonDeck relies on that note.
Finding 6 — applyOp serial shape uses parent, HTTP uses parent_id
Files: src/core/HttpServerModule.cpp applyOp() vs HTTP handler body
The serial APPLY_OP op for "add" uses {"op":"add","type":"...","id":"...","parent":"..."} (key: parent), while the HTTP /api/modules body uses {"type":"...","id":"...","parent_id":"..."} (key: parent_id). The applyOp dispatcher reads parent, and handleAddModule reads parent_id and passes it to the same applyAddModule(). So the two transports pass through the same core but parse different JSON keys.
This is intentional (documented in comments) and correct, but it means the serial op schema and the HTTP API schema are not interchangeable — a tool that re-uses HTTP payloads as APPLY_OP frames would need to rename parent_id → parent. This is worth calling out explicitly in ImprovProvisioningModule.md or the applyOp header comment to avoid future confusion.
Finding 7 — pendingOp_ buffer overflow check is off-by-one (edge case)
File: src/platform/esp32/platform_esp32_improv.cpp, improvHandleApplyOp()
if (g_improv.opLen + chunkLen >= g_improv.opOutLen) {
// drop
}
std::memcpy(g_improv.opOut + g_improv.opLen, chunk, chunkLen);
g_improv.opLen += chunkLen;
if (last) {
g_improv.opOut[g_improv.opLen] = 0; // NUL terminatorThe guard >= opOutLen correctly leaves one byte for the NUL. This is fine. ✅ (No action needed — noted for completeness.)
Finding 8 — CLAUDE.md date format is non-standard (YYYYDDMM)
File: CLAUDE.md
The naming convention for saved plans is Plan-YYYYDDMM - <title>.md with an explicit note that it is day-before-month (DDMM not MMDD), e.g. Plan-20262006 for 2026-06-20. This is intentional but unusual and easy to get wrong. The saved plan Plan-20262006 - Improv-as-REST: push device-model config over serial.md is correctly named.
Suggested clarification: Add an example or note to CLAUDE.md that explicitly warns "DDMM not MMDD" at the point of the rule, not just in the example, to prevent future agents from using the more common ISO YYYYMMDD order.
Summary table
| # | Severity | File(s) | Issue |
|---|---|---|---|
| 1 | Medium | HttpServerModule.cpp |
Merged 404 loses module-vs-control error specificity |
| 2 | Medium | ImprovProvisioningModule.h |
applyOp result dropped — silent failures on bad ops |
| 3 | Low–Medium | install-orchestrator.js |
No retry on busy-reject; 30 ms pacing not ack-based |
| 4 | Low | scenario_perf_full.json |
Large upper-bound jumps (4.8×) need confirmation |
| 5 | Low | HttpServerModule.cpp |
already exists note removed from add response |
| 6 | Low (doc) | HttpServerModule.cpp / .md |
parent vs parent_id key discrepancy not documented in spec |
| 7 | None | platform_esp32_improv.cpp |
Off-by-one guard is correct ✅ |
| 8 | Low (doc) | CLAUDE.md |
YYYYDDMM date convention easy to misread |
Follow-up to the "Improv = REST over serial" work on this branch: processes the CodeRabbit review of the prior commit and adds the host-side + device-side test coverage that makes config-push over serial provable without hardware. The Improv frame format (built three times — device C++, Python, installer JS) and the device's APPLY_OP chunk reassembly + sequence guard now have automated tests; the installer's frame builders were extracted into a shared, dependency-free module so the JS is testable in node at all. Also restores 18 historical plans under the corrected Plan-YYYYMMDD (ISO-8601) naming. KPI: 16384lights | PC:365KB | tick:111/87/110/9/1/312/37/15/19/111/11us(FPS:9009/11494/9090/111111/1000000/3205/27027/66666/52631/9009/90909) | ESP32:1249KB | tick:5155us(FPS:193) | heap:8335KB | src:97(19754) | test:68(10118) | lizard:77w (ESP32 tick 5155us is a fresh live capture from the S3 running the full testbench config — Grid + AudioSpectrum + RandomMap + RmtLed + NetworkSend broadcasting Art-Net. Higher than the old idle-ish 3.6ms because NetworkSend is active; this change touches no render-path code, so the tick is unchanged for a like-for-like config.) Core: - ImprovOpReassembler (new): extracted the APPLY_OP chunk-reassembly + out-of-order/duplicate sequence guard out of the ESP32 handler into a pure, header-only core state machine (the ImprovFrame.h precedent — algorithm in core, UART in platform). Returns a typed Continue/Ready/Error. This makes the heart of "REST over serial" desktop-unit-testable and shrinks the platform handler (removed two g_improv fields). - HttpServerModule: split OpResult::NotFound into ModuleNotFound + ControlNotFound so the HTTP /api/control handler reports the two distinct 404 bodies again (CodeRabbit #1); added OpResult::AlreadyExists so an idempotent add reports {"ok":true,"note":"already exists"} again (#5); documented the serial "parent" vs HTTP "parent_id" key difference at the applyOp site (#6). - ImprovProvisioningModule: a non-Ok APPLY_OP result is now surfaced — logged over serial and parked in provision_status — so a silently-misconfigured device is visible on a monitor and via /api/state instead of looking like a clean install (#2). Ok/AlreadyExists are both treated as success. Platform: - platform_esp32_improv.cpp: improvHandleApplyOp now delegates chunk merge + seq guard to mm::ImprovOpReassembler, keeping only the serial I/O (busy guard, ack/error sends). No behaviour change; the logic is now the unit-tested core path. UI: - improv-frame.js (new): the pure Improv frame builders (buildImprovFrame, encodeApplyOpFrames, the command IDs) extracted from install-orchestrator.js so node:test can import them without the orchestrator's top-level unpkg imports. The orchestrator imports them back — no behaviour change. - install-orchestrator.js: APPLY_OP inter-op pacing 30ms -> 120ms to cover the worst-case single-buffer consume window with headroom (CodeRabbit #3; the closed-loop read-back-ack upgrade is backlogged, ops are idempotent). Tests: - unit_ImprovOpReassembler (new, 12 cases): in-order multi-chunk reassembly + NUL-termination, duplicate-chunk rejection, out-of-order/skipped-seq rejection, overflow at the buffer-minus-NUL boundary, exact-fit boundary, mid-stream seq-0 reset, empty final chunk, recovery after every error. - test/js/improv-frame.test.mjs + test/python/test_improv_frame.py (new): assert the SAME golden frame (buildImprovFrame(0x03,[0x01]) == 49 4d 50 52 4f 56 01 03 01 01 e3) so the device C++, Python, and JS frame builders provably agree; the JS suite also pins APPLY_OP chunking (seq/last, the 125-byte boundary). Encode (JS) + reassemble (C++) prove APPLY_OP end to end without hardware. - unit_HttpServerModule_apply: updated for the ModuleNotFound/ControlNotFound/AlreadyExists split. Scripts / CI: - .github/workflows/test.yml (new): PR-triggered pytest (test/python) + node --test (test/js) — the first PR-level test gate (none existed). Python tests carry deps in a PEP-723 inline block (repo convention, no central pyproject). Docs / CI: - testing.md: new "Host-side tests (Python + JS)" tier with a per-test inventory of exactly what the Python/JS frame suites pin, plus the C++ reassembler coverage. test/python + test/js added to the file layout. - CLAUDE.md: added commit gate 10 (host-side Python/JS unit tests); corrected the plan naming convention to Plan-YYYYMMDD (ISO-8601, sorts chronologically) and clarified plans are product-owner reference (agents write, don't auto-read). - ImprovProvisioningModule.md: documented the serial-vs-HTTP parent key difference. - docs/history/plans: restored 18 historical plans (plan-01..18, deleted in an old merge) under Plan-YYYYMMDD names; renamed the Improv-as-REST plan to the corrected ISO date. - backlog: removed the shipped "board injection / general data injector" item (it IS APPLY_OP now); added the closed-loop-pacing follow-up; renamed stale boards.json -> deviceModels.json references. Reviews: - 🐇 #1 module-vs-control 404 specificity — fixed (OpResult split). - 🐇 #2 dropped APPLY_OP result — fixed (logged + provision_status). - 🐇 #3 open-loop 30ms pacing — fixed (bumped to 120ms; ack-loop backlogged). - 🐇 #4 perf-bound "regression" — accepted: the cited tick_us are observed.* telemetry (0 bounds, 21 observed), which only widens on a slow run; no render-path change, not a regression. - 🐇 #5 dropped "already exists" note — fixed (AlreadyExists OpResult). - 🐇 #6 parent vs parent_id — fixed (documented at code site + spec). - 🐇 #7 NUL-guard off-by-one — no action (CodeRabbit confirmed correct). - 🐇 #8 plan date format — fixed (YYYYDDMM -> ISO YYYYMMDD, all plans renamed). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 5
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
src/platform/esp32/platform_esp32_improv.cpp (1)
434-445:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winReject non-binary
lastflag values before reassembly.Line 435 is parsed as
uint8_t, and Line 444 implicitly treats any non-zero astrue. That accepts malformed frames and can finalize partial payloads early.Suggested fix
uint8_t seq = payload[1]; uint8_t last = payload[2]; + if (last > 1) { + improvSendError(static_cast<improv::Error>(IMPROV_ERROR_INVALID_OP)); + return; + } const uint8_t* chunk = payload + 3; size_t chunkLen = static_cast<size_t>(len) - 3; @@ - switch (reasm.feed(seq, last, chunk, chunkLen)) { + switch (reasm.feed(seq, last == 1, chunk, chunkLen)) {🤖 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/platform/esp32/platform_esp32_improv.cpp` around lines 434 - 445, The `last` variable is parsed as `uint8_t` from the payload but should only contain binary values (0 or 1) since it represents a flag. Add validation after line 435 where `last` is extracted from `payload[2]` to check that it is either 0 or 1, and return an error or reject the frame if it contains any other value before passing it to the `reasm.feed()` method on line 444.
🤖 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 @.github/workflows/test.yml:
- Around line 14-29: The path filters in the .github/workflows/test.yml workflow
are missing entries for firmware-side frame contract files, allowing changes to
device C++ frame and dispatch code to skip CI checks. Add path patterns for the
C++ firmware/device frame contract files to both the pull_request and push
sections' paths filters, ensuring that any modifications to the device-side wire
protocol definitions trigger the test workflow and prevent cross-language
contract drift from bypassing validation.
- Around line 38-41: Replace the version tag references with specific commit
SHAs to mitigate supply-chain risks. For the actions/checkout action, replace
`@v4` with a specific commit SHA. For the astral-sh/setup-uv action, replace `@v3`
with a specific commit SHA. You can find the correct commit SHAs by visiting the
respective action repositories on GitHub and selecting a release or specific
commit. This ensures your workflows use immutable commits instead of moving tags
that could be compromised.
In `@docs/history/plans/Plan-20260522` - Stream -api-state JSON (fix fixed-buffer
overflow).md:
- Around line 1-16: Add a new "Lessons" section to the plan document after the
verification section to capture key insights about buffer management decisions.
Document when streaming JSON responses is justified versus when fixed-size
buffers are sufficient, with a concrete example such as "Once a JSON response
exceeds 4 KB, streaming is simpler than guessing a larger static size." This
section should serve as a reference for future buffer-management decisions in
the codebase.
In `@docs/history/plans/Plan-20260522` - UI rewrite to ui-spec.md baseline (item
12).md:
- Around line 1-252: Plan-11 lacks a Lessons or Learnings section to document
non-obvious insights for future UI work. Add a new section between the "Risks
and mitigations" section and the "Verification" section titled "Lessons learned"
or "Insights for future work" (~10-20 lines) that captures key learnings such as
the complexity of localStorage key migration patterns, why design decisions like
one-root-visible were chosen, non-obvious patterns around WS state-push conflict
avoidance, and why preserving existing patterns like the dragTs cooldown despite
a full rewrite is important. This section mirrors the retrospective lens used in
Plan-09 and ensures archival completeness for future reference.
In `@docs/install/improv-frame.js`:
- Around line 43-50: The `buildImprovFrame` function assigns the payload length
directly to `frame[8]` without validating that it fits in a single byte. Add a
guard clause at the beginning of `buildImprovFrame` to check if the payload
length exceeds 255, and throw an error with a descriptive message if it does.
This prevents truncation of the length value and ensures only valid wire frames
are created.
---
Outside diff comments:
In `@src/platform/esp32/platform_esp32_improv.cpp`:
- Around line 434-445: The `last` variable is parsed as `uint8_t` from the
payload but should only contain binary values (0 or 1) since it represents a
flag. Add validation after line 435 where `last` is extracted from `payload[2]`
to check that it is either 0 or 1, and return an error or reject the frame if it
contains any other value before passing it to the `reasm.feed()` method on line
444.
🪄 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: 51114825-68f5-46cd-8a4a-1a620e1d8b96
📒 Files selected for processing (38)
.github/workflows/test.ymlCLAUDE.mddocs/backlog/backlog.mddocs/history/plans/Plan-20260519 - Core Pipeline on Desktop — Lights on Panel via ArtNet.mddocs/history/plans/Plan-20260519 - ESP32 Deployment.mddocs/history/plans/Plan-20260519 - HTTP Server + WebSocket + Web UI (Items 5+6a).mddocs/history/plans/Plan-20260519 - Noise Effect + Mirror Modifier + MappingLUT.mddocs/history/plans/Plan-20260520 - 3D WebGL Preview (Item 5b).mddocs/history/plans/Plan-20260520 - Adaptive Memory Allocation & Memory Scenario Testing.mddocs/history/plans/Plan-20260520 - Live Scenario Testing (Item 8).mddocs/history/plans/Plan-20260520 - SystemModule + NetworkModule (Items 9+10).mddocs/history/plans/Plan-20260521 - Control-list-driven JSON persistence (item 11).mddocs/history/plans/Plan-20260521 - FilesystemModule + flash partition scheme (attempted, abandoned).mddocs/history/plans/Plan-20260522 - Nest child module cards inside their parent card's box.mddocs/history/plans/Plan-20260522 - Replace a module with another type.mddocs/history/plans/Plan-20260522 - Side-nav (hamburger + footer), MoonLight logo + favicon, repo URL rename.mddocs/history/plans/Plan-20260522 - Stream -api-state JSON (fix fixed-buffer overflow).mddocs/history/plans/Plan-20260522 - UI rewrite to ui-spec.md baseline (item 12).mddocs/history/plans/Plan-20260523 - Top-level shape change toLayouts,Layers,Drivers.mddocs/history/plans/Plan-20260524 - Release 1.0 distribution: web installer + GitHub Releases.mddocs/history/plans/Plan-20260525 - Release-channel picker + first-boot WiFi provisioning.mddocs/history/plans/Plan-20260620 - Improv-as-REST: push device-model config over serial.mddocs/history/plans/Plan-20260621 - Improv frame-contract unit tests (pytest + node-test).mddocs/install/improv-frame.jsdocs/install/install-orchestrator.jsdocs/moonmodules/core/ImprovProvisioningModule.mddocs/testing.mdsrc/core/HttpServerModule.cppsrc/core/HttpServerModule.hsrc/core/ImprovOpReassembler.hsrc/core/ImprovProvisioningModule.hsrc/platform/esp32/platform_esp32_improv.cpptest/CMakeLists.txttest/js/improv-frame.test.mjstest/python/test_improv_frame.pytest/scenarios/light/scenario_perf_full.jsontest/unit/core/unit_HttpServerModule_apply.cpptest/unit/core/unit_ImprovOpReassembler.cpp
💤 Files with no reviewable changes (1)
- docs/history/plans/Plan-20260620 - Improv-as-REST: push device-model config over serial.md
| - uses: actions/checkout@v4 | ||
| with: | ||
| persist-credentials: false | ||
| - uses: astral-sh/setup-uv@v3 |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
cat -n .github/workflows/test.yml | sed -n '30,60p'Repository: MoonModules/projectMM
Length of output: 1153
Pin GitHub Actions to commit SHAs to mitigate supply-chain risks.
Using moving tags like @v4 and @v3 exposes your workflows to potential compromises of upstream action repositories. Pin each action to a specific commit SHA instead.
This applies to:
- Line 38:
actions/checkout@v4 - Line 41:
astral-sh/setup-uv@v3 - Line 51:
actions/checkout@v4
🧰 Tools
🪛 zizmor (1.25.2)
[error] 38-38: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy)
(unpinned-uses)
[error] 41-41: unpinned action reference (unpinned-uses): action is not pinned to a hash (required by blanket policy)
(unpinned-uses)
🤖 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 @.github/workflows/test.yml around lines 38 - 41, Replace the version tag
references with specific commit SHAs to mitigate supply-chain risks. For the
actions/checkout action, replace `@v4` with a specific commit SHA. For the
astral-sh/setup-uv action, replace `@v3` with a specific commit SHA. You can find
the correct commit SHAs by visiting the respective action repositories on GitHub
and selecting a release or specific commit. This ensures your workflows use
immutable commits instead of moving tags that could be compromised.
Source: Linters/SAST tools
| # Plan-15 — Stream /api/state JSON (fix fixed-buffer overflow) | ||
|
|
||
| ## Context | ||
|
|
||
| Adding several effects to a Layer broke the whole web UI: it showed *"Error: The string did not match the expected pattern."* and rendered no module cards, even after a refresh. | ||
|
|
||
| Root cause: `HttpServerModule` built the entire `/api/state` JSON into a single fixed `char jsonBuf_[4096]`. With a larger module tree the JSON exceeded 4 KB; `snprintf`-based appends silently dropped individual fragments past the limit, leaving **malformed JSON** (`…value":0},}]},},},}]}]}]}` — dangling commas, broken nesting). The browser's `JSON.parse` failed and the UI never rendered. The WebSocket state push had the identical bug — a `char json[4096]` stack buffer. | ||
|
|
||
| This is exactly the failure the plan-12 spec note predicted: *"revisit only if the tree outgrows the JSON buffer (the documented fallback is then streaming JSON to the socket)."* This plan implements that fallback. | ||
|
|
||
| ## Decision | ||
|
|
||
| Stream the state JSON with **no fixed-size ceiling**, rather than just enlarging the buffer (which only moves the cliff and costs ESP32 RAM). A `JsonSink` abstraction serves both consumers: | ||
|
|
||
| - **Socket mode** — a small (1 KB) staging buffer flushes to the `TcpConnection` as it fills; the whole response never lives in RAM at once. Used by `GET /api/state`. | ||
| - **Buffer mode** — bytes collect in a heap buffer that doubles on demand. Used by the WebSocket push, whose frame header needs the total length up front so it can't stream incrementally. |
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial | ⚡ Quick win
Plan-15 captures a critical bug fix and design shift.
The root cause is clear (line 7: fixed 4 KB buffer overflows with malformed JSON), and the solution (streaming JsonSink with socket/buffer modes, lines 20–26) is well-motivated. The verification (lines 31–34) confirms the fix works at scale (7 KB valid JSON).
Optional enhancement: Consider adding a brief "Lessons" section to capture insights about when streaming is justified vs. when fixed buffers suffice. For example: "Once a JSON response exceeds 4 KB, streaming is simpler than guessing a larger static size." This would help future buffer-management decisions. (Not required given the fix's success.)
Also applies to: 35-41
🧰 Tools
🪛 LanguageTool
[style] ~9-~9: Consider an alternative for the overused word “exactly”.
Context: ...char json[4096]` stack buffer. This is exactly the failure the plan-12 spec note predi...
(EXACTLY_PRECISELY)
[style] ~16-~16: ‘in a heap’ might be wordy. Consider a shorter alternative.
Context: ...ate`. - Buffer mode — bytes collect in a heap buffer that doubles on demand. Used by ...
(EN_WORDINESS_PREMIUM_IN_A_HEAP)
🤖 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/history/plans/Plan-20260522` - Stream -api-state JSON (fix fixed-buffer
overflow).md around lines 1 - 16, Add a new "Lessons" section to the plan
document after the verification section to capture key insights about buffer
management decisions. Document when streaming JSON responses is justified versus
when fixed-size buffers are sufficient, with a concrete example such as "Once a
JSON response exceeds 4 KB, streaming is simpler than guessing a larger static
size." This section should serve as a reference for future buffer-management
decisions in the codebase.
| # Plan-11 — UI rewrite to ui-spec.md baseline (item 12) | ||
|
|
||
| ## Context | ||
|
|
||
| The v3 web UI today is a thin first cut: `src/ui/index.html` (24 lines), `app.js` (576 lines), `style.css` (156 lines). It works but doesn't reflect what `docs/moonmodules_draft/core/ui-spec.md` lays out — the spec catalogues the v1 patterns proven at scale and the gap analysis between v1 and current v3. | ||
|
|
||
| Plan-11 rewrites the UI to that spec baseline. Once status bar + card layout + 9 control types + type picker + no-rebuild contract are in, any new MoonModule renders generically with zero UI-code cost — the spec's core promise. This is the prerequisite for an "effect/module switching from UI" user feature: the switching mechanism *is* the type picker plus reorder/delete buttons on top of a spec-compliant card layout. | ||
|
|
||
| **Scope:** The 8 items in ui-spec.md § Plan-12 scope (at plan time `docs/moonmodules_draft/core/ui-spec.md`; the spec has since shipped to [docs/moonmodules/core/ui.md](../moonmodules/core/ui.md)) — status bar, card layout, 9 control types, type picker, reset-to-default, light/dark theme, WS lifecycle, 3D preview polish — plus four items promoted from § Deferred to 1.x for this iteration: fps/ms toggle per card, reboot button with crashed-state styling, system stats in header (uptime · heap), drag handles for child reorder. | ||
|
|
||
| **Intended outcome:** Plan-11 owns its own engine additions where the UI needs them. After plan-11 lands, the v3 UI matches the spec, the spec promotes from `_draft/` to `moonmodules/`, and this plan archives as `docs/history/plan-11.md` (next sequential; plan-10 untouched). | ||
|
|
||
| ## Decisions already locked | ||
|
|
||
| - **Engine additions are owned by plan-11.** Three small endpoints + supporting code: `GET /api/types`, `POST /api/modules/<n>/move`, `POST /api/reboot`, plus `MoonModule::moveChild`, `ModuleFactory` role capture at registration, `SystemModule::bootReason` control. ~150 LOC total backend. | ||
| - **Up/down icon buttons AND drag handles ship together.** Up/down for touch users, drag for desktop. Both call the same `POST /api/modules/<n>/move` endpoint with `{to: N}` (absolute target index). Up = `to: currentIndex-1`, down = `to: currentIndex+1`, drag = `to: dropTargetIndex`. One endpoint, one round-trip per move regardless of distance. | ||
| - **`POST /api/modules/<n>/move` triggers `scheduler_->rebuild()`** after a successful move. This is unnecessary for effect-only moves but required for modifier and layout moves (LUT depends on modifier order; physical→logical mapping depends on layout). Same pattern as the existing add/delete handlers — simple, correct, no need to special-case by `role()`. | ||
| - **No-rebuild contract preserved.** Existing `dragTs` 1s cooldown (current `app.js` L319) and `if (ctrl.hidden) continue` (current `app.js` L154 — plan-10 feature) stay. The rewrite extends what's there, doesn't restart from zero. | ||
| - **localStorage key migration.** Current `mm.selectedModule` → spec's `mm_selectedRoot`. Read both on init, prefer new; one-release fallback. New keys `mm_theme`, `mm_timing_mode` per spec. | ||
| - **System stats in header** uses existing `SystemModule.uptime` control from `/api/state` WS push. No new endpoint needed for that — the data is already there. | ||
| - **Reboot button needs a backend endpoint.** Adds `platform::reboot()` (ESP32: `esp_restart()`, desktop: `exit(0)`) + `POST /api/reboot` handler. Crashed-state badge driven by a new `SystemModule.bootReason` ReadOnly control populated from `esp_reset_reason()`. | ||
| - **Plan archives as `docs/history/plan-11.md`** (plan-10 untouched in history). | ||
|
|
||
| ## Engine additions | ||
|
|
||
| Required so the UI scope items have endpoints to call against. All additive — no existing behavior changes. | ||
|
|
||
| ### `src/core/MoonModule.h` — `moveChildTo(child, newIndex)` | ||
|
|
||
| Move child to an absolute position 0..childCount-1. Shifts intervening siblings (memmove-style). Returns `false` if child not found or newIndex out of range. Sits alongside existing `addChild`/`removeChild`/`replaceChildAt`. ~18 LOC. | ||
|
|
||
| ### `src/core/ModuleFactory.h` — capture role at registration | ||
|
|
||
| Extend `TypeEntry` with `ModuleRole role`. Template `registerType<T>()` discovers role via a probe instance: `T probe; ModuleRole r = probe.role();` then forwards to the non-template overload. Add `static ModuleRole typeRole(uint8_t i)` accessor. ~10 LOC. | ||
|
|
||
| ### `src/core/HttpServerModule.h` — three new endpoints | ||
|
|
||
| - `GET /api/types` → `{"types":[{"name":"NoiseEffect","role":"effect"}, …]}`. Role string lowercased from `ModuleRole` enum. UI uses it for the picker's context filter (parent's `role()` → accepted child roles, derived in JS). ~25 LOC. | ||
| - `POST /api/modules/<n>/move {to: N}`. Route uses strict-suffix match — path must end with `/move` exactly, not `/movex`. Resolves module by name, finds its parent, calls `parent->moveChildTo(mod, to)`, marks dirty, notes filesystem dirty, calls `scheduler_->rebuild()` so any LUT depending on modifier/layout order rebuilds. ~30 LOC. | ||
| - `POST /api/reboot`. Calls `platform::reboot()` and returns `{"ok":true}` (the response races the actual restart on ESP32; that's fine — the UI sees a WS disconnect and reconnects when the device comes back up). ~10 LOC. | ||
|
|
||
| ### `src/platform/platform.h` + impls — `reboot()` | ||
|
|
||
| Add `void reboot();` to the API. ESP32 impl: `esp_restart()`. Desktop impl: `std::exit(0)` (a no-op or exit; matches "smoke-tested but not load-bearing" expectations on desktop). ~6 LOC across three files. | ||
|
|
||
| ### `src/core/SystemModule.h` — `bootReason` ReadOnly control | ||
|
|
||
| Add a ~32-byte `bootReasonStr_` member. In `setup()`, query `esp_reset_reason()`, map enum to "POWERON" / "SW" / "PANIC" / "WDT" / etc., snprintf into the buffer. In `onBuildControls`, bind it as a ReadOnly control. On desktop the buffer reads "OK" (no reset reason concept). The UI uses this to set the reboot button's `data-crashed` attribute when the value indicates an unclean prior boot (PANIC/WDT/BROWNOUT). ~20 LOC. | ||
|
|
||
| ## UI rewrite | ||
|
|
||
| ### `src/ui/index.html` — full restructure (was 24 lines, target ~50) | ||
|
|
||
| - Fixed top **status bar** (`<header>` becomes 44px fixed): brand logo + wordmark, device name (from `System.deviceName`), system stats span (`uptime · NN KB heap`), spacer, WS dot, reconnect button, **reboot button** (with crashed-state class hook), **theme toggle** button. | ||
| - Sticky **3D preview canvas** wrapper below status bar. | ||
| - Main column: single column `max-width: 500px; margin: 0 auto`, card list. Root modules rendered with depth=0; children indented with depth+1, etc. | ||
| - `<body data-theme="dark">` default. | ||
|
|
||
| ### `src/ui/app.js` — extend existing 576 lines | ||
|
|
||
| **Preserve as-is:** `dragTs` cooldown (L319), `if (ctrl.hidden) continue` (L154), the 7 working control type renderers (uint8 slider, uint16, bool, text, display, select, progress). | ||
|
|
||
| **WebSocket lifecycle (spec item 7):** | ||
| - Rewrite `connectWs()` (L13-43) with exponential backoff: `wsRetryMs` 500 → 1000 → 2000 → 4000 → 5000, reset on `onopen`. | ||
| - Add `setInterval(() => ws.readyState===1 && ws.send("ping"), 25000)` keepalive on connect; clearInterval on close. | ||
| - Module-level `let wsPaused = false`; gate `onmessage` body on `!wsPaused`. | ||
| - `document.addEventListener("visibilitychange", () => wsPaused = (document.visibilityState === "hidden"))`. | ||
| - `window.addEventListener("pageshow", e => { if (e.persisted) { wsPaused = false; if (ws.readyState !== 1) connectWs(); } })` for Safari bfcache. | ||
|
|
||
| **Status bar wiring (spec item 1 + 4 promoted items):** | ||
| - Device name from `state.modules[].controls[]` where name === "deviceName". | ||
| - System stats from `SystemModule.uptime` + free heap (computed via `freeHeap` field on /api/system or `dynamicBytes` from /api/state). Pull from the existing /api/state WS push — no new endpoint. Format: uptime as `Xd Yh Zm Ws`, heap as KB. | ||
| - Theme toggle button (`☀/🌙`): reads/writes `localStorage['mm_theme']`, sets `body.dataset.theme`. | ||
| - Reconnect button (already wired): force `ws.close()` then `connectWs()`. | ||
| - **Reboot button**: confirm dialog `confirm('Reboot device?')`, then `POST /api/reboot`. Add red border (`data-crashed="true"`) when `SystemModule.bootReason` indicates an unclean prior boot. | ||
|
|
||
| **Card rendering (spec item 2 + per-card fps/ms toggle):** | ||
| - `createCard(mod, depth)` accepts depth. Sets `card.dataset.depth = depth`. `renderCards()` recurses children with `depth+1`. | ||
| - Title line `[name] [stats] [actions]`. **Actions** appear for children whose `role()` is reorderable (Effect, Modifier): | ||
| - `↑` up button → `POST /api/modules/<name>/move {delta:-1}` | ||
| - `↓` down button → `POST /api/modules/<name>/move {delta:+1}` (both disabled at extremes) | ||
| - `✕` delete button → `DELETE /api/modules/<name>` | ||
| - Drag handle `☰` (desktop) — see drag section below | ||
| - **Stats span** is clickable; cycles fps↔ms display via `localStorage['mm_timing_mode']`. Shows `loopTimeUs` from `/api/state` formatted per mode. Single global toggle affects all cards. | ||
|
|
||
| **Control rendering (spec item 3 + 5):** | ||
| - Extend `createControl()` (L165-293) with three new branches: | ||
| - `button` — `<button>` calls `sendControl(name, 1)` on click, no echo. | ||
| - `password` — `<input type="password">` + hold-to-peek button (`onmousedown` shows, `onmouseup`/`onmouseleave` hides), 500ms debounce, placeholder shows `•` repeated to value length. | ||
| - `time` — read-only formatted via `fmtTime(seconds)` helper → `Xd Yh Zm Ws`. Updated via WS push. | ||
| - Add matching update branches in `updateModuleControls()` (L312-371). | ||
| - **Reset-to-default button (↺)**: in `createControl()`, when `ctrl.default !== undefined` (engine adds this field — see Engine additions below), append a small button. Class `dim` vs `active` based on `ctrl.value === ctrl.default`. Click → `sendControl(name, ctrl.default)`. `updateResetButtonState(mid, key, ctrl)` called from `updateModuleControls()` to refresh state. | ||
|
|
||
| **Type picker (spec item 4):** | ||
| - `roleAcceptsChild(parentRole, childRole)` map (~10 LOC): `Layer → [effect, modifier]`, `DriverGroup → [driver]`, `LayoutGroup → [layout]`, others → `[]`. | ||
| - `openTypePicker(parentMod, anchorEl)`: | ||
| - Fetches `/api/types` (cache for session). | ||
| - Filters by `roleAcceptsChild(parentMod.role, t.role)`. | ||
| - Renders inline list (not modal) below anchor: search input, filtered list, Create/Cancel buttons. | ||
| - Keyboard nav: ↓ enters list from search, ↑↓ moves selection, Enter → `POST /api/modules {type, parent_id: parentMod.name}` then re-fetch state, Esc closes. | ||
| - Search filters by substring on type name. | ||
| - `+ add child` button in each card's footer (for parents that accept children) → `openTypePicker(mod, button)`. | ||
| - `+ add module` button somewhere at the top (top-level addition, parent_id null/missing). | ||
|
|
||
| **Drag-to-reorder (promoted from Deferred):** | ||
| - `☰` drag handle in reorderable child cards (alongside existing up/down). | ||
| - On `dragstart`: store source card id in `dataTransfer`, add `.dragging` class. | ||
| - On `dragover` on a sibling card: `preventDefault()` to allow drop, add `.drag-over` class. | ||
| - On `drop`: compute delta from indices (source index vs drop target index), call `POST /api/modules/<name>/move {delta}` enough times to reach target (or extend the endpoint to accept absolute index; **decision: keep `delta:-1|+1` and call multiple times** — simpler endpoint, drag is short-range anyway. If we move 3 down, call delta:+1 three times in sequence with awaits between). | ||
| - `dragleave` / `dragend`: clean up classes. | ||
|
|
||
| **3D preview polish (spec item 8):** | ||
| - Wrap canvas in `.preview-wrap { position: sticky; top: 44px; z-index: 5; }`. | ||
| - Touch handlers (`touchstart`/`touchmove`) mirroring mouse drag for mobile orbit. | ||
| - **Sparse vertex buffer**: in `renderPreviewFrame()` (L459+), pre-count non-black voxels; skip RGB=0 in upload loop. Halves GPU work for typical effects. | ||
| - **Cache `lastFrame`** (the buf) so a `redrawFromCache()` can be called from orbit handlers between server frames — orbit feels smooth even at low FPS. | ||
| - Scroll listener on main column → set `--preview-shrink` 0→1 over 0→300px scroll, recompute canvas height via `requestAnimationFrame` throttling. Preview shrinks to 50% of natural height when fully scrolled. | ||
| - GLSL vertex: `gl_PointSize = uPtSize / gl_Position.w` (depth-corrected). | ||
| - GLSL fragment: tighten disc to `d > 0.25 → discard`, soft brightness falloff via `smoothstep(0.10, 0.25, d)`. | ||
|
|
||
| **localStorage migration:** | ||
| - On init, read `localStorage['mm_selectedRoot']` first, fall back to `localStorage['mm.selectedModule']`. Write only to the new key. One-release fallback. | ||
| - Add `mm_theme` (default `"dark"`) and `mm_timing_mode` (default `"fps"`) keys. | ||
|
|
||
| ### `src/ui/style.css` — restructure (was 156 lines, target ~350) | ||
|
|
||
| **Layer 1 — variables.** Define `:root` CSS variables for the palette: | ||
| ``` | ||
| --bg-0, --bg-1, --fg, --fg-muted, --accent, --accent-soft, | ||
| --card-bg-0, --card-bg-1, --card-bg-2 (depth-based backgrounds), | ||
| --border, --green (connected/ok), --red (error/crashed), --yellow (warn) | ||
| ``` | ||
| Existing dark colors refactor to use them. | ||
|
|
||
| **Layer 2 — `[data-theme="light"]` overrides.** ~10-12 variable flips. Per spec, ~30 lines total. | ||
|
|
||
| **Layer 3 — structural rules.** | ||
| - Fixed status bar (44px, position:fixed top, flex row, gap 8px). | ||
| - Sticky `.preview-wrap` (top:44px, z-index:5). | ||
| - Main column max-width 500px, centered, padding-top to clear sticky preview. | ||
| - Card depth backgrounds via `.card[data-depth="0/1/2"]` + left-border accent on indented children. | ||
| - 600px → 820px breakpoint per spec. | ||
|
|
||
| **Layer 4 — component styles.** | ||
| - `.card`, `.card-title`, `.card-stats` (cursor:pointer for fps/ms toggle), `.card-actions`, `.card-btn` (square 26×26 button), `.card-btn-del` (red variant). | ||
| - `.drag-handle` (cursor:grab). | ||
| - `.reboot-btn`, `.reboot-btn[data-crashed]` (red border). | ||
| - `.type-picker` (inline list styling). | ||
| - `.reset-btn` dim/active states. | ||
| - `.peek-btn` for password. | ||
|
|
||
| ## Test additions | ||
|
|
||
| Three small additions in `test/`: | ||
|
|
||
| - `test_movechild.cpp` — verify `MoonModule::moveChild` swaps siblings, returns false on out-of-range, doesn't disturb non-child slots. ~40 LOC. | ||
| - `test_module_factory.cpp` — verify the role probe captures correctly via `registerType<T>("…")` and `typeRole(i)` returns expected enum for the 10+ registered types in `main.cpp`. ~30 LOC. | ||
| - `test_system_module.cpp` — already exists; extend with a bootReason-present check (desktop value should be a non-empty string). ~5 LOC added. | ||
|
|
||
| `CMakeLists.txt` updated to include the two new test files. | ||
|
|
||
| ## Documentation | ||
|
|
||
| - **`docs/moonmodules/core/SystemModule.md`** — add `bootReason` to the controls list, note the UI's crashed-state behavior. | ||
| - **`docs/moonmodules/core/HttpServerModule.md`** — add the three new endpoints to the API table, with shapes. | ||
| - **`docs/moonmodules/core/MoonModule.md`** — add `moveChild` to the children API list (alongside `addChild`/`removeChild`/`replaceChildAt`). | ||
| - **`docs/testing.md`** — add entries for `test_movechild.cpp` and `test_module_factory.cpp`. | ||
| - **`ui-spec.md` final cleanup** — once the UI matches, the Quick guide's deferred items get updated (the 4 promoted items move out of Deferred-1.x and into "implemented"). Then `git mv docs/moonmodules_draft/core/ui-spec.md docs/moonmodules/core/ui-spec.md`. | ||
| - **`docs/plan.md`** — remove the `## 12.` section per the file's "Completed items are removed" rule. | ||
| - **`docs/history/plan-11.md`** — 1:1 copy of this plan file (per CLAUDE.md's "Save plan to history" rule). | ||
|
|
||
| ## Critical files | ||
|
|
||
| **Engine:** | ||
| - [src/core/MoonModule.h](src/core/MoonModule.h) — add `moveChild` | ||
| - [src/core/ModuleFactory.h](src/core/ModuleFactory.h) — role at registration | ||
| - [src/core/Control.h](src/core/Control.h) — add `default` field + `setDefault(i, val)` helper | ||
| - [src/core/HttpServerModule.h](src/core/HttpServerModule.h) — 3 endpoints, emit `default` field | ||
| - [src/core/SystemModule.h](src/core/SystemModule.h) — `bootReason` control | ||
| - [src/platform/platform.h](src/platform/platform.h) — declare `reboot()` | ||
| - [src/platform/desktop/platform_desktop.cpp](src/platform/desktop/platform_desktop.cpp) — `reboot()` stub | ||
| - [src/platform/esp32/platform_esp32.cpp](src/platform/esp32/platform_esp32.cpp) — `reboot()` via `esp_restart()` | ||
|
|
||
| **UI:** | ||
| - [src/ui/index.html](src/ui/index.html) — full restructure | ||
| - [src/ui/app.js](src/ui/app.js) — extend existing 576 lines | ||
| - [src/ui/style.css](src/ui/style.css) — restructure with CSS variables + light theme | ||
|
|
||
| **Tests:** | ||
| - [test/test_movechild.cpp](test/test_movechild.cpp) (new) | ||
| - [test/test_module_factory.cpp](test/test_module_factory.cpp) (new) | ||
| - [test/test_system_module.cpp](test/test_system_module.cpp) — extend | ||
| - [test/CMakeLists.txt](test/CMakeLists.txt) — register | ||
|
|
||
| **Docs:** | ||
| - [docs/moonmodules/core/SystemModule.md](docs/moonmodules/core/SystemModule.md) | ||
| - [docs/moonmodules/core/HttpServerModule.md](docs/moonmodules/core/HttpServerModule.md) | ||
| - [docs/moonmodules/core/MoonModule.md](docs/moonmodules/core/MoonModule.md) | ||
| - [docs/testing.md](docs/testing.md) | ||
| - `git mv docs/moonmodules_draft/core/ui-spec.md docs/moonmodules/core/ui-spec.md` | ||
| - [docs/plan.md](docs/plan.md) — remove step 12 | ||
| - [docs/history/plan-11.md](docs/history/plan-11.md) — new | ||
|
|
||
| ## Existing utilities to reuse (do NOT duplicate) | ||
|
|
||
| - `controls_` array + `addUint8/addBool/addText/addSelect/addReadOnly/addProgress` on every MoonModule | ||
| - `MoonModule::role()` returning `ModuleRole::{Generic, Effect, Modifier, Driver, Layout}` — picker filter derives from this in JS | ||
| - `MoonModule::children_` array + `addChild`/`removeChild`/`replaceChildAt` (plan-10) — `moveChild` joins these | ||
| - `MoonModule::loopTimeUs` + `dynamicBytes()` — already in `/api/state`, drive the fps/ms toggle | ||
| - `MoonModule::enabled()` / `setEnabled()` — already wired by HttpServerModule for the per-card checkbox; no new code needed for the enabled toggle UX | ||
| - `FilesystemModule` (plan-10) — persistence "just works" for new controls (bootReason isn't persisted because it's `ReadOnly`) | ||
| - `dragTs` cooldown + `if (ctrl.hidden) continue` in `app.js` — preserve, don't rewrite | ||
| - `ControlDescriptor.hidden` flag (plan-10) — already supported end-to-end | ||
|
|
||
| ## Risks and mitigations | ||
|
|
||
| 1. **`ui_embedded.h` regen.** UI files served from disk on desktop, but baked into `src/ui/ui_embedded.h` at ESP32 build. After UI edits, regen via `build_esp32.py` (CMake should regen automatically on file timestamps). Verify via ESP32 smoke test before declaring done. | ||
| 2. **localStorage migration.** Renaming the selected-module key silently drops old values once. Mitigation: read both old and new on init, prefer new. Acceptable one-release migration. | ||
| 3. **Persistence (plan-10) interaction.** Adding `default` field to `ControlDescriptor` is append-only; doesn't change persistence binary serialization. The new ReadOnly `bootReason` is correctly excluded from persistence (ReadOnly controls are derived, not state). Verify `test_filesystem_persistence.cpp` still passes. | ||
| 4. **No-rebuild contract.** Card rendering restructure must keep WS state pushes patching values in place via `[data-mid][data-key]` selectors — never call `renderCards()` from `updateValues()`. dragTs cooldown at L319 must still work after restructuring. Drag operations and add/delete DO trigger a re-fetch + re-render of the affected parent only. | ||
| 5. **Reboot endpoint response race.** `POST /api/reboot` returns 200 then the device restarts; the client may not see the response. Acceptable — the UI's existing reconnect-on-WS-close logic handles the disconnect cleanly. On desktop, `exit(0)` makes the server vanish; localhost smoke test should see clean WS close. | ||
| 6. **WS reconnect storm.** Exponential backoff without jitter could cause N clients to slam the device. Acceptable for now (one developer + browser). | ||
| 7. **bootReason on first boot.** Fresh ESP32 with no prior state reports POWERON_RESET, which is normal — UI must NOT show crashed-state for that. Map only PANIC, INT_WDT, TASK_WDT, BROWNOUT to "crashed". | ||
| 8. **Drag-to-reorder iteration cost.** Multiple `/move {delta}` calls in sequence for a multi-position drop. Acceptable for short-range drags; for long-range, the up/down buttons or repeated drags are fine. Avoid extending the endpoint to absolute-index for now. | ||
|
|
||
| ## Verification | ||
|
|
||
| Per CLAUDE.md pre-commit checklist (10 steps). Specific to this plan: | ||
|
|
||
| 1. `cmake --build build` — zero warnings (UI changes don't affect build but engine changes do) | ||
| 2. `ctest --output-on-failure` — existing tests pass + 2 new (`test_movechild`, `test_module_factory`) | ||
| 3. `./build/test/mm_scenarios` — exit 0 | ||
| 4. `python3 scripts/check/check_platform_boundary.py` — PASS (new `platform::reboot` correctly placed) | ||
| 5. `python3 scripts/check/check_specs.py` — `10+ modules ok` (HttpServer/SystemModule/MoonModule specs updated) | ||
| 6. `python3 scripts/build/build_esp32.py` — clean; `ui_embedded.h` regenerated | ||
| 7. Reviewer agent (Opus) over the staged diff | ||
| 8. KPI one-liner with PC + ESP32 tick/FPS per CLAUDE.md step 8 | ||
| 9. Hardware smoke test at `http://192.168.1.210/`: | ||
| - UI loads, status bar shows device name + green WS dot + system stats (uptime · NN KB heap) | ||
| - Theme toggle switches dark↔light, persists across reload | ||
| - Tab away 30 seconds, return: WS dot stays green (keepalive working) | ||
| - Click stats line on any card: cycles fps↔ms display; persists across reload | ||
| - Scroll main column: preview shrinks 50% over 300px; mouse-orbit during low-FPS stays smooth (frame cache) | ||
| - On Layer card, click `+ add child` → picker shows only effects + modifiers (NoiseEffect, RainbowEffect, MirrorModifier); search "noi" filters to Noise; Enter creates; new card appears | ||
| - Click ↑ / ↓ buttons on a child → order changes visibly in preview and in `/api/state` | ||
| - Drag a child to a new position → same effect as ↑/↓ | ||
| - Click ✕ on a child → confirm dialog → child disappears | ||
| - Click ↺ on a control with default off-default → snaps back, dragTs cooldown applies (no fight with WS push) | ||
| - Click reboot button → confirm dialog → device reboots, WS reconnects, UI returns | ||
| - If a panic/WDT happens on the device, reboot button shows red border on the next boot | ||
| 10. Documentation: ui-spec.md matches code, promoted out of `_draft/`; SystemModule.md / HttpServerModule.md / MoonModule.md updated; testing.md updated; plan.md step 12 removed; this plan archived as `docs/history/plan-11.md`. | ||
|
|
There was a problem hiding this comment.
🧹 Nitpick | 🔵 Trivial | ⚖️ Poor tradeoff
Plan-11 is comprehensive but lacks a Lessons section.
This is the largest plan in the batch (252 lines, covering major UI rewrite). The structure is strong: scope, decisions locked, engine additions, UI rewrite, tests, docs, verification. The risk-mitigations section (lines 215–224) is valuable.
Suggestion: Add a "Lessons" or "Learnings" section at the end (before or after verification, ~10–20 lines) to capture non-obvious insights, such as:
- The complexity of localStorage migration across keys
- Why one-root-visible was preferred to all-roots (user expectations, not technical constraint)
- Insights about WS state-push pushback handling
- Why
dragTscooldown persists despite full rewrite (the pattern worked, keep it)
Plan-09 does this excellently (lines 54–88); Plan-11 would benefit from the same retrospective lens so future UI work learns from gotchas encountered. Not blocking, but recommended for archival completeness.
🧰 Tools
🪛 LanguageTool
[style] ~109-~109: Three successive sentences begin with the same word. Consider rewording the sentence or use a thesaurus to find a synonym.
Context: ...o allow drop, add .drag-over class. - On drop: compute delta from indices (sou...
(ENGLISH_WORD_REPEAT_BEGINNING_RULE)
🤖 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/history/plans/Plan-20260522` - UI rewrite to ui-spec.md baseline (item
12).md around lines 1 - 252, Plan-11 lacks a Lessons or Learnings section to
document non-obvious insights for future UI work. Add a new section between the
"Risks and mitigations" section and the "Verification" section titled "Lessons
learned" or "Insights for future work" (~10-20 lines) that captures key
learnings such as the complexity of localStorage key migration patterns, why
design decisions like one-root-visible were chosen, non-obvious patterns around
WS state-push conflict avoidance, and why preserving existing patterns like the
dragTs cooldown despite a full rewrite is important. This section mirrors the
retrospective lens used in Plan-09 and ensures archival completeness for future
reference.
… lesson Processes the second CodeRabbit review (on the hardening commit) and the pre-merge gate items: fixes the test workflow so it doesn't double-run on PR branches and so a device-side wire-format change actually triggers the cross-language contract tests, two small validation guards, and records the branch's lesson in decisions.md ahead of the merge. Core: - platform_esp32_improv.cpp: reject a malformed APPLY_OP `last` byte (anything but 0/1) before reassembly, instead of coercing a stray value to "more chunks" (🐇 F6). Added a note at the static ImprovOpReassembler binding that it's bound once at first init and re-init with a different buffer isn't supported — moot under the single-Improv-task-per-device design (👾 Reviewer N1). UI: - improv-frame.js: buildImprovFrame throws if the payload exceeds 255 (the one-byte wire length field) rather than truncating into a corrupt frame — mirrors ImprovFrame.h's oversize rejection (🐇 F4). Docs / CI: - .github/workflows/test.yml: push now triggers on main only (a PR branch is covered by the pull_request event alone — listing feature branches under push too double-ran every PR). Added the device-side C++ frame-contract files (ImprovFrame.h, ImprovOpReassembler.h, platform_esp32_improv.cpp) to the path filters so a firmware wire-format change runs the Python/JS golden-vector tests and can't drift silently (🐇 F1). Path list shared between the two triggers via a YAML anchor. - decisions.md: recorded the "Improv = REST over serial" lesson — one apply-core shared by HTTP and serial, and the extract-the-hard-part-into-a-pure-core-primitive pattern (ImprovOpReassembler, improv-frame.js) that made config-push provable without hardware (PR-merge gate: carry forward lessons). Reviews: - 🐇 F1 test.yml missing C++ frame-file triggers — fixed. - 🐇 F2 actions pinned to @v4/@V3 not SHAs — skipped: release.yml uses the same tags; SHA-pinning is a repo-wide call, not this file's (Common patterns first). - 🐇 F3/F5 add "Lessons" sections to two restored historical plans — skipped: they're archives of original intent; back-dating a retrospective into them would be fabrication, and plans are PO reference, not living docs (the real lesson is in decisions.md). - 🐇 F4 buildImprovFrame payload>255 guard — fixed. - 🐇 F6 validate APPLY_OP `last` flag — fixed. - 👾 N1 static-reassembler rebind note — added. N2 (18-plan backfill) + N3 (CI invocation style) — advisory, no change (the backfill was product-owner-requested). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Summary
Reworks how the web installer applies a device-model's catalog defaults — "Improv = REST over serial". The installer now pushes the whole config over the serial port it already owns during provisioning, as REST operations, applied on the device by the same core the HTTP API uses. This works identically on the deployed HTTPS installer and the local preview, and on WiFi and Ethernet-only firmware.
The problem this fixes
The deployed installer (
https://moonmodules.org/projectMM/install/) often left a flashed device unconfigured:/api/control) is blocked on the deployed HTTPS site — a browser won't let an HTTPS page POST to anhttp://device (mixed-content).?deviceModel=) only ran if the user opened that exact link — easy to miss.The fix
During provisioning the installer holds the USB serial port, so it pushes the config over serial:
SET_DEVICE_MODELthenAPPLY_OP(one/api/modulesor/api/controlop per Improv frame). The device routes each op to the same apply-core the HTTP handlers call, so a REST call over the network and anAPPLY_OPover serial execute identical code. No HTTP, no mixed-content, no browser handoff — so preview and deployed behave the same. The device-side catalog fetch + the whole?deviceModel=handoff are deleted.Eth-only too
The Improv listener was bundled with WiFi (compiled out on
MM_NO_WIFI). This decouples the serial transport from WiFi: the listener now runs on every ESP32 target — the WiFi-provisioning RPCs (WIFI_SETTINGS,GET_WIFI_NETWORKS) are#ifdef-guarded out on eth, the vendor RPCs (SET_DEVICE_MODEL,SET_TX_POWER,APPLY_OP) stay, andGET_CURRENT_STATEreports from the Ethernet link. So an eth-only device (e.g. the P4) gets serial config injection too.What landed
applyAddModule/applySetControl/applyClearChildren/applyOp) extracted out of theTcpConnectionhandlers; HTTP handlers are now thin wrappers. HTTP and Improv-serial share it.APPLY_OP(0xFC) vendor RPC — one REST op as JSON per frame, chunked + reassembled for a rare long value, single-buffered, acked. Applied on the main loop (never the Improv task).MM_NO_WIFI;hasImprovtrue on every ESP32 target.pushDefaultsOverSerialon both the WiFi-provision path and the already-online/eth path (an eth device is online at boot, which previously skipped the push — that was the bug). TX-power cap still sent before provisioning.defaultsApplied(did the push run?), not the checkbox intent.consumePendingDeviceModelParam, catalog fetch), the HTTP fan-out, the Inject button. The device no longer reaches out to Pages.addProvisionedDeviceparam + "Your devices" state fieldboard → deviceModel(reads oldboardas a fallback — no migration).unit_HttpServerModule_apply— host-tests the apply-core directly (no socket), incl. robustness (bad input → typed result, never crash).docs/history/plans/), the new CLAUDE.md plan-storage rule, and backlog cleanup (the shipped "general data injector" item is APPLY_OP now).Verification
check_devices,check_firmwares, KPI.-Werror:esp32,esp32s3-n16r8,esp32p4-eth(the eth-Improv path).APPLY_OPframe → op applied + device acked).🤖 Generated with Claude Code
Commit 2 — Hardening (CodeRabbit fixes + frame/reassembly tests)
A follow-up commit (
3a5d3ce) processes the CodeRabbit review of commit 1 and adds the test coverage that makes config-push-over-serial provable without hardware.CodeRabbit findings — all 8 dispatched: 🐇1 split
OpResult::NotFound→ModuleNotFound/ControlNotFound(distinct 404s restored); 🐇2 a failedapplyOpnow logs over serial + surfaces inprovision_status; 🐇3 APPLY_OP pacing 30→120 ms (ack-loop backlogged); 🐇4 the flagged perf "regression" isobserved.*telemetry, not a bound (accepted); 🐇5OpResult::AlreadyExistsrestores the"already exists"note; 🐇6 documented the serialparentvs HTTPparent_idkey difference; 🐇7 NUL-guard confirmed correct (no action); 🐇8 plan date format → ISOYYYYMMDD.New test coverage:
src/core/ImprovOpReassembler.h(new) — extracted the APPLY_OP chunk-reassembly + out-of-order/duplicate sequence guard out of the ESP32 handler into a pure, desktop-testable core state machine.test/unit/core/unit_ImprovOpReassembler.cpp(12 cases) pins every path.docs/install/improv-frame.js(new) — pure frame builders extracted from the orchestrator sonode:testcan import them.test/python/test_improv_frame.py+test/js/improv-frame.test.mjsassert the same golden vector so the device C++, Python, and JS frame builders provably agree..github/workflows/test.yml(new) — first PR-level test gate: pytest +node --test.Also: restored 18 historical plans under the corrected
Plan-YYYYMMDDnaming; carried the "Improv = REST over serial" lesson intodecisions.md.Reviewer (👾, pre-merge over the whole branch): merge-ready, no blockers — both extractions justified for testability, the OpResult split is clean, the branch is a net deletion with strong new coverage, and it touches no render hot path.
Summary by CodeRabbit
New Features
Bug Fixes
Documentation
Tests