Board injection + LOLIN S3 N16R8 enablement#10
Conversation
…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>
|
Important Review skippedAuto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
|
There was a problem hiding this comment.
Actionable comments posted: 5
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
scripts/moondeck.py (1)
928-960: 🧹 Nitpick | 🔵 Trivial | 💤 Low valueConsider 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
📒 Files selected for processing (35)
.github/workflows/release.ymldocs/architecture.mddocs/install/boards.jsondocs/install/devices.jsdocs/install/index.htmldocs/install/install-orchestrator.jsdocs/moonmodules/core/BoardModule.mddocs/moonmodules/core/SystemModule.mddocs/plan.mdscripts/MoonDeck.mdscripts/moondeck.pyscripts/moondeck_ui/app.jsscripts/moondeck_ui/style.cssscripts/run/preview_installer.pysrc/core/BoardModule.hsrc/core/Control.hsrc/core/HttpServerModule.cppsrc/core/ImprovProvisioningModule.hsrc/core/SystemModule.hsrc/main.cppsrc/platform/desktop/platform_desktop.cppsrc/platform/esp32/platform_esp32_improv.cppsrc/platform/platform.hsrc/ui/app.jssrc/ui/release-picker.jstest/CMakeLists.txttest/scenarios/core/scenario_MoonModule_control_change.jsontest/scenarios/light/scenario_GridLayout_grid_sizes.jsontest/scenarios/light/scenario_GridLayout_resize.jsontest/scenarios/light/scenario_Layer_buildup.jsontest/scenarios/light/scenario_MirrorModifier_memory_lut.jsontest/scenarios/light/scenario_MirrorModifier_pipeline.jsontest/scenarios/light/scenario_PreviewDriver_detail.jsontest/unit/core/unit_BoardModule.cpptest/unit/core/unit_SystemModule.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>
There was a problem hiding this comment.
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 winAssert
readonlyon theboardcontrol to lock the UI contract.This test verifies type/wiring/max but misses
c.readonly == true, which is core behavior forBoardModule::onBuildControls(). Add it here to prevent regressions that makeboardeditable 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 winAlign 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 winUpdate 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 winDon't use the fire-and-forget control helper for the one-shot board handoff.
consumePendingBoardParam()deletes?board=before posting anything, butsendControl()swallows network errors and ignores non-2xx responses. A failed/api/controlwrite 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 winClear stale
pendingBoardwhen a later install no longer needs it.For existing rows,
pendingBoardis only written whenpendingBoardPushis true and never cleared otherwise. BecausebuildInjectUrl()preferspendingBoard, 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 winClear the Wi‑Fi password between install sessions.
#wifi-passwordis 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 winUpdate 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 winDocument 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 onAs 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 winWrap the single-statement
ifin 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-statementifas 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
⛔ Files ignored due to path filters (2)
docs/install/favicon.pngis excluded by!**/*.pngscripts/build/improv_smoke_test.pyis excluded by!**/build/**
📒 Files selected for processing (28)
.github/workflows/release.ymlCLAUDE.mdCMakeLists.txtdocs/architecture.mddocs/history/decisions.mddocs/install/README.mddocs/install/boards.jsondocs/install/devices.jsdocs/install/index.htmldocs/install/install-orchestrator.jsdocs/moonmodules/core/BoardModule.mddocs/moonmodules/core/FirmwareUpdateModule.mddocs/performance.mddocs/plan.mdesp32/main/CMakeLists.txtscripts/MoonDeck.mdscripts/run/preview_installer.pysrc/core/BoardModule.hsrc/core/HttpServerModule.cppsrc/core/ImprovProvisioningModule.hsrc/core/SystemModule.hsrc/core/ui_embedded.hsrc/main.cppsrc/platform/esp32/platform_esp32_improv.cppsrc/ui/app.jssrc/ui/embed_ui.cmakesrc/ui/install-picker.jstest/unit/core/unit_BoardModule.cpp
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>
There was a problem hiding this comment.
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 winValidate the inner SET_BOARD length fields exactly.
This accepts any payload where
strLen <= len - 3, even whenpayload[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
📒 Files selected for processing (24)
docs/history/decisions.mddocs/install/README.mddocs/install/boards.jsondocs/install/devices.jsdocs/install/index.htmldocs/install/install-orchestrator.jsdocs/moonmodules/core/BoardModule.mddocs/moonmodules/core/NetworkModule.mdscripts/MoonDeck.mdscripts/moondeck.pysrc/core/BoardModule.hsrc/core/HttpServerModule.cppsrc/core/HttpServerModule.hsrc/core/NetworkModule.hsrc/core/SystemModule.hsrc/main.cppsrc/platform/desktop/platform_desktop.cppsrc/platform/esp32/platform_esp32.cppsrc/platform/esp32/platform_esp32_improv.cppsrc/platform/platform.hsrc/ui/app.jssrc/ui/install-picker.jstest/scenario_runner.cpptest/unit/core/unit_BoardModule.cpp
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>
There was a problem hiding this comment.
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 liftPreserve transport identity through dispatch and replies.
These per-link parsers stop RX frame mixing, but they still feed a transport-agnostic
improvDispatchFrame(), andimprovSend()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 throughimprovFeedByte()/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 winApply the TX-power cap in the same call that starts Wi‑Fi.
noteRadioStopped()reopens the equality gate, but the first re-apply still waits forloop1s(). In the Improv path,setWifiCredentials()can start STA after this tick’ssyncTxPower()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 earlyfalseand retries later, so it is safe to call it immediately after every successfulwifiStaInit()/wifiApInit()oncestate_is set toWaitingStaorAP.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
📒 Files selected for processing (8)
docs/install/devices.jsdocs/install/index.htmldocs/install/install-orchestrator.jsdocs/moonmodules/core/BoardModule.mdsrc/core/BoardModule.hsrc/core/NetworkModule.hsrc/platform/esp32/platform_esp32.cppsrc/platform/esp32/platform_esp32_improv.cpp
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>
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>
…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>
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)
8a76be2—BoardModule(code-wired child of System,boardText control with the newreadonlyUI flag, persisted via FilesystemModule),docs/install/boards.jsoncatalog (single source of truth, shared across all consumers), MoonDeck pushes via the existingPOST /api/control. Bonus: newreadonlyflag onControlDescriptorlets a Text control render display-only in the UI without losing persistence semantics.058d979— Web installer board picker, firmware-name collision fix on SystemModule (was bindingfirmwaretwice with different types).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 brokedevices.js's "Your devices" auto-add — both fixed by owning the port end-to-end.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 fromrelease-picker→install-pickerto reflect its grown scope.LOLIN S3 N16R8 enablement (commit 5)
751f1d5— New "LOLIN S3 N16R8" board entry inboards.json. The board exposed three connected bugs that the board-injection feature was masking:Network.txPowerSettingcontrol (0..21 dBm, 0=no override), newplatform::wifiSetTxPowersetter, applied viasyncTxPower()re-checked on everyloop1stick.boards.jsoninjectsNetwork.txPowerSetting: 8for the LOLIN entry.loopiteration. Guarded by#if SOC_USB_SERIAL_JTAG_SUPPORTEDso ESP32-classic builds stay clean./api/controlPOSTs 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),
maxInternalAllocBlocksplit (the diagnosticmaxBlockwas 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)
eca3c2c— CodeRabbit round 1: 8 findings folded in. Most important: TX-power cap not re-applied after radio restart — cachedappliedTxPowerSetting_invariant was violated whenever ESP-IDF stops the radio (AP→STA cascade, STA reconnect, AP shutdown). NewnoteRadioStopped()helper called at every wifiStaStop/wifiApStop site invalidates the cache.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 nextloop1stick (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 viasetCustomValidity, BoardModule.h validation-asymmetry doc note. Also added NanoDLA v1.3 wiring + capture instructions toleddriver-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 genericcontrols.*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 samecontrols.<Module>.<control>shape and needs no code changes.KPI (head of branch, esp32s3-n16r8 on LOLIN S3 N16R8 at txPower=8 dBm)
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
cmake --build build, -Werror)ctest— 207 unit tests pass (8 BoardModule tests added)mm_scenarios— 8/8 passBoardModule.md+NetworkModule.md txPowerSettingbullet)esp32,esp32-eth,esp32-eth-wifi,esp32s3-n16r8all compile cleanPOST /api/controlon discover/refresh;/.config/BoardModule.jsonsurvives rebootNetwork.txPowerSetting: 8txPowerreports8 dBmpost-association, persists across reboot, re-applies on AP→STA cascadeeca3c2c): 22 findings, all addressed in6212fc0.git diff main...HEAD.🤖 Generated with Claude Code