Skip to content

Board injection + LOLIN S3 N16R8 enablement#10

Merged
ewowi merged 8 commits into
mainfrom
next-iteration
Jun 4, 2026
Merged

Board injection + LOLIN S3 N16R8 enablement#10
ewowi merged 8 commits into
mainfrom
next-iteration

Conversation

@ewowi

@ewowi ewowi commented Jun 3, 2026

Copy link
Copy Markdown
Collaborator

Summary

Multi-commit feature: get the physical-board identity onto the device (persisted, reported via /api/state, settable from MoonDeck and the web installer) so the device knows what hardware it's running on. The device cannot self-identify board (no readable PCB ID on classic ESP32), so the value is injected by an outside tool.

The branch grew during the work: after the first four commits landed the board-injection core, the next three commits added LOLIN S3 N16R8 support (which exposed three real bugs the board-injection feature happened to depend on) and folded in two CodeRabbit review rounds.

Seven commits, each a working stop along the way:

Board injection core (commits 1-4)

  1. 8a76be2BoardModule (code-wired child of System, board Text control with the new readonly UI flag, persisted via FilesystemModule), docs/install/boards.json catalog (single source of truth, shared across all consumers), MoonDeck pushes via the existing POST /api/control. Bonus: new readonly flag on ControlDescriptor lets a Text control render display-only in the UI without losing persistence semantics.

  2. 058d979 — Web installer board picker, firmware-name collision fix on SystemModule (was binding firmware twice with different types).

  3. 7354923 — Replaced ESP Web Tools with a custom orchestrator (docs/install/install-orchestrator.js) that owns the SerialPort across flash → Improv WiFi provision → SET_BOARD vendor RPC (command 0xFE, raw frame). Same root cause that broke EWT's board-injection also broke devices.js's "Your devices" auto-add — both fixed by owning the port end-to-end.

  4. 9f888af — Board-injection follow-ups: HTTP fallback for cases where Improv isn't available (Ethernet-only firmware, device already provisioned), Inject button on the "Your devices" row, picker module renamed from release-pickerinstall-picker to reflect its grown scope.

LOLIN S3 N16R8 enablement (commit 5)

  1. 751f1d5 — New "LOLIN S3 N16R8" board entry in boards.json. The board exposed three connected bugs that the board-injection feature was masking:

    • WiFi brown-out at full TX power. The LOLIN's on-module LDO can't sustain full radio TX; the device drops WiFi during association. Fix: writable Network.txPowerSetting control (0..21 dBm, 0=no override), new platform::wifiSetTxPower setter, applied via syncTxPower() re-checked on every loop1s tick. boards.json injects Network.txPowerSetting: 8 for the LOLIN entry.
    • USB-Serial-JTAG, not UART0. S3 native-USB boards expose USB-C via the chip's USB-Serial-JTAG peripheral, not via an external bridge to UART0. ESP-IDF's secondary-console feature mirrors stdio out both, so boot logs look fine — but the Improv listener-on-UART0 was deaf to host writes. Fix: install BOTH drivers, read symmetric non-blocking from both transports per loop iteration. Guarded by #if SOC_USB_SERIAL_JTAG_SUPPORTED so ESP32-classic builds stay clean.
    • CORS preflight silently blocked every cross-origin POST. The device's HTTP server returned 405 to OPTIONS, so the browser silently dropped subsequent /api/control POSTs from the web installer. Root cause of every "inject didn't work" symptom for the board-injection feature itself. Fix: OPTIONS handler returning 204 + Access-Control-Allow-* headers.

    Plus: Monitor button + serial-monitor modal in the web installer (live read, RTS-pulse reset), Retry on "Improv not detected" dialog (slow-boot races), lazy-render WiFi-creds form (avoids macOS iCloud Passwords prompt on page load), maxInternalAllocBlock split (the diagnostic maxBlock was reporting PSRAM ~8MB instead of the useful internal-block KPI), MoonDeck full controls fan-out (was Board.board only).

Review-driven cleanup (commits 6-7)

  1. eca3c2c — CodeRabbit round 1: 8 findings folded in. Most important: TX-power cap not re-applied after radio restart — cached appliedTxPowerSetting_ invariant was violated whenever ESP-IDF stops the radio (AP→STA cascade, STA reconnect, AP shutdown). New noteRadioStopped() helper called at every wifiStaStop/wifiApStop site invalidates the cache.

  2. 6212fc0 — CodeRabbit round 2: 3 findings folded in, 1 deferred with explicit doc note. Most important: syncTxPower() now called immediately after each successful wifiStaInit/wifiApInit — closes the up-to-1s full-power window between WiFi-up and the next loop1s tick (exactly the LOLIN brown-out scenario). Plus per-transport Improv reply routing (replies now go only on the requesting transport instead of broadcast), needs-ip dialog whitespace rejection via setCustomValidity, BoardModule.h validation-asymmetry doc note. Also added NanoDLA v1.3 wiring + capture instructions to leddriver-analysis-top-down.md.

Net: a fresh device flashed from the web installer ends up with BoardModule.board = "LOLIN S3 N16R8" (or whichever the user picked) persisted to /.config/BoardModule.json, surviving reboot. The LOLIN-specific TX-power cap is injected via the same generic controls.* fan-out and lands within ~50ms of WiFi-up. MoonDeck mirrors the same values via HTTP. Future per-board state (Ethernet pin maps, default module-config overrides) uses the same controls.<Module>.<control> shape and needs no code changes.

KPI (head of branch, esp32s3-n16r8 on LOLIN S3 N16R8 at txPower=8 dBm)

PC:375KB | tick:354/100/303/99/99/98/45/35/34us
          (FPS:2824/10000/3300/10101/10101/10204/22222/28571/29411)
ESP32:1171KB | tick:138506us(FPS:7)
heap free: 8358927  maxBlock (internal): 163840 (160 KB)
ArtNetSend: ~93 ms/tick (LOLIN at 8 dBm — known hardware trade-off)
src:62(10592) | test:38(5410) | lizard:40w

The LOLIN S3 at 8 dBm is fundamentally slower than Olimex Ethernet on ArtNet (cut TX power = slower UDP throughput on association margin). Known hardware-level trade-off of the brown-out fix, not a code regression. Use Ethernet-capable boards if FPS matters; use LOLIN S3 N16R8 if you need PSRAM headroom and accept the WiFi compromise.

Test plan

  • Desktop build clean (cmake --build build, -Werror)
  • ctest — 207 unit tests pass (8 BoardModule tests added)
  • mm_scenarios — 8/8 pass
  • Platform boundary check PASS
  • Spec check — 26/26 (BoardModule.md + NetworkModule.md txPowerSetting bullet)
  • ESP32 builds — esp32, esp32-eth, esp32-eth-wifi, esp32s3-n16r8 all compile clean
  • Hardware verification on LOLIN S3 N16R8:
    • Step 1: MoonDeck deduces board for unique-firmware boards; pushes via POST /api/control on discover/refresh; /.config/BoardModule.json survives reboot
    • Step 2: web installer picks the LOLIN board, flashes via custom orchestrator, provisions WiFi via Improv-over-USB-JTAG, sets board via SET_BOARD, HTTP-fan-outs Network.txPowerSetting: 8
    • Step 3: device's txPower reports 8 dBm post-association, persists across reboot, re-applies on AP→STA cascade
    • Step 4: cross-origin POSTs from preview_installer to device API now succeed (CORS OPTIONS handler returns 204)
  • CodeRabbit findings: 21 threads, 15 fixed, 6 rejected/deferred with explicit rationale; all resolved on PR.
  • On-demand pre-commit Reviewer (commit eca3c2c): 22 findings, all addressed in 6212fc0.
  • PR-merge Reviewer agent: running at merge time over git diff main...HEAD.

🤖 Generated with Claude Code

ewowi and others added 3 commits June 3, 2026 18:29
…eck push

Step 1 of the board-injection multi-commit plan. The device now persists
its physical-board name (e.g. "LOLIN D32") in a new `BoardModule` — a
code-wired child of SystemModule that holds a single `board` Text control.
MoonDeck deduces the value from firmware where unambiguous, lets the user
pick otherwise, and pushes the chosen name to the device via the standard
`POST /api/control` route. A single `docs/install/boards.json` catalog
replaces the previously-hardcoded board lists on both MoonDeck sides
(Python deduce + JS dropdown), and will be shared with the web installer
in Step 2.

The "persisted but injection-only" UX is delivered via a new `readonly`
flag on `ControlDescriptor` — independent of `ControlType`, this is a
UI-rendering hint that asks the device's own web UI to render the control
display-only while leaving `/api/control` writes intact (that's how the
injectors push). Generic enough that future "device shows it, doesn't
edit it, but persists" fields can reuse it.

KPI: 16384lights | PC:359KB | tick:352/98/300/99/99/99/45/35/35us(FPS:2840/10204/3333/10101/10101/10101/22222/28571/28571) | ESP32:1145KB | tick:217251us(FPS:4) | heap:54KB | src:63(10058) | test:38(5334) | lizard:37w

Note on the ESP32 tick: the captured monitor.log is from the LOLIN D32
test bench (esp32 firmware, WiFi-only), not the Olimex Gateway esp32-eth-wifi
reference the 11 FPS perf budget assumes. WiFi+ArtNet latency on LOLIN is
fundamentally slower than Ethernet on Olimex. BoardModule has zero hot-path
footprint (no loop, no loop1s) so the slow tick is not a regression from
this commit — it's the wrong reference device for the budget. Re-capture
on Olimex when bench-swapping for the next commit's perf gate.

### Core

- BoardModule (new, `src/core/BoardModule.h`): single Text `board` control with the new `readonly` UI flag; framework auto-persists to `/.config/BoardModule.json`. respectsEnabled()==false so identity stays visible regardless of enable state. ~13 LoC.
- Control.h: added `bool readonly` to `ControlDescriptor` + `setReadOnly(i, bool)` mutator. Mirrors the existing `hidden` flag's shape — independent of ControlType, ignored by persistence.
- HttpServerModule: per-control wrapper now emits `,"readonly":true` when the flag is set (mirrors the existing `,"hidden":true` pattern).
- SystemModule: chain to MoonModule::onBuildControls() at the end of the override so children's controls get bound — fix for "BoardModule appeared as a child but had no controls" symptom found during testing. The base default cascades, but overrides shadow it; established convention (see NetworkModule, Layer, Drivers).
- main.cpp: register BoardModule + wire it as a code-wired child of SystemModule, mirroring how Improv lives under Network. markWiredByCode() preserves it on devices whose saved SystemModule.json predates the addition.

### UI

- src/ui/app.js: text-control renderer honors `ctrl.readonly` — binds `input.readOnly = true` (allows copy/select but not edit) and skips the input handler.

### Scripts / MoonDeck

- moondeck.py: load `docs/install/boards.json` at startup; `_deduce_board` becomes a catalog reverse-lookup (firmware → unique board, else ""); `_probe_device` extracts the device's reported `board` from BoardModule and prefers it over the deduced value; `_push_board_to_device` posts to the device's `/api/control` (bulk push from discover/refresh merge paths, plus a per-change push from the dropdown).
- moondeck.py: new `GET /api/boards` (serves the catalog to the JS) and `POST /api/push-board` (per-device push triggered by dropdown changes).
- moondeck.py: dropped `deviceName`/`firmware` from `_VOLATILE_DEVICE_FIELDS` — they ARE displayed in the device row label, so the strip caused the row to lose its labels after every MoonDeck restart. Both fields are correctly overwritten by the next probe so no staleness drift.
- moondeck_ui/app.js: load boards via /api/boards at init; dropdown options derived from the catalog (with `<name> (unknown)` fallback for board values not in the catalog); per-device dropdown change fires `/api/push-board` so the device gets the value immediately rather than waiting for the next discover/refresh.
- moondeck_ui/app.js: device-row layout split into three rows (identity / info / board picker) so long board names don't get clipped against the narrow sidebar.
- moondeck_ui/style.css: matching .device-row + .device-info styles.
- preview_installer.py: stage *.json files from docs/install/ alongside html/js/css so the new boards.json catalog reaches the local preview tree.

### Tests

- test/unit/core/unit_BoardModule.cpp (new): verifies the `board` Text control is bound with the readonly flag and starts empty.
- test/unit/core/unit_SystemModule.cpp: dropped the brittle "exactly 12 controls" count assertion — fights legitimate additions; the deviceName-by-name check below it already verifies the meaningful invariant.
- scenario_MirrorModifier_pipeline.json + scenario_GridLayout_resize.json: routine widen-only observed-range drift (1µs).

### Catalog

- docs/install/boards.json (new): single-source-of-truth catalog with 4 initial entries (Olimex Gateway, LOLIN D32, Generic ESP32 DevKit, ESP32-S3 N16R8 DevKit). Single `name` field per entry (no key/label split — `name` is both identifier and display label).

### Docs / CI

- docs/moonmodules/core/BoardModule.md (new): module spec covering the control, injection path, catalog format, and forward note about future board-presets growth.
- docs/moonmodules/core/SystemModule.md: one-line BoardModule mention in the firmware control description.
- docs/architecture.md § Firmware vs board: updated to reference BoardModule + the catalog.
- scripts/MoonDeck.md: documented the board picker behaviour and catalog.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Step 2 of the multi-commit board-injection plan. The web installer's
release-picker renders a board <select> between the existing release and
firmware selects (Release → Board → Firmware). Picking a board narrows
the firmware dropdown to that board's compatible variants, pre-selects
the board's default_firmware, and disables the firmware select when
only one option remains. End users at the public installer can no
longer accidentally flash an esp32s3 binary on a LOLIN D32.

Step 2 ships UX-only: the originally-planned post-PROVISIONED HTTP push
of the board to the device was dropped after the EWT 10.x event surface
turned out not to expose it. ESP Web Tools fires `state-changed` on the
internal ImprovSerial client inside the dialog's shadow DOM, not as a
bubbling DOM event on the install button — so the install page can't
reliably learn the device URL needed for the fetch. (Verified by reading
EWT's src/install-dialog.ts and by instrumenting the listener locally —
the event never fires from outside the shadow DOM.) Same root cause
silently broke devices.js's "Your devices" auto-add since EWT 10.x. Both
land in Step 3 over the Improv Web Serial channel, which doesn't need a
DOM event or a network fetch. MoonDeck remains the working push path
until then.

Also fixes a latent name collision on SystemModule: two controls were
both named `firmware` (a string for the variant identifier, and a
progress bar for app-partition usage). Any consumer doing
`controls.find(c => c.name === "firmware")` got whichever was bound
first — the progress bar's integer value — and broke on string ops. The
on-device release picker's isCompatible() was hitting this since the
progress bar landed. Renaming the progress to `firmwarePartition`
disambiguates; also surfaced it in the doc which had never listed it.

While in release-picker, refined the firmware default precedence to
prefer the device's currently-flashed firmware (ownFirmwareKey) over
the previously-used localStorage pick — the natural OTA default is
"re-flash what I'm running," not "what I flashed last week."

KPI: 16384lights | PC:375KB | tick:355/106/365/99/99/99/46/36/34us(FPS:2816/9433/2739/10101/10101/10101/21739/27777/29411) | ESP32:1151KB | src:63(10064) | test:38(5334) | lizard:37w

ESP32 tick captured on LOLIN D32 (esp32 firmware, WiFi-only) — same
bench used for Step 1 verification. Not the Olimex Gateway eth-wifi
reference the 11 FPS perf budget assumes; WiFi+ArtNet on LOLIN is
fundamentally slower than Ethernet on Olimex. The release-picker code
changes have zero device-side runtime footprint (release-picker embeds
into flash but only runs in the on-device web UI, which is not what the
perf budget exercises).

### Core

- SystemModule: rename the firmware-partition Progress control from `firmware` to `firmwarePartition` to break the latent name collision with the `firmware` ReadOnly string control. Both controls were bound under the same name; lookups via `controls.find(name === "firmware")` returned whichever was bound first (the Progress), so its integer value broke any consumer expecting a string. Comment at the binding documents the rationale.

### UI

- src/ui/release-picker.js: fetches docs/install/boards.json same-origin at init (parallel with the GitHub releases fetch); renders a board <select> between Release and Firmware when `enableBoardPicker:true` (default) and the catalog loaded; narrows the firmware list to the picked board's `firmwares[]`; pre-selects with new precedence (ownFirmwareKey > board.default_firmware > saved > first compatible); disables the firmware select when narrowing yields one option. Catalog-fetch failure or empty array silently omits the board row.
- src/ui/app.js: on-device OTA picker passes `enableBoardPicker:false` — the device already knows its board (BoardModule) and a board picker there would invite mis-narrowing the firmware list.

### Docs / CI

- docs/install/index.html: comment on the existing state-changed listener documenting why it doesn't fire on EWT 10.x; behaviour unchanged for the legacy <10 path.
- docs/moonmodules/core/BoardModule.md: injection-path section explains MoonDeck is the sole working push path; web installer Step 2 is UX-only; Step 3 owns the device push.
- docs/moonmodules/core/SystemModule.md: added the previously-undocumented `firmwarePartition` progress control with the rename rationale.
- docs/plan.md: Step 2 marked DONE with the deferred-push note; Step 3 spec updated to call out that it also fixes the devices.js auto-add (same root cause).

### Tests

- 7 scenarios: routine widen-only observed-range drift (1-2 µs).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
End-to-end board injection from the web installer over the same Web
Serial channel that handles WiFi provisioning. The device gets its
board name set without an HTTP fetch — works on the HTTPS Pages
deployment where mixed-content blocked Step 2's planned path.

ESP Web Tools 10.x dropped entirely. EWT held the SerialPort
exclusively and fired its state-changed event inside the dialog's
shadow DOM (verified by reading esp-web-tools/src/install-dialog.ts);
that made post-PROVISIONED board injection structurally impossible and
silently broke the "Your devices" auto-add. Owning the SerialPort
across flash + Improv + RPC lets both fixes land. New
docs/install/install-orchestrator.js (~430 LOC) drives the flow via
esptool-js (for flash) + improv-wifi-serial-sdk (for WiFi provision)
sharing the same port, with raw-frame writing for SET_BOARD (the SDK's
writePacketToStream is private as of 2.5.0).

Custom install modal replaces EWT's dialog: stages for connecting /
flashing / wifi-creds / provisioning / done / error, collapsible log
panel fed by esptool-js's terminal callback, project version chip,
graceful "device may already be online" path when the chip boots with
saved WiFi (SDK's initialize() throws "not detected" — caught and
surfaced as success-with-caveat instead of failure). Opt-in "Erase
chip first" checkbox covers the rare cases where re-flashing in place
is wrong (partition-table change between firmware variants, wiping
saved WiFi to bypass the already-online path, clean-slate testing).

KPI: 16384lights | PC:375KB | tick:354/100/303/101/104/106/49/37/38us(FPS:2824/10000/3300/9900/9615/9433/20408/27027/26315) | ESP32:852KB | tick:240844us(FPS:4) | heap:63KB | src:63(10213) | test:38(5402) | lizard:38w

ESP32 tick from the LOLIN D32 bench (esp32 firmware, WiFi-only) — not
the Olimex Gateway eth-wifi reference the 11 FPS budget assumes;
WiFi+ArtNet on LOLIN is fundamentally slower. The SET_BOARD handler is
in the cold path (only runs during Improv provisioning), zero
hot-path footprint.

### Core

- BoardModule: new public `setBoard(const char*)` method for transports that bypass /api/control (the Improv RPC dispatcher today). Validates 1..23 ASCII-printable bytes, copies into the buffer, marks dirty, arms FilesystemModule debounced save. Same idiom as NetworkModule::setWifiCredentials.
- ImprovProvisioningModule: new `setBoardModule(BoardModule*)` setter + pendingBoard_/pendingBoardReady_ atomics. loop1s() polls the flag and calls setBoard() on the scheduler thread when SET_BOARD lands. Mirrors the WIFI_SETTINGS deferral pattern.
- platform/platform.h: improvProvisioningInit() signature gains optional boardOut/boardOutLen/boardReady params. Additive (default null), so the desktop and MM_NO_WIFI stubs don't break.
- platform/esp32/platform_esp32_improv.cpp: new improvHandleSetBoard handler for vendor RPC 0xFE. Validates payload (length + ASCII-printable), publishes to the module's buffer + flag, sends RpcResponse on success / ErrorState 0x80 on validation failure. Dispatcher short-circuits to handleSetBoard before improv::parse_improv_data — that helper is WIFI_SETTINGS-shaped and would default-empty on unknown commands.
- platform/desktop/platform_desktop.cpp: stub signature follows the additive params.
- main.cpp: one line `improvModule->setBoardModule(boardModule);` wiring.

### UI

- docs/install/install-orchestrator.js (NEW): orchestration entry point. installer.start() drives the full flow (request port → esptool-js flash → close+reopen port at Improv baudrate → ImprovSerial → WiFi creds form → provision → SET_BOARD vendor RPC). installer.eraseOnly() for the "Erase" button on bookmarks. CDN-pinned dependencies (esptool-js@0.4.7, improv-wifi-serial-sdk@2.5.0) with a dated comment for future bumps. Raw frame writer (buildImprovFrame, encodeSetBoardPayload, sendSetBoardFrame) for SET_BOARD since the SDK's writePacketToStream is private. bufferToBinaryString for esptool-js's "binary string" file format. eraseBefore opt-in for full chip-erase before flash. Graceful skip when the device boots into STATE_PROVISIONED with saved WiFi (SDK's initialize() throws "Improv Wi-Fi Serial not detected" in that case) — surfaced as "device may already be online" rather than a failure. Per-stage lastStage tracking so the error modal shows the failing stage rather than a generic "flow".
- docs/install/index.html: dropped EWT script include + button host. New install modal (~150 LOC HTML+CSS) with sections for connecting / flashing / wifi-creds / provisioning / done / error. Collapsible log panel fed by esptool-js's terminal callback. Project version chip next to the H1 (reads library.json same-origin). "Erase chip first" checkbox on the picker card. Replaced showInstallModal / state-changed listener / lastInstallManifest workaround with installer.start() / installer.eraseOnly() invocations. Chip-name shown in modal as soon as detection succeeds.
- docs/install/devices.js: addProvisionedDevice(url, board) signature gains the board arg (Step 2 plan landed it then we reverted; back now that the orchestrator actually has the value). Renders a board line in the bookmark row when set.
- src/ui/release-picker.js: re-added getSelectedBoard() (returns user's picked board for the orchestrator). Persisted user's last picked board to localStorage (PREF_BOARD_KEY) — mirrors PREF_FIRMWARE_KEY. Restores on next page load if the board is still in the catalog.

### Tests

- test/unit/core/unit_BoardModule.cpp: 5 new test cases for setBoard validation (happy path with dirty signaling, empty rejection, over-length rejection, non-printable rejection, nullptr rejection). Mirrors unit_NetworkModule.cpp's setWifiCredentials shape.
- 1 scenario observed-range drift (control_change): widen-only, routine.

### Docs / CI

- docs/moonmodules/core/BoardModule.md: rewrote Injection path to document both transports (MoonDeck HTTP via /api/control, web installer via Improv vendor RPC 0xFE). Added SET_BOARD wire contract subsection: frame type/command/payload/response/error codes.
- docs/plan.md: Step 3 marked DONE. Updated forward-look ("Improv as general data injector" dispatcher seed exists; still deferred until 2nd consumer). Resolved-risks list updated.
- .github/workflows/release.yml: copy library.json into pages/install/ so the version chip on the installer page resolves on production.
- scripts/run/preview_installer.py: same — stage library.json locally so the version chip works in preview.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 3, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 72654444-cc7a-441c-8c4d-742697ea77a5

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch next-iteration

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
scripts/moondeck.py (1)

928-960: 🧹 Nitpick | 🔵 Trivial | 💤 Low value

Consider skipping redundant board pushes.

The refresh flow pushes board to every online device with a non-empty board, even when the device already reports the same value. While the device's 2-second debounce coalesces redundant writes, this still generates unnecessary HTTP traffic on every refresh.

Consider adding the same check used in discover:

                for dev in merged:
-                   if dev.get("online") and dev.get("board") and dev.get("ip"):
+                   if dev.get("online") and dev.get("board") and dev.get("ip"):
+                       # Only push when MoonDeck's board differs from device's
+                       device_board = ""
+                       # dev came from refresh_devices which merges probe data
+                       for prior in (target.get("devices") or []):
+                           if prior.get("ip") == dev.get("ip"):
+                               # prior's board is what probe returned
+                               break
                        pushes.append((dev["ip"], dev["board"]))

Alternatively, store the probed board separately in refresh_devices results so you can compare.

🤖 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 `@scripts/moondeck.py` around lines 928 - 960, The _merge_refresh currently
enqueues pushes for every online device with a non-empty board regardless of
whether the device already reports that same board; update the push decision to
skip redundant pushes by comparing the merged device's board to the device's
previously-known board before appending to pushes. Inside _merge_refresh (where
merged and prior device list are available), find the prior device entry for the
same ip (from the earlier target.get("devices") or the prior loop) and only
append (dev["ip"], dev["board"]) to pushes when dev.get("board") is truthy and
dev.get("board") != prior_board (or if no prior entry existed). Keep using
mutate_state/_merge_refresh and _push_boards_in_parallel unchanged otherwise.
🤖 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 `@docs/architecture.md`:
- Line 181: Update the sentence that mentions the web installer as future work:
remove the timeline-relative phrase “(next commit)” and rephrase to state that
the catalog at docs/install/boards.json is shared between MoonDeck and the web
installer today; keep references to BoardModule, the device-side readonly
`board` Text control, and the mirror behavior (POST /api/control after
discover/dropdown change) unchanged so the architecture text reflects current
installer integration.

In `@docs/install/install-orchestrator.js`:
- Around line 354-361: Add an inline comment above the string match in the catch
block that explains this fragile check relies on the pinned SDK version
(referencing the pinned SDK at the top of the file) — specifically note that the
condition msg.includes("Improv Wi-Fi Serial not detected") is dependent on that
SDK error text and should be revisited if the SDK version is changed; leave the
logic setting alreadyOnline as-is but document the dependency for future
maintainers.

In `@docs/moonmodules/core/BoardModule.md`:
- Line 3: Update the stale future-tense wording in the BoardModule description
to present tense and reflect that installer integration (Step 3) is live:
rewrite the paragraph so it states that the physical board identity (e.g.,
`Olimex ESP32-Gateway Rev G`, `LOLIN D32`) is injected by external tools today
(MoonDeck and the web installer), that the `board` Text control is bound with
the `readonly` UI flag (display-only in the device web UI while HTTP writes
still accept injector updates), and that the catalog
(`docs/install/boards.json`) is the source of truth with MoonDeck mirroring the
picked/deduced value back on discovery if it drifts; ensure any "tomorrow/Step
2" phrasing is removed and the language is present-tense throughout.

In `@scripts/MoonDeck.md`:
- Line 12: The sentence in scripts/MoonDeck.md describing the board catalog is
in future tense ("the web installer will use"); update it to present tense to
reflect current behavior—e.g., change "the web installer will use" to "the web
installer uses" (the rest of the sentence mentioning docs/install/boards.json,
BoardModule and the POST /api/control flow should remain unchanged so the text
continues to say the dropdown uses docs/install/boards.json and MoonDeck mirrors
the chosen board to the device's BoardModule via POST /api/control).

In `@src/core/HttpServerModule.cpp`:
- Line 390: Wrap the single-line conditional that appends the readonly JSON
field in braces to satisfy -Wall -Wextra -Werror; locate the if (c.readonly)
sink.append(",\"readonly\":true"); statement in HttpServerModule.cpp and change
it to a braced block (open brace before the append call and close brace after)
preserving existing indentation and formatting so the behavior is unchanged.

---

Outside diff comments:
In `@scripts/moondeck.py`:
- Around line 928-960: The _merge_refresh currently enqueues pushes for every
online device with a non-empty board regardless of whether the device already
reports that same board; update the push decision to skip redundant pushes by
comparing the merged device's board to the device's previously-known board
before appending to pushes. Inside _merge_refresh (where merged and prior device
list are available), find the prior device entry for the same ip (from the
earlier target.get("devices") or the prior loop) and only append (dev["ip"],
dev["board"]) to pushes when dev.get("board") is truthy and dev.get("board") !=
prior_board (or if no prior entry existed). Keep using
mutate_state/_merge_refresh and _push_boards_in_parallel unchanged otherwise.
🪄 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: 79239a4f-23e2-4d30-aa59-06cc7d5e5923

📥 Commits

Reviewing files that changed from the base of the PR and between 015bd02 and 7354923.

📒 Files selected for processing (35)
  • .github/workflows/release.yml
  • docs/architecture.md
  • docs/install/boards.json
  • docs/install/devices.js
  • docs/install/index.html
  • docs/install/install-orchestrator.js
  • docs/moonmodules/core/BoardModule.md
  • docs/moonmodules/core/SystemModule.md
  • docs/plan.md
  • scripts/MoonDeck.md
  • scripts/moondeck.py
  • scripts/moondeck_ui/app.js
  • scripts/moondeck_ui/style.css
  • scripts/run/preview_installer.py
  • src/core/BoardModule.h
  • src/core/Control.h
  • src/core/HttpServerModule.cpp
  • src/core/ImprovProvisioningModule.h
  • src/core/SystemModule.h
  • src/main.cpp
  • src/platform/desktop/platform_desktop.cpp
  • src/platform/esp32/platform_esp32_improv.cpp
  • src/platform/platform.h
  • src/ui/app.js
  • src/ui/release-picker.js
  • test/CMakeLists.txt
  • test/scenarios/core/scenario_MoonModule_control_change.json
  • test/scenarios/light/scenario_GridLayout_grid_sizes.json
  • test/scenarios/light/scenario_GridLayout_resize.json
  • test/scenarios/light/scenario_Layer_buildup.json
  • test/scenarios/light/scenario_MirrorModifier_memory_lut.json
  • test/scenarios/light/scenario_MirrorModifier_pipeline.json
  • test/scenarios/light/scenario_PreviewDriver_detail.json
  • test/unit/core/unit_BoardModule.cpp
  • test/unit/core/unit_SystemModule.cpp

Comment thread docs/architecture.md Outdated
Comment thread docs/install/install-orchestrator.js
Comment thread docs/moonmodules/core/BoardModule.md Outdated
Comment thread scripts/MoonDeck.md Outdated
Comment thread src/core/HttpServerModule.cpp
Closes the two gaps Step 3 left open — eth-only flashes and re-flashes
with saved WiFi now route board injection through a Pages-fetched
boards.json catalog with a per-device Inject button. Renames the
release-picker module to install-picker (release + board + firmware,
not just release), bumps BoardModule's buffer 24→32 to fit longer
catalog names, and tightens UX across the install modal.

KPI: 16384lights | PC:375KB | tick:353/100/302/99/99/99/46/37/35us(FPS:2832/10000/3311/10101/10101/10101/21739/27027/28571) | ESP32:856KB | src:62(10211) | test:38(5402) | lizard:38w

ESP32 tick absent from the KPI line: USB-monitor capture wasn't
possible this session (Olimex on Ethernet for the install-flow
testing; LOLIN's USB-serial chip enumerated intermittently after
Chrome released the port). Both ESP32 firmware variants build clean;
device-side changes are the BoardModule 24→32 buffer bump and the
HttpServerModule query-string strip — no hot-path code touched.

Core
- HttpServerModule: strip ?query before route matching so /?board=…
  hits the root route instead of falling through to 404 — latent bug
  surfaced by the new Inject handoff.
- BoardModule: widen boardKey_ buffer 24 → 32 chars to fit catalog
  entries like "Olimex ESP32-Gateway Rev G" (26 chars) without
  visible truncation in the device UI.
- ImprovProvisioningModule: mirror pendingBoard_ buffer 24 → 32.
- SystemModule + main.cpp + platform_esp32_improv.cpp: comment
  refreshes for the install-picker rename and new buffer size.
- Removed stale tracked src/core/ui_embedded.h — orphaned from a past
  layout move; the live generated header lives at src/ui/ui_embedded.h.

UI
- app.js: consumePendingBoardParam() on init() picks up ?board=<name>
  from the URL, fetches the canonical boards.json from Pages, and
  POSTs each controls.<Module>.<control> leaf to /api/control. Strips
  the query param via history.replaceState before the network round-
  trip so a mid-fetch refresh doesn't double-push.
- install-picker.js (renamed from release-picker.js): exported symbol
  installPicker, embedded byte-array installPickerJs. Firmware-dropdown
  precedence reordered — saved localStorage pick now wins over the
  board's default_firmware so a returning user's choice sticks.
- index.html: new needs-ip modal section for the Improv-less / already-
  online fallback path; collapsed the post-skip "device is online"
  consolation popup into a direct modal close; port dropdown reduced
  to "Pick a port…" → "Port selected" + "Pick another port…", with
  mousedown-intercept so the no-port case opens the Web Serial picker
  on a single click; erase progress bar's inline width zeroed before
  applying .indeterminate (inline beats class); WiFi password field
  uses autocomplete=off + data-lpignore/data-1p-ignore to suppress
  password-manager fill prompts; credits footer crediting esptool-js,
  improv-wifi-serial-sdk, and the Improv-Serial protocol.
- install-orchestrator.js: needs-ip flow replaces the alreadyOnline
  early-return — when Improv's initialize() fails ("Improv Wi-Fi
  Serial not detected"), prompt for IP, attempt an HTTP /api/control
  fan-out in preview mode (canFetchHttp gates mixed-content), surface
  pendingBoard / httpBoardOk to the host page. Annotated the SDK
  string-match with a comment tying it to the pinned SDK version.
- devices.js: per-row Inject button (visible whenever device.board is
  set; idempotent re-clicks supported so popup-blocker rejections
  don't strand the user). Tooltips on Visit/Inject/Erase/Forget.
  URL link is bare device.url; only the Inject button decorates with
  ?board=<name>.

Catalog / install assets
- boards.json: schema extended with a controls: { "<Module>": { … } }
  payload per entry. Catalog fields (name/firmwares/default_firmware)
  stay top-level. Today only Board.board populated; future per-board
  state (pin maps, defaults) lands here without code changes.
- favicon.png + credits footer + project title bumped to "projectMM
  Installer" + indeterminate erase bar.

Scripts / MoonDeck
- preview_installer.py: serves Access-Control-Allow-Origin:* to
  match GitHub Pages' default static-asset CORS, so the device UI
  can cross-origin-fetch a local boards.json in dev. Header staging
  also picks up .png / .ico so the favicon lands in the preview tree.

Tests
- unit_BoardModule.cpp: setBoard boundary cases updated for the new
  32-byte buffer (31-char accept, 32-char reject).

Docs / CI
- BoardModule.md: three transports (MoonDeck HTTP, Improv RPC, HTTP
  fallback via Inject) documented with the boards.json schema and
  cross-component handoff sequence.
- architecture.md, FirmwareUpdateModule.md, install/README.md,
  performance.md, plan.md, scripts/MoonDeck.md, CLAUDE.md: present-
  tense the web-installer references and update all paths/symbols
  for the install-picker rename.
- docs/history/decisions.md: records the release-picker →
  install-picker rename so a future grep for the old name lands on
  the new one (no in-file "renamed from" comment — that's the kind
  of past-tense history CLAUDE.md says belongs only here).
- CMakeLists.txt, esp32/main/CMakeLists.txt, embed_ui.cmake,
  release.yml, improv_smoke_test.py: rename sweep.

Reviews
- 🐰 BoardModule.md "24-byte buffer" → "32-byte buffer" (Reviewer
  agent flagged the stale prose; header table was already updated).
- 🐰 install-orchestrator.js: annotated the SDK string-match for
  pinned-version dependency (CodeRabbit, intent valid; finding
  pointed at wrong line — fixed at the real location).
- 🐰 architecture.md line 181 + MoonDeck.md line 12: present-tensed
  "web installer (next commit)" / "will use" → live (CodeRabbit).
- 🐰 Skipped: CodeRabbit's "wrap if (c.readonly) in braces for
  -Werror" — no warning fires on brace-less single-statement if;
  build is clean with -Wall -Wextra -Werror as enabled today.
- 🐰 Skipped: CodeRabbit's "skip redundant board pushes by comparing
  to prior" — adjacent comment already documents the choice
  (debounce coalesces, single small POST, intentional simplicity).
- 🐰 Reviewer-agent confirmed bespoke choices (?board= query-string
  handoff, controls.<Module>.<control> nesting, mousedown-on-select
  for the single-option port picker) all carry their rationale at
  the introduction site, no further justification needed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (8)
test/unit/core/unit_BoardModule.cpp (1)

23-33: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Assert readonly on the board control to lock the UI contract.

This test verifies type/wiring/max but misses c.readonly == true, which is core behavior for BoardModule::onBuildControls(). Add it here to prevent regressions that make board editable again.

Suggested patch
     CHECK(c.type == mm::ControlType::Text);
+    CHECK(c.readonly);
     // The control's ptr points at the module's internal boardKey_ buffer;

As per coding guidelines, "test/**: ... Verify tests cover edge cases and match the specifications in docs/moonmodules/".

🤖 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 `@test/unit/core/unit_BoardModule.cpp` around lines 23 - 33, The test is
missing an assertion that the board control is marked readonly; update the unit
test in unit_BoardModule.cpp (the block that inspects board.controls()[0]) to
assert c.readonly == true so it verifies the UI contract produced by
BoardModule::onBuildControls(); locate the control check around
board.controls().count(), the local const auto& c = board.controls()[0], and add
the readonly check alongside the existing checks that validate c.name, c.type,
c.ptr (board.board()), and c.max to prevent regressions that make the board
editable.
docs/install/README.md (1)

3-4: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Align intro wording with the custom installer architecture.

The opening line still brands this as an “ESP Web Tools installer page,” but this flow is now orchestrator-driven with EWT removed from the install-button path. Tightening the intro avoids mixed messaging in the same doc.

Suggested wording
-This directory holds the source for the **ESP Web Tools installer page** at
+This directory holds the source for the **projectMM web installer page** at
🤖 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/install/README.md` around lines 3 - 4, Update the README's opening line
that currently says "This directory holds the source for the **ESP Web Tools
installer page**..." to reflect the new orchestrator-driven custom installer
architecture: remove the "ESP Web Tools installer page" branding and replace it
with a concise description such as "This directory holds the source for the
custom installer page driven by the orchestrator" (or equivalent wording that
emphasizes the orchestrator-driven flow and that EWT is no longer on the
install-button path); update the top-level introductory sentence so the document
consistently conveys the new installer flow.
scripts/MoonDeck.md (1)

65-66: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Update flash-ready flow text to the current installer runtime.

This still says the flow opens the ESP Web Tools Improv modal, but the branch replaced EWT with the custom install orchestrator. The current wording misstates what developers should expect during local validation.

Suggested doc patch
-- **Flash-ready.** When at least one ESP32 build exists, the script additionally stages every `build/esp32-*/projectMM.bin` it finds into `releases/local-dev/` and generates matching Pages-relative manifests via the same `generate_manifest.py` the release workflow uses. The picker shows `local-dev` as the newest tag; clicking **Install** flashes a USB-connected ESP32 and opens the ESP Web Tools Improv WiFi modal — end-to-end, same code paths as the public installer. This is the developer's test ground for the install flow before deploying to GitHub Pages: Web Serial works on `http://localhost` without the secure-origin requirement that gates the public site.
+- **Flash-ready.** When at least one ESP32 build exists, the script additionally stages every `build/esp32-*/projectMM.bin` it finds into `releases/local-dev/` and generates matching Pages-relative manifests via the same `generate_manifest.py` the release workflow uses. The picker shows `local-dev` as the newest tag; clicking **Install** flashes a USB-connected ESP32 and runs the custom install orchestrator (flash → provision → board injection) — end-to-end, same code paths as the public installer. This is the developer's test ground for the install flow before deploying to GitHub Pages: Web Serial works on `http://localhost` without the secure-origin requirement that gates the public site.
🤖 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 `@scripts/MoonDeck.md` around lines 65 - 66, Update the "Flash-ready."
paragraph so it no longer says the flow opens the "ESP Web Tools Improv WiFi
modal" and instead describes the current installer runtime: when at least one
ESP32 build exists the script stages build/esp32-*/projectMM.bin into
releases/local-dev/ and generates Pages manifests via generate_manifest.py, the
picker shows "local-dev" and clicking "Install" flashes a USB-connected ESP32
and invokes the repository's custom install orchestrator UI (not ESP Web Tools),
following the same end-to-end code paths as the public installer for local
validation.
src/ui/app.js (1)

174-181: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't use the fire-and-forget control helper for the one-shot board handoff.

consumePendingBoardParam() deletes ?board= before posting anything, but sendControl() swallows network errors and ignores non-2xx responses. A failed /api/control write therefore looks like success and the retry token is already gone, so board injection can silently disappear.

Also applies to: 206-247

🤖 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/ui/app.js` around lines 174 - 181, The current sendControl function
swallows network errors and ignores non-2xx responses, causing
consumePendingBoardParam to delete the ?board= token before ensuring the
/api/control write succeeded; update sendControl to await fetch, check
response.ok and reject/throw on non-ok or network errors so callers can detect
failure, and modify consumePendingBoardParam to only remove the retry token (the
query param) after sendControl resolves successfully, otherwise preserve the
token and surface/log the error so the board handoff can be retried.
docs/install/devices.js (1)

277-302: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Clear stale pendingBoard when a later install no longer needs it.

For existing rows, pendingBoard is only written when pendingBoardPush is true and never cleared otherwise. Because buildInjectUrl() prefers pendingBoard, a device that was once pending and later reinstalled successfully can still inject the old board config.

Suggested fix
         if (existing) {
             existing.lastSeen = now;
             // Only overwrite board when caller supplied a value — re-flashing
             // with "(any board)" mustn't blank a previously-set entry.
             if (board) existing.board = board;
-            if (pendingBoardPush) existing.pendingBoard = board;
+            if (pendingBoardPush) existing.pendingBoard = board;
+            else delete existing.pendingBoard;
         } else {
🤖 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/install/devices.js` around lines 277 - 302, In addProvisionedDevice,
existing devices never have their pendingBoard cleared when pendingBoardPush is
false, so later calls can still use stale pendingBoard; update the
existing-device branch in addProvisionedDevice (and ensure new entries don't
inherit it) to explicitly remove or unset existing.pendingBoard when
pendingBoardPush is falsy (use delete existing.pendingBoard or set to
undefined/null) and only set existing.pendingBoard = board when pendingBoardPush
is true so buildInjectUrl() will not prefer an out-of-date pendingBoard.
docs/install/index.html (1)

605-620: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Clear the Wi‑Fi password between install sessions.

#wifi-password is never reset when the modal opens or when the Wi‑Fi step completes, so the previous network password stays in the DOM and is prefilled on the next install attempt. That unnecessarily retains sensitive data after the flow is over.

Suggested fix
 function openModal(titleText) {
   title.textContent = titleText;
   backdrop.classList.add("open");
+  document.getElementById("wifi-password").value = "";
   // Reset the log per install session so users see only the current run.
   document.getElementById("install-log").textContent = "";
@@
         case "wifi-creds-form": {
           showSection("wifiForm");
           // Prefill the last-used SSID (not password — that stays out of
           // localStorage as a privacy / security-scanner concession). Focus
           // password if SSID is prefilled (user only needs to type the
           // password), else focus SSID.
           const ssidEl = document.getElementById("wifi-ssid");
           const passEl = document.getElementById("wifi-password");
+          passEl.value = "";

Also applies to: 716-729, 768-779

🤖 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/install/index.html` around lines 605 - 620, The Wi-Fi password input
(`#wifi-password`) is never cleared and retains sensitive data between installs;
update the modal open logic (function openModal) to explicitly clear the
password element by setting document.getElementById("wifi-password").value = ""
and also clear any value attribute or textContent as appropriate; additionally,
ensure the same clearing happens when the Wi‑Fi step completes or when the flow
ends (e.g., in the handlers that run on done / error / cancel or in
disarmUnloadGuard-related completion functions) so the password is removed from
the DOM regardless of how the modal closes.
src/platform/esp32/platform_esp32_improv.cpp (1)

53-59: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Update the stale buffer-size comment.

The comment at line 54 says "module-owned BoardModule buffer (24 bytes)" but BoardModule::boardKey_ is now 32 bytes (changed in this PR). The comment should reflect the current size.

📝 Proposed fix
     // Vendor SET_BOARD RPC (command 0xFE): module-owned BoardModule buffer
-    // (24 bytes) + ready flag. Same producer/consumer dance as ssid/password.
+    // (32 bytes) + ready flag. Same producer/consumer dance as ssid/password.
🤖 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 53 - 59, The
comment describing the vendor SET_BOARD buffer size is stale—update the comment
referencing the module-owned BoardModule buffer to reflect that
BoardModule::boardKey_ is now 32 bytes (not 24); locate the block with boardOut,
boardOutLen and boardReady and change the "(24 bytes)" remark to "(32 bytes)"
and/or reword to derive size from BoardModule::boardKey_ to avoid future drift.
src/core/BoardModule.h (1)

53-71: 🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Document the rationale for the ASCII-printable constraint.

The validation enforces ASCII-printable bytes (0x20–0x7E), which is a bespoke convention not widely recognized from standard frameworks. As per coding guidelines, "Document exception reasons for bespoke conventions (not recognized from widely-used projects/frameworks) in a one-line comment at the introduction site."

Please add a brief justification explaining why this constraint exists (e.g., JSON safety, display compatibility, wire protocol requirements, catalog alignment).

📝 Example justification
     // External setter for transports that bypass /api/control (today: Improv
-    // vendor RPC SET_BOARD from the web installer). Validates: 1..31 chars,
-    // ASCII-printable (0x20–0x7E), no embedded NUL. Returns false on
+    // vendor RPC SET_BOARD from the web installer). Validates: 1..31 chars,
+    // ASCII-printable (0x20–0x7E) for JSON/display safety + catalog alignment,
+    // no embedded NUL. Returns false on

As per coding guidelines: documentation for bespoke conventions must be present.

🤖 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/core/BoardModule.h` around lines 53 - 71, Add a one-line comment above
the setBoard function explaining the ASCII-printable (0x20–0x7E) constraint and
why it is required (e.g., to ensure safe JSON/HTTP/wire encoding, human-readable
display, and to avoid embedded NULs that would break C-string semantics); update
the comment near the setBoard declaration (referencing setBoard and boardKey_)
so future readers know this bespoke validation rationale while leaving the
existing validation logic and calls to markDirty() and
FilesystemModule::noteDirty() unchanged.
♻️ Duplicate comments (1)
src/core/HttpServerModule.cpp (1)

398-398: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Wrap the single-statement if in braces.

This is the same issue flagged in a previous review. The coding guideline requires building with -Wall -Wextra -Werror, and Clang flags this single-statement if as missing braces (readability-braces-around-statements). As per coding guidelines, no warning is allowed — either fix it or silence it explicitly with a justified -Wno-….

🔧 Proposed fix
-        if (c.readonly) sink.append(",\"readonly\":true");
+        if (c.readonly) {
+            sink.append(",\"readonly\":true");
+        }

As per coding guidelines: build with -Wall -Wextra -Werror.

🤖 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/core/HttpServerModule.cpp` at line 398, The single-statement if checking
c.readonly should be wrapped in braces to satisfy
readability-braces-around-statements; update the expression that currently reads
"if (c.readonly) sink.append(...)" to use a braced block (e.g., if (c.readonly)
{ sink.append(...); }) so the compiler warnings are eliminated; locate the
occurrence using the symbols c and sink in HttpServerModule.cpp and apply the
brace fix.
🤖 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 `@docs/install/devices.js`:
- Around line 178-185: Update the erase confirmation text in the erase button
callback (the erase variable created via makeBtn) to remove the outdated
reference to "ESP Web Tools will offer to flash a fresh firmware" and instead
describe the current flow that runs installer.eraseOnly(); e.g., warn that this
will wipe WiFi credentials and module state and that a fresh firmware install
will not be performed automatically, so the user must re-install firmware
manually or via the installer after erase; keep references to device.name and
the existing state.onErase(device) callback intact.

In `@docs/install/install-orchestrator.js`:
- Around line 355-360: The saved prePickedPort handle is used unconditionally
which prevents reprompting if the handle is stale; update both start() and
eraseOnly() to first attempt to open the prePickedPort (call its open/connect
method) inside a try/catch, and if that attempt throws or fails, clear or ignore
prePickedPort and then call navigator.serial.requestPort({}) to show the native
picker; ensure the same pattern is applied to the other affected block (the
similar code around lines 621-642) so any stale opts.port triggers a re-prompt
and the existing reconnect fallback can operate correctly.
- Around line 536-547: After uiWaitForCreds() returns, detect a Skip by checking
if ssid is an empty string (e.g., const { ssid, password } = await
uiWaitForCreds(); if (!ssid) ...). If ssid is empty, do not call
improvClient.provision(); instead transition to the SoftAP/Skip path (e.g.,
trackProgress for the softAP flow or call the existing softAP handler) and avoid
throwing a provisioning error. Only call improvClient.provision(ssid, password,
30000) and check improvClient.nextUrl/deviceUrl when ssid is non-empty.

---

Outside diff comments:
In `@docs/install/devices.js`:
- Around line 277-302: In addProvisionedDevice, existing devices never have
their pendingBoard cleared when pendingBoardPush is false, so later calls can
still use stale pendingBoard; update the existing-device branch in
addProvisionedDevice (and ensure new entries don't inherit it) to explicitly
remove or unset existing.pendingBoard when pendingBoardPush is falsy (use delete
existing.pendingBoard or set to undefined/null) and only set
existing.pendingBoard = board when pendingBoardPush is true so buildInjectUrl()
will not prefer an out-of-date pendingBoard.

In `@docs/install/index.html`:
- Around line 605-620: The Wi-Fi password input (`#wifi-password`) is never
cleared and retains sensitive data between installs; update the modal open logic
(function openModal) to explicitly clear the password element by setting
document.getElementById("wifi-password").value = "" and also clear any value
attribute or textContent as appropriate; additionally, ensure the same clearing
happens when the Wi‑Fi step completes or when the flow ends (e.g., in the
handlers that run on done / error / cancel or in disarmUnloadGuard-related
completion functions) so the password is removed from the DOM regardless of how
the modal closes.

In `@docs/install/README.md`:
- Around line 3-4: Update the README's opening line that currently says "This
directory holds the source for the **ESP Web Tools installer page**..." to
reflect the new orchestrator-driven custom installer architecture: remove the
"ESP Web Tools installer page" branding and replace it with a concise
description such as "This directory holds the source for the custom installer
page driven by the orchestrator" (or equivalent wording that emphasizes the
orchestrator-driven flow and that EWT is no longer on the install-button path);
update the top-level introductory sentence so the document consistently conveys
the new installer flow.

In `@scripts/MoonDeck.md`:
- Around line 65-66: Update the "Flash-ready." paragraph so it no longer says
the flow opens the "ESP Web Tools Improv WiFi modal" and instead describes the
current installer runtime: when at least one ESP32 build exists the script
stages build/esp32-*/projectMM.bin into releases/local-dev/ and generates Pages
manifests via generate_manifest.py, the picker shows "local-dev" and clicking
"Install" flashes a USB-connected ESP32 and invokes the repository's custom
install orchestrator UI (not ESP Web Tools), following the same end-to-end code
paths as the public installer for local validation.

In `@src/core/BoardModule.h`:
- Around line 53-71: Add a one-line comment above the setBoard function
explaining the ASCII-printable (0x20–0x7E) constraint and why it is required
(e.g., to ensure safe JSON/HTTP/wire encoding, human-readable display, and to
avoid embedded NULs that would break C-string semantics); update the comment
near the setBoard declaration (referencing setBoard and boardKey_) so future
readers know this bespoke validation rationale while leaving the existing
validation logic and calls to markDirty() and FilesystemModule::noteDirty()
unchanged.

In `@src/platform/esp32/platform_esp32_improv.cpp`:
- Around line 53-59: The comment describing the vendor SET_BOARD buffer size is
stale—update the comment referencing the module-owned BoardModule buffer to
reflect that BoardModule::boardKey_ is now 32 bytes (not 24); locate the block
with boardOut, boardOutLen and boardReady and change the "(24 bytes)" remark to
"(32 bytes)" and/or reword to derive size from BoardModule::boardKey_ to avoid
future drift.

In `@src/ui/app.js`:
- Around line 174-181: The current sendControl function swallows network errors
and ignores non-2xx responses, causing consumePendingBoardParam to delete the
?board= token before ensuring the /api/control write succeeded; update
sendControl to await fetch, check response.ok and reject/throw on non-ok or
network errors so callers can detect failure, and modify
consumePendingBoardParam to only remove the retry token (the query param) after
sendControl resolves successfully, otherwise preserve the token and surface/log
the error so the board handoff can be retried.

In `@test/unit/core/unit_BoardModule.cpp`:
- Around line 23-33: The test is missing an assertion that the board control is
marked readonly; update the unit test in unit_BoardModule.cpp (the block that
inspects board.controls()[0]) to assert c.readonly == true so it verifies the UI
contract produced by BoardModule::onBuildControls(); locate the control check
around board.controls().count(), the local const auto& c = board.controls()[0],
and add the readonly check alongside the existing checks that validate c.name,
c.type, c.ptr (board.board()), and c.max to prevent regressions that make the
board editable.

---

Duplicate comments:
In `@src/core/HttpServerModule.cpp`:
- Line 398: The single-statement if checking c.readonly should be wrapped in
braces to satisfy readability-braces-around-statements; update the expression
that currently reads "if (c.readonly) sink.append(...)" to use a braced block
(e.g., if (c.readonly) { sink.append(...); }) so the compiler warnings are
eliminated; locate the occurrence using the symbols c and sink in
HttpServerModule.cpp and apply the brace fix.
🪄 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: 20308dd3-8bbe-4744-9cf1-f1d3a6416426

📥 Commits

Reviewing files that changed from the base of the PR and between 7354923 and 9f888af.

⛔ Files ignored due to path filters (2)
  • docs/install/favicon.png is excluded by !**/*.png
  • scripts/build/improv_smoke_test.py is excluded by !**/build/**
📒 Files selected for processing (28)
  • .github/workflows/release.yml
  • CLAUDE.md
  • CMakeLists.txt
  • docs/architecture.md
  • docs/history/decisions.md
  • docs/install/README.md
  • docs/install/boards.json
  • docs/install/devices.js
  • docs/install/index.html
  • docs/install/install-orchestrator.js
  • docs/moonmodules/core/BoardModule.md
  • docs/moonmodules/core/FirmwareUpdateModule.md
  • docs/performance.md
  • docs/plan.md
  • esp32/main/CMakeLists.txt
  • scripts/MoonDeck.md
  • scripts/run/preview_installer.py
  • src/core/BoardModule.h
  • src/core/HttpServerModule.cpp
  • src/core/ImprovProvisioningModule.h
  • src/core/SystemModule.h
  • src/core/ui_embedded.h
  • src/main.cpp
  • src/platform/esp32/platform_esp32_improv.cpp
  • src/ui/app.js
  • src/ui/embed_ui.cmake
  • src/ui/install-picker.js
  • test/unit/core/unit_BoardModule.cpp

Comment thread docs/install/devices.js
Comment thread docs/install/install-orchestrator.js Outdated
Comment thread docs/install/install-orchestrator.js
Adds the LOLIN S3 N16R8 board entry to the catalog and the full chain
of fixes it needed: a writable WiFi-TX-power cap to prevent the
on-module LDO brown-out, USB-Serial-JTAG support in the Improv
listener so the device responds on native-USB S3 boards, a CORS
preflight handler so cross-origin POSTs from the web installer
actually reach `/api/control`, and a generic per-board controls
fan-out across MoonDeck and the installer paths so future per-board
fields land without code changes. Web installer also gains a
Monitor button + retry affordance, and `maxBlock` now reports
internal-RAM block (useful) instead of PSRAM block (always ~8MB).

KPI: tick:153831us(FPS:6) on esp32s3-n16r8 with txPower capped to
8 dBm (LOLIN brown-out fix). Slower than full-power ArtNet because
8 dBm cuts radio TX margin — known hardware trade-off, not a code
regression.

Core:
- NetworkModule: added writable `txPowerSetting` (0..21 dBm, 0=no
  override) re-applied on change via syncTxPower(). Setting back to 0
  actively pushes 80 quarter-dBm to undo any prior cap (no IDF
  "reset to default" API exists; sticky-cap was the previous behavior).
- BoardModule: ASCII-printable validation rationale documented at
  setBoard() (JSON encoding, UI rendering, C-string round-trip).
- HttpServerModule: added OPTIONS preflight handler returning 204 +
  CORS headers. Root cause of every "cross-origin POST silently
  blocked" symptom — the orchestrator's HTTP inject from preview now
  lands properly. Also documented path-agnostic preflight choice.
- SystemModule: maxBlock display switched to maxInternalAllocBlock;
  added comment explaining how PSRAM is detected (heap-size derivation,
  not a flag).

Platform:
- platform.h / platform_esp32.cpp / platform_desktop.cpp: new
  `maxInternalAllocBlock()` API alongside existing `maxAllocBlock`.
  Internal-only block is the memory-pressure KPI; the all-memory
  variant reports ~8 MB on PSRAM boards and tells you nothing.
  Layer::canAllocate keeps the all-memory call (light buffers may
  live in PSRAM); every diagnostic site (SystemModule, main.cpp tick
  log, HttpServerModule /api/state, scenario_runner) now uses the
  internal-only variant.
- platform.h / platform_esp32.cpp / platform_desktop.cpp: new
  `wifiSetTxPower(int8_t quarterDbm)` setter wrapping
  esp_wifi_set_max_tx_power. Clamps into ESP-IDF's 8..84 range.
- platform_esp32_improv.cpp: USB-Serial-JTAG read+write path added
  alongside UART0 (guarded by SOC_USB_SERIAL_JTAG_SUPPORTED). LOLIN
  S3 N16R8 and other native-USB S3 boards expose USB-C through
  USB-Serial-JTAG, not UART0 — without this the Improv task was deaf
  to host writes on those boards. Both transports drained
  symmetrically each loop with a single 10 ms yield when both are
  empty; ESP_LOGW for driver-install warnings + compound
  `"listening (uart unavailable)"` status so partial-transport
  failures stay visible.

UI:
- src/ui/install-picker.js: added `installRowExtras` slot so the
  installer page can slot the Erase-chip checkbox between firmware
  dropdown and Install button without the on-device OTA UI inheriting
  installer-specific affordances.
- src/ui/app.js: sendControl() now logs non-ok HTTP + network errors
  to console.warn (no retry — design intent for the single-shot
  `?board=` consume path).

Scripts / MoonDeck:
- MoonDeck `_push_board_to_device` now fans out the full
  `controls.<Module>.<control>` block from boards.json on every push
  (was Board.board only). Falls back to bare-board for catalog-
  missing names; no-op on empty board. Generic — any future per-board
  control in boards.json lands via MoonDeck without code changes.
- docs/install/boards.json: new "LOLIN S3 N16R8" entry injecting
  `Network.txPowerSetting: 8` (8 dBm — well below the ~13 dBm
  threshold ESPHome documents for the brown-out symptom).
- docs/install/index.html: rich installer-UI changes — Monitor
  button + serial-monitor modal (Reset/Clear/Close, autoscroll,
  UTF-8 decode, RTS-pulse reset), Retry button on the "Improv not
  detected" dialog with stale-port probe + 250 ms post-close wait
  for SDK lock release, lazy-render WiFi-creds form (avoids macOS
  iCloud Passwords prompt on page load), Skip-creds path exits
  cleanly via onSuccess({url:""}) rather than failing in provision(),
  HTTP injection runs on the Improv-success path too (was needsIp-
  only — fixes the "txPower not applied after Improv install"
  symptom), 2 s → 3 s post-flash port-reopen wait (boot race against
  Improv task init on S3), monitor button auto-disabled during
  install (Web Serial port-mutex enforcement), pendingBoard fallback
  now covers Improv-success HTTP fail.
- docs/install/install-orchestrator.js: stale-prePickedPort probe
  with fallback to requestPort() in both start() and eraseOnly();
  isTransientImprovError helper extracted next to isImprovNotDetected
  so SDK error-string drift has one update site; uiShowNeedsIpRetrying
  contract reconciled with actual behavior.
- docs/install/devices.js: pendingBoard explicitly cleared on
  successful inject (was leaking across re-installs); erase confirm
  text rewritten to reflect the orchestrator-driven flow (EWT
  branding removed).

Docs / CI:
- docs/install/README.md, scripts/MoonDeck.md: replaced "ESP Web
  Tools installer page" branding with the orchestrator-driven
  description (present tense, no backward-looking change-log talk).
- docs/moonmodules/core/BoardModule.md: MoonDeck section updated to
  reflect full controls fan-out; "Pre-WiFi limitation" paragraph
  trimmed to a one-line pointer; full timing-constraint rationale
  moved to docs/history/decisions.md as the lesson worth carrying
  forward.
- docs/moonmodules/core/NetworkModule.md: txPowerSetting bullet
  added (trimmed to wire contract + intended use).
- docs/history/decisions.md: new "Board-injection pipeline timing
  constraint" entry — names the controls that wouldn't tolerate the
  HTTP-after-WiFi fan-out (country code, antenna selector, pre-
  association TX-power) and the two escape hatches (new vendor RPC,
  sdkconfig bake). Warns against extending SET_BOARD's wire format.

Tests:
- test/scenario_runner.cpp: switched to maxInternalAllocBlock for
  the regression KPI; updated rationale to explain why (PSRAM
  boards previously masked internal-heap fragmentation).
- test/unit/core/unit_BoardModule.cpp: added c.readonly assertion
  on the board control — regression guard against accidentally
  making the BoardModule.board field user-editable.

Reviews:
- 👾 Reviewer (on-demand pre-commit): 0 blockers, 10 should-fix, 12
  nits — all 22 addressed in this commit. Highlights: #1
  syncTxPower=0 now actively lifts the cap (was sticky until reboot);
  #2 pendingBoard fallback covers the Improv-success HTTP-fail path
  the LOLIN feature most needs to defend; #4 Monitor button now
  actually disabled during install (matched the documented mutex);
  #5 Improv driver-install warnings now logged via ESP_LOGW and
  carried in compound status (were silently overwritten by
  "listening"); #7 JTAG TX timeout 50 ms → 0 (truly non-blocking);
  #8 UART/JTAG polling made symmetric. The remaining items are
  comment trims, sentinel-vs-tagged-union docstring clarifications,
  and the 32-vs-34-quarter-dBm example fix in platform.h.
- 🐇 CodeRabbit (prior round): 10 findings processed earlier in the
  branch; 2 rejected with reason (HttpServerModule single-line `if
  (c.readonly)` brace fix — no clang-tidy config in the repo, and
  the codebase has many single-line ifs; consumePendingBoardParam
  retry-token — contradicts the documented "no retry" single-shot
  design).

KPI Details:

  Desktop:
    Lights: 16,384
    Binary: 375 KB
    [doctest] test cases: 207 | 207 passed | 0 failed | 0 skipped
    tick: 352us, 99us, 304us, 100us, 99us, 100us, 45us, 35us, 35us
    (FPS: 2840, 10101, 3289, 10000, 10101, 10000, 22222, 28571, 28571)
    === 10 scenario(s), 10 passed, 0 failed ===
    Platform boundary: PASS
    Specs: 26 modules, 26 ok, 0 missing, 0 outdated
  ESP32 (esp32s3-n16r8 on LOLIN S3 N16R8 at txPower=8 dBm):
    Image: 1,307,517 bytes (69% partition free)
    Flash (code+data): 1171 KB
    tick: 153831us (FPS: 6)
    free heap: 8354995  maxBlock (internal): 163840 (160 KB)
    ArtNetSend: ~105 ms/tick (LOLIN at 8 dBm — known trade-off)
  Code:
    62 source files (10491 lines)
    38 test files (5410 lines)
    39 specs, 10 scenarios

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 11

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)

259-267: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Validate the inner SET_BOARD length fields exactly.

This accepts any payload where strLen <= len - 3, even when payload[1] disagrees with the actual body size or extra trailing bytes are present. For a raw vendor RPC, that is too lax and can persist a board name from a malformed frame.

Suggested fix
-    uint8_t strLen = payload[2];
-    if (strLen == 0 || strLen >= g_improv.boardOutLen || 3u + strLen > len) {
+    uint8_t dataLen = payload[1];
+    uint8_t strLen = payload[2];
+    if (strLen == 0 ||
+        strLen >= g_improv.boardOutLen ||
+        dataLen != static_cast<uint8_t>(1u + strLen) ||
+        len != static_cast<uint8_t>(3u + strLen)) {
         improvSendError(static_cast<improv::Error>(IMPROV_ERROR_INVALID_BOARD));
         return;
     }
🤖 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 259 - 267, The
code accepts frames where payload[1] (data_len) disagrees with payload[2]
(strLen) or where extra trailing bytes exist; update the validation in
platform_esp32_improv.cpp so that after reading uint8_t strLen = payload[2] you
(1) require strLen > 0 && strLen < g_improv.boardOutLen, (2) require payload[1]
== static_cast<uint8_t>(1u + strLen) to ensure data_len matches the strLen+the
str_len byte, and (3) require len == static_cast<size_t>(3u + strLen) so the
frame length exactly matches the expected size; on any mismatch call
improvSendError(static_cast<improv::Error>(IMPROV_ERROR_INVALID_BOARD)) and
return.
🤖 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 `@docs/install/devices.js`:
- Around line 295-301: The current logic clears existing.pendingBoard whenever
pendingBoardPush is false, which removes the outstanding-injection marker on an
"(any board)" refresh; change the branch so pendingBoard is only deleted when a
real board was pushed/changed. In the block that uses pendingBoardPush, update
the else to only delete existing.pendingBoard when board is truthy (or when the
refresh represents an actual board change) — e.g., keep existing.pendingBoard
intact on empty/any-board refresh by checking board before deleting; references:
pendingBoardPush, existing.pendingBoard, existing.board, board.

In `@docs/install/index.html`:
- Around line 1108-1112: The fallback path calls openPortPicker() even when Web
Serial is not available; update the logic around pickedPort and openPortPicker()
to first check for navigator.serial (or an existing isWebSerialAvailable helper)
and if missing, show the unsupported-browser warning/message and return early
instead of calling openPortPicker(); ensure pickedPort remains falsy and no
requestPort() is attempted when navigator.serial is undefined so Monitor serial
port clicks on Safari/Firefox fail gracefully.

In `@docs/install/install-orchestrator.js`:
- Around line 615-618: The branch handling ipResult.action === "ip" incorrectly
treats malformed addresses as success: after calling
normalizeDeviceUrl(ipResult.url||"") it sets deviceUrl, viaHttp = true and
breaks even when normalizeDeviceUrl returns an empty string. Update the branch
so you first assign deviceUrl = normalizeDeviceUrl(...), then only set viaHttp =
true and break if deviceUrl is non-empty; if deviceUrl is empty, do not set
viaHttp or break (allow the needs-IP flow to remain open and let the user retry
or handle the error). Ensure you reference normalizeDeviceUrl, ipResult,
deviceUrl and viaHttp when making the change.

In `@docs/moonmodules/core/BoardModule.md`:
- Line 79: The paragraph uses future/roadmap phrasing ("future per-board state …
lands"); change it to present-tense, behavior-only wording that describes
current behavior and examples. Edit the sentence mentioning per-board state so
it states that per-board state is represented as additional leaves in the
controls map today (e.g., Ethernet RMII clock GPIO, MDIO/MDC pins, default
module-config overrides are examples of per-board leaves), and remove "future"
phrasing; keep references to controls, the two-level map shape, the fact that
every fan-out path (MoonDeck's _push_board_to_device, the web-installer
post-Improv push, and the device-side ?board= consumer) iterates the map
generically, and retain that catalog fields (name, firmwares, default_firmware)
remain top-level.

In `@scripts/moondeck.py`:
- Around line 188-191: The refresh currently can re-queue stale probed values
and overwrite a newer user selection because refresh_devices() and
_merge_refresh() treat the probed value as authoritative; change the logic to
persist a per-device pending desired board when a /api/push-board attempt fails
and make refresh_devices()/_merge_refresh() prefer MoonDeck's stored board (or
the persisted pending desired board) over the probed value until a push
succeeds. Concretely: when the push-board call (where /api/push-board is
invoked) fails, record the attempted board into a new pending_desired_board map
keyed by device id; update _merge_refresh() to check pending_desired_board first
and skip replacing it with the probed value, and clear pending_desired_board for
that device only when a subsequent push to /api/push-board succeeds; also ensure
refresh_devices() will attempt to re-push any pending_desired_board entries on
the next cycle so retries occur instead of reverting to stale values.
- Around line 197-198: The handler currently returns True when the incoming
picker value is empty, which leaves the previous selection persisted; instead,
detect the falsy/empty "board" in the push handler and explicitly clear the
stored value by setting the model's Board.board to an empty value (e.g., "" or
None) and persist that change (call the existing save/update/persist method for
the Board record) before returning success so the device's persisted state is
actually cleared.

In `@src/core/BoardModule.h`:
- Around line 54-57: Update the comment on the ASCII-printable constraint (the
block starting "ASCII-printable (0x20–0x7E), no embedded NUL.") to remove the
incorrect claim that this makes raw JSON embedding safe; instead state that the
intent is to reject control bytes and embedded NULs to protect downstream
consumers (JSON serializers, device UI, etc.), but explicitly note that
printable ASCII still contains characters like double-quote and backslash that
require normal JSON escaping. Keep the focus on rejecting control bytes/embedded
NULs rather than "no escaping required."

In `@src/core/NetworkModule.h`:
- Around line 291-292: The code currently skips reapplying TX power because
appliedTxPowerSetting_ is never invalidated when the radio stops; update the
radio stop/restart handling (where STA/AP stop and restart occur) to
reset/invalidate appliedTxPowerSetting_ (e.g., set to an impossible/ sentinel
value) so syncTxPower() will not short-circuit on the equality check with
txPowerSetting_ and will reapply persisted caps; also ensure any equality gating
logic in syncTxPower() (the block that tests txPowerSetting_ ==
appliedTxPowerSetting_) is reached after restart by calling syncTxPower() during
radio start and by clearing appliedTxPowerSetting_ during radio stop in the same
places referenced around the existing checks (the STA/AP stop handlers and the
restart path).

In `@src/platform/esp32/platform_esp32_improv.cpp`:
- Around line 430-466: The current loop uses a single ImprovFrameParser instance
(ImprovFrameParser parser) for both UART and USB-Serial-JTAG, letting bytes from
one transport finish frames started on the other; create and use separate parser
instances (e.g., ImprovFrameParser parser_uart and ImprovFrameParser
parser_jtag) and feed each transport's bytes only to its corresponding parser in
the loops where uartReady and g_jtagReady are handled (replace calls to
improvFeedByte(parser, b) with improvFeedByte(parser_uart, b) for UART and
improvFeedByte(parser_jtag, b) for JTAG), ensuring each parser retains its own
payload buffer and lifetime.

In `@src/platform/esp32/platform_esp32.cpp`:
- Line 582: The wifiSetTxPower stub currently returns false for all inputs, but
per the new contract wifiSetTxPower(0) must be a successful no-op; update the
wifiSetTxPower(int8_t /*quarterDbm*/) implementation to return true when the
argument is 0 (indicating “no override”) and return false for any non-zero value
so Ethernet-only builds treat 0 as success while still indicating failure for
actual power-setting attempts.

In `@src/ui/app.js`:
- Around line 174-191: sendControl currently swallows errors (logs warnings)
which allows consumePendingBoardParam to continue and consume the board handoff
even when some POSTs fail; change sendControl to accept an options param (e.g.,
{throwOnError}) and, when true, throw on non-ok responses or network errors
instead of only console.warn, and update callers (notably
consumePendingBoardParam) to await sendControl(moduleName, controlName, value,
{throwOnError: true}) so the board-injection path can stop and surface the first
failed write.

---

Outside diff comments:
In `@src/platform/esp32/platform_esp32_improv.cpp`:
- Around line 259-267: The code accepts frames where payload[1] (data_len)
disagrees with payload[2] (strLen) or where extra trailing bytes exist; update
the validation in platform_esp32_improv.cpp so that after reading uint8_t strLen
= payload[2] you (1) require strLen > 0 && strLen < g_improv.boardOutLen, (2)
require payload[1] == static_cast<uint8_t>(1u + strLen) to ensure data_len
matches the strLen+the str_len byte, and (3) require len ==
static_cast<size_t>(3u + strLen) so the frame length exactly matches the
expected size; on any mismatch call
improvSendError(static_cast<improv::Error>(IMPROV_ERROR_INVALID_BOARD)) and
return.
🪄 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: 085a2f4b-c112-41da-b1ae-0db106ba7ce1

📥 Commits

Reviewing files that changed from the base of the PR and between 9f888af and 751f1d5.

📒 Files selected for processing (24)
  • docs/history/decisions.md
  • docs/install/README.md
  • docs/install/boards.json
  • docs/install/devices.js
  • docs/install/index.html
  • docs/install/install-orchestrator.js
  • docs/moonmodules/core/BoardModule.md
  • docs/moonmodules/core/NetworkModule.md
  • scripts/MoonDeck.md
  • scripts/moondeck.py
  • src/core/BoardModule.h
  • src/core/HttpServerModule.cpp
  • src/core/HttpServerModule.h
  • src/core/NetworkModule.h
  • src/core/SystemModule.h
  • src/main.cpp
  • src/platform/desktop/platform_desktop.cpp
  • src/platform/esp32/platform_esp32.cpp
  • src/platform/esp32/platform_esp32_improv.cpp
  • src/platform/platform.h
  • src/ui/app.js
  • src/ui/install-picker.js
  • test/scenario_runner.cpp
  • test/unit/core/unit_BoardModule.cpp

Comment thread docs/install/devices.js Outdated
Comment thread docs/install/index.html
Comment thread docs/install/install-orchestrator.js Outdated
Comment thread docs/moonmodules/core/BoardModule.md Outdated
Comment thread scripts/moondeck.py
Comment thread src/core/BoardModule.h Outdated
Comment thread src/core/NetworkModule.h
Comment thread src/platform/esp32/platform_esp32_improv.cpp Outdated
Comment thread src/platform/esp32/platform_esp32.cpp Outdated
Comment thread src/ui/app.js
Eight CodeRabbit findings folded in, three rejected with reason. The
most important one is the txPower-cap-not-re-applied-after-radio-restart
bug: when the radio stopped (AP-to-STA cascade, STA reconnect, AP
shutdown), our cached `appliedTxPowerSetting_` stayed equal to the
desired value but ESP-IDF reset the radio's power state, so the
equality check in syncTxPower() short-circuited and the cap never
landed on the restarted radio. The LOLIN board would associate at
full power until the user touched the control — exactly the
brown-out hazard the cap exists to prevent.

KPI: tick:141456us(FPS:7) on esp32s3-n16r8 with txPower cap at
8 dBm. Slightly better than last commit's 153ms/FPS:6 (within noise;
ArtNetSend at 8 dBm continues to dominate the tick at ~97ms).

Core:
- NetworkModule: new noteRadioStopped() helper resets
  appliedTxPowerSetting_ to the -1 sentinel so the next syncTxPower()
  tick re-applies the persisted cap. Called at every wifiStaStop /
  wifiApStop call site (6 in total: AP-to-STA tear-down in
  setWifiCredentials, STA-to-STA tear-down, WaitingSta-timeout,
  ConnectedSta-dropped, teardown, and the lower-priority shutdowns
  in onConnected). Without this the LOLIN brown-out fix only landed
  once per power-cycle.
- BoardModule: setBoard() ASCII-printable-floor comment rewritten
  to drop the wrong "no JSON escaping required" claim and explain
  the actual rationale (rejects control bytes / NUL that corrupt
  JSON serializers, UI rendering, and C-string handling; printable
  ASCII still contains " and \\ which JSON serializers must escape
  normally).

Platform:
- platform_esp32_improv.cpp: two ImprovFrameParser instances
  instead of one — bytes from UART0 feed `parser_uart`, bytes from
  USB-Serial-JTAG feed `parser_jtag`. A shared parser would let a
  partial frame on one transport be corrupted by bytes arriving on
  the other, since the framing state machine doesn't know they came
  from different sources. ~150 B per parser on stack, well within
  the 6 KB task budget.
- platform_esp32_improv.cpp: SET_BOARD payload validation tightened.
  Beyond the existing strLen and 3+strLen <= len checks, now also
  requires data_len (payload[1]) == 1+strLen AND len == 3+strLen.
  Catches malformed frames where the internal data_len disagrees
  with str_len or where extra trailing bytes follow inside the
  framing-level payload.
- platform_esp32.cpp: MM_NO_WIFI stub for wifiSetTxPower(0) now
  returns true to match the documented "0 = no-override = success"
  contract. Non-zero still returns false (no radio to set). Same
  truth-table the wifi-enabled implementation already produces.

UI:
- docs/install/index.html: openMonitor() now checks `"serial" in
  navigator` before touching pickedPort / openPortPicker. On
  Safari / Firefox (no Web Serial), surfaces the existing page-load
  banner explicitly and returns rather than letting the click die
  silently inside openPortPicker's TypeError catch.
- docs/install/install-orchestrator.js: the needs-ip "ip" action
  now guards against an empty normalizeDeviceUrl() return —
  whitespace-only input slipped past the input's `required`
  attribute and would have broken out of the loop with viaHttp=true
  and a useless empty deviceUrl. Now `continue`s the loop instead,
  letting the user retry.
- docs/install/devices.js: addProvisionedDevice() only clears
  existing.pendingBoard when caller supplied a real board name.
  Previously an "(any board)" refresh cleared the outstanding-
  injection marker from a prior real-board push that hadn't
  succeeded yet.

Docs / CI:
- docs/moonmodules/core/BoardModule.md: "future per-board state
  lands" rewritten in present-tense per § Principles. Lists the
  current concrete examples (Board.board, Network.txPowerSetting)
  alongside Ethernet pin maps and module-config overrides as
  members of the same shape.

Reviews:
- 🐇 CodeRabbit (this round): 11 findings; 8 fixed (above), 3
  rejected with reason. (a) pending_desired_board map in MoonDeck:
  real bug pattern but the proposed fix is ~50 lines of state
  machinery; the simpler alternative (strengthen the refresh
  user-board-wins line) risks regressions in the deduce-board path.
  Deferred to a follow-up with clearer scope. (b) Push handler
  clearing device's Board.board on empty input: contradicts the
  documented "(any board) = leave alone" UX that
  docs/install/devices.js:294 also enforces. (c)
  sendControl({throwOnError}): contradicts the documented
  single-shot best-effort design at src/ui/app.js:200 — per-leaf
  failures must NOT short-circuit the loop since independent
  writes need independent success.

KPI Details:

  Desktop:
    Lights: 16,384
    Binary: 375 KB
    [doctest] test cases: 207 | 207 passed | 0 failed | 0 skipped
    tick: 356us, 100us, 303us, 99us, 99us, 100us, 46us, 34us, 36us
    (FPS: 2808, 10000, 3300, 10101, 10101, 10000, 21739, 29411, 27777)
    === 10 scenario(s), 10 passed, 0 failed ===
    Platform boundary: PASS
    Specs: 26 modules, 26 ok, 0 missing, 0 outdated
  ESP32 (esp32s3-n16r8 on LOLIN S3 N16R8 at txPower=8 dBm):
    Image: 1,307,589 bytes (69% partition free)
    Flash (code+data): 1171 KB
    tick: 141456us (FPS: 7)
    free heap: 8356059  maxBlock (internal): 167936 (164 KB)
    ArtNetSend: ~97 ms/tick (LOLIN at 8 dBm — known trade-off)
  Code:
    62 source files (10535 lines)
    38 test files (5410 lines)
    39 specs, 10 scenarios

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

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)

438-472: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Preserve transport identity through dispatch and replies.

These per-link parsers stop RX frame mixing, but they still feed a transport-agnostic improvDispatchFrame(), and improvSend() mirrors every response/error to both links. A valid RPC on UART therefore still injects unsolicited frames onto USB-Serial-JTAG (and vice versa), so concurrent clients can desynchronize even after this fix. Pass the source transport through improvFeedByte()/improvDispatchFrame() and reply only on the originating interface.

🤖 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 438 - 472, The
parser instances (parser_uart, parser_jtag) feed bytes into improvFeedByte()
which currently dispatches via a transport-agnostic improvDispatchFrame() and
improvSend(), causing replies to be broadcast to both links; change the flow to
carry the source transport through the parser -> feed -> dispatch path and
restrict replies to that transport. Concretely: extend improvFeedByte() and
improvDispatchFrame() to accept a transport identifier (e.g., an enum or pointer
identifying UART vs JTAG), have parser_uart pass UART and parser_jtag pass JTAG
when calling improvFeedByte(), and modify improvSend()/reply logic to send only
on the provided transport rather than both links; update any call sites of
improvDispatchFrame()/improvSend() accordingly and ensure existing parser state
structs (ImprovFrameParser) carry or relay the transport id as needed.
♻️ Duplicate comments (1)
src/core/NetworkModule.h (1)

59-69: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Apply the TX-power cap in the same call that starts Wi‑Fi.

noteRadioStopped() reopens the equality gate, but the first re-apply still waits for loop1s(). In the Improv path, setWifiCredentials() can start STA after this tick’s syncTxPower() already ran, so the radio spends its first association/AP-start window at full power. On the affected LOLIN boards, that is the exact window where the brown-out happens.

syncTxPower() already tolerates an early false and retries later, so it is safe to call it immediately after every successful wifiStaInit() / wifiApInit() once state_ is set to WaitingSta or AP.

Suggested fix
         if (platform::wifiStaInit(ssid_, password_)) {
             state_ = State::WaitingSta;
             stateChangeTime_ = platform::millis();
+            syncTxPower();
             std::snprintf(statusBuf_, sizeof(statusBuf_), "WiFi STA: %s", ssid_);
             setStatus(statusBuf_, Severity::Status);
@@
             if (ssid_[0] != 0 && platform::wifiStaInit(ssid_, password_)) {
                 state_ = State::WaitingSta;
+                syncTxPower();
                 std::printf("NetworkModule: WiFi STA init started, SSID: %s\n", ssid_);
             } else {
                 startAP();
@@
                         if (ssid_[0] != 0 && platform::wifiStaInit(ssid_, password_)) {
                             state_ = State::WaitingSta;
                             stateChangeTime_ = now;
+                            syncTxPower();
                         } else {
                             startAP();
                         }
@@
                         if (ssid_[0] != 0 && platform::wifiStaInit(ssid_, password_)) {
                             state_ = State::WaitingSta;
                             stateChangeTime_ = now;
+                            syncTxPower();
                         } else {
                             startAP();
                         }
@@
         if (platform::wifiApInit(apName, "4.3.2.1")) {
             state_ = State::AP;
             stateChangeTime_ = platform::millis();
+            syncTxPower();
             apShutdownPending_ = true;
             std::snprintf(statusBuf_, sizeof(statusBuf_), "AP: %s @ 4.3.2.1", apName); setStatus(statusBuf_, Severity::Status);
             std::printf("NetworkModule: AP started: %s\n", apName);

Also applies to: 90-94, 186-190, 218-222, 387-392, 480-489

🤖 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/core/NetworkModule.h` around lines 59 - 69, After successfully starting
Wi‑Fi (e.g. after a true return from platform::wifiStaInit(...) or
platform::wifiApInit(...)) and after setting state_ to State::WaitingSta or
State::AP, immediately call syncTxPower() so TX-power caps are applied before
the first association/AP window; update the wifiStaInit and wifiApInit success
branches (the blocks that set state_ = State::WaitingSta or State::AP and call
rebuildControls()/setStatus(...)) to invoke syncTxPower() right there, and
mirror the same change wherever noteRadioStopped() or setWifiCredentials() can
cause an immediate wifi init so the radio never spends its initial connect/start
period at full power.
🤖 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 `@docs/install/install-orchestrator.js`:
- Around line 615-630: When normalizeDeviceUrl(ipResult.url || "") returns an
empty/unparseable result, instead of only writing to onLog and looping, surface
a visible validation error in the host UI: call a visible-error callback (e.g.,
add and invoke an onValidationError/onUserError callback) with a clear message
like "Malformed device address" and keep the existing onLog call; leave
deviceUrl, viaHttp and the continue logic unchanged. Update callers to accept
and render that onValidationError message in the modal so the user sees the
validation feedback.

In `@src/core/BoardModule.h`:
- Around line 52-65: The HTTP /api/control write path currently bypasses
BoardModule::setBoard() and its board-name validation, so unify validation by
either (A) extracting the validation logic from setBoard() into a single
callable function (e.g. BoardModule::validateBoardName(const std::string&)) and
calling that from both the HTTP control handler and setBoard(), or (B) changing
the HTTP control handler to route board-name updates through
BoardModule::setBoard() directly; ensure the chosen approach still copies the
accepted value into boardKey_ and triggers FilesystemModule's debounced save
(same behavior as NetworkModule::setWifiCredentials).

---

Outside diff comments:
In `@src/platform/esp32/platform_esp32_improv.cpp`:
- Around line 438-472: The parser instances (parser_uart, parser_jtag) feed
bytes into improvFeedByte() which currently dispatches via a transport-agnostic
improvDispatchFrame() and improvSend(), causing replies to be broadcast to both
links; change the flow to carry the source transport through the parser -> feed
-> dispatch path and restrict replies to that transport. Concretely: extend
improvFeedByte() and improvDispatchFrame() to accept a transport identifier
(e.g., an enum or pointer identifying UART vs JTAG), have parser_uart pass UART
and parser_jtag pass JTAG when calling improvFeedByte(), and modify
improvSend()/reply logic to send only on the provided transport rather than both
links; update any call sites of improvDispatchFrame()/improvSend() accordingly
and ensure existing parser state structs (ImprovFrameParser) carry or relay the
transport id as needed.

---

Duplicate comments:
In `@src/core/NetworkModule.h`:
- Around line 59-69: After successfully starting Wi‑Fi (e.g. after a true return
from platform::wifiStaInit(...) or platform::wifiApInit(...)) and after setting
state_ to State::WaitingSta or State::AP, immediately call syncTxPower() so
TX-power caps are applied before the first association/AP window; update the
wifiStaInit and wifiApInit success branches (the blocks that set state_ =
State::WaitingSta or State::AP and call rebuildControls()/setStatus(...)) to
invoke syncTxPower() right there, and mirror the same change wherever
noteRadioStopped() or setWifiCredentials() can cause an immediate wifi init so
the radio never spends its initial connect/start period at full power.
🪄 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: 2982bbe0-69ee-4615-ae81-09ab19770414

📥 Commits

Reviewing files that changed from the base of the PR and between 751f1d5 and eca3c2c.

📒 Files selected for processing (8)
  • docs/install/devices.js
  • docs/install/index.html
  • docs/install/install-orchestrator.js
  • docs/moonmodules/core/BoardModule.md
  • src/core/BoardModule.h
  • src/core/NetworkModule.h
  • src/platform/esp32/platform_esp32.cpp
  • src/platform/esp32/platform_esp32_improv.cpp

Comment thread docs/install/install-orchestrator.js
Comment thread src/core/BoardModule.h
Four CodeRabbit findings folded in (three with leaner shapes than
proposed), one deferred with reason. Most user-visible: syncTxPower()
now runs immediately after every successful wifiStaInit / wifiApInit,
so the LOLIN brown-out cap lands BEFORE the first association burst
rather than up to 1 s after — closes the window the cap exists to
defend. Also: Improv replies now go only on the transport that asked,
the needs-ip dialog catches whitespace-only input via the browser's
own validation tooltip, and the BoardModule.h ASCII-validation
asymmetry between SET_BOARD-over-Improv (validates) and HTTP
/api/control (doesn't) is documented explicitly. Plus an unrelated
docs addition: the NanoDLA v1.3 logic analyzer is named as the
project's reference fx2lafw device with wiring + capture instructions
for measuring ESP32 WS2812 output.

KPI: tick:138506us(FPS:7) on esp32s3-n16r8 at txPower=8 dBm. Within
noise of last commit's 141ms/FPS:7; the syncTxPower-on-init change
doesn't measurably affect the tick.

Core:
- NetworkModule: syncTxPower() called immediately after each of the
  5 wifiStaInit / wifiApInit success sites (setWifiCredentials, setup,
  the two ethernet-cascade-to-WiFi paths in loop1s, and startAP). The
  comment at setWifiCredentials's call is the canonical one; the rest
  point back to it. Closes the up-to-1 s gap between WiFi-up and the
  next loop1s tick during which the radio was at full power.
- BoardModule: documented the HTTP-write-bypasses-ASCII-validation
  asymmetry on setBoard()'s comment. The HTTP path uses the generic
  Text-control write at applyControlValue (Control.cpp) which does
  no printable-ASCII check. Acceptable today because HTTP-write
  callers (MoonDeck, web installer's HTTP inject) source values from
  boards.json (project-controlled). If the threat model grows, the
  right fix is a per-control validator hook on ControlDescriptor —
  named in the comment for the next reader.

Platform:
- platform_esp32_improv.cpp: per-transport reply routing. New
  static `g_replySource` set at the top of improvFeedByte's
  FrameReady branch and read inside improvSend; replies go ONLY on
  the transport that received the request rather than broadcast to
  both. The Improv task is single-threaded so a single static is
  sufficient; resets to ImprovSource::Both after dispatch so any
  unsolicited send (status broadcasts) keeps the prior shape.
  Lighter than threading an enum through every signature — same
  correctness outcome.

UI:
- docs/install/index.html: uiWaitForIp's submit handler now rejects
  whitespace-only input via setCustomValidity + reportValidity.
  Surfaces the browser's existing field-validation tooltip on the
  same field rather than letting normalizeDeviceUrl() return empty
  and the orchestrator silently re-prompt. The `required` attribute
  catches empty submits but not "   " — trim first, then flag.

Docs / CI:
- docs/moonmodules/light/leddriver-analysis-top-down.md: named the
  NanoDLA v1.3 as the project's reference fx2lafw-class logic
  analyzer (replacing the generic "$10-15 fx2lafw clone" mention in
  the TL;DR and recommended-suite table) and added a new §5.3.1
  "Wiring the NanoDLA v1.3 to an ESP32 and capturing one frame" —
  common-ground / signal-tap / power-isolation / strand-power
  wiring, voltage-level reasoning (3.3→3.3 direct, no shifter
  needed), sample-rate math for typical strands, copy-pasteable
  sigrok-cli capture + decode sequence, and common gotchas in the
  order they'll appear.

Tests:
- test/scenarios/light/scenario_PreviewDriver_detail.json: drift-
  tracking observed[pc-macos].tick_us[0] updated from 297→296.
  Normal CI noise persisted by scripts/scenario/run_scenario.py
  per the docs/testing.md drift-visibility contract.

Reviews:
- 🐇 CodeRabbit round 2: 4 findings; 3 fixed, 1 deferred. (a) #1
  empty-IP validation: chose host-side setCustomValidity instead of
  proposed onValidationError callback (smaller, equivalent UX, no
  new API surface). (b) Outside #improv-routing: chose static-
  g_replySource instead of proposed signature-threading (same
  correctness, ~30 fewer touched call sites). (c) #4 syncTxPower-
  after-wifi-init: implemented as-proposed. (d) #2 unified
  validation between HTTP and Improv board-name paths: deferred —
  both clean fixes are architectural (per-control validator hook on
  ControlDescriptor, or per-module HTTP dispatch exception); the
  asymmetry is documented with the right fix-shape named, to be
  picked up when the threat model warrants it.

KPI Details:

  Desktop:
    Lights: 16,384
    Binary: 375 KB
    [doctest] test cases: 207 | 207 passed | 0 failed | 0 skipped
    tick: 354us, 100us, 303us, 99us, 99us, 98us, 45us, 35us, 34us
    (FPS: 2824, 10000, 3300, 10101, 10101, 10204, 22222, 28571, 29411)
    === 10 scenario(s), 10 passed, 0 failed ===
    Platform boundary: PASS
    Specs: 26 modules, 26 ok, 0 missing, 0 outdated
  ESP32 (esp32s3-n16r8 on LOLIN S3 N16R8 at txPower=8 dBm):
    Image: 1,307,661 bytes (69% partition free)
    Flash (code+data): 1171 KB
    tick: 138506us (FPS: 7)
    free heap: 8358927  maxBlock (internal): 163840 (160 KB)
    ArtNetSend: ~93 ms/tick (LOLIN at 8 dBm — known trade-off)
  Code:
    62 source files (10592 lines)
    38 test files (5410 lines)
    39 specs, 10 scenarios

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@ewowi ewowi changed the title Board injection: BoardModule + boards.json + MoonDeck push + web installer board picker + Improv vendor RPC Board injection + LOLIN S3 N16R8 enablement Jun 4, 2026
PR-merge review pass turned up 12 items; 5 fixed pre-merge per the
Reviewer's "quick wins worth landing before merge" recommendation,
2 deferred to docs/plan.md as concrete follow-ups, 5 deferred as
nits. Plus the gate-3 (carry-forward lessons) and gate-7 (live perf
snapshot to docs/performance.md) outputs that the PR-merge checklist
mandates whenever the branch touches tick-path code.

KPI: tick:138506us(FPS:7) on esp32s3-n16r8 — unchanged from commit
6212fc0 (this round's only src/ change is a desktop stub return-
value tweak, no ESP32 behavioral delta).

Core:
- platform_desktop.cpp: wifiSetTxPower(0) now returns true to match
  the documented "0 = no-override = success" contract — same shape
  the MM_NO_WIFI ESP32 stub already uses. Non-zero still returns
  false (no radio to set). Without this, NetworkModule's
  syncTxPower on desktop never updated appliedTxPowerSetting_ and
  retried on every loop1s tick — harmless but inconsistent.

Docs / CI:
- docs/install/index.html: user-visible browser-warning text
  "ESP Web Tools needs Web Serial" → "Web Serial is required".
  EWT is gone; the warning lives on past it.
- docs/install/README.md: same string at the bottom of the local-
  preview docs.
- docs/moonmodules/core/BoardModule.md: rewrote the past-tense "This
  commit replaced ESP Web Tools' install button..." paragraph as a
  present-tense statement about what the orchestrator does today.
  Per § Principles, module spec pages describe the system as-is;
  changelog-style narrative lives in history.
- docs/moonmodules/core/NetworkModule.md: "Board-specific GPIO config
  ... will be handled by a future IO module" → present-tense ("is
  hardcoded per board via compile-time defines in
  sdkconfig.defaults.<board>"). Forward-looking promises belong in
  plan.md.
- docs/history/decisions.md: three new "Lessons from the LOLIN S3
  N16R8 enablement branch" entries — USB-Serial-JTAG vs UART0 on
  S3 (the dual-stdio mirror that made the wrong inference cheap),
  CORS preflight silent-failure (the root cause behind every "inject
  silently fails" symptom), and cache-staleness on hardware-event
  invalidation (the appliedTxPowerSetting_ pattern). Plus the
  ESP-Web-Tools replacement rationale moved out of BoardModule.md.
- docs/plan.md: two new open-follow-up entries under § Board injection
  — per-control validator hook on ControlDescriptor (the right fix
  for the HTTP-vs-Improv board-name validation asymmetry), and
  shared JS helpers across device-UI and web-installer (defer until
  a second cross-context helper earns the 5-file build-glue cost).
- docs/performance.md: new "ESP32-S3 — LOLIN S3 N16R8" section
  covering tick budget at txPower=8 dBm (~138 ms / FPS 7), the
  ArtNet-slow-at-low-TX explanation, memory layout (Layer in PSRAM,
  maxBlock as internal-RAM-only KPI), and the use-case framing
  (LOLIN fits "lots of PSRAM, accept WiFi compromise"; Ethernet
  boards win on FPS).

Reviews:
- 👾 PR-merge Reviewer (whole branch diff, 7 commits): 0 blockers,
  7 should-fix, 5 nits. (a) #1-3 (stale EWT copy, past-tense spec,
  forward-looking spec) → fixed above. (b) #10 (desktop stub
  contract) → fixed above. (c) #4 (validation hook) + #6 (shared
  helpers) → documented as follow-ups in plan.md. (d) #5, #7-9,
  #11-12 → deferred per the Reviewer's own "future-work, not merge
  blocker / low priority / tiny / minor / nit" framing.

KPI Details:

  Desktop:
    Lights: 16,384
    Binary: 375 KB
    [doctest] test cases: 207 | 207 passed | 0 failed | 0 skipped
    tick: 354us, 100us, 303us, 99us, 99us, 98us, 45us, 35us, 34us
    (FPS: 2824, 10000, 3300, 10101, 10101, 10204, 22222, 28571, 29411)
    === 10 scenario(s), 10 passed, 0 failed ===
    Platform boundary: PASS
    Specs: 26 modules, 26 ok, 0 missing, 0 outdated
  ESP32 (esp32s3-n16r8 on LOLIN S3 N16R8 at txPower=8 dBm):
    tick: 138506us (FPS: 7)   [unchanged from 6212fc0 — desktop-
    only src/ change this round, no ESP32 behavioral delta]
  Code:
    62 source files (10591 lines)
    38 test files (5410 lines)
    39 specs, 10 scenarios

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@ewowi
ewowi merged commit dfaa476 into main Jun 4, 2026
1 check passed
@ewowi
ewowi deleted the next-iteration branch June 4, 2026 17:51
ewowi added a commit that referenced this pull request Jun 12, 2026
…bit review

Renames the audio module from MicModule to AudioModule (it does audio acquisition plus level/FFT analysis, and will gain line-in / USB sources beyond the I²S mic, so the name now reflects the module, not one source). Makes the primary-source references in the docs clickable (datasheets, Espressif API pages, vendor sites) per the spec-and-test principle. Processes the CodeRabbit review on the prior commit: six findings fixed, six skipped with a stated reason.

KPI: 16384lights | PC:340KB | tick:121/89/119/122/56/2/37/21/117/342us(FPS:8264/11235/8403/8196/17857/500000/27027/47619/8547/2923) | tick:21177us(FPS:47) | heap:33066KB | src:87(16753) | test:53(8518) | lizard:68w

Core:
- MicModule.h → AudioModule.h (git mv, history preserved): class MicModule → AudioModule, registration string "MicModule" → "AudioModule", the latestFrame() static, the main.cpp wiring. The mic-source platform seam (hasI2sMic, audioMicRead, AudioMicHandle) is deliberately NOT renamed: it correctly names today's actual source; it broadens when line-in lands (concrete-first). Verified live on the S3: AudioModule registers and runs (AudioModule:523us in the tick breakdown), no boot-loop.

Light domain:
- AudioLevel.h: clamp the DcBlocker IIR output to the int32 range before narrowing (a settling transient could push the float past int32 bounds, which is UB on the cast). 🐇 #1.
- AudioSpectrumEffect.h: fix a height-gradient off-by-one — the gradient divided by (h-1) but the spectrum spans specH rows; when the bottom row is the level meter (specH == h-1) the top spectrum row never reached full red. Now divides by (specH-1). 🐇 #4.
- AudioVolumeEffect.h: defensively zero any per-light channels beyond RGB (buffers are RGB/cpl=3 today; this keeps the write correct for any cpl and leaves no stale bytes). 🐇 #3.

Scripts / build:
- idf_component.yml: pin esp_wifi_remote ~1.6.1 and esp_hosted ~2.12.9 (the P4-NANO-validated versions) instead of "*", so the P4 build can't silently drift to a new minor; also corrected the stale "esp_hosted bring-up prelude" comment (that prelude was removed). 🐇 #2. Verified the pins resolve and the P4 builds + boots.

Docs / CI:
- AudioModule.md (was MicModule.md): retitled; opening reframed as "acquires an audio source ... named for what it does, not one source" (present-tense, no claim line-in exists yet). Added primary-source links: INMP441 datasheet, esp-dsp, ESP-IDF I2S API, Hann window. First fully em-dash-free module spec.
- RmtLedDriver.md / LcdLedDriver.md / ParlioLedDriver.md: linked WS2812B datasheet, ESP-IDF RMT v2 / esp_lcd / Parlio API pages, the v6.0 migration guide (legacy-RMT removal), ESP32-P4 product page. (Caught + fixed an agent-guessed Parlio URL that 404'd; all links verified live.)
- SystemModule.md / building.md: linked ESP32-C6, esp_hosted, esp_wifi_remote, Waveshare ESP32-P4-NANO.
- decisions.md: reworded the "designed fresh" audio lesson so it no longer reads as "don't look at prior art at all" (which fought the study-with-respect principle); now "reference proven behaviour, don't trace structure," with the flat-mic / no-correction-table call as the example. 🐇 #6.
- AudioVolumeEffect.md: em-dashes removed (touched alongside the effect). 🐇 #7 (partial).
- AudioSpectrumEffect.md / AudioFrame.h / test files: AudioModule rename propagated (@module annotations, references).

Reviews — 🐇 CodeRabbit, prior commit (b79d54f):
- Fixed #1 (DcBlocker int32 clamp), #2 (component version pins), #3 (AudioVolumeEffect extra-channel clear), #4 (AudioSpectrumEffect specH off-by-one), #6 (decisions.md prior-art wording), #7 (AudioVolumeEffect.md em-dashes).
- Skipped #5 (architecture.md Buffer*/Correction* "pin at wiring time"): describes the identity-mapping fast path where the pointer is genuinely stable; the suggestion proposes a different architecture, not a doc fix; paragraph untouched this branch.
- Skipped #7 (README.md / backlog.md bulk em-dash sweep): the rule is "as files are touched, not a single sweep"; a ~176-dash reformat is its own task.
- Skipped #8 (AudioModule stale frame): already handled — latestFrame() returns the static silent frame when active_ is null (teardown nulls it); reinit keeps the same active mic and refreshes within one loop.
- Skipped #9 (AudioModule platform boundary): not a violation — the boundary check passes and the neutral platform.h facade is included by ~10 core modules; the rule forbids platform-specific code outside src/platform/, not the abstract interface. An IAudioSource indirection is exactly the bespoke abstraction the architecture avoids.
- Skipped #10 (desktop improv signature): decl and definition match exactly; the desktop build passes, which proves it.
- Skipped #11/#12 (platform_esp32.cpp fcntl error-handling / include): pre-existing code not changed this branch; expanding into untouched code violates minimal-change scope.

Hardware verified this commit: S3 boots clean at 225 FPS with AudioModule live; P4 boots clean at 60 FPS on Ethernet with the pinned components resolved and wifiCoproc reporting "not detected" (the known C6-slave-firmware blocker, unchanged).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant