From 67fbe644ed1b2cfdfbc394c4dce0630d5faec1a9 Mon Sep 17 00:00:00 2001 From: ewowi Date: Wed, 19 Aug 2026 22:16:41 +0200 Subject: [PATCH 1/5] Move to ESP-IDF v6.1-rc1; P4 WiFi boots at last The ESP32-P4's WiFi firmware now boots, associates and serves the UI, closing a blocker that stood since early July. P4 images are renamed to carry the chip revision they target (esp32p4rev1-*), and new esp32p4rev3-* variants cover the current v3.x silicon that the rev1 images cannot boot on. KPI: 16384lights | Desktop:1148KB | ESP32:1617KB | tick:7296us(FPS:137) | heap:33121KB | src:222(58450) | test:164(34575) | lizard:162w Core: - platform_esp32: bound coprocessorWifi()'s version query to 5 attempts. An unanswered C6 query retried every tick and cost SystemModule 1,012,344us per tick; now 257us. - platform_esp32: log the negotiated Ethernet link speed on link-up. "Link up" alone sent one debug session hunting DHCP when the question was the speed. Light domain: - none. UI: - install-picker: esp32p4rev1-eth-wifi is no longer flagged "does not boot" and installs normally. The warning mechanism is repurposed for the two rev3 images, which are genuinely unverified, with honest wording ("untested" not "does not boot"). Scripts/MoonDeck: - IDF pinned to v6.1-rc1 (44f0c59f7c8), replacing the v6.1-dev pin. - FIRMWARES: esp32p4-eth -> esp32p4rev1-eth, esp32p4-eth-wifi -> esp32p4rev1-eth-wifi; added esp32p4rev3-eth and esp32p4rev3-eth-wifi. The rev3 fragment overrides only the chip revision and reuses the rev1 board fragment, so the partition table and EMAC config stay in one place. - generate_firmwares: report the number of variants WRITTEN, not len(FIRMWARES). It printed 12 while check_firmwares printed 11 from the same projection. Tests: - scenario contracts + python tests follow the firmware key rename. Docs/CI: - sdkconfig fragment: the ICG block contradicted itself, saying "held until #18759 lands an option" directly above the block applying that option. - backlog: #18759 marked resolved-by-workaround with what actually shipped; round 3's status is now "working, but the link is slow" with the per-module and per-task bench tables. - esptool-js: re-verified there is still no ESP32-S31 support in 0.6.1, and noted that 0.6.1 does fix the deflate regression that pins us to 0.5.7 (untested). Reviews: - No external review on this branch yet. Notes: - Two P4 defects are open and recorded in the backlog, neither introduced here: a ~17x HTTP slowdown when esp_hosted is compiled in (bisected to the render task burning 2.6x the CPU for identical work, with every SDIO task idle), and a visible ~1s LED hiccup our averaged instrumentation cannot see. - esp32p4rev3-eth and esp32p4rev3-eth-wifi ship HAVING NEVER BOOTED: no v3 board is on the bench. They are flagged untested in the installer. - S31 and classic boards were not re-flashed after the rc1 rebuild. - Improv smoke test (manual gate) not run. - KPI measured with the P4 attached, not per supported target. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/release.yml | 2 +- docs/architecture.md | 2 +- docs/backlog/backlog-core.md | 66 +++++++++++++-- docs/building.md | 14 ++-- docs/metrics/repo-health.json | 61 +++++++------- docs/metrics/repo-health.md | 43 +++++----- docs/reference/mhc-wled-esp32-p4-shield.md | 2 +- esp32/main/idf_component.yml | 6 +- ...eth => sdkconfig.defaults.esp32p4rev1-eth} | 8 ++ ...> sdkconfig.defaults.esp32p4rev1-eth-wifi} | 32 +++---- esp32/sdkconfig.defaults.esp32p4rev3 | 28 +++++++ moondeck/build/build_esp32.py | 84 +++++++++++++------ moondeck/build/generate_firmwares.py | 7 +- moondeck/build/setup_esp_idf.py | 2 +- moondeck/moondeck.py | 4 +- src/platform/esp32/platform_esp32.cpp | 29 ++++++- src/ui/install-picker.js | 26 +++--- test/python/test_build_esp32_s31.py | 2 +- test/python/test_moondeck_port_identity.py | 8 +- .../scenario_MoonModule_control_change.json | 8 +- .../scenario_NetworkModule_mdns_toggle.json | 6 +- .../light/scenario_GridLayout_resize.json | 6 +- .../scenario_MoonLiveEffect_livescript.json | 16 ++-- .../light/scenario_modifier_swap.json | 6 +- test/scenarios/light/scenario_perf_full.json | 40 ++++----- test/scenarios/light/scenario_perf_light.json | 12 +-- .../light/scenario_peripheral_grid_sweep.json | 32 +++---- .../light/scenario_peripheral_switch.json | 12 +-- web-installer/deviceModels.json | 8 +- web-installer/firmwares.json | 22 ++++- web-installer/install-orchestrator.js | 12 ++- web-installer/install.js | 11 ++- 32 files changed, 403 insertions(+), 214 deletions(-) rename esp32/{sdkconfig.defaults.esp32p4-eth => sdkconfig.defaults.esp32p4rev1-eth} (81%) rename esp32/{sdkconfig.defaults.esp32p4-eth-wifi => sdkconfig.defaults.esp32p4rev1-eth-wifi} (72%) create mode 100644 esp32/sdkconfig.defaults.esp32p4rev3 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 130491a1..782722eb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -188,7 +188,7 @@ jobs: # the ip101 PHY + esp_hosted, both manifest-gated on target == esp32p4), # everything # else → esp32. (The matrix is the `ships` subset of firmwares.json; - # esp32p4-eth-wifi has ships=False in build_esp32.py so it stays out — + # esp32p4rev1-eth-wifi has ships=False in build_esp32.py so it stays out — # its C6-slave Kconfig defaults don't survive a plain CI build.) target: ${{ startsWith(matrix.firmware, 'esp32s31') && 'esp32s31' || startsWith(matrix.firmware, 'esp32s3') && 'esp32s3' || startsWith(matrix.firmware, 'esp32p4') && 'esp32p4' || 'esp32' }} path: 'esp32' diff --git a/docs/architecture.md b/docs/architecture.md index c3599d0b..6d865d5d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -257,7 +257,7 @@ Three distinct things, kept distinct in the vocabulary: - **deviceModel** — the whole assembled product, identified by its catalog name (`Olimex ESP32-Gateway Rev G`). This is *which hardware this is*. It is distinct from **`deviceName`**, *which individual unit this is* (per-unit identity the user sets — see [§ Device name](#device-name-one-identity-every-network-name-derives-from-it)); a **device** (the umbrella term) has a `deviceName` and a `deviceModel`. - **board** — the bare PCB *only*. The word survives in its literal sense: **on-board** LED, **on-board** peripherals, board-soldered pins — things physically *on the PCB*. (A deviceModel is a board plus whatever is wired onto it.) -**Firmware** is the compiled binary: chip target plus which radios/peripherals/sdkconfig fragments are included. Today's variants: `esp32` (classic, WiFi **and** RMII Ethernet in one binary — Ethernet comes up only when a PHY is present, pins/PHY per deviceModel), `esp32-eth` (classic, Ethernet only, WiFi excluded), `esp32-16mb` (classic with 16 MB flash, WiFi + Ethernet), `esp32s3-n16r8` / `esp32s3-n8r8` (S3 with WiFi + W5500 SPI Ethernet), `esp32p4-eth` (Waveshare ESP32-P4-NANO, Ethernet only), `esp32p4-eth-wifi` (the same P4 hardware with WiFi via its on-board ESP32-C6 over esp_hosted). Each chip's firmware carries the Ethernet *driver(s)* it can host (RMII EMAC for classic/P4, W5500 SPI for S3); which PHY/pins a deviceModel uses is runtime config. Selected by `build_esp32.py --firmware `, reported by `SystemModule.firmware`, used as the contract target key in scenarios. +**Firmware** is the compiled binary: chip target plus which radios/peripherals/sdkconfig fragments are included. Today's variants: `esp32` (classic, WiFi **and** RMII Ethernet in one binary — Ethernet comes up only when a PHY is present, pins/PHY per deviceModel), `esp32-eth` (classic, Ethernet only, WiFi excluded), `esp32-16mb` (classic with 16 MB flash, WiFi + Ethernet), `esp32s3-n16r8` / `esp32s3-n8r8` (S3 with WiFi + W5500 SPI Ethernet), `esp32p4rev1-eth` (Waveshare ESP32-P4-NANO, Ethernet only), `esp32p4rev1-eth-wifi` (the same P4 hardware with WiFi via its on-board ESP32-C6 over esp_hosted). Each chip's firmware carries the Ethernet *driver(s)* it can host (RMII EMAC for classic/P4, W5500 SPI for S3); which PHY/pins a deviceModel uses is runtime config. Selected by `build_esp32.py --firmware `, reported by `SystemModule.firmware`, used as the contract target key in scenarios. **deviceModel** is the physical hardware: chip + PCB + on-board peripherals (PHY, USB-serial, PSRAM, antenna), identified by its product name. Examples: `Olimex ESP32-Gateway Rev G`, `LOLIN D32`, `Generic ESP32 Dev`. A unit cannot identify its own deviceModel (no readable PCB ID on classic ESP32), so MoonDeck deduces it from the firmware where unambiguous (`esp32-eth*` ⇒ Olimex) and otherwise lets the user pick. It is stored on the unit as SystemModule's `deviceModel` Text control (display-only in the UI; HTTP `/api/control` writes still apply). MoonDeck mirrors the picked / deduced value to the unit via `POST /api/control` after each discover and after every dropdown change. The catalog of valid deviceModels lives at [web-installer/deviceModels.json](../web-installer/deviceModels.json), shared between MoonDeck and the web installer: MoonDeck reads it for its dropdown and HTTP push (plain REST on the LAN); the web installer reads it for its picker and pushes the whole entry — deviceModel plus every module/control — over serial during provisioning as REST ops (**"Improv = REST over serial"**, the `APPLY_OP` vendor RPC; see [ImprovProvisioningModule.md](moonmodules/core/moxygen/ImprovProvisioningModule.md)). Pushing over serial sidesteps the mixed-content block that stops an HTTPS installer page from POSTing to an `http://` device; an already-running device is re-configured via MoonDeck on the LAN. diff --git a/docs/backlog/backlog-core.md b/docs/backlog/backlog-core.md index f6f7d913..5452a574 100644 --- a/docs/backlog/backlog-core.md +++ b/docs/backlog/backlog-core.md @@ -8,9 +8,9 @@ Forward-looking to-build items for the **core / infrastructure** domain (`src/co 1.0 ships ESP32 firmware (4 variants) + macOS arm64 + Windows x64. Still to add: -- **ESP32-P4** firmware variant — **`esp32p4-eth` (Ethernet-only) shipped**: in `build_esp32.py`'s `FIRMWARES`, the `deviceModels.json` catalog (Waveshare P4-NANO), and CI builds + publishes it to the web installer + releases. **Still to ship: `esp32p4-eth-wifi`** (the C6-WiFi variant) — it doesn't build reproducibly in CI yet (the `CONFIG_WIFI_RMT_*` Kconfig defaults don't survive a plain build without a fresh `set-target`), so it's held out of the release matrix until that's fixed; see § ESP32-P4 round 3. +- **ESP32-P4** firmware variant — **`esp32p4rev1-eth` (Ethernet-only) shipped**: in `build_esp32.py`'s `FIRMWARES`, the `deviceModels.json` catalog (Waveshare P4-NANO), and CI builds + publishes it to the web installer + releases. **`esp32p4rev1-eth-wifi` now ships too** (2026-08-19): it boots and associates on IDF v6.1-rc1, so it is out of the experimental set in the installer and carries a normal description. Its open defect is throughput, not shipping — see § ESP32-P4 round 3, open issue 0. - **ESP32-S31 web-flash (waiting on esptool-js)** — the `esp32s31` firmware ships (build, catalog, CI matrix, web installer listing), and CLI flashing works (`flash_esp32.py` → esptool.py, which has S31 support since v5.2.0). **Browser flashing does not**: the web installer's `esptool-js` (pinned 0.5.7) has no S31 chip class. Worse than a missing entry — the S31's ROM magic (`15736195`) *collides* with the classic ESP32's; esptool.py disambiguates with secondary register detection (S31 `USES_MAGIC_VALUE=False`), but esptool-js has only the magic table, so it would mis-identify the RISC-V S31 as a classic Xtensa ESP32 and flash the wrong stub/params. `install.js`'s `WEB_FLASH_UNSUPPORTED_CHIPS` guard catches an S31 connect-flash failure and points the user at the CLI. **No upstream timeline**: as of 2026-06 the esptool-js repo has zero S31 issues/PRs/commits and its last release was 2026-03 (it lags esptool.py on new chips by months). **Removal trigger**: when esptool-js ships S31 support *with* the secondary detection (not just a magic-table entry — re-check the chip-detect switch, not the version number), bump the esptool-js pin in `install-orchestrator.js` and drop `ESP32-S31` from `WEB_FLASH_UNSUPPORTED_CHIPS`. -- **ESP32-P4 v3.x silicon variant (backlog)** — `esp32p4-eth` is built for pre-v3 P4 (`CONFIG_ESP32P4_SELECTS_REV_LESS_V3` + `REV_MIN_0`), because the v6.1 IDF default (v3.1) refused to boot on the bench/field v1.x P4 and rev <3.0 vs >=3.0 are "huge hardware difference" (one binary can't cover both). The field is pre-v3 P4 today, so the single image is correct for now. When v3.x P4 silicon arrives, add an `esp32p4-eth-v3` firmware variant (REV_MIN_300+) + the catalog/device-model mapping so each P4 board points at the matching image, rather than the pre-v3 `esp32p4-eth` for everything. (CodeRabbit flagged the single-variant exposure; deferred until v3.x P4 is actually in play.) +- **ESP32-P4 v3.x silicon variant (backlog)** — `esp32p4rev1-eth` is built for pre-v3 P4 (`CONFIG_ESP32P4_SELECTS_REV_LESS_V3` + `REV_MIN_0`), because the v6.1 IDF default (v3.1) refused to boot on the bench/field v1.x P4 and rev <3.0 vs >=3.0 are "huge hardware difference" (one binary can't cover both). **DONE (2026-08-19): `esp32p4rev3-eth` and `esp32p4rev3-eth-wifi` ship** (`REV_MIN_300`, which covers v3.0-v3.99), reusing the rev1 board fragment so the partition table and EMAC config stay in one place. **Still open: neither has ever been booted** — both bench boards are v1.3 engineering samples, so the rev3 images are flagged experimental in the installer and need a v3 board to verify. Espressif does not recommend v0.x/v1.x for new designs, so a board bought today is v3.x and needs these. - **Linux desktop binary** — third desktop job in `release.yml`, static-linked libstdc++. - **Teensy 4.1** — toolchain-file build, `.hex` for Teensy Loader. - **Raspberry Pi** — ARM64, cross-built or native. @@ -20,7 +20,7 @@ Forward-looking to-build items for the **core / infrastructure** domain (`src/co - **Installer UX polish** — clear "Pre-release (beta)" warning on RC/latest picks, yank-by-asset-tag instead of yank-by-release-deletion. - **Offer projectMM/MoonLight as a library** — a downstream sketch where another firmware/app consumes the light pipeline (or a subset) as an embeddable dependency rather than running the whole binary. `library.json` is already a PlatformIO *library* manifest, so the seed exists. When this is designed, give it a small public **identity surface**: one runtime constant the consumer reads (a `kProjectName`, likely a `ProjectInfo` bundle of name + version + url) that the network wire-strings (ArtNet/E1.31 source-name + CID), the UI banner, and any "About" string all *derive from* — the one place a consumer queries "what am I embedding." This is the genuine home for the name-centralisation that the rename ([rename-to-moonlight.md § Phase 1.3](rename-to-moonlight.md)) deliberately *didn't* do: the rename is a one-time sweep (a constant would just split it), but a library consumer references the identity ongoing and widely, which is the test a constant must pass. Build it *then*, against the real library API, not speculatively now. - **ESP32-P4 panics with `Cache error` every few minutes, pre-existing** (2026-08-19): the bench - P4 (Waveshare P4-NANO, `esp32p4-eth`) reboots roughly every four minutes while IDLE, with + P4 (Waveshare P4-NANO, `esp32p4rev1-eth`) reboots roughly every four minutes while IDLE, with `Guru Meditation Error: Core 0 panic'ed (Cache error)`, sometimes followed by an `Illegal instruction` and a `CHIP_LP_WDT_RESET` on the way down. @@ -556,25 +556,77 @@ This is hardening, not a known bug — the shipped fix is correct for the cases ### ESP32-P4 support — rounds 3-4 (in progress) Rounds 1 (board + Ethernet-only) and 2 (Parlio LED driver) have landed. Remaining rounds, each its own plan + commit: -- **Round 3 — WiFi via the C6 co-processor. PARTIALLY PROVEN — C6 link up, STA failover not yet working.** The P4 has no native radio (`SOC_WIFI_SUPPORTED` absent); WiFi comes from the on-board ESP32-C6 over SDIO via `esp_wifi_remote` / esp_hosted. Landed as the `esp32p4-eth-wifi` firmware variant: components pulled P4-only (`rules:` gate in `idf_component.yml`), and `ensureWifiInit()` adds an `esp_hosted_init` + `connect_to_slave` prelude before `esp_wifi_init` (gated on `platform::usesRemoteWifi`). The rest of the WiFi seam is unchanged because `esp_wifi_remote` is API-compatible. A deliberate, documented [v6.0-floor exception](../building.md#esp-idf-version); C6 config via `CONFIG_SLAVE_IDF_TARGET_ESP32C6` + `CONFIG_ESP_HOSTED_CP_TARGET_ESP32C6` + the `CONFIG_ESP_HOSTED_P4_DEV_BOARD_FUNC_BOARD` SDIO-pin preset. +- **Round 3 — WiFi via the C6 co-processor. WORKING, BUT THE LINK IS SLOW (2026-08-19).** Boots and associates on IDF v6.1-rc1 (see round 4); the remaining defect is throughput, bench-bisected below. The P4 has no native radio (`SOC_WIFI_SUPPORTED` absent); WiFi comes from the on-board ESP32-C6 over SDIO via `esp_wifi_remote` / esp_hosted. Landed as the `esp32p4rev1-eth-wifi` firmware variant: components pulled P4-only (`rules:` gate in `idf_component.yml`), and `ensureWifiInit()` adds an `esp_hosted_init` + `connect_to_slave` prelude before `esp_wifi_init` (gated on `platform::usesRemoteWifi`). The rest of the WiFi seam is unchanged because `esp_wifi_remote` is API-compatible. A deliberate, documented [v6.0-floor exception](../building.md#esp-idf-version); C6 config via `CONFIG_SLAVE_IDF_TARGET_ESP32C6` + `CONFIG_ESP_HOSTED_CP_TARGET_ESP32C6` + the `CONFIG_ESP_HOSTED_P4_DEV_BOARD_FUNC_BOARD` SDIO-pin preset. **Hardware results (bench, P4-NANO, 2026-06-12):** - ✅ **esp_hosted / C6 SDIO comes up at boot.** `host_init: ESP Hosted`, `H_API: ESP-Hosted starting`, `add_esp_wifi_remote_channels`, `H_SDIO_DRV: sdio_data_to_rx_buf_task started`. No NVS error / assert / panic / hang. Device boots fully (~57-60 FPS), `hasWiFi` true, WiFi controls present. esp_hosted **self-initialises at boot via a constructor** (`ESP_SYSTEM_INIT_FN` → `esp_hosted_init`), so no bring-up code is needed in our platform layer — an earlier explicit `esp_hosted_init` + `esp_hosted_connect_to_slave` prelude was *removed*: init was a redundant no-op and `connect_to_slave` is actually a transport *reconfigure* (slave GPIO-54 reset + SDIO re-init). SDIO config confirmed correct on the wire: `CLK[18] CMD[19] D0[14] D1[15] D2[16] D3[17] Slave_Reset[54]`, 4-bit 40 MHz. - ❌ **WiFi STA connect fails on the SDIO re-init.** The cascade DOES fire correctly (`Ethernet no link, cascading` → STA path), but `esp_wifi_init()` (forwarded to esp_wifi_remote) internally triggers `esp_hosted_reconfigure` → `Reset slave using GPIO[54]` → **`sdmmc_card_init failed` (×15) → `card init failed` → `esp_wifi_init failed: ESP_FAIL`**. So: the boot-time SDIO init succeeds, but a **runtime slave reset can't re-establish the SDIO link**. The C6 doesn't come back after the GPIO-54 reset during operation. This is an esp_hosted/SDIO/C6-slave-firmware level issue (reset timing or slave image), below our application code — the pins and Kconfig are correct. **Open issues before this is done:** + + 0. **The esp_hosted build is ~17x slower on HTTP, with a ~1 s stutter — BISECTED to the hosted link, not to us (2026-08-19).** Same board, same commit, same application code, measured over **Ethernet on both builds** so the radio is not in the path: + + | build | per-request (`/api/system`) | throughput (73 KB `app.js`) | + |---|---|---| + | `esp32p4rev1-eth-wifi` | alternating 0.434 / 0.788 / 0.446 / 0.813 s | 42 KB/s | + | `esp32p4rev1-eth` | 13 ms flat, 12 consecutive fetches | 708 KB/s | + + Render is healthy in both (121-134 fps), so this is not frame-loop contention. Because the two images share every line of application code, the penalty is **esp_hosted being compiled in and its SDIO link serviced**, degrading traffic on an interface it is not even carrying. The cost is **per-request, not per-byte** (a 1 KB fetch cost nearly as much as a 36 KB fetch), which points at a periodic blocker a request must wait out rather than a slow pipe. + + Already excluded on the bench: SDIO width/clock (already 4-bit/40 MHz, the maximum), WiFi buffer counts (match the S3's), ICMP latency (6.6 ms). An earlier `SystemModule` fix (`coprocessorWifi()` retried an unanswered version query every tick: 1,012,344 us → 257 us) was real and is kept, but it was **not** this: the stutter outlived it and vanished only when the C6 path left the image. + + **Per-module tick timing, both builds on the SAME Ethernet interface (2026-08-19).** The measurement that says what this is NOT: + + | module | eth-only | eth-wifi | factor | + |---|---|---|---| + | HttpServer | 113 us | 3441 us | **30x** | + | File Manager | 279 us | 4686 us | 17x | + | Services | 242 us | 1388 us | 5.7x | + | Audio | 239 us | 1376 us | 5.7x | + | Effects | 172 us | 397 us | 2.3x | + | **Network** | 10 us | **92 us** | (negligible in absolute terms) | + | Drivers | 6601 us | 4215 us | **0.64x (FASTER)** | + | ParallelLed | 7186 us | 6569 us | 0.91x (faster) | + + **Two theories are refused by this table.** It is not a blocking WiFi call in `NetworkModule` (`Network` costs 92 us, and `wifiStaRssi`/`wifiTxPower` are state-gated off entirely in `ConnectedEth`); and it is not periodic WiFi scanning, since no module carries anything like a 0.37 s cost. Instead **modules that touch no network at all** (Audio, Noise, Effects, Layer) slow by the same kind of factor as the HTTP path: a broad, roughly proportional slowdown of ordinary code. + + **Working theory: L2 cache contention.** Both builds are byte-identical in every memory setting (`CACHE_L2_CACHE_128KB`, `SPIRAM_SPEED_200M`, `FLASHFREQ_40M`, `SPIRAM_MODE_HEX` — a full sdkconfig diff shows no cache/PSRAM/flash/CPU-freq difference), and code executes from flash through that shared 128 KB L2 in both. Adding esp_hosted's tasks, ISRs and DMA buffers evicts application code that was previously resident, so ordinary work starts missing to flash/PSRAM. This also explains the otherwise odd inversion: the DMA-bandwidth-bound `Drivers`/`ParallelLed` got *faster*, which is what happens when the CPU competes less for the same bus. **Consistent with the data, not yet proven** — separating cache contention from plain CPU stealing needs a cache-hit-rate counter or a per-tick histogram, neither of which we expose today. + + **The cache theory does NOT explain the visible LED hiccup, and there are TWO effects here (PO observation, 2026-08-19).** Uniform cache contention predicts a smooth frame-rate drop (133 → 96 → ~73 fps), which is what the averages show. It does not predict a *stall*, yet a once-per-second hiccup is plainly visible on the fixture. So the throughput loss and the hiccup are separate problems and must be chased separately. + + **Our instrumentation structurally cannot see the hiccup.** `MoonModule::tickTimeUs_` is a MEAN (`accumUs_ / frameCount`, [MoonModule.h:656](../../src/core/MoonModule.h)) and `Scheduler::fps()` is derived from it, so a single 300 ms frame among 70 good ones shifts the average ~4 ms and vanishes. Every per-module number in the table above is an average and none of them can confirm or refute a stall. External evidence of the stall is therefore weak but non-zero: sampling `fps` once per second for 30 s gives a steady 73 with periodic dips to 69-70 (~55 ms lost in those seconds), and the dips are NOT on a clean 1 s period. Ruled out along the way: the degradation is not caused by our own HTTP polling (fps is identical at 76 after 25 s of zero traffic and under continuous polling), and the early 96 → 73 decay is warm-up, not load. + + **Per-TASK CPU, both builds, `--task-cpu-stats` (2026-08-19) — this is the decisive measurement.** Same profiling overhead on both, so the comparison is clean: + + | task | prio | eth-only | eth-wifi | | + |---|---|---|---|---| + | `main` (render) | 1 | **35.4%** | **93.3%** | the same work costs 2.6x the CPU | + | `IDLE0` | 0 | 63.3% | 5.1% | core 0 headroom is gone | + | `ipc1` | 24 | 7.8% | 12.7% | present in BOTH, so not the cause | + | `mmEncode` | 5 | 3.4% | 6.0% | | + | `sdio_read` / `sdio_write` / `sdio_process_rx` / `rpc_rx` / `rpc_tx` | 23 | absent | **0.0-0.1%** | the hosted tasks are IDLE | + + **What this refutes.** Not a busy WiFi task: every SDIO/RPC task sits at 0.0-0.1%. Not periodic WiFi scanning, and not a blocking call in our render path either, since `main` is not *waiting* — it is *running*, and burning 2.6x the cycles for identical work. Not `ipc1` (priority 24, preempts everything), which is present on the eth-only build too. There is no WiFi *activity* to stop, so a "compile it in but don't run it" variant would likely change nothing; the cost is already there with the link idle. + + **What this supports.** The same instruction stream executing 2.6x slower with no extra runnable work is the signature of **memory contention**, consistent with the L2-cache theory above: esp_hosted's footprint evicts application code from the shared 128 KB L2, so ordinary code stalls on flash/PSRAM fetches. Cycles are spent *inside* `main`, which is why every module slowed proportionally and why the DMA-bound drivers (bandwidth-bound, not cache-resident) got faster. + + **Still unexplained: the visible ~1 s LED hiccup.** Cache contention predicts the steady 2.6x, not a stall. Per-task CPU is cumulative-since-boot and cannot show a spike either. So the hiccup remains unmeasured, and the worst-case instrumentation below is still the next step for it specifically. + + **Instrumentation gap to close first — worst-case tick tracking.** Add a per-module and per-scheduler *max* (and ideally a coarse histogram or a "frames over 2x mean" counter) alongside the existing mean, and expose it in `/api/system`. Cheap (one comparison per tick), and it converts "the PO can see a hiccup that no number shows" into a measurable quantity. Without it, every theory below is unfalsifiable from the host side. Reading the P4 console needs `CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y` or a UART adapter on GPIO 37/38 (see the dev-loop note in round 4), so serial is not the fast path. + + Then, cheapest first: (a) read the new max/histogram to see whether the cost is uniform or spiky (uniform favors cache contention, spiky favors a blocking call, and the two coexisting is now the leading reading); (b) the P4 cache performance counters for a direct miss-rate read; (c) moving the hot render path to IRAM/internal RAM and re-measuring, which is also a candidate fix rather than only a diagnostic. 1. **Runtime SDIO re-init of the C6 fails — CONFIRMED a C6 slave-firmware problem (not a guess).** SystemModule now exposes a `wifiCoproc` read-only control (via `platform::coprocessorWifi()` → `esp_hosted_get_coprocessor_fwversion()`), and on the bench it reads **`not detected`** — the C6 returns no valid firmware version (0.0.0 / handshake never completes), which is exactly the documented signature of absent / incompatible C6 slave firmware. So this is proven off the device, not inferred. Likely a version mismatch on top of that: The host pulled esp_hosted **2.12.9**; Espressif's P4-Function-EV-Board ships its C6 pre-flashed with esp_hosted slave **v0.0.6**, and the **Waveshare NANO is a different board that may carry a different / absent C6 slave image**. The symptom fits: boot inits the host SDIO master fine, but resetting the C6 (GPIO 54) and re-enumerating it as a slave fails (`sdmmc_card_init failed`) because the C6 has no compatible slave firmware responding. **Primary next step: build + flash the version-matched esp_hosted slave firmware onto the NANO's C6.** The slave project is already vendored at `esp32/managed_components/espressif__esp_hosted/slave/` (`sdkconfig.defaults.esp32c6`, `partitions.esp32c6.csv`); `idf.py create-project-from-example "espressif/esp_hosted:slave"` → `set-target esp32c6` → flash. **Caveat / needs PO + bench hardware:** flashing the C6 on the EV board uses an **ESP-Prog wired to the `PROG_C6` header** with the P4 held in bootloader mode (esp_hosted `docs/esp32_p4_function_ev_board.md` §5.2) — the NANO's C6-flash path must be confirmed (separate USB? equivalent header? ESP-Prog?), and an ESP-Prog may be needed. An OTA slave-update path exists but needs a *working* link first (chicken-and-egg here). This is a hardware-provisioning task, not application code. Secondary fallbacks if firmware-match doesn't fix it: an esp_hosted option to skip the reconfigure/slave-reset when the transport is already up at boot; a slower SDIO freq or 1-bit mode; verify GPIO 54 reset polarity/timing for the NANO. **(Note: EIM — the building.md v6.0-adoption item — does NOT help here; it's a host-machine installer, unrelated to device-side C6 firmware.)** **User lead (2026-07-09) — avoid the WiFi teardown/re-init on hosted targets entirely; it may sidestep the slave-reset failure.** A user hit the mirror symptom on a *different* codebase (ESP32-Sveltekit / WLED-MM, not projectMM — its `lib/framework/WiFiSettingsService.cpp` doesn't exist here): after a clean C6-flash the P4-NANO WiFi worked, but their app's boot-time `WiFi.disconnect(true)` fully tore down the WiFi stack, and the later AP bring-up then failed with `esp_hosted_transport_config: Transport already initialized` / `esp_hosted_init failed!` / `AP enable failed!`. Their working fix: on `CONFIG_ESP_WIFI_REMOTE_ENABLED` (= hosted) targets, **don't do a full stack reset** — keep STA enabled, reconnect *without* a full teardown/re-init, and never call `WiFi.disconnect(true)` in the disconnect callback. **Why this is relevant to us even though the file differs:** our own comment at `platform_esp32.cpp:796` already documents that the esp_hosted transport is set up **once at boot** and is fragile to re-init (`connect_to_slave` = a transport reconfigure that resets the slave + re-inits SDIO and fails on a live link). Our disconnect *callback* is already safe (`wifiEventHandler` on `STA_DISCONNECTED` only sets a flag — no teardown), BUT our **failover path is not**: `wifiStaStop()` (`platform_esp32.cpp:915`) calls `esp_wifi_deinit()`, and the STA-retry / AP-fallback then re-runs `ensureWifiInit()` → `esp_wifi_init()` — the exact deinit→reinit cycle that on a hosted target triggers the GPIO-54 slave reset (round-3 open-issue #2's `sdmmc_card_init failed`). So the round-3 failure ("runtime SDIO re-init of the C6 fails") and this user's report may be **the same root cause**: the re-init shouldn't happen at all on a hosted target. **Concrete next step to try on the bench:** guard the teardown/re-init on `platform::hasWifiCoprocessor` (already defined = `isEsp32P4 && hasWiFi`) — on hosted targets, do NOT `esp_wifi_deinit()` in `wifiStaStop()` and do NOT re-`esp_wifi_init()` in `ensureWifiInit()` once the boot-time init is up; instead just `esp_wifi_disconnect()` + `esp_wifi_set_config()` + `esp_wifi_connect()` (STA retry) or `esp_wifi_set_mode(APSTA)` for the fallback, reusing the live transport. This is cheaper than the C6 reflash and independent of #18759, so it's worth trying first once the board boots. If it works, it also removes the slave-reset from the normal failover, not just the AP case. Blocked behind the #18759 boot crash like everything else P4-WiFi, but this is the first thing to try when the board boots again. - 2. **Co-processor components no longer compile into `esp32p4-eth` — FIXED.** The gate is now `rules: if CONFIG_MM_P4_WIFI == True` (a Kconfig option declared in `esp32/main/Kconfig.projbuild`, set only by `sdkconfig.defaults.esp32p4-eth-wifi`), so `esp_hosted` / `esp_wifi_remote` are pulled **only** by the WiFi build, never by eth-only. The old `target == esp32p4` gate pulled them into `esp32p4-eth` too; that wasn't merely build-time waste — esp_hosted self-inits its SDIO master at boot, which on the eth-only build interfered with the EMAC bring-up (a red herring chased during the P4 no-DHCP hunt). The eth-only image dropped 1.36→1.12 MB once gated out. The `wifiCoproc` read-out stays compile-gated on `platform::hasWifiCoprocessor` (`isEsp32P4 && hasWiFi`). + 2. **Co-processor components no longer compile into `esp32p4rev1-eth` — FIXED.** The gate is now `rules: if CONFIG_MM_P4_WIFI == True` (a Kconfig option declared in `esp32/main/Kconfig.projbuild`, set only by `sdkconfig.defaults.esp32p4rev1-eth-wifi`), so `esp_hosted` / `esp_wifi_remote` are pulled **only** by the WiFi build, never by eth-only. The old `target == esp32p4` gate pulled them into `esp32p4rev1-eth` too; that wasn't merely build-time waste — esp_hosted self-inits its SDIO master at boot, which on the eth-only build interfered with the EMAC bring-up (a red herring chased during the P4 no-DHCP hunt). The eth-only image dropped 1.36→1.12 MB once gated out. The `wifiCoproc` read-out stays compile-gated on `platform::hasWifiCoprocessor` (`isEsp32P4 && hasWiFi`). 3. **Build reproducibility.** `build_esp32.py` does not yet build this variant reliably: the C6 slave-target Kconfig `default ... if IDF_TARGET_ESP32P4` only fires on `set-target`, and the reconfigure a plain `build` triggers drops it back to ESP32-H2 (no WiFi) → fails on missing `CONFIG_WIFI_RMT_*`. A clean manual sequence works (`rm -rf ` → `set-target esp32p4` → `build`, all with the same `-DSDKCONFIG`/`-DSDKCONFIG_DEFAULTS`); the wrapper needs a fix so the auto-default sticks across reconfigures (see the KNOWN ISSUE comment in `build_esp32.py`). **Round 4 — the IDF-update regression (2026-07-03).** After the IDF bump to `v6.1-dev-5215-g0d928780081`, the variant stopped building entirely, then stopped booting. Two distinct causes, both IDF/component-manager side (all our config was correct): - ❌→✅ **Build: `esp_hosted.h` not found — the manifest `if` syntax changed.** The component manager (now 3.0.3) silently skipped `esp_hosted`/`esp_wifi_remote` (`NOTICE: Skipping optional dependency`) because our `idf_component.yml` rule used the bare `CONFIG_MM_P4_WIFI == True`. The current manager only recognises a Kconfig variable in the **`$CONFIG{...}`** form (its `KCONFIG_VAR_REGEX = \$CONFIG\{([^}]+)}`); the bare `CONFIG_X` falls through to plain string-eval → false → skipped. **Fixed:** `if: "$CONFIG{MM_P4_WIFI} == True"` (note: NO `CONFIG_` prefix inside the braces). Confirmed by [idf-component-manager #104](https://github.com/espressif/idf-component-manager/issues/104) + the official manifest docs. This also supersedes open-issue #3 above — with the correct syntax the pull is reliable (no `set-target` dance needed for the *dependency*). - - ❌ **Boot: `sleep_clock_icg_startup_init` aborts with `ESP_ERR_NO_MEM` (0x101) → reboot loop.** A KNOWN, OPEN ESP-IDF bug: **[esp-idf #18759 (IDFGH-17859)](https://github.com/espressif/esp-idf/issues/18759)** — on ESP32-P4 + PSRAM, this sleep-clock retention init runs unconditionally at a SECONDARY boot phase (before `app_main`, NOT gated by `CONFIG_PM_ENABLE`) and fails to allocate its REGDMA retention links when early internal DRAM is tight, which it is once esp_hosted's SDIO stack is pulled in (WiFi build only; the eth-only P4 has DRAM to spare). Espressif's guidance on the issue: reduce early internal-DRAM static usage. Bench findings (2026-07-03): `CONFIG_SPIRAM_TRY_ALLOCATE_WIFI_LWIP` does NOT help (those buffers allocate after the boot init); `CONFIG_PM_POWER_DOWN_PERIPHERAL_IN_LIGHT_SLEEP=y` drops the ICG file but only moves the failure to the next retention alloc (PCR / int_wdt) then a `sleep_retention.c:914` assert; `.bss`/`.noinit` → PSRAM (`CONFIG_SPIRAM_ALLOW_{BSS,NOINIT}_SEG_EXTERNAL_MEMORY`) still fails — because `MALLOC_CAP_RETENTION` is a *specific reserved memory region*, not general DRAM, so freeing general DRAM doesn't reach it. This is the current blocker; MoonLight ran the same board on IDF 5.5 without it, so it's a 6.1-era regression. + - ❌ **Boot: `sleep_clock_icg_startup_init` aborts with `ESP_ERR_NO_MEM` (0x101) → reboot loop.** A KNOWN, OPEN ESP-IDF bug: **[esp-idf #18759 (IDFGH-17859)](https://github.com/espressif/esp-idf/issues/18759)** — on ESP32-P4 + PSRAM, this sleep-clock retention init runs unconditionally at a SECONDARY boot phase (before `app_main`, NOT gated by `CONFIG_PM_ENABLE`) and fails to allocate its REGDMA retention links when early internal DRAM is tight, which it is once esp_hosted's SDIO stack is pulled in (WiFi build only; the eth-only P4 has DRAM to spare). Espressif's guidance on the issue: reduce early internal-DRAM static usage. Bench findings (2026-07-03): `CONFIG_SPIRAM_TRY_ALLOCATE_WIFI_LWIP` does NOT help (those buffers allocate after the boot init); `CONFIG_PM_POWER_DOWN_PERIPHERAL_IN_LIGHT_SLEEP=y` drops the ICG file but only moves the failure to the next retention alloc (PCR / int_wdt) then a `sleep_retention.c:914` assert; `.bss`/`.noinit` → PSRAM (`CONFIG_SPIRAM_ALLOW_{BSS,NOINIT}_SEG_EXTERNAL_MEMORY`) still fails — because `MALLOC_CAP_RETENTION` is a *specific reserved memory region*, not general DRAM, so freeing general DRAM doesn't reach it. MoonLight ran the same board on IDF 5.5 without it, so it's a 6.1-era regression. **RESOLVED for us 2026-08-19 by resolution path 1 below** (`CONFIG_PM_SLEEP_CLK_ICG_ENABLE=n` on IDF v6.1-rc1) — a workaround, since the allocation failure is untouched and #18759 remains open upstream. **Resolution paths (each independent):** - 1. **Upstream fix — SHIPPED on master, needs a v6.1 back-port or a cherry-pick (rechecked 2026-07-27).** #18759 is now **CLOSED (Resolution: Done)**. The fix is exactly the knob we wanted: commit `7d31b82d27d` ("change(esp_pm): add kconfig option for REGDMA sleep clock ICG", 2026-07-06) adds **`CONFIG_PM_SLEEP_CLK_ICG_ENABLE`** (`bool`, default `y`) — set it **`n`** and the crashing `sleep_clock_icg_startup_init` is not built/run. Since a 236-FPS LED controller never light-sleeps, ICG retention is dead weight for us, so `=n` costs nothing. **But it is on `origin/master` only — NOT back-ported to `release/v6.1`** (our pinned IDF `14f663f`/dev-5880 and the current `origin/release/v6.1` are the same commit; neither has it). So bumping the pinned v6.1 IDF does *not* get it yet. **Decision (2026-07-27): wait for the next v6.1 beta** that carries the back-port, rather than cherry-pick `7d31b82d27d` onto the pinned IDF — a manual local IDF patch is bespoke, drifts, and complicates the single-pinned-IDF story for a fix that is a clean one-line sdkconfig change once it's in the branch. When a new v6.1 beta publishes: check it contains `7d31b82d27d` (or the `CONFIG_PM_SLEEP_CLK_ICG_ENABLE` symbol), bump the pinned IDF, add `CONFIG_PM_SLEEP_CLK_ICG_ENABLE=n` to `sdkconfig.defaults.esp32p4-eth-wifi`, and bench-test the P4-WiFi boot. Once it boots, resume round-3 (the runtime SDIO re-init / C6 slave-reset work above). Watch `origin/release/v6.1` for the back-port. + 1. **Upstream fix — SHIPPED on master, needs a v6.1 back-port or a cherry-pick (rechecked 2026-07-27).** #18759 is now **CLOSED (Resolution: Done)**. The fix is exactly the knob we wanted: commit `7d31b82d27d` ("change(esp_pm): add kconfig option for REGDMA sleep clock ICG", 2026-07-06) adds **`CONFIG_PM_SLEEP_CLK_ICG_ENABLE`** (`bool`, default `y`) — set it **`n`** and the crashing `sleep_clock_icg_startup_init` is not built/run. Since a 236-FPS LED controller never light-sleeps, ICG retention is dead weight for us, so `=n` costs nothing. **But it is on `origin/master` only — NOT back-ported to `release/v6.1`** (our pinned IDF `14f663f`/dev-5880 and the current `origin/release/v6.1` are the same commit; neither has it). So bumping the pinned v6.1 IDF does *not* get it yet. **Decision (2026-07-27): wait for the next v6.1 beta** that carries the back-port, rather than cherry-pick `7d31b82d27d` onto the pinned IDF — a manual local IDF patch is bespoke, drifts, and complicates the single-pinned-IDF story for a fix that is a clean one-line sdkconfig change once it's in the branch. **DONE 2026-08-19 — that plan executed exactly as written.** v6.1-rc1 published carrying the option as `4b8e1e87106` (verified present in `components/esp_pm/Kconfig` at the pinned commit `44f0c59f7c8`); the IDF pin was bumped, `CONFIG_PM_SLEEP_CLK_ICG_ENABLE=n` added to `sdkconfig.defaults.esp32p4rev1-eth-wifi`, and the P4-WiFi boot bench-tested: it boots, associates (RSSI -52) and serves the UI. The variant is out of the installer's experimental set and ships as a normal firmware. Reported back on the upstream issue ([comment](https://github.com/espressif/esp-idf/issues/18759#issuecomment-5345660136)), which also told the original reporter the option had landed — nobody had announced it in the thread. **#18759 stays OPEN upstream** and we did not ask for it to be closed: the retention allocation still fails under early-DRAM pressure, the option merely means nothing requests that memory. Round 3 is now unblocked, and its remaining defect is throughput, not boot (see open issue 0 above). 2. **IDF 5.5 fallback — investigated 2026-07-03, NOT a cheap escape.** MoonLight ran this exact board on IDF **5.5** without the crash, so a 5.5 build of just this variant (everything else on 6.1) looked like a timeline-independent path. A bench attempt against IDF 5.5.4 found `src/platform/esp32/` has drifted to genuinely require IDF **6.x**: four distinct 5.5↔6.1 API breaks in `platform_esp32.cpp` / `platform_config.h` — (a) `RMT_LL_TX_CANDIDATES_PER_INST` renamed (5.5: `SOC_RMT_TX_CANDIDATES_PER_GROUP`); (b) `esp_eth_phy_ip101.h` moved (5.5: ctor lives in core `esp_eth_phy.h`, no standalone header); (c) `CHIP_ESP32S31` enum is 6.1-only; (d) `ETH_ESP32_EMAC_DEFAULT_CONFIG()` in 5.5 has out-of-declaration-order designated initializers — a C++ **hard error no compiler flag suppresses** (`-fpermissive` doesn't touch it). So a working 5.5 binary needs permanent `#if`-IDF-version compat branches in the platform layer (an EMAC-init back-port + a second IDF 5.5.4 in the CI matrix) — a real feature, not a throwaway. Only worth it if #18759 stalls long enough that a shippable P4-WiFi is needed sooner. 3. **Version-matched C6 slave reflash** (see round-3 item 1) — may change the boot memory picture, but is blocked behind the boot crash (host must boot to test the C6 handshake), so it only matters once 1 or 2 gets us to `app_main` stably. - **Round 4 — Parlio loopback self-test FIXED (2026-07-09), real long strip still to prove.** The Parlio loopback self-test now passes on P4 hardware at any grid size (verified on MM-P4, jumper GPIO 32↔33, at both 8×8 and 128×128). A real *long* WS2812 strip (not just the bench panel) is the remaining hardware proof. diff --git a/docs/building.md b/docs/building.md index ce1043a7..18258c73 100644 --- a/docs/building.md +++ b/docs/building.md @@ -84,7 +84,7 @@ Every host needs [uv](https://docs.astral.sh/uv/), CMake 3.20+, and a C++20 comp The ESP32 target uses ESP-IDF directly, not the Arduino framework. -**Tested IDF version:** **v6.1-beta1** (commit `b1d13e9f`). CI builds against the `v6.1-beta1` Docker tag and local builds should match (clone command below). The why, the alternatives, and how to check for a newer one are in [ESP-IDF version](#esp-idf-version) below. +**Tested IDF version:** **v6.1-rc1** (commit `44f0c59f`). CI builds against the `v6.1-rc1` Docker tag and local builds should match (clone command below). The why, the alternatives, and how to check for a newer one are in [ESP-IDF version](#esp-idf-version) below. ### Prerequisites @@ -93,16 +93,16 @@ You need [uv](https://docs.astral.sh/uv/) (Python launcher), CMake 3.20+, and a **macOS / Linux:** ```sh -git clone --depth 1 --branch v6.1-beta1 https://github.com/espressif/esp-idf.git ~/esp/esp-idf +git clone --depth 1 --branch v6.1-rc1 https://github.com/espressif/esp-idf.git ~/esp/esp-idf ``` **Windows** (PowerShell — run once with admin to enable long paths if you haven't already): ```powershell # IDF and its tooling have deeply nested paths; without longpaths the clone -# trips MAX_PATH (260 chars) inside the v6.1-beta1 tree. +# trips MAX_PATH (260 chars) inside the v6.1-rc1 tree. git config --global core.longpaths true -git clone --depth 1 --branch v6.1-beta1 https://github.com/espressif/esp-idf.git "$env:USERPROFILE\esp\esp-idf" +git clone --depth 1 --branch v6.1-rc1 https://github.com/espressif/esp-idf.git "$env:USERPROFILE\esp\esp-idf" ``` Then run the one-time Python environment setup — either open MoonDeck (`uv run moondeck/moondeck.py`), go to the ESP32 tab, and click **Setup ESP-IDF**, or run it directly: @@ -173,9 +173,9 @@ After the driver installs and Windows finishes binding (a few seconds), the boar ### ESP-IDF version -**Pinned to `v6.1-beta1`** (commit `b1d13e9f`, a signed pre-release tag). `setup_esp_idf.py` holds the exact commit in `PINNED_IDF_VERSION`, warns loudly when the installed tree differs, and by default offers to check the pin out so a stray `git pull` or a fresh shallow clone landing on a newer commit converges back rather than silently building against the wrong tree (`--no-checkout` keeps it warn-only). Minimum is ESP-IDF v5.1 (C++20 needs GCC 12+); the project uses v6.x APIs (`esp_eth_phy_new_generic`, the component manager for mDNS, the modern RMT/parlio/LCD drivers) so v5.x would need adjustments. +**Pinned to `v6.1-rc1`** (commit `44f0c59f`, a signed pre-release tag). `setup_esp_idf.py` holds the exact commit in `PINNED_IDF_VERSION`, warns loudly when the installed tree differs, and by default offers to check the pin out so a stray `git pull` or a fresh shallow clone landing on a newer commit converges back rather than silently building against the wrong tree (`--no-checkout` keeps it warn-only). Minimum is ESP-IDF v5.1 (C++20 needs GCC 12+); the project uses v6.x APIs (`esp_eth_phy_new_generic`, the component manager for mDNS, the modern RMT/parlio/LCD drivers) so v5.x would need adjustments. -**Why a v6.1 pre-release and not a stable tag.** The v6.x line is: **v6.0 is the current stable** (GA 2026-02-27); **v6.1 is pre-release** (beta1 2026-06-24, RC1 and GA targeted late July 2026). We pin the `v6.1-beta1` *tag* (a fixed, signed pre-release, not the rolling `release/v6.1` branch) because it carries driver fixes for the newer SoCs (P4 parlio, RMT v2 on every chip) **and is on the earliest IDF line that carries the `esp32s31` preview target** — and because v6.0 vs v6.1 is a small delta. Riding the betas toward GA means breakage from the v6.1 delta surfaces incrementally, not all at once at the GA re-pin. The trade-off is honest: a pre-release gets **no support guarantee**, which is why the pin is a fixed tag, not a floating branch. The clean inflection point is **v6.1 GA**: re-pin to the `v6.1` tag then, which starts the 30-month support clock (see below). Each pin move (beta1 → RC → GA) is a deliberate re-test pass, not a routine pull. Tracked in [backlog](backlog/README.md). +**Why a v6.1 pre-release and not a stable tag.** The v6.x line is: **v6.0 is the current stable** (GA 2026-02-27); **v6.1 is pre-release** (beta1 2026-06-24, rc1 2026-08-14, GA to follow). We pin the `v6.1-rc1` *tag* (a fixed, signed pre-release, not the rolling `release/v6.1` branch) because it carries driver fixes for the newer SoCs (P4 parlio, RMT v2 on every chip) **and is on the earliest IDF line that carries the `esp32s31` preview target** — and because v6.0 vs v6.1 is a small delta. Riding the betas toward GA means breakage from the v6.1 delta surfaces incrementally, not all at once at the GA re-pin. The trade-off is honest: a pre-release gets **no support guarantee**, which is why the pin is a fixed tag, not a floating branch. The clean inflection point is **v6.1 GA**: re-pin to the `v6.1` tag then, which starts the 30-month support clock (see below). Each pin move (beta1 → RC → GA) is a deliberate re-test pass, not a routine pull. Tracked in [backlog](backlog/README.md). **v6.0 is the floor — don't depend on anything newer than it.** Because **v6.0 stable is our fallback** if the v6.1 line proves troublesome, the firmware and build tooling must stay buildable on v6.0. The rule is generic: **use no IDF API, component, Kconfig symbol, or tool that isn't present in v6.0.** A feature that exists only on the v6.1-dev branch (or arrives in a later minor) is off-limits until v6.0 is no longer the fallback. When adopting anything new from the IDF, confirm it shipped in v6.0 first (check the v6.0 docs / release notes, not `latest`); if it's v6.1-only, it waits. @@ -229,7 +229,7 @@ Tracked in [backlog](backlog/README.md). `build_esp32.py --firmware` selects one of the shipping variants. The key combines chip name + feature flags + (for SKU-sensitive chips) module. ("Firmware" here is the compiled binary; the physical product (deviceModel) is a separate concept — see [architecture.md § Firmware vs deviceModel vs board](architecture.md#firmware-vs-devicemodel-vs-board).) `build_esp32.py --help` lists the full set. -The canonical list is the **`FIRMWARES` dict** in [`moondeck/build/build_esp32.py`](../moondeck/build/build_esp32.py) — the single source of truth, carrying each variant's `chip`, sdkconfig `fragments`, `eth_only`, `ships`, and `description`. Its machine-readable projection is [`web-installer/firmwares.json`](../web-installer/firmwares.json) (generated by `generate_firmwares.py`, drift-guarded by `check_firmwares.py`), which the CI release matrix, the ESP Web Tools manifest loops, and MoonDeck all read — so the list lives in exactly one place. `esp32p4-eth-wifi` has `ships: false` (its C6-slave Kconfig isn't reproducible in CI yet), so it builds from the CLI but stays out of the release matrix. +The canonical list is the **`FIRMWARES` dict** in [`moondeck/build/build_esp32.py`](../moondeck/build/build_esp32.py) — the single source of truth, carrying each variant's `chip`, sdkconfig `fragments`, `eth_only`, `ships`, and `description`. Its machine-readable projection is [`web-installer/firmwares.json`](../web-installer/firmwares.json) (generated by `generate_firmwares.py`, drift-guarded by `check_firmwares.py`), which the CI release matrix, the ESP Web Tools manifest loops, and MoonDeck all read — so the list lives in exactly one place. `esp32p4rev1-eth-wifi` has `ships: false` (its C6-slave Kconfig isn't reproducible in CI yet), so it builds from the CLI but stays out of the release matrix. ESP-IDF v6.x has no `CONFIG_ESP_WIFI_ENABLED` switch (the symbol is forced on for WiFi-capable SoCs), so dropping WiFi at compile time happens via `EXCLUDE_COMPONENTS` plus `MM_NO_WIFI` (set when `MM_ETH_ONLY=1`, applied in `esp32/main/CMakeLists.txt`). The `esp32-eth` variant takes this path; the default `esp32` keeps both stacks compiled in and uses the runtime cascade in `NetworkModule` (Ethernet first, WiFi fallback when no PHY responds). diff --git a/docs/metrics/repo-health.json b/docs/metrics/repo-health.json index 3a52d0b4..b2ce68b1 100644 --- a/docs/metrics/repo-health.json +++ b/docs/metrics/repo-health.json @@ -1,22 +1,25 @@ { - "commit": "762676fb", + "commit": "2c97ce9e", "flash": { - "esp32": 1743056, - "esp32p4-eth": 1632720, - "esp32p4-eth-wifi": 1793760, - "esp32s3-n16r8": 1781728, + "esp32": 1749472, + "esp32p4rev1-eth": 1639056, + "esp32p4rev1-eth-wifi": 1927776, + "esp32s3-n16r8": 1788704, "esp32s3-n8r8": 1753232, - "esp32s31": 2054480, - "desktop": 1175560, + "esp32s31": 2069184, + "desktop": 1175624, "esp32-16mb": 1714608, "esp32-eth": 1324816, "esp32-wrover": 1765504, - "qemu": 1318160 + "qemu": 1318160, + "esp32p4-eth": 1638752, + "esp32p4-eth-wifi": 1927136, + "esp32p4rev3-eth": 1643760 }, "perf": { "desktop": { - "tick_us": 260, - "fps": 3846 + "tick_us": 181, + "fps": 5524 }, "esp32": { "tick_us": 2151, @@ -24,53 +27,53 @@ } }, "loc": { - "core": 19185, - "light": 24925, - "platform": 13466, - "ui": 6738, - "test": 43507, - "moondeck": 20949 + "core": 19258, + "light": 24915, + "platform": 13489, + "ui": 6746, + "test": 43524, + "moondeck": 20984 }, "comments": { "core": { - "lines": 7503, - "ratio": 0.424 + "lines": 7548, + "ratio": 0.425 }, "light": { - "lines": 9752, + "lines": 9746, "ratio": 0.432 }, "platform": { - "lines": 4776, - "ratio": 0.391 + "lines": 4794, + "ratio": 0.392 }, "ui": { - "lines": 1736, - "ratio": 0.274 + "lines": 1743, + "ratio": 0.275 }, "test": { - "lines": 7775, + "lines": 7779, "ratio": 0.206 }, "moondeck": { - "lines": 3377, + "lines": 3383, "ratio": 0.185 } }, "tests": { - "cases": 1405, + "cases": 1406, "scenarios": 23 }, "docs": { "md_files": 180, - "md_lines": 25715, + "md_lines": 25885, "plans_files": 93, - "backlog_lines": 3714, + "backlog_lines": 3803, "lessons_lines": 549, "claude_md_lines": 135 }, "complexity": { - "functions": 2586, + "functions": 2585, "over_threshold": 162, "worst_ccn": 108 } diff --git a/docs/metrics/repo-health.md b/docs/metrics/repo-health.md index 426e7aa8..9e403d31 100644 --- a/docs/metrics/repo-health.md +++ b/docs/metrics/repo-health.md @@ -1,6 +1,6 @@ # Repo health -Measured at `762676fb`. Generated by [`moondeck/check/repo_health.py`](../../moondeck/check/repo_health.py) on every KPI-gate run. **Do not edit by hand.** +Measured at `2c97ce9e`. Generated by [`moondeck/check/repo_health.py`](../../moondeck/check/repo_health.py) on every KPI-gate run. **Do not edit by hand.** Current state only; the trend is this file's git history (`git log -p docs/metrics/repo-health.md`). Nothing here fails a build: the numbers make growth visible, the judgment stays human. @@ -9,58 +9,61 @@ Current state only; the trend is this file's git history (`git log -p docs/metri | Target | Flash | |---|---:| | desktop | 1,148 KB (+0 KB) ⚠ | -| esp32 | 1,702 KB (+7 KB) ⚠ | +| esp32 | 1,708 KB (+6 KB) ⚠ | | esp32-16mb | 1,674 KB | | esp32-eth | 1,294 KB | | esp32-wrover | 1,724 KB | -| esp32p4-eth | 1,594 KB (+15 KB) ⚠ | -| esp32p4-eth-wifi | 1,752 KB | -| esp32s3-n16r8 | 1,740 KB (+7 KB) ⚠ | +| esp32p4-eth | 1,600 KB (+6 KB) ⚠ | +| esp32p4-eth-wifi | 1,882 KB (+130 KB) ⚠ | +| esp32p4rev1-eth | 1,601 KB | +| esp32p4rev1-eth-wifi | 1,883 KB | +| esp32p4rev3-eth | 1,605 KB | +| esp32s3-n16r8 | 1,747 KB (+7 KB) ⚠ | | esp32s3-n8r8 | 1,712 KB | -| esp32s31 | 2,006 KB (+7 KB) ⚠ | +| esp32s31 | 2,021 KB (+14 KB) ⚠ | | qemu | 1,287 KB | ## Render performance | Target | Tick | FPS | |---|---:|---:| -| desktop | 260 µs (+74 µs) ⚠ | 3,846 (−1,530) ⚠ | +| desktop | 181 µs (−79 µs) ✓ | 5,524 (+1,678) ✓ | | esp32 | 2,151 µs | 464 | ## Code | Area | Lines | Comments | Comment share | |---|---:|---:|---:| -| core | 19,185 (+85) ⚠ | 7,503 | 42.4 % | -| light | 24,925 (+148) ⚠ | 9,752 | 43.2 % | -| platform | 13,466 | 4,776 | 39.1 % | -| ui | 6,738 (+270) ⚠ | 1,736 | 27.4 % | -| test | 43,507 (+315) ⚠ | 7,775 | 20.6 % (+0.1 %) ⚠ | -| moondeck | 20,949 | 3,377 | 18.5 % | +| core | 19,258 (+73) ⚠ | 7,548 | 42.5 % (+0.1 %) ⚠ | +| light | 24,915 (−10) ✓ | 9,746 | 43.2 % | +| platform | 13,489 (+23) ⚠ | 4,794 | 39.2 % (+0.1 %) ⚠ | +| ui | 6,746 (+8) ⚠ | 1,743 | 27.5 % (+0.1 %) ⚠ | +| test | 43,524 (+17) ⚠ | 7,779 | 20.6 % | +| moondeck | 20,984 (+35) ⚠ | 3,383 | 18.5 % | ## Tests | Kind | Count | |---|---:| -| unit cases | 1,405 (+15) ✓ | +| unit cases | 1,406 (+1) ✓ | | scenarios | 23 | ## Complexity | Metric | Value | |---|---:| -| functions | 2,586 (+14) ✓ | -| over threshold | 162 (+1) ⚠ | +| functions | 2,585 (−1) ⚠ | +| over threshold | 162 | | worst CCN | 108 | ## Documentation | Metric | Value | |---|---:| -| markdown files | 180 (+1) ⚠ | -| markdown lines | 25,715 (+254) ⚠ | -| plan files | 93 (+1) ⚠ | -| backlog lines | 3,714 (+2) ⚠ | +| markdown files | 180 | +| markdown lines | 25,885 (+170) ⚠ | +| plan files | 93 | +| backlog lines | 3,803 (+89) ⚠ | | lessons lines | 549 | | CLAUDE.md lines | 135 | diff --git a/docs/reference/mhc-wled-esp32-p4-shield.md b/docs/reference/mhc-wled-esp32-p4-shield.md index d963c345..a792eb82 100644 --- a/docs/reference/mhc-wled-esp32-p4-shield.md +++ b/docs/reference/mhc-wled-esp32-p4-shield.md @@ -1,6 +1,6 @@ # MHC-WLED ESP32-P4 shield — hardware reference -Terminal pinout and onboard features for the **MHC-WLED ESP32-P4 shield** (myhome-control), the P4-NANO carrier used on the bench (catalog `deviceModel: "MHC-WLED ESP32-P4 shield"`, `esp32p4-eth` firmware). Read from the board silkscreen + the builder's schematics so projectMM work reads this instead of the marketing render. The shield sits on a **Waveshare ESP32-P4-NANO**; GPIO numbers are the P4's. +Terminal pinout and onboard features for the **MHC-WLED ESP32-P4 shield** (myhome-control), the P4-NANO carrier used on the bench (catalog `deviceModel: "MHC-WLED ESP32-P4 shield"`, `esp32p4rev1-eth` firmware). Read from the board silkscreen + the builder's schematics so projectMM work reads this instead of the marketing render. The shield sits on a **Waveshare ESP32-P4-NANO**; GPIO numbers are the P4's. > **Board revision:** the terminal map and RS-485 wiring below are transcribed from a **V1** board (the builder's labelled V1 photos + schematics). The overview render is a **V2** render. Whether V2 keeps the identical GPIO↔terminal wiring is **not confirmed here** — treat the map as V1-specific and verify against your own board's silkscreen if you have a different revision. diff --git a/esp32/main/idf_component.yml b/esp32/main/idf_component.yml index 7a27dd59..46fd968d 100644 --- a/esp32/main/idf_component.yml +++ b/esp32/main/idf_component.yml @@ -56,9 +56,9 @@ dependencies: # code needed). DELIBERATE v6.0-floor exception (docs/building.md § ESP-IDF # version): these are managed components outside mainline v6.0, accepted # explicitly. The `rules` gate pulls them ONLY when CONFIG_MM_P4_WIFI is set - # (declared in main/Kconfig.projbuild, set by sdkconfig.defaults.esp32p4-eth-wifi) + # (declared in main/Kconfig.projbuild, set by sdkconfig.defaults.esp32p4rev1-eth-wifi) # — i.e. the WiFi build only. Gating on `target == esp32p4` alone was WRONG: it - # also pulled them into the Ethernet-only esp32p4-eth build, where esp_hosted's + # also pulled them into the Ethernet-only esp32p4rev1-eth build, where esp_hosted's # boot-time self-init claims the default netif and breaks the EMAC Ethernet DHCP # (link up, no IP — bench-found 2026-06-15). Versions pinned to patch-level (the # versions validated on the P4-NANO bench) so the build doesn't drift to a new minor. @@ -67,7 +67,7 @@ dependencies: # (`\$CONFIG\{([^}]+)}`) recognises. A bare `CONFIG_MM_P4_WIFI == True` is NOT # matched: it falls through to plain string-eval, resolves to false, and the # dependency is silently skipped (`NOTICE: Skipping optional dependency`) — which - # is what broke the esp32p4-eth-wifi build after the IDF/component-manager update. + # is what broke the esp32p4rev1-eth-wifi build after the IDF/component-manager update. espressif/esp_wifi_remote: version: "~1.6.1" rules: diff --git a/esp32/sdkconfig.defaults.esp32p4-eth b/esp32/sdkconfig.defaults.esp32p4rev1-eth similarity index 81% rename from esp32/sdkconfig.defaults.esp32p4-eth rename to esp32/sdkconfig.defaults.esp32p4rev1-eth index 53684b34..3ec9f24d 100644 --- a/esp32/sdkconfig.defaults.esp32p4-eth +++ b/esp32/sdkconfig.defaults.esp32p4rev1-eth @@ -26,6 +26,14 @@ CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y # (rev <3.0 and >=3.0 are "huge hardware difference", so a binary targets one or # the other, not both); enable it, then pin the minimum to v0 to run on any # pre-v3 P4. Revisit if/when the field moves to v3.x P4 silicon. +# ONE BINARY CANNOT COVER BOTH P4 GENERATIONS. IDF states it at the top of +# esp_hw_support/port/esp32p4/Kconfig.hw_support: "Support of ESP32-P4 rev. <3.0 and +# >=3.0 is mutually exclusive ... huge hardware difference ... not compatible". The +# Kconfig enforces it structurally: REV_MIN_0/1/100 require SELECTS_REV_LESS_V3=y and +# REV_MIN_300/301 require it =n, with the max clamped to 199 or 399. So this image +# supports revisions 0.0 through 1.99 and will refuse to boot on a v3.x chip, which is +# the bench/field silicon today. A v3.x board needs its own firmware variant (backlog: +# esp32p4-eth-v3), not a flag flipped here. CONFIG_ESP32P4_SELECTS_REV_LESS_V3=y CONFIG_ESP32P4_REV_MIN_0=y diff --git a/esp32/sdkconfig.defaults.esp32p4-eth-wifi b/esp32/sdkconfig.defaults.esp32p4rev1-eth-wifi similarity index 72% rename from esp32/sdkconfig.defaults.esp32p4-eth-wifi rename to esp32/sdkconfig.defaults.esp32p4rev1-eth-wifi index b9564812..d696ff4b 100644 --- a/esp32/sdkconfig.defaults.esp32p4-eth-wifi +++ b/esp32/sdkconfig.defaults.esp32p4rev1-eth-wifi @@ -61,19 +61,21 @@ CONFIG_WIFI_RMT_TX_BA_WIN=32 CONFIG_WIFI_RMT_AMPDU_RX_ENABLED=y CONFIG_WIFI_RMT_RX_BA_WIN=16 -# --- UNRESOLVED boot crash (IDF 6.1, WiFi build only). KNOWN OPEN IDF BUG: esp-idf -# #18759 (IDFGH-17859) — on ESP32-P4 + PSRAM, sleep_clock_icg_startup_init (a SECONDARY -# init fn, priority 106, runs before app_main, NOT gated by CONFIG_PM_ENABLE) allocates -# REGDMA sleep-retention links needing MALLOC_CAP_RETENTION and aborts with -# ESP_ERR_NO_MEM (0x101) → reboot loop, once esp_hosted's SDIO stack is pulled in (this -# WiFi build only; the eth-only P4 boots fine). See docs/backlog § ESP32-P4 round 4 and -# https://github.com/espressif/esp-idf/issues/18759 for the full trail + upstream status. +# The P4-WiFi image crash-LOOPED at boot before this: sleep_clock_icg_startup_init (a SECONDARY +# init fn, priority 106, before app_main, NOT gated by CONFIG_PM_ENABLE) could not allocate its +# REGDMA sleep-retention links from MALLOC_CAP_RETENTION and cpu_start aborted with ESP_ERR_NO_MEM +# (0x101) — upstream [esp-idf#18759](https://github.com/espressif/esp-idf/issues/18759), which is +# still OPEN because the allocation failure itself is unfixed. IDF v6.1-rc1 added this option +# (4b8e1e87106), and turning it off makes esp_sleep_clock_config log a warning and continue +# instead of allocating, so nothing asks for that memory. A WORKAROUND, not a fix. # -# Bench-tried 2026-07-03, NONE fixed it (the RETENTION pool is a specific reserved region, -# not general DRAM, so freeing general DRAM does not help): -# - CONFIG_SPIRAM_TRY_ALLOCATE_WIFI_LWIP=y → those buffers allocate after boot init -# - CONFIG_PM_POWER_DOWN_PERIPHERAL_IN_LIGHT_SLEEP=y → drops the ICG file, but the next -# retention alloc (PCR / int_wdt) fails, then a sleep_retention.c:914 assert -# - CONFIG_SPIRAM_ALLOW_{BSS,NOINIT}_SEG_EXTERNAL_MEMORY=y → retention alloc still fails -# Held here until esp-idf #18759 lands an "disable ICG / retention" option, or a -# version-matched C6 slave reflash changes the memory picture (see round 4 note). +# Why it only ever hit this build: the retention pool is a specific reserved region, not general +# DRAM, and esp_hosted's SDIO stack is what tips it over — the eth-only P4 boots fine. That is also +# why freeing general DRAM does not help; see docs/backlog § ESP32-P4 round 4 for the three bench +# attempts that failed for exactly that reason. +# +# What it costs: clock gating for digital peripherals during LIGHT SLEEP. A peripheral that needs +# its clock kept alive across a sleep no longer gets it ungated. This device never light-sleeps +# (the render loop runs continuously), so the feature buys us nothing and its allocation is pure +# cost. Revisit if power management is ever enabled here. +CONFIG_PM_SLEEP_CLK_ICG_ENABLE=n diff --git a/esp32/sdkconfig.defaults.esp32p4rev3 b/esp32/sdkconfig.defaults.esp32p4rev3 new file mode 100644 index 00000000..f10df289 --- /dev/null +++ b/esp32/sdkconfig.defaults.esp32p4rev3 @@ -0,0 +1,28 @@ +# ESP32-P4 revision v3.x — the CURRENT silicon. +# +# Layered on top of sdkconfig.defaults.esp32p4rev1-eth, which carries the board config +# (partition table, flash size, EMAC/RMII). This fragment overrides ONLY the chip +# revision, because that is the entire difference between the two images: everything +# about the board is the same. +# +# Why a second image at all: rev <3.0 and rev >=3.0 are not binary compatible. IDF says +# so at the top of esp_hw_support/port/esp32p4/Kconfig.hw_support ("Support of ESP32-P4 +# rev. <3.0 and >=3.0 is mutually exclusive ... huge hardware difference ... not +# compatible") and enforces it in the Kconfig: REV_MIN_0/1/100 require +# SELECTS_REV_LESS_V3=y, REV_MIN_300/301 require it =n. So one binary cannot serve both, +# and this is not a flag we can relax. +# +# TWO images cover every P4 that exists, not one per revision: REV_MIN_300 supports +# v3.0 through v3.99 (REV_MAX_FULL=399), so v3.0 and v3.1 share this one. The rev1 image +# covers v0.0 through v1.99 the same way. +# +# NOT VERIFIED ON HARDWARE. Both bench boards are v1.3, so this image has never been +# booted. It is published so someone with current silicon can test it: a v3 board +# otherwise has no image at all, since the rev1 binary is rejected by the bootloader. +# Espressif calls v1.x "engineering samples" and does not recommend v0.x/v1.x for new +# designs, so a board bought today is v3.x and this is the image it needs. + +# Undo the rev1 fragment's sub-v3 selection, then take the v3 minimum. REV_MIN_300 rather +# than 301: it covers both v3.0 and v3.1, where 301 would refuse a v3.0 chip. +CONFIG_ESP32P4_SELECTS_REV_LESS_V3=n +CONFIG_ESP32P4_REV_MIN_300=y diff --git a/moondeck/build/build_esp32.py b/moondeck/build/build_esp32.py index d3462714..6381a2bc 100644 --- a/moondeck/build/build_esp32.py +++ b/moondeck/build/build_esp32.py @@ -25,15 +25,15 @@ ] # The ESP-IDF commit every target (classic ESP32, S3, P4, S31) has been -# validated against — the `v6.1-beta1` tag, on the earliest IDF line that +# validated against — the `v6.1-rc1` tag, on the earliest IDF line that # carries the esp32s31 preview target. Kept here (not in setup_esp_idf.py) so # the pre-build drift check below can share the constant — a stale local IDF is # the single most common source of an "it built for me last week" ESP32 build # failure, so the check runs on every build_esp32 invocation, not just when the # user remembers to re-run setup_esp_idf.py. setup_esp_idf.py imports these # two constants. -PINNED_IDF_COMMIT = "b1d13e9fe441c4f75e240c98a26fd631b7b3232f" -PINNED_IDF_VERSION = "v6.1-beta1" +PINNED_IDF_COMMIT = "44f0c59f7c81a72a5868a52d5f6dfbbf88829704" +PINNED_IDF_VERSION = "v6.1-rc1" def installed_idf_commit(idf_path: Path) -> str: @@ -93,11 +93,11 @@ def check_idf_pin(idf_path: Path) -> None: # # NOTE on the P4 co-processor components (esp_hosted / esp_wifi_remote / eppp_link): # the `rules: target == esp32p4` gate in main/idf_component.yml pulls them for ANY -# esp32p4 build, including the WiFi-less esp32p4-eth, because manifest rules can't +# esp32p4 build, including the WiFi-less esp32p4rev1-eth, because manifest rules can't # see our eth-only flag. EXCLUDE_COMPONENTS does NOT drop them (the component # manager resolves the managed dependency before the exclude applies). It's a # *build-time* cost only: the linker dead-strips the unused code, so they add ~0 -# bytes of flash to esp32p4-eth (our coprocessorWifi() is the empty stub there, so +# bytes of flash to esp32p4rev1-eth (our coprocessorWifi() is the empty stub there, so # no esp_hosted symbol is referenced — confirmed: their .text size is 0x0 in the # .map). Left as-is rather than fought; see docs/backlog/. ETH_ONLY_EXCLUDE = ["esp_wifi", "wpa_supplicant", "esp_coex"] @@ -208,32 +208,64 @@ def check_idf_pin(idf_path: Path) -> None: "8 MB board, so N8R8 boards (LightCrafter etc.) need this variant.", "ships": True, }, - "esp32p4-eth": { + "esp32p4rev1-eth": { "chip": "esp32p4", - "fragments": ["sdkconfig.defaults", "sdkconfig.defaults.esp32p4-eth"], + "fragments": ["sdkconfig.defaults", "sdkconfig.defaults.esp32p4rev1-eth"], "eth_only": True, - "description": "Waveshare ESP32-P4-NANO — Ethernet only (IP101 PHY). The " - "WiFi-less fallback; esp32p4-eth-wifi adds the C6 radio.", + "description": "Waveshare ESP32-P4-NANO — Ethernet only (IP101 PHY), for P4 " + "revisions 0.x/1.x ONLY. The WiFi-less fallback; " + "esp32p4rev1-eth-wifi adds the C6 radio.", "ships": True, "panel_cards": True, }, - "esp32p4-eth-wifi": { + "esp32p4rev1-eth-wifi": { "chip": "esp32p4", - "fragments": ["sdkconfig.defaults", "sdkconfig.defaults.esp32p4-eth", - "sdkconfig.defaults.esp32p4-eth-wifi"], + "fragments": ["sdkconfig.defaults", "sdkconfig.defaults.esp32p4rev1-eth", + "sdkconfig.defaults.esp32p4rev1-eth-wifi"], "eth_only": False, - "description": "⚠️ DOES NOT BOOT — repro build for esp-idf #18759 " - "(github.com/espressif/esp-idf/issues/18759). Waveshare " - "ESP32-P4-NANO, Ethernet + WiFi via the on-board ESP32-C6 over " - "SDIO (esp_hosted). Builds + flashes but crash-loops at boot: " - "sleep_clock_icg_startup_init fails ESP_ERR_NO_MEM on IDF 6.1. " - "Published so the IDF team can one-click-flash the failing " - "binary. Use esp32p4-eth for a working P4.", - # ships=True purely to PUBLISH the (crash-looping) binary for the esp-idf - # #18759 repro — see backlog § ESP32-P4 round 4. The CI build itself passes - # (the $CONFIG{} manifest fix); only the flashed binary crashes at boot, which - # CI doesn't boot-test. NOT a usable firmware: the board's deviceModels entry - # flags it experimental so the installer warns before flashing. + "description": "Waveshare ESP32-P4-NANO — Ethernet + WiFi via the on-board " + "ESP32-C6 over SDIO (esp_hosted), for P4 revisions 0.x/1.x " + "ONLY. Boots and associates as of IDF v6.1-rc1 with " + "CONFIG_PM_SLEEP_CLK_ICG_ENABLE=n, which sidesteps esp-idf " + "#18759 (sleep_clock_icg_startup_init failing ESP_ERR_NO_MEM " + "and aborting cpu_start).", + # Was a crash-repro build for esp-idf #18759 and is now a working firmware: + # bench-verified on a v1.3 P4 (associates, RSSI -52, serves the UI). #18759 is + # NOT fixed upstream — CONFIG_PM_SLEEP_CLK_ICG_ENABLE=n (an option v6.1-rc1 + # added) skips the allocation that failed, at the cost of peripheral clock + # gating during light sleep, which this device never enters. + "ships": True, + "panel_cards": True, + }, + "esp32p4rev3-eth": { + "chip": "esp32p4", + "fragments": ["sdkconfig.defaults", "sdkconfig.defaults.esp32p4rev1-eth", + "sdkconfig.defaults.esp32p4rev3"], + "eth_only": True, + "description": "⚠️ UNTESTED — Waveshare ESP32-P4-NANO, Ethernet only (IP101 " + "PHY), for P4 revisions 3.x (the CURRENT silicon). Identical to " + "esp32p4rev1-eth apart from the chip revision, which the two " + "generations cannot share. Published so someone with a v3 board " + "can test it: both bench boards are v1.3, so this image has " + "never been booted.", + # The board fragment is REUSED rather than copied: the two images differ only in + # CONFIG_ESP32P4_SELECTS_REV_LESS_V3 / REV_MIN, so duplicating the partition + # table, flash size and EMAC config would be the same fact in two places, and + # they would drift the first time the board config changed. + "ships": True, + "panel_cards": True, + }, + "esp32p4rev3-eth-wifi": { + "chip": "esp32p4", + "fragments": ["sdkconfig.defaults", "sdkconfig.defaults.esp32p4rev1-eth", + "sdkconfig.defaults.esp32p4rev1-eth-wifi", + "sdkconfig.defaults.esp32p4rev3"], + "eth_only": False, + "description": "⚠️ UNTESTED — Waveshare ESP32-P4-NANO, Ethernet + WiFi via the " + "on-board ESP32-C6 (esp_hosted), for P4 revisions 3.x (the " + "CURRENT silicon). The rev1 build of this image is bench-verified; " + "this one differs only in the chip revision and has never been " + "booted.", "ships": True, "panel_cards": True, }, @@ -683,12 +715,12 @@ def main(): # set-target is skipped — switching to another firmware uses a different # build_dir entirely, so its sdkconfig is untouched. # - # KNOWN ISSUE (esp32p4-eth-wifi): esp_wifi_remote's slave target + # KNOWN ISSUE (esp32p4rev1-eth-wifi): esp_wifi_remote's slave target # (SLAVE_IDF_TARGET_ESP32C6) is selected by a Kconfig `default ... if # IDF_TARGET_ESP32P4` that fires during `set-target` but is dropped by the # reconfigure a plain `build` triggers, falling back to ESP32-H2 (no WiFi) and # failing on missing CONFIG_WIFI_RMT_* symbols. A clean manual sequence works: - # rm -rf build/esp32-esp32p4-eth-wifi && idf.py -B -DSDKCONFIG=/sdkconfig \ + # rm -rf build/esp32-esp32p4rev1-eth-wifi && idf.py -B -DSDKCONFIG=/sdkconfig \ # -DSDKCONFIG_DEFAULTS="..." set-target esp32p4 && (same) build # but this wrapper does not yet reproduce it reliably — tracked in # docs/backlog/ (ESP32-P4 round 3). Until fixed, build this variant diff --git a/moondeck/build/generate_firmwares.py b/moondeck/build/generate_firmwares.py index 6826b86e..9c5c0bfc 100644 --- a/moondeck/build/generate_firmwares.py +++ b/moondeck/build/generate_firmwares.py @@ -72,8 +72,11 @@ def main() -> int: args.out.parent.mkdir(parents=True, exist_ok=True) # ensure_ascii=False keeps the em-dashes in descriptions literal (readable # diffs), matching the hand-authored deviceModels.json sibling. - args.out.write_text(json.dumps(build_doc(), indent=2, ensure_ascii=False) + "\n") - print(f"generate_firmwares: wrote {args.out} ({len(FIRMWARES)} variants)") + doc = build_doc() + args.out.write_text(json.dumps(doc, indent=2, ensure_ascii=False) + "\n") + # Count what was WRITTEN, not len(FIRMWARES): build_doc drops non-installable + # variants, so the raw dict size contradicts check_firmwares.py's count. + print(f"generate_firmwares: wrote {args.out} ({len(doc['firmwares'])} variants)") return 0 diff --git a/moondeck/build/setup_esp_idf.py b/moondeck/build/setup_esp_idf.py index 99dc038b..2ce9ddb1 100644 --- a/moondeck/build/setup_esp_idf.py +++ b/moondeck/build/setup_esp_idf.py @@ -42,7 +42,7 @@ def _checkout_pinned(idf_path: Path) -> bool: co = subprocess.run(["git", "checkout", PINNED_IDF_COMMIT], cwd=str(idf_path)) if co.returncode != 0: print(f" Checkout failed — the pinned commit may not be fetched yet. " - f"In {idf_path}: git fetch origin tag v6.1-beta1, then re-run.") + f"In {idf_path}: git fetch origin tag {PINNED_IDF_VERSION}, then re-run.") return False # The new commit points its submodules at different SHAs; sync them so the # build sees the matching component sources. diff --git a/moondeck/moondeck.py b/moondeck/moondeck.py index 6538278a..5026c26b 100644 --- a/moondeck/moondeck.py +++ b/moondeck/moondeck.py @@ -89,7 +89,7 @@ def _load_firmwares(): generated projection of build_esp32's FIRMWARES dict (the single source of truth, shared with the CI release matrix). Returns [] on missing/malformed file, so the MoonDeck UI just shows no firmware entries. Filtering on - `ships` keeps held-out variants (e.g. esp32p4-eth-wifi) out of the picker. + `ships` keeps held-out variants (e.g. esp32p4rev1-eth-wifi) out of the picker. """ try: doc = json.loads(FIRMWARES_FILE.read_text(encoding="utf-8")) @@ -1010,7 +1010,7 @@ def _chip_from_usb(vid: int, pid: int) -> str: def _firmware_to_chip(firmware: str) -> str: """Best-effort ESP32 family from a registry firmware id (e.g. - 'esp32s3-n8r8' → 'esp32-s3', 'esp32p4-eth' → 'esp32-p4', 'esp32' → + 'esp32s3-n8r8' → 'esp32-s3', 'esp32p4rev1-eth' → 'esp32-p4', 'esp32' → 'esp32 (classic)'). The fallback for boards behind a UART bridge. Pure.""" f = (firmware or "").lower() for key, chip in (("p4", "esp32-p4"), ("s3", "esp32-s3"), diff --git a/src/platform/esp32/platform_esp32.cpp b/src/platform/esp32/platform_esp32.cpp index 87c59a75..9a4ebffa 100644 --- a/src/platform/esp32/platform_esp32.cpp +++ b/src/platform/esp32/platform_esp32.cpp @@ -342,16 +342,34 @@ const char* coprocessorWifi() { // of 0.0.0 (or an error) means the slave never completed its handshake — the // signature of absent / incompatible C6 slave firmware, which is exactly the // case we want to surface rather than infer. + // Asked a BOUNDED number of times, then never again. esp_hosted_get_coprocessor_fwversion is a + // blocking RPC over the host link and SystemModule calls this from tick1s(), which runs INLINE + // ON THE RENDER THREAD (the periodic-tick rule: a slow tick1s stutters the LEDs at its cadence). + // + // Measured on a P4 with WiFi live on the C6: the call TIMES OUT after ~1 s, every second, + // forever. SystemModule showed 1,012,344 us per tick, fps 0, and every HTTP request queued a + // second or more behind the render loop, which is what "very very slow" over WiFi actually was. + // The link works (WiFi associates and serves traffic) while this particular RPC does not answer, + // so retrying it buys nothing and costs a second of every tick. + // + // A few attempts rather than one: the C6 may still be handshaking right after boot, and the + // answer is worth having when it comes. After that the display latches on whatever it learned. + // The VERSION cannot change while the host runs, since reflashing the C6 takes the host with it. static char buf[24] = "querying…"; + static uint8_t attemptsLeft = 5; + if (attemptsLeft == 0) return buf; esp_hosted_coprocessor_fwver_t ver = {}; if (esp_hosted_get_coprocessor_fwversion(&ver) == ESP_OK && (ver.major1 || ver.minor1 || ver.patch1)) { + attemptsLeft = 0; // answered: never ask again std::snprintf(buf, sizeof(buf), "C6 fw %u.%u.%u", static_cast(ver.major1), static_cast(ver.minor1), static_cast(ver.patch1)); - } else { - std::snprintf(buf, sizeof(buf), "not detected"); + } else if (--attemptsLeft == 0) { + // Out of attempts. Say WHY the field is empty rather than asserting the C6 is absent: the + // query is what failed, and on this bench WiFi runs fine while it does. + std::snprintf(buf, sizeof(buf), "no version reply"); } return buf; #else @@ -525,12 +543,17 @@ static void ensureNetifInit() { #ifndef MM_NO_ETH +uint16_t ethLinkSpeedMbps() MM_NONBLOCKING; // defined below; the link-up log reports it + static void ethEventHandler(void* /*arg*/, esp_event_base_t base, int32_t id, void* data) { if (base == ETH_EVENT) { if (id == ETHERNET_EVENT_CONNECTED) { - ESP_LOGI(NET_TAG, "Ethernet link up"); ethLinkUp_.store(true, std::memory_order_relaxed); + // The NEGOTIATED speed, not just "up". A gigabit PHY that fell back to 100M behaves + // differently enough to matter (the S31's RGMII Tx-clock skew is speed-dependent), and + // "link up" alone sent one debug session hunting DHCP when the question was the speed. + ESP_LOGI(NET_TAG, "Ethernet link up (%u Mbps)", ethLinkSpeedMbps()); if (ethStatic_.load(std::memory_order_acquire)) { // Static mode: do NOT let the DHCP client restart on this link-up (applyHostname // would) — that is what made a re-plugged cable grab a DHCP lease instead of the diff --git a/src/ui/install-picker.js b/src/ui/install-picker.js index 6d348298..cd0dbb2c 100644 --- a/src/ui/install-picker.js +++ b/src/ui/install-picker.js @@ -52,12 +52,17 @@ const PREF_RELEASE_KEY = "projectMM.picker.releaseTag"; const PREF_FIRMWARE_KEY = "projectMM.picker.firmware"; const PREF_BOARD_KEY = "projectMM.picker.board"; -// Firmware variants published but known NOT to run — flagged in the dropdown so a -// user can't select them expecting a working device. esp32p4-eth-wifi is shipped -// solely as a one-click-flashable repro for esp-idf #18759 (P4 + esp_hosted boot -// crash); see docs/backlog § ESP32-P4 round 4. Remove a key here once its variant -// boots (e.g. when the upstream ICG fix lands). -const EXPERIMENTAL_FIRMWARES = new Set(["esp32p4-eth-wifi"]); +// Firmware variants published but NEVER RUN ON HARDWARE — flagged in the dropdown so a +// user knows before flashing. The P4 rev3 images are built for the current v3.x silicon, +// which no bench board has (both are v1.3 "engineering samples"), so they are published +// for someone with a v3 board to try: without them a v3 board has no image at all, since +// the rev1 binary is rejected by its bootloader. Remove a key once its variant is +// bench-verified. +// +// esp32p4rev1-eth-wifi was listed here as an esp-idf #18759 boot-crash repro and is now +// REMOVED: it boots, associates and serves the UI as of IDF v6.1-rc1 with +// CONFIG_PM_SLEEP_CLK_ICG_ENABLE=n. +const EXPERIMENTAL_FIRMWARES = new Set(["esp32p4rev3-eth", "esp32p4rev3-eth-wifi"]); // One picker instance per init() call. Each tracks its own state so multiple // pickers on a page (unused today but possible) don't fight over selections. @@ -487,12 +492,11 @@ function render(state) { const opt = document.createElement("option"); opt.value = f.firmware; // The dropdown shows the bare firmware key, so a variant that flashes but - // doesn't run (published only as a bug repro) must carry its own visible - // warning here — the firmwares.json `description` isn't loaded by the picker - // (it parses names from release asset filenames). esp32p4-eth-wifi is the - // esp-idf #18759 boot-crash repro; flag it so a user can't pick it blind. + // is unverified must carry its own visible warning here — the firmwares.json + // `description` isn't loaded by the picker (it parses names from release asset + // filenames), so a user picking blind would see nothing. opt.textContent = EXPERIMENTAL_FIRMWARES.has(f.firmware) - ? `⚠️ ${f.firmware} (does not boot — repro)` + ? `⚠️ ${f.firmware} (untested — no board to verify on)` : f.firmware; firmwareEl.appendChild(opt); }); diff --git a/test/python/test_build_esp32_s31.py b/test/python/test_build_esp32_s31.py index 7be1d762..358ff6df 100644 --- a/test/python/test_build_esp32_s31.py +++ b/test/python/test_build_esp32_s31.py @@ -92,7 +92,7 @@ def infer_target(firmware: str) -> str: assert infer_target("esp32s31") == "esp32s31", "S31 must NOT be misread as esp32s3" assert infer_target("esp32s3-n16r8") == "esp32s3" assert infer_target("esp32s3-n8r8") == "esp32s3" - assert infer_target("esp32p4-eth") == "esp32p4" + assert infer_target("esp32p4rev1-eth") == "esp32p4" assert infer_target("esp32") == "esp32" assert infer_target("esp32-16mb") == "esp32" diff --git a/test/python/test_moondeck_port_identity.py b/test/python/test_moondeck_port_identity.py index ddb6609b..13657fac 100644 --- a/test/python/test_moondeck_port_identity.py +++ b/test/python/test_moondeck_port_identity.py @@ -42,7 +42,7 @@ def test_resolve_native_usb_p4_not_mislabeled_as_s3(): # A P4 on native USB shares PID 0x1001 with the S3 — the descriptor must NOT guess "esp32-s3". # With a registry match (MAC), the P4's real chip (from firmware) wins over the generic guess. devs = [{"deviceName": "MM-P4", "mac": "80:F1:B2:D0:AC:F7", "ip": "192.168.1.133", - "firmware": "esp32p4-eth"}] + "firmware": "esp32p4rev1-eth"}] usb = {"vid": 0x303A, "pid": 0x1001, "product": "USB JTAG/serial debug unit", "serial": "80:F1:B2:D0:AC:F7"} got = _resolve_port("/dev/cu.usbmodem5ABA0767221", usb, devs) @@ -60,7 +60,7 @@ def test_chip_from_usb_external_adapter_reveals_nothing(): # --- level 2 fallback: chip from the registry firmware id --------------------- def test_firmware_to_chip(): - assert _firmware_to_chip("esp32p4-eth") == "esp32-p4" + assert _firmware_to_chip("esp32p4rev1-eth") == "esp32-p4" assert _firmware_to_chip("esp32s3-n8r8") == "esp32-s3" assert _firmware_to_chip("esp32s31") == "esp32-s3" # S31 is an S3 variant assert _firmware_to_chip("esp32") == "esp32 (classic)" @@ -83,7 +83,7 @@ def test_port_serial_extracts_stable_key(): {"deviceName": "projectMM-testbench-S3", "mac": "CC:BA:97:0A:F3:F8", "ip": "192.168.1.159", "firmware": "esp32s3-n16r8"}, # native USB, no usbSerial {"deviceName": "MM-P4", "mac": "80:F1:B2:D0:AC:F7", "ip": "192.168.1.133", - "firmware": "esp32p4-eth", "usbSerial": "5ABA0767221"}, + "firmware": "esp32p4rev1-eth", "usbSerial": "5ABA0767221"}, ] @@ -196,7 +196,7 @@ def test_parse_esptool_probe(): def test_apply_probe_caches_serial_and_chip(): - devs = [{"deviceName": "MM-P4", "mac": "80:F1:B2:D0:AC:F7", "firmware": "esp32p4-eth"}] + devs = [{"deviceName": "MM-P4", "mac": "80:F1:B2:D0:AC:F7", "firmware": "esp32p4rev1-eth"}] _apply_probe_results(devs, {"/dev/cu.usbmodem5ABA0767221": {"chip": "esp32-p4", "mac": "80:f1:b2:d0:ac:f7"}}) assert devs[0]["usbSerial"] == "5ABA0767221" diff --git a/test/scenarios/core/scenario_MoonModule_control_change.json b/test/scenarios/core/scenario_MoonModule_control_change.json index 3db25498..7640fe3d 100644 --- a/test/scenarios/core/scenario_MoonModule_control_change.json +++ b/test/scenarios/core/scenario_MoonModule_control_change.json @@ -205,7 +205,7 @@ "2026-07-08" ] }, - "esp32p4-eth": { + "esp32p4rev1-eth": { "tick_us": [ 160, 203 @@ -356,7 +356,7 @@ "2026-07-08" ] }, - "esp32p4-eth": { + "esp32p4rev1-eth": { "tick_us": [ 163, 168 @@ -507,7 +507,7 @@ "2026-07-08" ] }, - "esp32p4-eth": { + "esp32p4rev1-eth": { "tick_us": [ 165, 179 @@ -666,7 +666,7 @@ "2026-07-08" ] }, - "esp32p4-eth": { + "esp32p4rev1-eth": { "tick_us": [ 164, 188 diff --git a/test/scenarios/core/scenario_NetworkModule_mdns_toggle.json b/test/scenarios/core/scenario_NetworkModule_mdns_toggle.json index 31d50fe3..771d8c7b 100644 --- a/test/scenarios/core/scenario_NetworkModule_mdns_toggle.json +++ b/test/scenarios/core/scenario_NetworkModule_mdns_toggle.json @@ -85,7 +85,7 @@ "2026-07-22" ] }, - "esp32p4-eth": { + "esp32p4rev1-eth": { "tick_us": [ 21, 21 @@ -212,7 +212,7 @@ "2026-07-22" ] }, - "esp32p4-eth": { + "esp32p4rev1-eth": { "tick_us": [ 21, 21 @@ -339,7 +339,7 @@ "2026-07-22" ] }, - "esp32p4-eth": { + "esp32p4rev1-eth": { "tick_us": [ 20, 22 diff --git a/test/scenarios/light/scenario_GridLayout_resize.json b/test/scenarios/light/scenario_GridLayout_resize.json index cef3d40b..ec053398 100644 --- a/test/scenarios/light/scenario_GridLayout_resize.json +++ b/test/scenarios/light/scenario_GridLayout_resize.json @@ -205,7 +205,7 @@ "2026-07-08" ] }, - "esp32p4-eth": { + "esp32p4rev1-eth": { "tick_us": [ 1136, 1353 @@ -356,7 +356,7 @@ "2026-07-08" ] }, - "esp32p4-eth": { + "esp32p4rev1-eth": { "tick_us": [ 575, 655 @@ -507,7 +507,7 @@ "2026-07-08" ] }, - "esp32p4-eth": { + "esp32p4rev1-eth": { "tick_us": [ 1143, 1312 diff --git a/test/scenarios/light/scenario_MoonLiveEffect_livescript.json b/test/scenarios/light/scenario_MoonLiveEffect_livescript.json index 105de834..d8a783a7 100644 --- a/test/scenarios/light/scenario_MoonLiveEffect_livescript.json +++ b/test/scenarios/light/scenario_MoonLiveEffect_livescript.json @@ -115,7 +115,7 @@ "2026-07-24" ] }, - "esp32p4-eth": { + "esp32p4rev1-eth": { "tick_us": [ 11291, 11291 @@ -198,7 +198,7 @@ "2026-07-24" ] }, - "esp32p4-eth": { + "esp32p4rev1-eth": { "tick_us": [ 10167, 10167 @@ -281,7 +281,7 @@ "2026-07-24" ] }, - "esp32p4-eth": { + "esp32p4rev1-eth": { "tick_us": [ 10572, 10572 @@ -364,7 +364,7 @@ "2026-07-24" ] }, - "esp32p4-eth": { + "esp32p4rev1-eth": { "tick_us": [ 10708, 10708 @@ -447,7 +447,7 @@ "2026-07-24" ] }, - "esp32p4-eth": { + "esp32p4rev1-eth": { "tick_us": [ 1160, 1160 @@ -530,7 +530,7 @@ "2026-07-24" ] }, - "esp32p4-eth": { + "esp32p4rev1-eth": { "tick_us": [ 10310, 10310 @@ -611,7 +611,7 @@ "2026-07-24" ] }, - "esp32p4-eth": { + "esp32p4rev1-eth": { "tick_us": [ 11336, 11336 @@ -694,7 +694,7 @@ "2026-07-24" ] }, - "esp32p4-eth": { + "esp32p4rev1-eth": { "tick_us": [ 10997, 10997 diff --git a/test/scenarios/light/scenario_modifier_swap.json b/test/scenarios/light/scenario_modifier_swap.json index 0b8ba772..5fbd032e 100644 --- a/test/scenarios/light/scenario_modifier_swap.json +++ b/test/scenarios/light/scenario_modifier_swap.json @@ -185,7 +185,7 @@ "2026-06-08" ] }, - "esp32p4-eth": { + "esp32p4rev1-eth": { "tick_us": [ 165, 179 @@ -307,7 +307,7 @@ "2026-06-08" ] }, - "esp32p4-eth": { + "esp32p4rev1-eth": { "tick_us": [ 362, 364 @@ -429,7 +429,7 @@ "2026-06-08" ] }, - "esp32p4-eth": { + "esp32p4rev1-eth": { "tick_us": [ 156, 158 diff --git a/test/scenarios/light/scenario_perf_full.json b/test/scenarios/light/scenario_perf_full.json index 81aa4ce6..905ba9df 100644 --- a/test/scenarios/light/scenario_perf_full.json +++ b/test/scenarios/light/scenario_perf_full.json @@ -137,7 +137,7 @@ "2026-07-24" ] }, - "esp32p4-eth": { + "esp32p4rev1-eth": { "tick_us": [ 57, 67 @@ -244,7 +244,7 @@ "2026-07-24" ] }, - "esp32p4-eth": { + "esp32p4rev1-eth": { "tick_us": [ 53, 55 @@ -351,7 +351,7 @@ "2026-07-24" ] }, - "esp32p4-eth": { + "esp32p4rev1-eth": { "tick_us": [ 54, 57 @@ -456,7 +456,7 @@ "2026-07-24" ] }, - "esp32p4-eth": { + "esp32p4rev1-eth": { "tick_us": [ 94, 114 @@ -568,7 +568,7 @@ "2026-07-24" ] }, - "esp32p4-eth": { + "esp32p4rev1-eth": { "tick_us": [ 55, 63 @@ -673,7 +673,7 @@ "2026-07-24" ] }, - "esp32p4-eth": { + "esp32p4rev1-eth": { "tick_us": [ 57, 69 @@ -789,7 +789,7 @@ "2026-07-24" ] }, - "esp32p4-eth": { + "esp32p4rev1-eth": { "tick_us": [ 56, 63 @@ -958,7 +958,7 @@ "2026-07-31" ] }, - "esp32p4-eth": { + "esp32p4rev1-eth": { "tick_us": [ 56, 63 @@ -1085,7 +1085,7 @@ "2026-07-24" ] }, - "esp32p4-eth": { + "esp32p4rev1-eth": { "tick_us": [ 53, 65 @@ -1196,7 +1196,7 @@ "2026-07-24" ] }, - "esp32p4-eth": { + "esp32p4rev1-eth": { "tick_us": [ 132, 138 @@ -1307,7 +1307,7 @@ "2026-07-24" ] }, - "esp32p4-eth": { + "esp32p4rev1-eth": { "tick_us": [ 448, 498 @@ -1418,7 +1418,7 @@ "2026-07-24" ] }, - "esp32p4-eth": { + "esp32p4rev1-eth": { "tick_us": [ 1744, 1940 @@ -1537,7 +1537,7 @@ "2026-07-24" ] }, - "esp32p4-eth": { + "esp32p4rev1-eth": { "tick_us": [ 297, 349 @@ -1648,7 +1648,7 @@ "2026-07-24" ] }, - "esp32p4-eth": { + "esp32p4rev1-eth": { "tick_us": [ 1113, 1252 @@ -1759,7 +1759,7 @@ "2026-07-24" ] }, - "esp32p4-eth": { + "esp32p4rev1-eth": { "tick_us": [ 4358, 5101 @@ -1870,7 +1870,7 @@ "2026-07-24" ] }, - "esp32p4-eth": { + "esp32p4rev1-eth": { "tick_us": [ 17433, 18626 @@ -1989,7 +1989,7 @@ "2026-07-24" ] }, - "esp32p4-eth": { + "esp32p4rev1-eth": { "tick_us": [ 154, 190 @@ -2100,7 +2100,7 @@ "2026-07-24" ] }, - "esp32p4-eth": { + "esp32p4rev1-eth": { "tick_us": [ 533, 613 @@ -2211,7 +2211,7 @@ "2026-07-24" ] }, - "esp32p4-eth": { + "esp32p4rev1-eth": { "tick_us": [ 2058, 2285 @@ -2322,7 +2322,7 @@ "2026-07-24" ] }, - "esp32p4-eth": { + "esp32p4rev1-eth": { "tick_us": [ 9846, 11588 diff --git a/test/scenarios/light/scenario_perf_light.json b/test/scenarios/light/scenario_perf_light.json index 540ca1d4..fb955f2a 100644 --- a/test/scenarios/light/scenario_perf_light.json +++ b/test/scenarios/light/scenario_perf_light.json @@ -153,7 +153,7 @@ "2026-07-24" ] }, - "esp32p4-eth": { + "esp32p4rev1-eth": { "tick_us": [ 54, 73 @@ -259,7 +259,7 @@ "2026-07-24" ] }, - "esp32p4-eth": { + "esp32p4rev1-eth": { "tick_us": [ 97, 116 @@ -357,7 +357,7 @@ "2026-07-24" ] }, - "esp32p4-eth": { + "esp32p4rev1-eth": { "tick_us": [ 93, 96 @@ -462,7 +462,7 @@ "2026-07-24" ] }, - "esp32p4-eth": { + "esp32p4rev1-eth": { "tick_us": [ 154, 180 @@ -573,7 +573,7 @@ "2026-07-24" ] }, - "esp32p4-eth": { + "esp32p4rev1-eth": { "tick_us": [ 532, 624 @@ -684,7 +684,7 @@ "2026-07-24" ] }, - "esp32p4-eth": { + "esp32p4rev1-eth": { "tick_us": [ 2038, 2432 diff --git a/test/scenarios/light/scenario_peripheral_grid_sweep.json b/test/scenarios/light/scenario_peripheral_grid_sweep.json index 96d37bba..a6424954 100644 --- a/test/scenarios/light/scenario_peripheral_grid_sweep.json +++ b/test/scenarios/light/scenario_peripheral_grid_sweep.json @@ -135,7 +135,7 @@ "2026-07-25" ] }, - "esp32p4-eth": { + "esp32p4rev1-eth": { "tick_us": [ 103, 165 @@ -213,7 +213,7 @@ "2026-07-25" ] }, - "esp32p4-eth": { + "esp32p4rev1-eth": { "tick_us": [ 491, 559 @@ -291,7 +291,7 @@ "2026-07-25" ] }, - "esp32p4-eth": { + "esp32p4rev1-eth": { "tick_us": [ 2208, 2480 @@ -369,7 +369,7 @@ "2026-07-25" ] }, - "esp32p4-eth": { + "esp32p4rev1-eth": { "tick_us": [ 11495, 11565 @@ -468,7 +468,7 @@ "2026-07-25" ] }, - "esp32p4-eth": { + "esp32p4rev1-eth": { "tick_us": [ 208, 581 @@ -546,7 +546,7 @@ "2026-07-25" ] }, - "esp32p4-eth": { + "esp32p4rev1-eth": { "tick_us": [ 446, 495 @@ -624,7 +624,7 @@ "2026-07-25" ] }, - "esp32p4-eth": { + "esp32p4rev1-eth": { "tick_us": [ 1867, 2296 @@ -702,7 +702,7 @@ "2026-07-25" ] }, - "esp32p4-eth": { + "esp32p4rev1-eth": { "tick_us": [ 9046, 11371 @@ -801,7 +801,7 @@ "2026-07-25" ] }, - "esp32p4-eth": { + "esp32p4rev1-eth": { "tick_us": [ 240, 598 @@ -879,7 +879,7 @@ "2026-07-25" ] }, - "esp32p4-eth": { + "esp32p4rev1-eth": { "tick_us": [ 502, 685 @@ -957,7 +957,7 @@ "2026-07-25" ] }, - "esp32p4-eth": { + "esp32p4rev1-eth": { "tick_us": [ 2304, 2609 @@ -1035,7 +1035,7 @@ "2026-07-25" ] }, - "esp32p4-eth": { + "esp32p4rev1-eth": { "tick_us": [ 6674, 11410 @@ -1134,7 +1134,7 @@ "2026-07-25" ] }, - "esp32p4-eth": { + "esp32p4rev1-eth": { "tick_us": [ 387, 572 @@ -1212,7 +1212,7 @@ "2026-07-25" ] }, - "esp32p4-eth": { + "esp32p4rev1-eth": { "tick_us": [ 510, 535 @@ -1290,7 +1290,7 @@ "2026-07-25" ] }, - "esp32p4-eth": { + "esp32p4rev1-eth": { "tick_us": [ 2313, 2384 @@ -1368,7 +1368,7 @@ "2026-07-25" ] }, - "esp32p4-eth": { + "esp32p4rev1-eth": { "tick_us": [ 4622, 11440 diff --git a/test/scenarios/light/scenario_peripheral_switch.json b/test/scenarios/light/scenario_peripheral_switch.json index 93730c5c..c0c3de2c 100644 --- a/test/scenarios/light/scenario_peripheral_switch.json +++ b/test/scenarios/light/scenario_peripheral_switch.json @@ -179,7 +179,7 @@ "2026-07-28" ] }, - "esp32p4-eth": { + "esp32p4rev1-eth": { "tick_us": [ 208, 208 @@ -269,7 +269,7 @@ "2026-07-31" ] }, - "esp32p4-eth": { + "esp32p4rev1-eth": { "tick_us": [ 215, 215 @@ -359,7 +359,7 @@ "2026-07-28" ] }, - "esp32p4-eth": { + "esp32p4rev1-eth": { "tick_us": [ 216, 216 @@ -448,7 +448,7 @@ "2026-07-31" ] }, - "esp32p4-eth": { + "esp32p4rev1-eth": { "tick_us": [ 209, 209 @@ -538,7 +538,7 @@ "2026-07-28" ] }, - "esp32p4-eth": { + "esp32p4rev1-eth": { "tick_us": [ 217, 217 @@ -644,7 +644,7 @@ "2026-07-31" ] }, - "esp32p4-eth": { + "esp32p4rev1-eth": { "tick_us": [ 206, 206 diff --git a/web-installer/deviceModels.json b/web-installer/deviceModels.json index 25ac2a7e..c50cedec 100644 --- a/web-installer/deviceModels.json +++ b/web-installer/deviceModels.json @@ -681,7 +681,7 @@ "name": "MM testbench P4", "chip": "ESP32-P4", "firmwares": [ - "esp32p4-eth" + "esp32p4rev1-eth" ], "image": "assets/deviceModels/esp32-p4-testbench.jpg", "supported": [ @@ -963,8 +963,8 @@ "name": "Waveshare ESP32-P4-NANO", "chip": "ESP32-P4", "firmwares": [ - "esp32p4-eth", - "esp32p4-eth-wifi" + "esp32p4rev1-eth", + "esp32p4rev1-eth-wifi" ], "image": "assets/deviceModels/waveshare-esp32-p4-nano.jpg", "url": "https://www.waveshare.com/esp32-p4-nano.htm", @@ -1022,7 +1022,7 @@ "name": "MHC-WLED ESP32-P4 shield", "chip": "ESP32-P4", "firmwares": [ - "esp32p4-eth" + "esp32p4rev1-eth" ], "image": "assets/deviceModels/mhc-wled-esp32-p4-shield.jpg", "url": "https://shop.myhome-control.de/en/ABC-WLED-ESP32-P4-shield/HW10027", diff --git a/web-installer/firmwares.json b/web-installer/firmwares.json index 09693fcd..f5cfcfa5 100644 --- a/web-installer/firmwares.json +++ b/web-installer/firmwares.json @@ -43,18 +43,32 @@ "description": "ESP32-S3 (N8R8: 8 MB flash, 8 MB octal PSRAM) — WiFi + W5500 SPI Ethernet. Half the flash of N16R8; the N16R8 binary overruns an 8 MB board, so N8R8 boards (LightCrafter etc.) need this variant." }, { - "name": "esp32p4-eth", + "name": "esp32p4rev1-eth", "chip": "esp32p4", "eth_only": true, "ships": true, - "description": "Waveshare ESP32-P4-NANO — Ethernet only (IP101 PHY). The WiFi-less fallback; esp32p4-eth-wifi adds the C6 radio." + "description": "Waveshare ESP32-P4-NANO — Ethernet only (IP101 PHY), for P4 revisions 0.x/1.x ONLY. The WiFi-less fallback; esp32p4rev1-eth-wifi adds the C6 radio." }, { - "name": "esp32p4-eth-wifi", + "name": "esp32p4rev1-eth-wifi", "chip": "esp32p4", "eth_only": false, "ships": true, - "description": "⚠️ DOES NOT BOOT — repro build for esp-idf #18759 (github.com/espressif/esp-idf/issues/18759). Waveshare ESP32-P4-NANO, Ethernet + WiFi via the on-board ESP32-C6 over SDIO (esp_hosted). Builds + flashes but crash-loops at boot: sleep_clock_icg_startup_init fails ESP_ERR_NO_MEM on IDF 6.1. Published so the IDF team can one-click-flash the failing binary. Use esp32p4-eth for a working P4." + "description": "Waveshare ESP32-P4-NANO — Ethernet + WiFi via the on-board ESP32-C6 over SDIO (esp_hosted), for P4 revisions 0.x/1.x ONLY. Boots and associates as of IDF v6.1-rc1 with CONFIG_PM_SLEEP_CLK_ICG_ENABLE=n, which sidesteps esp-idf #18759 (sleep_clock_icg_startup_init failing ESP_ERR_NO_MEM and aborting cpu_start)." + }, + { + "name": "esp32p4rev3-eth", + "chip": "esp32p4", + "eth_only": true, + "ships": true, + "description": "⚠️ UNTESTED — Waveshare ESP32-P4-NANO, Ethernet only (IP101 PHY), for P4 revisions 3.x (the CURRENT silicon). Identical to esp32p4rev1-eth apart from the chip revision, which the two generations cannot share. Published so someone with a v3 board can test it: both bench boards are v1.3, so this image has never been booted." + }, + { + "name": "esp32p4rev3-eth-wifi", + "chip": "esp32p4", + "eth_only": false, + "ships": true, + "description": "⚠️ UNTESTED — Waveshare ESP32-P4-NANO, Ethernet + WiFi via the on-board ESP32-C6 (esp_hosted), for P4 revisions 3.x (the CURRENT silicon). The rev1 build of this image is bench-verified; this one differs only in the chip revision and has never been booted." }, { "name": "esp32s31", diff --git a/web-installer/install-orchestrator.js b/web-installer/install-orchestrator.js index 283e9482..79c3b043 100644 --- a/web-installer/install-orchestrator.js +++ b/web-installer/install-orchestrator.js @@ -47,6 +47,14 @@ // regression persists in 0.6.0-as-tagged; keep 0.5.7. (0.6.0 also brings no ESP32-S31 // support — misdetection is tracked upstream in esptool-js#248 — so the bump has no // upside for us either.) Pinned 2026-06-28, re-verified 2026-07-27. +// Re-checked 2026-08-19: **0.6.1 shipped (2026-08-06) and its notes name the fix we +// are waiting on** — upstream #245 "Add retries to FLASH_DATA and FLASH_DEFL_DATA", +// plus #244 (uncompressed data in writeFlash) and #249 (connection reliability). That +// is the deflate write path this pin exists to avoid, so 0.6.1 is the first bump worth +// a real P4 bench flash. NOT yet tested here — the pin stays 0.5.7 until a P4 +// web-flash completes on 0.6.1. Still no ESP32-S31 support in 0.6.1 (that is separate, +// see WEB_FLASH_UNSUPPORTED_CHIPS in install.js), so the S31 CLI path is unaffected +// either way. export const ESPTOOL_JS_VERSION = "0.5.7"; export const IMPROV_SDK_VERSION = "2.5.0"; import { ESPLoader, Transport } from "https://unpkg.com/esptool-js@0.5.7/bundle.js?module"; @@ -462,7 +470,7 @@ export const installer = { * a normal re-flash overwrites in place and users usually want * persistent state to survive a firmware bump. * @param {boolean} [opts.ethOnly=false] - the picked firmware has WiFi compiled - * out (firmwares.json `eth_only`: esp32-eth, esp32p4-eth). Such a build connects + * out (firmwares.json `eth_only`: esp32-eth, esp32p4rev1-eth). Such a build connects * over Ethernet only and has no WiFi-provisioning RPC, so when the device isn't * already online from the boot log we SKIP the WiFi-credentials step (which would * otherwise send WIFI_SETTINGS and get UNKNOWN_RPC_COMMAND) and report a clear @@ -850,7 +858,7 @@ export const installer = { alreadyOnline = true; defaultsApplied = await pushDefaultsOverSerial(port, board, applyDefaults, trackProgress, onLog); } else if (ethOnly) { - // Ethernet-only firmware (WiFi compiled out: esp32-eth, esp32p4-eth) that + // Ethernet-only firmware (WiFi compiled out: esp32-eth, esp32p4rev1-eth) that // did NOT print an IP — i.e. no Ethernet cable was connected at boot. There // is no point attempting WiFi provisioning: the build has no WIFI_SETTINGS // RPC, so sending one returns UNKNOWN_RPC_COMMAND (the error users hit). The diff --git a/web-installer/install.js b/web-installer/install.js index 2447cf37..adcad780 100644 --- a/web-installer/install.js +++ b/web-installer/install.js @@ -577,10 +577,19 @@ document.addEventListener('DOMContentLoaded', () => { // ESP32 and try to flash it with the wrong stub + flash params — corruption, // not a lucky success. esptool-js needs the S31 secondary-detection logic // before browser flashing is safe; a version bump alone is not enough. + // + // Re-verified 2026-08-19 against esptool-js main and the 0.6.1 release (2026-08-06): + // still NO S31. src/targets/ has no esp32s31.ts (esp32{,c2,c3,c5,c6,c61,h2,p4,s2,s3}.ts + // + esp8266/rom only), esploader.ts's magic2Chip has no ESP32S31ROM entry, and a repo + // code search for "S31" returns nothing. Support exists only as OPEN PR + // esptool-js#250, which adds ESP32S31ROM and — confirming the collision analysis above + // — identifies the chip via GET_SECURITY_INFO rather than the magic table; it is + // blocked on overlapping unreviewed work in #197. esp-web-tools depends on + // esptool-js ^0.6.0, so it inherits the same gap. Watch #250 landing in a 0.6.2/0.7.0. const WEB_FLASH_UNSUPPORTED_CHIPS = new Set(["ESP32-S31"]); // Map a firmware key to its chip family ("esp32s31" → "ESP32-S31", "esp32s3-n16r8" - // → "ESP32-S3", "esp32p4-eth" → "ESP32-P4", "esp32*" → "ESP32") — the same prefix + // → "ESP32-S3", "esp32p4rev1-eth" → "ESP32-P4", "esp32*" → "ESP32") — the same prefix // vocabulary build_esp32's TARGET_TO_FAMILY uses. Used as the chip fallback when no // board is picked (OTA / generic flash). "" for an unrecognised key. function firmwareToChip(firmware) { From 575b4472567dab44af40b0506e9b4314465e2d1e Mon Sep 17 00:00:00 2001 From: ewowi Date: Thu, 20 Aug 2026 09:11:04 +0200 Subject: [PATCH 2/5] A MoonLive script can expose a 16-bit control MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A script member wider than a byte can now reach the UI as one control: `addUint16("cycle", cycle, 100, 2000)`. The shipped ember effect uses it for the wrap point of its counter, a value no byte slider could reach. Three scenario tests that had quietly stopped testing anything now test the thing they name. KPI: 16384lights | Desktop:1164KB | ESP32-S3:1747KB | tick:7471us(FPS:133) | heap:8270KB | src:222(58576) | test:164(34756) | lizard:162w Core: - addUint16 as a script builtin, beside addUint8. One shared implementation parameterised by width, so the bound check and the sink call have one home. - A control's declared width must match its member's. addUint8 on a uint16_t member drove only its low byte: a slider moved to 5 on a member holding 900 produced 773, silently. Now a compile error naming the call to use instead. - DeclaredControl's min/max widen to uint16_t, and the builtin descriptor carries the member width its by-reference argument takes (stated per builtin, like byRef and byStr, rather than the parser matching on a name). - CtrlType moves to MoonLiveBuiltins.h so a descriptor can name it. - A member keeps its live value across a recompile only if its SHAPE is unchanged too: widening a scalar or growing an array now reseeds the whole extent, where before the new bytes kept the previous program's values. - MoonLiveLayout installed the addLight sink before checking the entry existed, so an early return left it pointing at a dead stack frame. - "is anything compiled" becomes its own flag: 0 is a legitimate content hash. UI: - The editor serialises concurrent saves (blur, Cmd+S and the Save button could issue overlapping POSTs of one file) and aborts a superseded load, which could paint an old file over a newer one. - Switching file or opening the modal flushes unsaved edits first. - A file-path control with no directory returned "/name" instead of "name". Scripts/MoonDeck: - run_scenario refuses a runner older than its sources. The gate ran a ten-day-old binary, which is how three scenarios asserted nothing without failing. - repo_health carried forward flash rows forever; the ghost esp32p4-eth* entries were stale BUILD DIRECTORIES, which snapshot() globs. - The release workflow moves to v6.1-rc1 (cache key, comment, esp_idf_version). - The esp32p4rev1-eth-wifi "needs a manual set-target" note is gone: retested on rc1, clean and incremental builds both keep the C6 slave target. Tests: - A write_file scenario op, so a scenario can stage a deliberately broken script or one whose control set changes — neither has a shipped file to select. - The runner's JSON parser dropped escapes, turning "\n" into a literal 'n'. Latent until write_file was the first thing to need a multi-line string. - Three MoonLive scenarios migrated from a `source` control that no longer exists. - Two scenarios asserted a rendered buffer without ticking after the select. Docs/CI: - addUint16 in the three MoonLive catalog pages; rev3 variants in architecture.md. - coprocessorWifi's header contract said "not detected"; the bench shows the C6 serving traffic while that RPC times out, so the header now matches the code. - Plan-20260817 and Plan-20260818 marked shipped. Reviews: - CodeRabbit (9): 7 fixed. Skipped the exec-memory injection (core owning the platform seam is the documented architecture) and moving coprocessorWifi to a worker task (already bounded to ~5 boot ticks, 257us each). One inverted: the "not detected" contract was wrong in the header, not the code. Notes: - Verified on hardware, both ISAs: S3 (Xtensa) and S31 (RISC-V) publish ember's `cycle` as uint16 and carry 2000 and 250 intact, at 116/103 fps. - Three scenario observations reverted: they were widened by runs under parallel build load, not by the code. - P4 #2 flashed and hash-verified but has no network path yet (no cable, no credentials), so the second-board slowdown comparison is still open. - KPI is desktop + one S3; not measured per supported target. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/release.yml | 14 +- docs/architecture.md | 2 +- docs/backlog/backlog-core.md | 2 +- ...e frame is where values live (shipped).md" | 4 +- ...MoonLive scripts are classes (shipped).md} | 2 +- ...and a filesystem change seam (shipped).md} | 0 docs/metrics/repo-health.json | 42 +++--- docs/metrics/repo-health.md | 36 +++-- docs/moonmodules/light/MoonLiveEffect.md | 14 +- docs/moonmodules/light/MoonLiveLayout.md | 4 +- docs/moonmodules/light/MoonLiveModifier.md | 2 +- esp32/sdkconfig.defaults.esp32p4rev1-eth | 5 +- moondeck/build/build_esp32.py | 18 +-- moondeck/check/repo_health.py | 19 +++ moondeck/moondeck.py | 2 +- moondeck/scenario/run_scenario.py | 64 +++++++- moonlive/effects/ember.mle | 11 +- src/core/SystemModule.h | 2 +- src/core/moonlive/MoonLive.cpp | 8 +- src/core/moonlive/MoonLive.h | 34 ++++- src/core/moonlive/MoonLiveBuiltins.h | 12 ++ src/core/moonlive/MoonLiveCompiler.cpp | 11 ++ src/core/moonlive/MoonLiveIr.h | 15 +- src/light/moonlive/MoonLiveBuiltins_light.h | 48 ++++-- src/light/moonlive/MoonLiveEffect.h | 15 +- src/light/moonlive/MoonLiveLayout.h | 25 +++- src/light/moonlive/MoonLiveModifier.h | 15 +- src/light/moonlive/MoonLiveScript.h | 11 +- src/platform/platform.h | 14 +- src/ui/app.js | 65 ++++++-- test/scenario_runner.cpp | 51 ++++++- .../scenario_MoonLiveEffect_controls.json | 101 +++++++++++-- .../scenario_MoonLiveEffect_livescript.json | 79 +++++++++- .../light/scenario_MoonLive_pipeline.json | 118 +++++++++++---- .../light/scenario_peripheral_grid_sweep.json | 12 +- test/unit/core/unit_moonlive_fill.cpp | 140 +++++++++++++++++- web-installer/install-orchestrator.js | 2 +- 37 files changed, 821 insertions(+), 198 deletions(-) rename docs/history/plans/{Plan-20260817 - MoonLive scripts are classes.md => Plan-20260817 - MoonLive scripts are classes (shipped).md} (99%) rename docs/history/plans/{Plan-20260818 - A file editor control and a filesystem change seam.md => Plan-20260818 - A file editor control and a filesystem change seam (shipped).md} (100%) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 782722eb..e3c44659 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -129,7 +129,7 @@ jobs: ~/.espressif ~/esp/esp-idf # Bump the suffix when changing the IDF version to invalidate. - key: esp-idf-v6.1-beta1-${{ runner.os }}-v1 + key: esp-idf-v6.1-rc1-${{ runner.os }}-v1 # The release-channel tag burned into the binary as MM_RELEASE (shown by # SystemModule alongside the semver). Same resolution as the release @@ -172,24 +172,22 @@ jobs: - name: Build firmware uses: espressif/esp-idf-ci-action@v1 with: - # Match the local development IDF (v6.1-beta1, commit b1d13e9f). + # Match the local development IDF (v6.1-rc1, commit 44f0c59f). # The project uses v6-era APIs — `esp_eth_phy_new_generic`, the new # mDNS component manager, `int clock_gpio` in the EMAC config (v5.x # had a strongly-typed enum). v5.4 fails to compile platform_esp32.cpp. - # `v6.1-beta1` is the espressif/idf Docker tag pinned to the same - # tagged beta the local pin uses — a fixed, signed pre-release (not the + # `v6.1-rc1` is the espressif/idf Docker tag pinned to the same + # tagged pre-release the local pin uses — fixed and signed (not the # rolling `release-v6.1` branch tag), so CI and local build the exact # same tree. This tracks the v6.1 line toward GA; re-pin to the `v6.1` # tag once it ships (a deliberate re-test pass, see docs/building.md). - esp_idf_version: v6.1-beta1 + esp_idf_version: v6.1-rc1 # The IDF target follows the firmware-key prefix: esp32s31* → esp32s31 # (checked BEFORE esp32s3 — esp32s31 also startsWith 'esp32s3'), # esp32s3* → esp32s3, esp32p4* → esp32p4 (the only target that pulls # the ip101 PHY + esp_hosted, both manifest-gated on target == esp32p4), # everything - # else → esp32. (The matrix is the `ships` subset of firmwares.json; - # esp32p4rev1-eth-wifi has ships=False in build_esp32.py so it stays out — - # its C6-slave Kconfig defaults don't survive a plain CI build.) + # else → esp32. (The matrix is the `ships` subset of firmwares.json.) target: ${{ startsWith(matrix.firmware, 'esp32s31') && 'esp32s31' || startsWith(matrix.firmware, 'esp32s3') && 'esp32s3' || startsWith(matrix.firmware, 'esp32p4') && 'esp32p4' || 'esp32' }} path: 'esp32' # We run our own builder (not the action's default `idf.py build`) diff --git a/docs/architecture.md b/docs/architecture.md index 6d865d5d..1cc2355c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -257,7 +257,7 @@ Three distinct things, kept distinct in the vocabulary: - **deviceModel** — the whole assembled product, identified by its catalog name (`Olimex ESP32-Gateway Rev G`). This is *which hardware this is*. It is distinct from **`deviceName`**, *which individual unit this is* (per-unit identity the user sets — see [§ Device name](#device-name-one-identity-every-network-name-derives-from-it)); a **device** (the umbrella term) has a `deviceName` and a `deviceModel`. - **board** — the bare PCB *only*. The word survives in its literal sense: **on-board** LED, **on-board** peripherals, board-soldered pins — things physically *on the PCB*. (A deviceModel is a board plus whatever is wired onto it.) -**Firmware** is the compiled binary: chip target plus which radios/peripherals/sdkconfig fragments are included. Today's variants: `esp32` (classic, WiFi **and** RMII Ethernet in one binary — Ethernet comes up only when a PHY is present, pins/PHY per deviceModel), `esp32-eth` (classic, Ethernet only, WiFi excluded), `esp32-16mb` (classic with 16 MB flash, WiFi + Ethernet), `esp32s3-n16r8` / `esp32s3-n8r8` (S3 with WiFi + W5500 SPI Ethernet), `esp32p4rev1-eth` (Waveshare ESP32-P4-NANO, Ethernet only), `esp32p4rev1-eth-wifi` (the same P4 hardware with WiFi via its on-board ESP32-C6 over esp_hosted). Each chip's firmware carries the Ethernet *driver(s)* it can host (RMII EMAC for classic/P4, W5500 SPI for S3); which PHY/pins a deviceModel uses is runtime config. Selected by `build_esp32.py --firmware `, reported by `SystemModule.firmware`, used as the contract target key in scenarios. +**Firmware** is the compiled binary: chip target plus which radios/peripherals/sdkconfig fragments are included. Today's variants: `esp32` (classic, WiFi **and** RMII Ethernet in one binary — Ethernet comes up only when a PHY is present, pins/PHY per deviceModel), `esp32-eth` (classic, Ethernet only, WiFi excluded), `esp32-16mb` (classic with 16 MB flash, WiFi + Ethernet), `esp32s3-n16r8` / `esp32s3-n8r8` (S3 with WiFi + W5500 SPI Ethernet), `esp32p4rev1-eth` (Waveshare ESP32-P4-NANO, Ethernet only), `esp32p4rev1-eth-wifi` (the same P4 hardware with WiFi via its on-board ESP32-C6 over esp_hosted), `esp32p4rev3-eth` / `esp32p4rev3-eth-wifi` (the same two images built for P4 **v3.x** silicon, which is not binary-compatible with rev <3.0 — untested, no v3 board on the bench). Each chip's firmware carries the Ethernet *driver(s)* it can host (RMII EMAC for classic/P4, W5500 SPI for S3); which PHY/pins a deviceModel uses is runtime config. Selected by `build_esp32.py --firmware `, reported by `SystemModule.firmware`, used as the contract target key in scenarios. **deviceModel** is the physical hardware: chip + PCB + on-board peripherals (PHY, USB-serial, PSRAM, antenna), identified by its product name. Examples: `Olimex ESP32-Gateway Rev G`, `LOLIN D32`, `Generic ESP32 Dev`. A unit cannot identify its own deviceModel (no readable PCB ID on classic ESP32), so MoonDeck deduces it from the firmware where unambiguous (`esp32-eth*` ⇒ Olimex) and otherwise lets the user pick. It is stored on the unit as SystemModule's `deviceModel` Text control (display-only in the UI; HTTP `/api/control` writes still apply). MoonDeck mirrors the picked / deduced value to the unit via `POST /api/control` after each discover and after every dropdown change. The catalog of valid deviceModels lives at [web-installer/deviceModels.json](../web-installer/deviceModels.json), shared between MoonDeck and the web installer: MoonDeck reads it for its dropdown and HTTP push (plain REST on the LAN); the web installer reads it for its picker and pushes the whole entry — deviceModel plus every module/control — over serial during provisioning as REST ops (**"Improv = REST over serial"**, the `APPLY_OP` vendor RPC; see [ImprovProvisioningModule.md](moonmodules/core/moxygen/ImprovProvisioningModule.md)). Pushing over serial sidesteps the mixed-content block that stops an HTTPS installer page from POSTing to an `http://` device; an already-running device is re-configured via MoonDeck on the LAN. diff --git a/docs/backlog/backlog-core.md b/docs/backlog/backlog-core.md index 5452a574..9f1e41f6 100644 --- a/docs/backlog/backlog-core.md +++ b/docs/backlog/backlog-core.md @@ -556,7 +556,7 @@ This is hardening, not a known bug — the shipped fix is correct for the cases ### ESP32-P4 support — rounds 3-4 (in progress) Rounds 1 (board + Ethernet-only) and 2 (Parlio LED driver) have landed. Remaining rounds, each its own plan + commit: -- **Round 3 — WiFi via the C6 co-processor. WORKING, BUT THE LINK IS SLOW (2026-08-19).** Boots and associates on IDF v6.1-rc1 (see round 4); the remaining defect is throughput, bench-bisected below. The P4 has no native radio (`SOC_WIFI_SUPPORTED` absent); WiFi comes from the on-board ESP32-C6 over SDIO via `esp_wifi_remote` / esp_hosted. Landed as the `esp32p4rev1-eth-wifi` firmware variant: components pulled P4-only (`rules:` gate in `idf_component.yml`), and `ensureWifiInit()` adds an `esp_hosted_init` + `connect_to_slave` prelude before `esp_wifi_init` (gated on `platform::usesRemoteWifi`). The rest of the WiFi seam is unchanged because `esp_wifi_remote` is API-compatible. A deliberate, documented [v6.0-floor exception](../building.md#esp-idf-version); C6 config via `CONFIG_SLAVE_IDF_TARGET_ESP32C6` + `CONFIG_ESP_HOSTED_CP_TARGET_ESP32C6` + the `CONFIG_ESP_HOSTED_P4_DEV_BOARD_FUNC_BOARD` SDIO-pin preset. +- **Round 3 — WiFi via the C6 co-processor. WORKING, BUT THE LINK IS SLOW (2026-08-19).** Boots and associates on IDF v6.1-rc1 (see round 4); the remaining defect is throughput, bench-bisected below. The P4 has no native radio (`SOC_WIFI_SUPPORTED` absent); WiFi comes from the on-board ESP32-C6 over SDIO via `esp_wifi_remote` / esp_hosted. Landed as the `esp32p4rev1-eth-wifi` firmware variant: components pulled P4-only (`rules:` gate in `idf_component.yml`), and `ensureWifiInit()` needs no hosted bring-up of its own: esp_hosted self-initialises at boot via a constructor (`ESP_SYSTEM_INIT_FN`), which sets up the SDIO transport, RPC and wifi-remote channels before `app_main`. Calling `esp_hosted_init`/`connect_to_slave` there would be worse than redundant — `connect_to_slave` is a transport *reconfigure* that resets the slave and re-inits SDIO, which fails on a live link. The rest of the WiFi seam is unchanged because `esp_wifi_remote` is API-compatible. A deliberate, documented [v6.0-floor exception](../building.md#esp-idf-version); C6 config via `CONFIG_SLAVE_IDF_TARGET_ESP32C6` + `CONFIG_ESP_HOSTED_CP_TARGET_ESP32C6` + the `CONFIG_ESP_HOSTED_P4_DEV_BOARD_FUNC_BOARD` SDIO-pin preset. **Hardware results (bench, P4-NANO, 2026-06-12):** - ✅ **esp_hosted / C6 SDIO comes up at boot.** `host_init: ESP Hosted`, `H_API: ESP-Hosted starting`, `add_esp_wifi_remote_channels`, `H_SDIO_DRV: sdio_data_to_rx_buf_task started`. No NVS error / assert / panic / hang. Device boots fully (~57-60 FPS), `hasWiFi` true, WiFi controls present. esp_hosted **self-initialises at boot via a constructor** (`ESP_SYSTEM_INIT_FN` → `esp_hosted_init`), so no bring-up code is needed in our platform layer — an earlier explicit `esp_hosted_init` + `esp_hosted_connect_to_slave` prelude was *removed*: init was a redundant no-op and `connect_to_slave` is actually a transport *reconfigure* (slave GPIO-54 reset + SDIO re-init). SDIO config confirmed correct on the wire: `CLK[18] CMD[19] D0[14] D1[15] D2[16] D3[17] Slave_Reset[54]`, 4-bit 40 MHz. diff --git "a/docs/history/plans/Plan-20260813 - MoonLive on a stack machine \342\200\224 the frame is where values live (shipped).md" "b/docs/history/plans/Plan-20260813 - MoonLive on a stack machine \342\200\224 the frame is where values live (shipped).md" index f14bcb22..4aaf5651 100644 --- "a/docs/history/plans/Plan-20260813 - MoonLive on a stack machine \342\200\224 the frame is where values live (shipped).md" +++ "b/docs/history/plans/Plan-20260813 - MoonLive on a stack machine \342\200\224 the frame is where values live (shipped).md" @@ -343,7 +343,7 @@ Each step is independently verifiable, and the branch stays green throughout: Steps 10 to 13, which replaced step 7, moved to their own plan once they grew into a language change rather than a refactor: [Plan-20260817 — MoonLive scripts are -classes](Plan-20260817%20-%20MoonLive%20scripts%20are%20classes.md). +classes](Plan-20260817%20-%20MoonLive%20scripts%20are%20classes%20(shipped).md). 8. ✅ **Bench: S3 and P4**, a scripted layout and a scripted effect, both with nested loops. Done on FOUR boards (S3, classic ESP32, P4, S31), scripted layout + effect, plasma and the heavier @@ -447,7 +447,7 @@ lowering serves every backend, one system-variable vocabulary serves every role, frame contract that blocked the whole thing is fixed and pinned. What a script LOOKS LIKE is the next question, and it continues in -[Plan-20260817 — MoonLive scripts are classes](Plan-20260817%20-%20MoonLive%20scripts%20are%20classes.md). +[Plan-20260817 — MoonLive scripts are classes](Plan-20260817%20-%20MoonLive%20scripts%20are%20classes%20(shipped).md). ## Then, separately diff --git a/docs/history/plans/Plan-20260817 - MoonLive scripts are classes.md b/docs/history/plans/Plan-20260817 - MoonLive scripts are classes (shipped).md similarity index 99% rename from docs/history/plans/Plan-20260817 - MoonLive scripts are classes.md rename to docs/history/plans/Plan-20260817 - MoonLive scripts are classes (shipped).md index e8d84fd2..a93ceb97 100644 --- a/docs/history/plans/Plan-20260817 - MoonLive scripts are classes.md +++ b/docs/history/plans/Plan-20260817 - MoonLive scripts are classes (shipped).md @@ -625,7 +625,7 @@ two. The one concrete use case is a text overlay in a showcase effect, and that on literals plus the numeric vocabulary already present. 10. ✅ **The editing loop, which is the thing people will actually see.** Done, in - [Plan-20260818](Plan-20260818%20-%20A%20file%20editor%20control%20and%20a%20filesystem%20change%20seam.md). + [Plan-20260818](Plan-20260818%20-%20A%20file%20editor%20control%20and%20a%20filesystem%20change%20seam%20(shipped).md). A card carries a file picker and an editor; typing and clicking away recompiles. Built EARLIER than this plan's "last step, against the finished shape" reasoning suggested, and diff --git a/docs/history/plans/Plan-20260818 - A file editor control and a filesystem change seam.md b/docs/history/plans/Plan-20260818 - A file editor control and a filesystem change seam (shipped).md similarity index 100% rename from docs/history/plans/Plan-20260818 - A file editor control and a filesystem change seam.md rename to docs/history/plans/Plan-20260818 - A file editor control and a filesystem change seam (shipped).md diff --git a/docs/metrics/repo-health.json b/docs/metrics/repo-health.json index b2ce68b1..380f13b8 100644 --- a/docs/metrics/repo-health.json +++ b/docs/metrics/repo-health.json @@ -1,20 +1,18 @@ { - "commit": "2c97ce9e", + "commit": "67fbe644", "flash": { - "esp32": 1749472, + "esp32": 1749712, "esp32p4rev1-eth": 1639056, - "esp32p4rev1-eth-wifi": 1927776, + "esp32p4rev1-eth-wifi": 1928640, "esp32s3-n16r8": 1788704, "esp32s3-n8r8": 1753232, "esp32s31": 2069184, - "desktop": 1175624, "esp32-16mb": 1714608, "esp32-eth": 1324816, "esp32-wrover": 1765504, "qemu": 1318160, - "esp32p4-eth": 1638752, - "esp32p4-eth-wifi": 1927136, - "esp32p4rev3-eth": 1643760 + "esp32p4rev3-eth": 1643760, + "desktop": 1192328 }, "perf": { "desktop": { @@ -27,20 +25,20 @@ } }, "loc": { - "core": 19258, - "light": 24915, + "core": 19310, + "light": 24985, "platform": 13489, - "ui": 6746, - "test": 43524, - "moondeck": 20984 + "ui": 6781, + "test": 43705, + "moondeck": 21057 }, "comments": { "core": { - "lines": 7548, + "lines": 7574, "ratio": 0.425 }, "light": { - "lines": 9746, + "lines": 9779, "ratio": 0.432 }, "platform": { @@ -48,32 +46,32 @@ "ratio": 0.392 }, "ui": { - "lines": 1743, - "ratio": 0.275 + "lines": 1763, + "ratio": 0.276 }, "test": { - "lines": 7779, + "lines": 7831, "ratio": 0.206 }, "moondeck": { - "lines": 3383, - "ratio": 0.185 + "lines": 3390, + "ratio": 0.184 } }, "tests": { - "cases": 1406, + "cases": 1410, "scenarios": 23 }, "docs": { "md_files": 180, - "md_lines": 25885, + "md_lines": 25889, "plans_files": 93, "backlog_lines": 3803, "lessons_lines": 549, "claude_md_lines": 135 }, "complexity": { - "functions": 2585, + "functions": 2587, "over_threshold": 162, "worst_ccn": 108 } diff --git a/docs/metrics/repo-health.md b/docs/metrics/repo-health.md index 9e403d31..6481dc62 100644 --- a/docs/metrics/repo-health.md +++ b/docs/metrics/repo-health.md @@ -1,6 +1,6 @@ # Repo health -Measured at `2c97ce9e`. Generated by [`moondeck/check/repo_health.py`](../../moondeck/check/repo_health.py) on every KPI-gate run. **Do not edit by hand.** +Measured at `67fbe644`. Generated by [`moondeck/check/repo_health.py`](../../moondeck/check/repo_health.py) on every KPI-gate run. **Do not edit by hand.** Current state only; the trend is this file's git history (`git log -p docs/metrics/repo-health.md`). Nothing here fails a build: the numbers make growth visible, the judgment stays human. @@ -8,51 +8,49 @@ Current state only; the trend is this file's git history (`git log -p docs/metri | Target | Flash | |---|---:| -| desktop | 1,148 KB (+0 KB) ⚠ | -| esp32 | 1,708 KB (+6 KB) ⚠ | +| desktop | 1,164 KB (+16 KB) ⚠ | +| esp32 | 1,709 KB (+0 KB) ⚠ | | esp32-16mb | 1,674 KB | | esp32-eth | 1,294 KB | | esp32-wrover | 1,724 KB | -| esp32p4-eth | 1,600 KB (+6 KB) ⚠ | -| esp32p4-eth-wifi | 1,882 KB (+130 KB) ⚠ | | esp32p4rev1-eth | 1,601 KB | -| esp32p4rev1-eth-wifi | 1,883 KB | +| esp32p4rev1-eth-wifi | 1,883 KB (+1 KB) ⚠ | | esp32p4rev3-eth | 1,605 KB | -| esp32s3-n16r8 | 1,747 KB (+7 KB) ⚠ | +| esp32s3-n16r8 | 1,747 KB | | esp32s3-n8r8 | 1,712 KB | -| esp32s31 | 2,021 KB (+14 KB) ⚠ | +| esp32s31 | 2,021 KB | | qemu | 1,287 KB | ## Render performance | Target | Tick | FPS | |---|---:|---:| -| desktop | 181 µs (−79 µs) ✓ | 5,524 (+1,678) ✓ | +| desktop | 181 µs | 5,524 | | esp32 | 2,151 µs | 464 | ## Code | Area | Lines | Comments | Comment share | |---|---:|---:|---:| -| core | 19,258 (+73) ⚠ | 7,548 | 42.5 % (+0.1 %) ⚠ | -| light | 24,915 (−10) ✓ | 9,746 | 43.2 % | -| platform | 13,489 (+23) ⚠ | 4,794 | 39.2 % (+0.1 %) ⚠ | -| ui | 6,746 (+8) ⚠ | 1,743 | 27.5 % (+0.1 %) ⚠ | -| test | 43,524 (+17) ⚠ | 7,779 | 20.6 % | -| moondeck | 20,984 (+35) ⚠ | 3,383 | 18.5 % | +| core | 19,310 (+52) ⚠ | 7,574 | 42.5 % | +| light | 24,985 (+70) ⚠ | 9,779 | 43.2 % | +| platform | 13,489 | 4,794 | 39.2 % | +| ui | 6,781 (+35) ⚠ | 1,763 | 27.6 % (+0.1 %) ⚠ | +| test | 43,705 (+181) ⚠ | 7,831 | 20.6 % | +| moondeck | 21,057 (+73) ⚠ | 3,390 | 18.4 % (−0.1 %) ✓ | ## Tests | Kind | Count | |---|---:| -| unit cases | 1,406 (+1) ✓ | +| unit cases | 1,410 (+4) ✓ | | scenarios | 23 | ## Complexity | Metric | Value | |---|---:| -| functions | 2,585 (−1) ⚠ | +| functions | 2,587 (+2) ✓ | | over threshold | 162 | | worst CCN | 108 | @@ -61,9 +59,9 @@ Current state only; the trend is this file's git history (`git log -p docs/metri | Metric | Value | |---|---:| | markdown files | 180 | -| markdown lines | 25,885 (+170) ⚠ | +| markdown lines | 25,889 (+4) ⚠ | | plan files | 93 | -| backlog lines | 3,803 (+89) ⚠ | +| backlog lines | 3,803 | | lessons lines | 549 | | CLAUDE.md lines | 135 | diff --git a/docs/moonmodules/light/MoonLiveEffect.md b/docs/moonmodules/light/MoonLiveEffect.md index 1a27db45..cf97b91e 100644 --- a/docs/moonmodules/light/MoonLiveEffect.md +++ b/docs/moonmodules/light/MoonLiveEffect.md @@ -32,17 +32,19 @@ The functions are **not built into the compiler** — `setRGB`, `fill`, `random1 Type in the box and the script compiles when you click away, press Ctrl/Cmd+S, or press Save; a dot on the Save button marks unsaved work. A valid script swaps in on the next tick. A failed compile frees the old code, shows the diagnostic in the module status, and renders dark until it is fixed, so a typo costs a message rather than a reboot. Fixing it in place is enough: nothing has to be renamed. The card also creates and deletes scripts (delete asks twice), and the same editor is what the File Manager opens from a file row. The control is [`filepath`](../core/ui.md#control-types), which is generic: the module says only where its files are and which extension they carry. -- **Scripted controls**: a script declares members, then says which of them the UI shows by calling `addUint8` inside a `defineControls()`, the same call a compiled module makes. Each becomes a real `uint8` MoonModule control (slider + UI + persistence), bound to a live value the running native code reads each tick: +- **Scripted controls**: a script declares members, then says which of them the UI shows by calling `addUint8` (or `addUint16`) inside a `defineControls()`, the same call a compiled module makes. Each becomes a real MoonModule control (slider + UI + persistence), bound to a live value the running native code reads each tick: ```c class SpeedyEffect { - uint8_t speed = 50; - uint8_t hue = 128; - uint8_t phase = 0; // a member, not a control: the UI never shows it + uint8_t speed = 50; + uint8_t hue = 128; + uint16_t dwell = 900; // a value a byte cannot hold + uint8_t phase = 0; // a member, not a control: the UI never shows it defineControls() { addUint8("speed", speed, 0, 99); addUint8("hue", hue, 0, 255); + addUint16("dwell", dwell, 0, 1000); } tick() { setRGB(speed, hue, phase, 255); } @@ -56,7 +58,9 @@ The functions are **not built into the compiler** — `setRGB`, `fill`, `random1 The compiled form is the same call with a receiver: `controls_.addUint8("speed", speed, 1, 255)`. The member is named by identifier rather than by repeating the string, so a typo is a compile error here as it is there, and the quoted name is the UI label, free to differ from the member's name. The **default** comes from the member's initializer, so there is one home for the starting value. The range arguments are ordinary expressions, like every other argument in the language: `addUint8("speed", speed, base, base * 4 + 5)` is valid. - `defineControls()` runs once after a successful compile, the way the Scheduler runs a compiled module's. Editing a control's slider does **not** recompile: the value lands in the engine's control-values arena and the next render tick reads it (the live-edit guarantee, the *no-reboot* principle). Saving the script and re-naming it recompiles and re-derives the control set; a control kept across the edit keeps its slider value, a removed control's saved value drops. Stage 1 is `uint8` only. + `defineControls()` runs once after a successful compile, the way the Scheduler runs a compiled module's. Editing a control's slider does **not** recompile: the value lands in the engine's control-values arena and the next render tick reads it (the live-edit guarantee, the *no-reboot* principle). Saving the script and re-naming it recompiles and re-derives the control set; a control kept across the edit keeps its slider value, a removed control's saved value drops. + + **The call has to match the member's width**: `addUint8` binds a `uint8_t` and `addUint16` a `uint16_t`. A mismatch is a compile error naming the call to use instead, because the alternative is silent: `addUint8` on a wide member would drive only its low byte, leaving the high half holding whatever it had, so the number the script reads is one nobody chose. A control binds a single member, never an array. ### System variables — what the engine hands a script diff --git a/docs/moonmodules/light/MoonLiveLayout.md b/docs/moonmodules/light/MoonLiveLayout.md index 45e45ad7..2012bd50 100644 --- a/docs/moonmodules/light/MoonLiveLayout.md +++ b/docs/moonmodules/light/MoonLiveLayout.md @@ -58,7 +58,7 @@ for (i = 0; i < count; i = i + 1) { ### What a script can read -A script reads whatever it declares. `uint8_t cols = 16;` is a member the script owns; naming it in `defineControls()` with `addUint8("cols", cols, 1, 64)` also makes it a real slider in the UI, and the loop reads it, which is how a panel gets resized without editing code. A member no `addUint8` names stays private to the script. +A script reads whatever it declares. `uint8_t cols = 16;` is a member the script owns; naming it in `defineControls()` with `addUint8("cols", cols, 1, 64)` also makes it a real slider in the UI, and the loop reads it, which is how a panel gets resized without editing code. A `uint16_t` member is surfaced the same way with `addUint16`, which the call must match. A member no such call names stays private to the script. `t` is the one [system variable](MoonLiveEffect.md#system-variables--what-the-engine-hands-a-script) a layout is given, and it is always **0** here: the script runs twice per rebuild (once to count, once to place) and must agree with itself, so it is handed a fixed clock rather than a live one — a moving `t` would let the two passes disagree on how many lights there are. `width`/`height`/`depth` name the grid a layout is *defining*, so asking for one is a compile error rather than a silent zero; `x` and `y` are free to use as loop counters. @@ -107,7 +107,7 @@ Past half full, the status also names the tightest limit the script is approachi |---|---| | `script` | the file name under `/moonlive/`; naming it (or re-naming it after an edit) recompiles and re-places the lights live | -Plus one control per `addUint8` in the script's `defineControls()`. +Plus one control per `addUint8` / `addUint16` in the script's `defineControls()`. Editing any of them rebuilds the pipeline, because every one can change where the lights are. A script that fails to compile leaves a fixture with no lights, shows the parse error on the module, and the device keeps running. diff --git a/docs/moonmodules/light/MoonLiveModifier.md b/docs/moonmodules/light/MoonLiveModifier.md index a8bcabc5..eecfe7dc 100644 --- a/docs/moonmodules/light/MoonLiveModifier.md +++ b/docs/moonmodules/light/MoonLiveModifier.md @@ -60,7 +60,7 @@ Past half full, the status also names the tightest limit the script is approachi |---|---| | `script` | the file name under `/moonlive/`; naming it (or re-naming it after an edit) recompiles and re-maps live | -Plus one control per `addUint8` in the script's `defineControls()`: `addUint8("amount", amount, 0, 64)` +Plus one control per `addUint8` / `addUint16` in the script's `defineControls()`: `addUint8("amount", amount, 0, 64)` becomes a slider, and moving it rebuilds the mapping just as editing the script does. Editing the script asks the Layer to rebuild its mapping, so a change is visible immediately. A script that fails to compile shows the parse error on the module and the mapping falls back to passing coordinates straight through — the transform disappears until the script parses again, and the device keeps rendering throughout. diff --git a/esp32/sdkconfig.defaults.esp32p4rev1-eth b/esp32/sdkconfig.defaults.esp32p4rev1-eth index 3ec9f24d..56af9049 100644 --- a/esp32/sdkconfig.defaults.esp32p4rev1-eth +++ b/esp32/sdkconfig.defaults.esp32p4rev1-eth @@ -32,8 +32,9 @@ CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y # Kconfig enforces it structurally: REV_MIN_0/1/100 require SELECTS_REV_LESS_V3=y and # REV_MIN_300/301 require it =n, with the max clamped to 199 or 399. So this image # supports revisions 0.0 through 1.99 and will refuse to boot on a v3.x chip, which is -# the bench/field silicon today. A v3.x board needs its own firmware variant (backlog: -# esp32p4-eth-v3), not a flag flipped here. +# the bench/field silicon today. A v3.x board needs its own firmware variant — those now +# exist as esp32p4rev3-eth and esp32p4rev3-eth-wifi (sdkconfig.defaults.esp32p4rev3 layers +# the revision over this file) — not a flag flipped here. CONFIG_ESP32P4_SELECTS_REV_LESS_V3=y CONFIG_ESP32P4_REV_MIN_0=y diff --git a/moondeck/build/build_esp32.py b/moondeck/build/build_esp32.py index 6381a2bc..353c7456 100644 --- a/moondeck/build/build_esp32.py +++ b/moondeck/build/build_esp32.py @@ -715,16 +715,14 @@ def main(): # set-target is skipped — switching to another firmware uses a different # build_dir entirely, so its sdkconfig is untouched. # - # KNOWN ISSUE (esp32p4rev1-eth-wifi): esp_wifi_remote's slave target - # (SLAVE_IDF_TARGET_ESP32C6) is selected by a Kconfig `default ... if - # IDF_TARGET_ESP32P4` that fires during `set-target` but is dropped by the - # reconfigure a plain `build` triggers, falling back to ESP32-H2 (no WiFi) and - # failing on missing CONFIG_WIFI_RMT_* symbols. A clean manual sequence works: - # rm -rf build/esp32-esp32p4rev1-eth-wifi && idf.py -B -DSDKCONFIG=/sdkconfig \ - # -DSDKCONFIG_DEFAULTS="..." set-target esp32p4 && (same) build - # but this wrapper does not yet reproduce it reliably — tracked in - # docs/backlog/ (ESP32-P4 round 3). Until fixed, build this variant - # with the manual sequence above. + # esp32p4rev1-eth-wifi used to need a manual set-target sequence here: + # esp_wifi_remote's slave target (SLAVE_IDF_TARGET_ESP32C6) is selected by a + # Kconfig `default ... if IDF_TARGET_ESP32P4` that fired during `set-target` but + # was dropped by the reconfigure a plain `build` triggered, falling back to + # ESP32-H2 (no WiFi) and failing on missing CONFIG_WIFI_RMT_* symbols. Re-tested + # on IDF v6.1-rc1 (2026-08-19): a clean build and an incremental rebuild through + # this wrapper both keep CONFIG_SLAVE_IDF_TARGET_ESP32C6=y, so the variant builds + # like any other and ships in the release matrix. extra = firmware_cmake_args(firmware, args.release, args.version, task_cpu_stats=args.task_cpu_stats) diff --git a/moondeck/check/repo_health.py b/moondeck/check/repo_health.py index eb8d5566..abcdaabd 100644 --- a/moondeck/check/repo_health.py +++ b/moondeck/check/repo_health.py @@ -31,6 +31,11 @@ from pathlib import Path ROOT = Path(__file__).resolve().parent.parent.parent + +# The firmware registry, so carry-forward can drop rows for variants that no longer exist +# (see merge_carry_forward). Same single source of truth check_firmwares.py reads. +sys.path.insert(0, str(ROOT / "moondeck" / "build")) +from build_esp32 import FIRMWARES # noqa: E402 HEALTH_FILE = ROOT / "docs" / "metrics" / "repo-health.json" # The same snapshot as a table a human reads: units applied, ratios as percentages, areas # grouped. The JSON stays the source the delta is computed from; this is the view. Both @@ -275,9 +280,23 @@ def merge_carry_forward(new, old): A commit that did not build the P4 firmware, or ran without a bench board, should not silently drop those numbers — the alternative is a file whose contents depend on which targets happened to be built, which makes every diff unreadable. + + Carrying forward is bounded by the firmware REGISTRY, not by history: a renamed or + deleted variant would otherwise linger forever, since nothing ever measures it again to + overwrite the stale row. `esp32p4-eth` and `esp32p4-eth-wifi` outlived their rename to + `esp32p4rev1-*` exactly this way. Only `flash` is keyed by firmware; `perf` and + `complexity` are keyed by platform/metric and are carried forward as they were. """ + # Drop rows for ESP32 variants that no longer exist, but keep everything else: `flash` + # also holds non-firmware targets (`desktop`), which are not in FIRMWARES and must not be + # filtered out. So the rule is "an esp32* key that is not a known firmware is a ghost", + # which is exactly what a rename leaves behind and nothing else. + known = set(FIRMWARES) for key in ("flash", "perf", "complexity"): merged = dict(old.get(key, {})) + if key == "flash": + merged = {k: v for k, v in merged.items() + if not k.startswith("esp32") or k in known} merged.update(new.get(key, {})) new[key] = merged return new diff --git a/moondeck/moondeck.py b/moondeck/moondeck.py index 5026c26b..9df8638d 100644 --- a/moondeck/moondeck.py +++ b/moondeck/moondeck.py @@ -89,7 +89,7 @@ def _load_firmwares(): generated projection of build_esp32's FIRMWARES dict (the single source of truth, shared with the CI release matrix). Returns [] on missing/malformed file, so the MoonDeck UI just shows no firmware entries. Filtering on - `ships` keeps held-out variants (e.g. esp32p4rev1-eth-wifi) out of the picker. + `ships` keeps held-out variants out of the picker. """ try: doc = json.loads(FIRMWARES_FILE.read_text(encoding="utf-8")) diff --git a/moondeck/scenario/run_scenario.py b/moondeck/scenario/run_scenario.py index e33734c4..4da0d991 100644 --- a/moondeck/scenario/run_scenario.py +++ b/moondeck/scenario/run_scenario.py @@ -51,6 +51,64 @@ def _resolve_runner() -> Path: RUNNER = _resolve_runner() + +# What feeds mm_scenarios. Same question check_esp32_built.py asks of a firmware image, for +# the same reason: a binary older than its sources reports on code that is no longer there. +_RUNNER_SOURCE_DIRS = ("src", "test") +_RUNNER_SOURCE_SUFFIXES = {".c", ".cpp", ".h", ".hpp"} +_RUNNER_SKIP_PARTS = {"build", "__pycache__", ".git"} + + +def _stale_runner_reason() -> str: + """The newest source file NEWER than the runner binary, or "" when it is fresh. + + This exists because a stale runner is INVISIBLE: it runs, it prints PASSED, and every + assertion is against code that has since changed. It cost ten days of three MoonLive + scenarios silently asserting nothing — `set_control` steps naming a control the modules no + longer had, reported as applied by a binary built before the rename. + + Freshness is measured against the SOURCES, never the clock: a wall-clock rule passes a + binary that predates an edit made minutes ago, which is the exact trap this catches. + + The trap is easy to fall into because two build directories exist: `cmake --build build` + (what a developer types) writes build/, while this script runs build// — the tree + build_desktop.py produces. Rebuilding the wrong one leaves the runner untouched and no + error anywhere. + """ + if not RUNNER.exists(): + return "missing" + binary_mtime = RUNNER.stat().st_mtime + newest, newest_path = 0.0, None + for d in _RUNNER_SOURCE_DIRS: + for f in (ROOT / d).rglob("*"): + if not f.is_file() or f.suffix not in _RUNNER_SOURCE_SUFFIXES: + continue + if _RUNNER_SKIP_PARTS & set(f.parts): + continue + m = f.stat().st_mtime + if m > newest: + newest, newest_path = m, f + if newest > binary_mtime and newest_path is not None: + mins = (newest - binary_mtime) / 60.0 + return (f"{newest_path.relative_to(ROOT)} is {mins:.0f} min newer than the runner") + return "" + + +def _require_fresh_runner() -> None: + """Refuse to run against a missing or stale binary, naming the rebuild command.""" + reason = _stale_runner_reason() + if not reason: + return + rel = RUNNER.relative_to(ROOT) if RUNNER.is_relative_to(ROOT) else RUNNER + if reason == "missing": + print(f"Scenario runner not built: {rel}") + else: + print(f"Scenario runner is STALE: {rel}") + print(f" {reason}") + print(" It would report on code that is no longer there.") + print(" rebuild: uv run moondeck/build/build_desktop.py --tests") + sys.exit(1) + # Format emitted by scenario_runner.cpp's measure block: # MEASURE : tick=Nus FPS=N lights=N heap=N (step: ±N) block=N # `` may contain hyphens and underscores. heap is the absolute free @@ -210,10 +268,8 @@ def main(): parser.error("--update-contract requires --reason " "(e.g. --reason 'tightened after Layer optimisation')") - if not RUNNER.exists(): - print(f"Scenario runner not found: {RUNNER}") - print("Compile it first: uv run moondeck/build/build_desktop.py --tests (MoonDeck → desktop → Compile Tests).") - sys.exit(1) + # Missing OR stale: both mean the results would not describe the code on disk. + _require_fresh_runner() module_filter = args.module if (args.module and args.module.lower() != "all") else None diff --git a/moonlive/effects/ember.mle b/moonlive/effects/ember.mle index a6efbed1..8c7de498 100644 --- a/moonlive/effects/ember.mle +++ b/moonlive/effects/ember.mle @@ -8,23 +8,28 @@ // // Uses every construct the language grew for that: an array read and written by index, a member // assigned (`heat[i] = ...`), `if`/`else` choosing a colour ramp, and a `uint16_t` counter that -// keeps counting past the 255 a byte stops at. +// keeps counting past the 255 a byte stops at — surfaced as a control with `addUint16`, because +// its range is exactly what a byte cannot express. class EmberEffect { uint8_t cool = 30; // how fast a lit cell fades back to black uint8_t spark = 60; // chance out of 256 that a cell re-ignites this frame uint16_t phase = 0; // a free-running counter, wider than a byte on purpose + uint16_t cycle = 1000; // where that counter wraps: a value no byte slider could reach uint8_t heat[16]; // the simulation itself: one heat value per cell, kept between frames defineControls() { addUint8("cool", cool, 1, 120); addUint8("spark", spark, 0, 200); + // addUint16, not addUint8: the wrap point runs to 2000, and a byte control would drive + // only the low half of the member — the compiler refuses that pairing outright. + addUint16("cycle", cycle, 100, 2000); } tick() { - // Wider than a byte, so it counts to 1000 rather than wrapping at 255. + // Wider than a byte, so it counts past 255. `cycle` sets where it wraps, live. phase = phase + 7; - if (phase >= 1000) { phase = 0; } + if (phase >= cycle) { phase = 0; } // Cool: every cell loses `cool`, clamped at zero rather than wrapping to white. for (i = 0; i < 16; i = i + 1) { diff --git a/src/core/SystemModule.h b/src/core/SystemModule.h index f11a06cf..7760decb 100644 --- a/src/core/SystemModule.h +++ b/src/core/SystemModule.h @@ -32,7 +32,7 @@ namespace mm { /// **`wifiCoproc`:** shown only on boards whose radio is a separate chip (the ESP32-P4 /// with its on-board ESP32-C6 over esp_hosted); absent on native-radio targets (the /// platform returns an empty string and the control is not added). Reports the detected -/// slave firmware version (`C6 fw 2.12.9`) when the link is up, or `not detected` when +/// slave firmware version (`C6 fw 2.12.9`) when the query answers, or `no version reply` when /// the C6 never completes its handshake / reports 0.0.0 — the signature of absent or /// incompatible C6 slave firmware. `tick1s()` re-queries it, so the state stays current /// if the link comes up after boot or the C6 is reflashed without a host reboot. diff --git a/src/core/moonlive/MoonLive.cpp b/src/core/moonlive/MoonLive.cpp index 192ac712..84e114ef 100644 --- a/src/core/moonlive/MoonLive.cpp +++ b/src/core/moonlive/MoonLive.cpp @@ -161,8 +161,12 @@ bool MoonLive::ensureArena(const DeclaredControl* decls, uint8_t count) { if ((seeded_ >> off) & 1ull) for (uint8_t k = 0; k < seededCount_; k++) if (seededName_[k].offset == off) { prev = &seededName_[k]; break; } + // Same NAME, same SHAPE. A widened scalar or a grown array keeps its name and offset, and + // reusing its bytes on that basis would leave the new extent holding the old program's + // values (see SeededMember). const bool same = prev && std::strncmp(prev->name, decls[i].name, n) == 0 && - prev->name[n] == '\0'; + prev->name[n] == '\0' && + prev->type == decls[i].type && prev->count == decls[i].count; if (!same) { // Seed the member's WHOLE extent: every element, at its width, little-endian to match // every backend's halfword load. Writing only the first element left an ARRAY holding @@ -178,6 +182,8 @@ bool MoonLive::ensureArena(const DeclaredControl* decls, uint8_t count) { } if (kept < kMaxCtrls) { seededName_[kept].offset = off; + seededName_[kept].type = decls[i].type; + seededName_[kept].count = static_cast(decls[i].count); for (uint8_t c = 0; c < n; c++) seededName_[kept].name[c] = decls[i].name[c]; seededName_[kept].name[n] = '\0'; kept++; diff --git a/src/core/moonlive/MoonLive.h b/src/core/moonlive/MoonLive.h index ef69490f..8c829b4a 100644 --- a/src/core/moonlive/MoonLive.h +++ b/src/core/moonlive/MoonLive.h @@ -108,9 +108,14 @@ class MoonLive { /// /// `name` must outlive the engine: it points into the string pool this engine owns, which is /// what the compiler interned it into. - void addDeclaredControl(const char* name, uint8_t offset, uint8_t lo, uint8_t hi) { + void addDeclaredControl(const char* name, uint8_t offset, uint16_t lo, uint16_t hi, + CtrlType type = CtrlType::Uint8) { if (controlCount_ >= kMaxCtrls || !name || offset >= kArenaBytes) return; if (lo > hi) return; + // A wide control owns TWO arena bytes, so the second one has to exist. The compiler already + // aligned and bounded the member; this is the engine refusing to publish a control whose + // high byte would sit outside the arena. + if (ctrlWidth(type) == 2 && offset + 1 >= kArenaBytes) return; // Two controls on one member would give the UI two cards writing the same byte, each // overwriting the other, and two labels the same persistence key. for (uint8_t i = 0; i < controlCount_; i++) @@ -124,11 +129,20 @@ class MoonLive { // record. Clamping only the record would leave the out-of-range value driving the effect // while the UI showed a slider that could not reach it. This is the one place that knows // both the range and the live byte at the same moment. - uint8_t def = ctrlArena_ ? ctrlArena_[offset] : lo; + // Read at the DECLARED width, little-endian to match every backend's halfword load, so a + // wide control's default is the member's whole value rather than its low byte. + uint16_t def = lo; + if (ctrlArena_) { + def = ctrlArena_[offset]; + if (ctrlWidth(type) == 2) def |= static_cast(ctrlArena_[offset + 1]) << 8; + } if (def < lo) def = lo; else if (def > hi) def = hi; - if (ctrlArena_) ctrlArena_[offset] = def; - controls_[controlCount_] = {name, lo, hi, def, 0, CtrlType::Uint8, offset}; + if (ctrlArena_) { + ctrlArena_[offset] = static_cast(def & 0xff); + if (ctrlWidth(type) == 2) ctrlArena_[offset + 1] = static_cast(def >> 8); + } + controls_[controlCount_] = {name, lo, hi, def, 0, type, offset}; // nameLen is what the binding reports; measured here rather than passed, so a caller // cannot disagree with the string it handed over. // The BOUND is tested first: `name[n] && n < limit` reads the byte before deciding whether @@ -249,7 +263,17 @@ class MoonLive { // inside every scripted module and constructed on the main task's stack by ModuleFactory's // probe. The offset is stored alongside, which is what the byte-indexed form was really using. static constexpr uint8_t kSeedNameLen = 12; - struct SeededMember { uint8_t offset = 0; char name[kSeedNameLen] = {}; }; + // Type and count ride along because a member's IDENTITY is its whole shape, not just where it + // sits and what it is called. `uint8 x` edited to `uint16 x`, or `uint8 a[4]` to `a[8]`, keeps + // both offset and name — so without these the member reads as unchanged and its NEW bytes (a + // uint16's high half, elements 4..7) keep the previous program's values instead of the + // declared default. 2 bytes per row, 16 across the table. + struct SeededMember { + uint8_t offset = 0; + CtrlType type = CtrlType::Uint8; + uint8_t count = 1; + char name[kSeedNameLen] = {}; + }; // A uint64_t, and the table is sized to the SCRIPT's region rather than the whole arena. This // was a uint32_t when kCtrlBytes was 16; the byte budget then grew to 64 and the mask did not, // so `1u << off` for a member at offset 32 or beyond was undefined behaviour and in practice diff --git a/src/core/moonlive/MoonLiveBuiltins.h b/src/core/moonlive/MoonLiveBuiltins.h index a5c0a470..587ac074 100644 --- a/src/core/moonlive/MoonLiveBuiltins.h +++ b/src/core/moonlive/MoonLiveBuiltins.h @@ -22,6 +22,13 @@ namespace mm::moonlive { +// The width of a script member, and how many arena bytes one element of it occupies. Here rather +// than with the IR because a builtin descriptor names the width its by-reference argument takes. +enum class CtrlType : uint8_t { Uint8, Uint16 }; + +constexpr uint8_t ctrlWidth(CtrlType t) { return t == CtrlType::Uint16 ? 2 : 1; } + + // Neutral inline opcodes — "store shapes a backend can emit", not "LED operations". A host maps // its function names onto these; a backend implements them. StoreElem = store N bytes (one // element) at a computed index; FillElems = a counted loop writing one element per slot. The @@ -78,6 +85,11 @@ struct Builtin { // host a pointer built from a color byte. Stated per builtin for the same reason byRef is, // rather than special-cased by name in the parser. uint8_t byStr = 0; + // The member WIDTH a by-reference argument must have, for the control-declaring builtins: + // addUint8 takes a uint8_t member, addUint16 a uint16_t one. Stated here for the same reason + // byRef and byStr are, rather than the parser matching on the builtin's name — a name test + // would silently mis-classify the next by-reference builtin somebody adds. + CtrlType refType = CtrlType::Uint8; }; // A fixed-capacity table the host fills and the compiler reads. No heap; a host registers a diff --git a/src/core/moonlive/MoonLiveCompiler.cpp b/src/core/moonlive/MoonLiveCompiler.cpp index 8f04f2d5..c5c7da05 100644 --- a/src/core/moonlive/MoonLiveCompiler.cpp +++ b/src/core/moonlive/MoonLiveCompiler.cpp @@ -470,6 +470,17 @@ struct Parser { if (lex.kind != Tok::Ident) { fail("expected the member this control is bound to"); return; } const int mi = findMember(lex.identBeg, lex.identLen); if (mi < 0) { fail("no member of that name is declared in this class"); return; } + // The BUILTIN'S width must match the MEMBER'S. addUint8 on a uint16_t member + // would drive only its low byte and addUint16 on a uint8_t member would write + // past it, both silently — so the mismatch is a diagnostic naming the call to + // use instead. A control also drives one value, never an array: binding one + // would move element 0 and leave the rest, with nothing on screen saying so. + if (members[mi].type != fn->refType) + { fail(fn->refType == CtrlType::Uint16 + ? "addUint16 binds a uint16_t member (use addUint8)" + : "addUint8 binds a uint8_t member (use addUint16)"); return; } + if (members[mi].count > 1) + { fail("a control binds a single member, not an array"); return; } v = alloc(); emit({IrOp::Const, v, 0,0,0,0, members[mi].offset, nullptr, {}}); lex.advance(); diff --git a/src/core/moonlive/MoonLiveIr.h b/src/core/moonlive/MoonLiveIr.h index 625afb7f..4c934a73 100644 --- a/src/core/moonlive/MoonLiveIr.h +++ b/src/core/moonlive/MoonLiveIr.h @@ -156,17 +156,16 @@ struct IrInst { // the arena slot. `type` is a neutral kind — Uint8 only in Stage 1 — NOT a projectMM ControlType. /// A member's element type. The WIDTH is derived from it rather than stored beside it, so the two /// can never disagree: a record carrying both would let a `Uint16` claim one byte. -enum class CtrlType : uint8_t { Uint8, Uint16 }; - -/// Bytes one element of `t` occupies in the arena. -constexpr uint8_t ctrlWidth(CtrlType t) { return t == CtrlType::Uint16 ? 2 : 1; } +// CtrlType / ctrlWidth live in MoonLiveBuiltins.h: a builtin descriptor names the member +// width its by-reference argument takes, and that header cannot include this one. struct DeclaredControl { const char* name = nullptr; // script-declared name (points into the source buffer) - uint8_t min = 0, max = 255; // the UI range; a control is a uint8 slider either way - // The initializer, wide enough for the widest member type. A control's range stays 0..255 - // because that is what addUint8 declares and what a slider spans; the DEFAULT is separate, - // since a uint16_t member holds a value no slider needs to reach. + // The UI range, as wide as the widest member a control can bind: addUint8 declares 0..255 and + // addUint16 the full 16-bit span, so the field has to hold the wider one. + uint16_t min = 0, max = 255; + // The initializer, wide enough for the widest member type. Separate from the range because a + // member may be seeded to a value outside what its slider spans. uint16_t def = 0; uint8_t nameLen = 0; // length (the source is not NUL-terminated per token) CtrlType type = CtrlType::Uint8; diff --git a/src/light/moonlive/MoonLiveBuiltins_light.h b/src/light/moonlive/MoonLiveBuiltins_light.h index c64c9c14..5fe0d5c9 100644 --- a/src/light/moonlive/MoonLiveBuiltins_light.h +++ b/src/light/moonlive/MoonLiveBuiltins_light.h @@ -188,10 +188,15 @@ using AddLightFn = void (*)(void* ctx, uint16_t x, uint16_t y, uint16_t z); /// and a third would mean a genuinely new concurrency story rather than a bigger table. struct AddLightSink { AddLightFn fn = nullptr; void* ctx = nullptr; }; -/// Where a running `defineControls()` sends each `addUint8`. Same shape and same reason as the -/// addLight sink: a builtin has no receiver, so the binding installs one for the duration of the -/// run and the call reaches the engine through it. -using AddControlFn = void (*)(void* ctx, const char* name, uint8_t offset, uint8_t lo, uint8_t hi); +/// Where a running `defineControls()` sends each `addUint8` / `addUint16`. Same shape and same +/// reason as the addLight sink: a builtin has no receiver, so the binding installs one for the +/// duration of the run and the call reaches the engine through it. +/// +/// `type` is the width the SCRIPT declared, which is what decides the UI control and how many +/// arena bytes a write touches. It is checked against the member's own type by the compiler, so +/// by the time a call arrives here the two already agree. +using AddControlFn = void (*)(void* ctx, const char* name, uint8_t offset, + uint16_t lo, uint16_t hi, CtrlType type); struct AddControlSink { AddControlFn fn = nullptr; void* ctx = nullptr; }; namespace detail { @@ -301,22 +306,33 @@ inline void setAddLightSink(AddLightFn fn, void* ctx) { // this runs. What is left is the call itself, which exists so that a script declares a control the // way a compiled module does: `defineControls()` is an ordinary function the binding calls after a // successful compile, and this is an ordinary builtin it calls. -extern "C" inline uint32_t mm_light_addUint8(const uintptr_t* args, uint32_t, const uint8_t*) { +// Shared by addUint8 and addUint16: identical but for the width they declare, so the bound check +// and the sink call live once rather than in two copies that could drift. +inline uint32_t addControlDecl(const uintptr_t* args, CtrlType type) { // args: (name, memberOffset, min, max). The name is a pointer into the compiled program's // string pool, which outlives the run; the offset is the member's arena byte, which the // compiler passed by reference. const char* name = reinterpret_cast(args[0]); const AddControlSink s = addControlSink(); if (!name || !s.fn || !s.ctx) return 0; // no binding listening: the call is a no-op - // A bound is a byte, and the range is an ARBITRARY EXPRESSION, so `addUint8("n", n, 0, x * 64)` - // can compute past 255. Truncating would publish a slider whose top silently wraps to a small - // number; refusing the declaration leaves the control absent, which the user can see. - if (args[2] > 255 || args[3] > 255) return 0; + // The range is an ARBITRARY EXPRESSION, so `addUint8("n", n, 0, x * 64)` can compute past what + // the declared width holds. Truncating would publish a slider whose top silently wraps to a + // small number; refusing the declaration leaves the control absent, which the user can see. + const uintptr_t limit = (type == CtrlType::Uint16) ? 65535u : 255u; + if (args[2] > limit || args[3] > limit) return 0; s.fn(s.ctx, name, static_cast(args[1]), - static_cast(args[2]), static_cast(args[3])); + static_cast(args[2]), static_cast(args[3]), type); return 0; } +extern "C" inline uint32_t mm_light_addUint8(const uintptr_t* args, uint32_t, const uint8_t*) { + return addControlDecl(args, CtrlType::Uint8); +} + +extern "C" inline uint32_t mm_light_addUint16(const uintptr_t* args, uint32_t, const uint8_t*) { + return addControlDecl(args, CtrlType::Uint16); +} + extern "C" inline uint32_t mm_light_addLight(const uintptr_t* args, uint32_t, const uint8_t*) { const uint32_t x = uint32_t(args[0]), y = uint32_t(args[1]), z = uint32_t(args[2]); // Both halves checked: a sink is only ever installed as a pair, but a context of null with a live @@ -499,7 +515,12 @@ inline BuiltinTable lightBuiltins() { // argument as the MEMBER, so the compiler passes its arena offset rather than its value, which // is what makes the script read as the reference a compiled module passes. t.add({"addUint8", 4, /*returns*/ false, BuiltinKind::Call, &mm_light_addUint8, {}, - /*byRef*/ 0x2, /*byStr*/ 0x1}); + /*byRef*/ 0x2, /*byStr*/ 0x1, /*refType*/ CtrlType::Uint8}); + // addUint16(name, member, min, max) → the same call against a uint16_t member, so a script can + // expose a value a byte cannot hold (a dwell time, a 0..1000 scale) instead of packing it into + // two byte controls. Same by-ref/by-str marking: only the declared width differs. + t.add({"addUint16", 4, /*returns*/ false, BuiltinKind::Call, &mm_light_addUint16, {}, + /*byRef*/ 0x2, /*byStr*/ 0x1, /*refType*/ CtrlType::Uint16}); return t; } @@ -521,8 +542,9 @@ inline void runDefineControls(MoonLive& engine) { // clear-then-run would drop every control and rebuild none of them: the script would appear to // declare nothing. Keeping the previous set is the honest degrade, and the run is skipped // rather than executed into a dead sink. - if (!setAddControlSink([](void* ctx, const char* n, uint8_t off, uint8_t lo, uint8_t hi) { - static_cast(ctx)->addDeclaredControl(n, off, lo, hi); + if (!setAddControlSink([](void* ctx, const char* n, uint8_t off, + uint16_t lo, uint16_t hi, CtrlType type) { + static_cast(ctx)->addDeclaredControl(n, off, lo, hi, type); }, &engine)) return; engine.clearDeclaredControls(); // re-runnable: rebuild rather than append // A one-light scratch buffer: this entry point writes no pixels, but `run` refuses a null or diff --git a/src/light/moonlive/MoonLiveEffect.h b/src/light/moonlive/MoonLiveEffect.h index 7fd72dbc..7a3152d0 100644 --- a/src/light/moonlive/MoonLiveEffect.h +++ b/src/light/moonlive/MoonLiveEffect.h @@ -47,7 +47,20 @@ class MoonLiveEffect : public EffectBase { // The engine owns its declared names (MoonLive::compile copies them out of the // source before the text is freed), so the descriptor can borrow that pointer // directly — a second per-binding pool would be the same fact in two places. - controls_.addUint8(decls[i].name, *slot, decls[i].min, decls[i].max); + // Published at the width the script declared. A uint16_t member reaches the UI as a + // 16-bit control writing both its arena bytes; publishing it as a uint8 would drive + // only the low one and leave the high half holding whatever it had. + if (decls[i].type == moonlive::CtrlType::Uint16) { + // Safe to view as a uint16_t: the compiler aligns every wide member to an even + // arena offset (two backends cannot encode an odd halfword offset at all), and the + // arena base comes from platform::alloc, which is aligned for any fundamental type. + controls_.addUint16(decls[i].name, *reinterpret_cast(slot), + decls[i].min, decls[i].max); + } else { + controls_.addUint8(decls[i].name, *slot, + static_cast(decls[i].min), + static_cast(decls[i].max)); + } } } diff --git a/src/light/moonlive/MoonLiveLayout.h b/src/light/moonlive/MoonLiveLayout.h index 674d661f..5647f17b 100644 --- a/src/light/moonlive/MoonLiveLayout.h +++ b/src/light/moonlive/MoonLiveLayout.h @@ -56,7 +56,20 @@ class MoonLiveLayout : public LayoutBase { for (uint8_t i = 0; i < n; i++) { uint8_t* slot = script_.engine().controlSlot(decls[i].offset); if (!slot) continue; - controls_.addUint8(decls[i].name, *slot, decls[i].min, decls[i].max); + // Published at the width the script declared. A uint16_t member reaches the UI as a + // 16-bit control writing both its arena bytes; publishing it as a uint8 would drive + // only the low one and leave the high half holding whatever it had. + if (decls[i].type == moonlive::CtrlType::Uint16) { + // Safe to view as a uint16_t: the compiler aligns every wide member to an even + // arena offset (two backends cannot encode an odd halfword offset at all), and the + // arena base comes from platform::alloc, which is aligned for any fundamental type. + controls_.addUint16(decls[i].name, *reinterpret_cast(slot), + decls[i].min, decls[i].max); + } else { + controls_.addUint8(decls[i].name, *slot, + static_cast(decls[i].min), + static_cast(decls[i].max)); + } } } @@ -154,10 +167,14 @@ class MoonLiveLayout : public LayoutBase { /// there harmlessly. void runScript(moonlive::AddLightFn fn, void* ctx) const { uint8_t scratch[3] = {0, 0, 0}; - moonlive::setAddLightSink(fn, ctx); - // The placement moment: run `placeLights` if the script defined one. A script without it - // places no lights, which the module reports as an empty fixture rather than a failure. + // Checked BEFORE the sink is installed. `ctx` is the caller's stack-local Counter or + // Emitter, so returning between install and clear would leave the global sink pointing at + // a dead frame until the next runScript happened to overwrite it. + // + // A script without placeLights places no lights, which the module reports as an empty + // fixture rather than a failure. if (!script_.engine().hasEntry(moonlive::kEntryPlaceLights)) return; + moonlive::setAddLightSink(fn, ctx); script_.engine().run(scratch, 1, 3, 0, moonlive::kEntryPlaceLights); moonlive::setAddLightSink(nullptr, nullptr); } diff --git a/src/light/moonlive/MoonLiveModifier.h b/src/light/moonlive/MoonLiveModifier.h index c329c6e0..d7ba383a 100644 --- a/src/light/moonlive/MoonLiveModifier.h +++ b/src/light/moonlive/MoonLiveModifier.h @@ -55,7 +55,20 @@ class MoonLiveModifier : public ModifierBase { for (uint8_t i = 0; i < n; i++) { uint8_t* slot = script_.engine().controlSlot(decls[i].offset); if (!slot) continue; - controls_.addUint8(decls[i].name, *slot, decls[i].min, decls[i].max); + // Published at the width the script declared. A uint16_t member reaches the UI as a + // 16-bit control writing both its arena bytes; publishing it as a uint8 would drive + // only the low one and leave the high half holding whatever it had. + if (decls[i].type == moonlive::CtrlType::Uint16) { + // Safe to view as a uint16_t: the compiler aligns every wide member to an even + // arena offset (two backends cannot encode an odd halfword offset at all), and the + // arena base comes from platform::alloc, which is aligned for any fundamental type. + controls_.addUint16(decls[i].name, *reinterpret_cast(slot), + decls[i].min, decls[i].max); + } else { + controls_.addUint8(decls[i].name, *slot, + static_cast(decls[i].min), + static_cast(decls[i].max)); + } } } diff --git a/src/light/moonlive/MoonLiveScript.h b/src/light/moonlive/MoonLiveScript.h index bbf726f8..5ca021d1 100644 --- a/src/light/moonlive/MoonLiveScript.h +++ b/src/light/moonlive/MoonLiveScript.h @@ -40,9 +40,9 @@ class MoonLiveScript { // answers it, against a compile's read plus parse, codegen and exec-block allocation. uint32_t fileHash = 0; const bool readable = scriptFileHash(name_, fileHash); - if (readable && engine_.ok() && compiledHash_ != 0 && fileHash == compiledHash_) return false; + if (readable && engine_.ok() && haveCompiled_ && fileHash == compiledHash_) return false; - // A failed compile leaves compiledHash_ at 0 and the engine not ok(), which is + // A failed compile leaves haveCompiled_ false and the engine not ok(), which is // indistinguishable from "not compiled yet", so without this latch a layout re-reads and // re-compiles the file on every lightCount()/placeLights(). Each attempt is two LittleFS // operations (~5 ms on an S3), the pipeline asks repeatedly while sizing and walking the @@ -83,8 +83,11 @@ class MoonLiveScript { std::snprintf(failedScript_, sizeof(failedScript_), "%s", name_); } // The CONTENT hash, not a copy of the text: 4 bytes to answer "is what I compiled still what - // the file says". 0 means nothing is compiled. + // the file says". Whether anything IS compiled is a separate flag rather than hash != 0, + // because 0 is a legitimate hash: a script that happened to hash to it would recompile on + // every prepare sweep, which is the cost this comparison exists to avoid. compiledHash_ = hash; + haveCompiled_ = engine_.ok(); // a FAILED compile has no program, whatever the file hashed to owner.setDynamicBytes(engine_.heapBytes()); return true; } @@ -107,6 +110,7 @@ class MoonLiveScript { /// disabled, where the engine was released but the name was kept. void invalidate() { compiledHash_ = 0; + haveCompiled_ = false; compileFailed_ = false; failedHash_ = 0; failedReadable_ = false; @@ -126,6 +130,7 @@ class MoonLiveScript { // "no script" until one is named, rather than every new module compiling the same default. char name_[kMaxScriptName + 1] = ""; uint32_t compiledHash_ = 0; + bool haveCompiled_ = false; // 0 is a valid hash, so "is anything compiled" is its own flag // The name AND content that failed, so a retry is skipped only while both still match. Content // too, because the whole point of this step is that a file's text changes under a fixed name: // latching on the name alone would refuse to re-try a script the user just fixed. diff --git a/src/platform/platform.h b/src/platform/platform.h index 7c32cd0d..aef95334 100644 --- a/src/platform/platform.h +++ b/src/platform/platform.h @@ -275,11 +275,15 @@ const char* psramType(); // WiFi co-processor status, for boards whose radio lives on a separate chip (the // ESP32-P4 + on-board ESP32-C6 over esp_hosted). Returns a short status string: -// the detected co-processor firmware version when the link is up (e.g. -// "C6 fw 2.12.9"), "not detected" when the slave never completed its handshake or -// reports 0.0.0 (the tell for absent / incompatible C6 slave firmware), or "" on -// targets with a native radio (no co-processor). Lets SystemModule prove the C6 -// firmware state instead of guessing. Empty string => render nothing. +// the detected co-processor firmware version when the query answers (e.g. +// "C6 fw 2.12.9"), "querying…" while attempts remain, "no version reply" once a +// bounded number of attempts have gone unanswered, or "" on targets with a native +// radio (no co-processor). Empty string => render nothing. +// +// "no version reply" rather than "not detected": on the bench the C6 associates and +// serves traffic while this particular RPC times out, so declaring the slave absent +// would be a false statement about working hardware. The field says what is known — +// the query did not answer — and leaves the conclusion to whoever reads it. const char* coprocessorWifi(); // This host's LAN IPv4 address as a dotted string, or "" if unavailable. diff --git a/src/ui/app.js b/src/ui/app.js index 087b5d38..03fcf6e9 100644 --- a/src/ui/app.js +++ b/src/ui/app.js @@ -1770,7 +1770,9 @@ function createControl(moduleName, moduleType, ctrl) { // what kind of file this is. const dir = ctrl.dir || ""; const ext = ctrl.ext || ""; - const pathOf = (n) => (n ? joinFsPath(dir, n) : ""); + // No `dir` means the name IS the path: joinFsPath("", n) would return "/n" and point + // at the filesystem root instead of the file the module named. + const pathOf = (n) => (n ? (dir ? joinFsPath(dir, n) : n) : ""); const stack = document.createElement("div"); stack.className = "control-fileedit-stack"; @@ -1872,11 +1874,16 @@ function createControl(moduleName, moduleType, ctrl) { // whatever it saved is what this pane should now show. popBtn.addEventListener("click", async () => { if (!picker.value) return; + // Flush unsaved edits first: the modal loads the file from the device, so opening + // it on a dirty pane would show stale bytes and then save them back over the edit. + await editor.save(); await openFileEditor(pathOf(picker.value)); await editor.load(pathOf(picker.value)); }); - picker.addEventListener("change", () => { + picker.addEventListener("change", async () => { + // Same reason as the modal above: switching files discards the edit otherwise. + await editor.save(); dragTs[key] = Date.now(); sendControl(moduleName, ctrl.name, picker.value); editor.load(pathOf(picker.value)); @@ -4671,9 +4678,9 @@ async function fmDropUpload(destDir, files) { // Load `relPath` into `textarea`. Returns {readOnly, message}: read-only when the file cannot be // safely round-tripped through a