diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 43c60ae6..688645af 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -53,7 +53,13 @@ jobs: # (latest) and workflow_dispatch with a non-version tag. runs-on: ubuntu-latest steps: + # persist-credentials: false on every checkout that doesn't push — keeps the + # GITHUB_TOKEN out of .git/config where later steps (incl. third-party Docker + # actions) could read it. The `release` job is the one exception: its + # "Re-create latest" step does `git push -f`, so it keeps the credential. - uses: actions/checkout@v4 + with: + persist-credentials: false - uses: astral-sh/setup-uv@v3 - name: Verify tag matches library.json version if: startsWith(github.ref, 'refs/tags/v') && !contains(github.ref_name, '-') || (github.event_name == 'workflow_dispatch' && startsWith(inputs.tag, 'v')) @@ -66,15 +72,36 @@ jobs: TAG: ${{ inputs.tag || github.ref_name }} run: uv run scripts/build/verify_version.py --tag "$TAG" - build-esp32: + # The shipping firmware list, read from the generated docs/install/firmwares.json + # (projected from build_esp32.py's FIRMWARES dict, drift-guarded by + # check_firmwares.py). Emitted as a JSON array so build-esp32's matrix can + # fromJSON() it — GitHub matrices can't read a file at parse time, so a job + # output is the standard bridge. This is the ONLY firmware list in CI now. + firmwares: needs: verify-version runs-on: ubuntu-latest + outputs: + list: ${{ steps.gen.outputs.list }} + steps: + - uses: actions/checkout@v4 + with: + persist-credentials: false + - id: gen + run: | + set -euo pipefail + echo "list=$(jq -c '[.firmwares[] | select(.ships) | .name]' docs/install/firmwares.json)" >> "$GITHUB_OUTPUT" + + build-esp32: + needs: [verify-version, firmwares] + runs-on: ubuntu-latest strategy: fail-fast: false matrix: - firmware: [esp32, esp32-16mb, esp32-eth, esp32-eth-wifi, esp32s3-n16r8, esp32s3-n8r8, esp32p4-eth] + firmware: ${{ fromJSON(needs.firmwares.outputs.list) }} steps: - uses: actions/checkout@v4 + with: + persist-credentials: false - name: Cache ESP-IDF tooling uses: actions/cache@v4 @@ -115,9 +142,9 @@ jobs: # The IDF target follows the firmware-key prefix: esp32s3* → esp32s3, # esp32p4* → esp32p4 (the only target that pulls the ip101 PHY + # esp_hosted, both manifest-gated on target == esp32p4), everything - # else → esp32. (esp32p4-eth-wifi is intentionally NOT in the matrix - # yet — its C6-slave Kconfig defaults don't survive a plain CI build; - # tracked in backlog § ESP32-P4 round 3.) + # 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 — + # its C6-slave Kconfig defaults don't survive a plain CI build.) target: ${{ 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`) @@ -136,10 +163,18 @@ jobs: # CI uses one per matrix job, dev machines use as many as built. B=build/esp32-${{ matrix.firmware }} PREFIX="firmware-${{ matrix.firmware }}-v$V" - cp "$B/projectMM.bin" "dist/${PREFIX}.bin" - cp "$B/bootloader/bootloader.bin" "dist/${PREFIX}-bootloader.bin" - cp "$B/partition_table/partition-table.bin" "dist/${PREFIX}-partition-table.bin" - cp "$B/ota_data_initial.bin" "dist/${PREFIX}-ota-data.bin" + # App + bootloader are unique per firmware — staged with the prefix. + cp "$B/projectMM.bin" "dist/${PREFIX}.bin" + cp "$B/bootloader/bootloader.bin" "dist/${PREFIX}-bootloader.bin" + # ota-data is byte-identical for ALL firmwares, and partition-table is + # identical within a flash-size group — staged under SHARED names so the + # release uploads each once instead of one-per-firmware. Every matrix job + # writes the same shared filename(s); the flatten step keeps one copy + # (byte-identical, so last-writer-wins is correct). The generated + # manifests point at these same names (generate_manifest.py). + SIZE=$(jq -r .flash_settings.flash_size "$B/flasher_args.json" | tr 'A-Z' 'a-z') + cp "$B/partition_table/partition-table.bin" "dist/partition-table-$SIZE.bin" + cp "$B/ota_data_initial.bin" "dist/shared-ota-data.bin" # Per-firmware flasher_args.json — the release job feeds it to # generate_manifest.py so offsets come from the real build. cp "$B/flasher_args.json" "dist/flasher-${{ matrix.firmware }}.json" @@ -154,6 +189,8 @@ jobs: runs-on: macos-14 steps: - uses: actions/checkout@v4 + with: + persist-credentials: false # CMakeLists.txt calls find_program(UV_EXECUTABLE … REQUIRED) so the # build-host Python (gzip / build_info.h) is reached through uv. The # runners don't ship uv by default — install it before package_desktop. @@ -170,6 +207,8 @@ jobs: runs-on: windows-latest steps: - uses: actions/checkout@v4 + with: + persist-credentials: false # Same uv prerequisite as build-macos — see the comment there. - uses: astral-sh/setup-uv@v3 - name: Build + package Windows x64 @@ -194,8 +233,13 @@ jobs: # tagged releases with zero binaries. Pages deploy lives in its own job # below (gated to main, the only ref the environment trusts). steps: + # NB: this checkout KEEPS persisted credentials (unlike the other jobs) — + # the "Re-create latest" step below force-pushes the `latest` tag with git, + # which needs the token in .git/config. - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v3 + - uses: actions/download-artifact@v4 with: path: artifacts @@ -235,11 +279,10 @@ jobs: # — no CORS). The Pages-relative manifests are generated in the # deploy-pages job, where the web installer (CORS-bound) consumes them. BASE="https://github.com/${REPO}/releases/download/$TAG" - # Keep in sync with the build-esp32 matrix above (the source of truth - # for which firmware variants exist). A variant missing here builds but - # gets no manifest, so the installer can't flash it. - for F in esp32 esp32-16mb esp32-eth esp32-eth-wifi esp32s3-n16r8 esp32s3-n8r8 esp32p4-eth; do - python scripts/build/generate_manifest.py \ + # The shipping firmware list — the same docs/install/firmwares.json the + # build matrix reads, so manifests and builds can't drift. + for F in $(jq -r '.firmwares[] | select(.ships) | .name' docs/install/firmwares.json); do + uv run python scripts/build/generate_manifest.py \ --firmware "$F" \ --version "$V" \ --release-url "$BASE" \ @@ -303,6 +346,8 @@ jobs: # is a glob pattern, the action does NOT strip `#` as comment syntax. files: | dist/firmware-*.bin + dist/shared-ota-data.bin + dist/partition-table-*.bin dist/manifest-*.json dist/projectMM-*.tar.gz dist/projectMM-*.zip @@ -326,6 +371,10 @@ jobs: url: ${{ steps.deploy-pages.outputs.page_url }} steps: - uses: actions/checkout@v4 + with: + persist-credentials: false + + - uses: astral-sh/setup-uv@v3 - uses: actions/download-artifact@v4 with: @@ -346,10 +395,9 @@ jobs: # because the web installer is same-origin-bound by CORS. The # absolute release-asset manifests live in the `release` job. mkdir -p pages-manifests - # Keep in sync with the build-esp32 matrix above (the source of truth - # for which firmware variants exist). A variant missing here builds but - # gets no manifest, so the installer can't flash it. - for F in esp32 esp32-16mb esp32-eth esp32-eth-wifi esp32s3-n16r8 esp32s3-n8r8 esp32p4-eth; do + # The shipping firmware list — the same docs/install/firmwares.json the + # build matrix reads, so manifests and builds can't drift. + for F in $(jq -r '.firmwares[] | select(.ships) | .name' docs/install/firmwares.json); do python scripts/build/generate_manifest.py \ --firmware "$F" \ --version "$V" \ @@ -391,6 +439,8 @@ jobs: gh release download "$T" \ --dir "pages/install/releases/$T" \ --pattern 'firmware-*.bin' \ + --pattern 'shared-ota-data.bin' \ + --pattern 'partition-table-*.bin' \ --pattern 'projectMM-*.tar.gz' \ --pattern 'projectMM-*.zip' \ || echo " (skip $T — no matching assets)" @@ -423,6 +473,19 @@ jobs: cp src/ui/install-picker.js pages/install/ # library.json — install page reads the project version from it. cp library.json pages/install/ + # Board picker images live in docs/assets/boards/ (the project's asset + # home, also a library for boards not yet in the catalog). Stage ONLY the + # images a boards.json entry actually references, under install/assets/, + # so an "image": "assets/boards/.jpg" resolves same-origin from + # /install/ without shipping the unused library to Pages. + mkdir -p pages/install/assets/boards + # rel is "assets/boards/." (the path served from /install/); + # the source file lives in docs/ (i.e. docs/assets/boards/...). + jq -r '.[].image // empty' docs/install/boards.json | while read -r rel; do + src="docs/$rel" + [ -f "$src" ] && cp "$src" "pages/install/$rel" \ + || echo "WARNING: boards.json image not found: $src" + done # Root landing page (moonmodules.org/projectMM/) → Flash button + repo # links. Without it the bare root 404s; only /install/ would exist. cp docs/landing/index.html pages/index.html diff --git a/CLAUDE.md b/CLAUDE.md index edeeab92..3960e482 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -110,8 +110,10 @@ The narrow safety net: "this snapshot is internally consistent." 5. Platform boundary, `check_platform_boundary.py`, if any file under `src/` (excluding `src/platform/`) changed. 6. ESP32 build, `build_esp32.py`, if any file under `src/` (excluding `src/platform/desktop/`), `esp32/`, `CMakeLists.txt`, or `library.json` changed. 7. KPI collection, `collect_kpi.py --commit`, if any file under `src/` changed. **The one-liner MUST include `tick:Xus(FPS:Y)` for every supported target** (PC + ESP32 today; Teensy/RPi when added). If a target's tick/FPS is missing (e.g. ESP32 wasn't monitored recently and `esp32/monitor.log` is stale), re-run a short live capture before committing, or note explicitly in the commit body why the value is absent. +8. Board catalog, `check_boards.py`, fast (<1s), if `docs/install/boards.json` or `scripts/check/check_boards.py` changed. Validates the installer catalog: required fields, `default_firmware ∈ firmwares`, every `image` resolves on disk, each `Board.board` control equals its entry `name`, module `type`s are factory-registered (or boot-wired singletons), `pins` controls live only on `*LedDriver` modules, and `supported` capabilities stay within the known vocabulary. +9. Firmware list, `check_firmwares.py`, fast (<1s), if `scripts/build/build_esp32.py`, `docs/install/firmwares.json`, or `scripts/check/check_firmwares.py` changed. Regenerates the firmware projection from the `FIRMWARES` dict and fails on drift from the committed `docs/install/firmwares.json` (so a `FIRMWARES` edit without regenerating is caught). Trigger includes `build_esp32.py` because that dict is the upstream source. -A commit that touches *only* `.github/`, `docs/`, `scripts/` (non-test), `README.md`, `CLAUDE.md`, or `.claude/` therefore runs only the spec check; the rest are no-ops because their triggers don't fire. This is the intended pre-commit cost for CI-only or doc-only changes. +A commit that touches *only* `.github/`, `docs/`, `scripts/` (non-test), `README.md`, `CLAUDE.md`, or `.claude/` therefore runs only the spec check (plus the board-catalog and firmware checks when their specific files changed); the build/test/ESP32/KPI gates are no-ops because their triggers don't fire. This is the intended pre-commit cost for CI-only or doc-only changes. **Recommended (manual, not blocking):** diff --git a/README.md b/README.md index 60c6e46a..170b5e53 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ If you like projectMM, give it a ⭐️, fork it, or open an issue or pull reque 💡 **DMX *and* addressable LEDs in one setup**: RGB strips, RGBW pixels, par lights, moving heads, all through the same pipeline. -🔌 **Parallel WS2812 output**: drive many strands at once over three ESP32 peripherals — RMT (every chip), the S3's LCD_CAM i80 bus (8 lanes), and the P4's Parlio engine (up to 20 simultaneous strands on the P4) — each with an on-device loopback self-test that bit-verifies the wire signal. +🔌 **Parallel WS2812 output**: drive many strands at once over three ESP32 peripherals — RMT (every chip), the S3's LCD_CAM i80 bus (8 lanes), and the P4's Parlio engine (up to 8 lanes) — each with an on-device loopback self-test that bit-verifies the wire signal. 🌐 **Industry protocols, both directions**: send *and* receive [Art-Net](https://art-net.org.uk/), [E1.31/sACN](https://tsp.esta.org/tsp/documents/docs/ANSI_E1-31-2018.pdf), and [DDP](http://www.3waylabs.com/ddp/) over the network — interoperable with Falcon, Advatek, xLights, Resolume, LedFx and other industry gear. @@ -58,20 +58,20 @@ The **Desktop** column is host-CPU-bound, not OS-bound: the numbers track the ma ### Frames per second -| Grid | Lights | Desktop | Olimex `esp32-eth-wifi` | Olimex `esp32-eth` | LOLIN S3 N16R8 `esp32s3-n16r8` | +| Grid | Lights | Desktop | Olimex `esp32` | Olimex `esp32-eth` | LOLIN S3 N16R8 `esp32s3-n16r8` | |---|---:|---:|---:|---:|---:| | 16×16 | 256 | *(below host clock resolution)* | 1,543 | 1,628 | 1,672 | | 32×32 | 1,024 | 166,667 | 447 | 432 | 287 | | 64×64 | 4,096 | 40,000 | 81 | 71 | 25 | | 128×128 | 16,384 | 9,708 | 11 | 10 | 6 | -The LOLIN S3 N16R8 is WiFi-only and runs with `Network.txPowerSetting` capped to 8 dBm (the brown-out fix, see below). At 128×128 it's bound by ArtNet over WiFi at reduced TX power (~93 ms of the ~164 ms tick), which is why it trails the Ethernet boards despite a faster core. The board's niche is PSRAM headroom (8 MB) for large pixel buffers, not raw ArtNet FPS; use an Ethernet board when frame rate matters. +The Olimex `esp32` figures were measured on the WiFi+Ethernet build (the pre-collapse `esp32-eth-wifi`, now the default `esp32`). The LOLIN S3 N16R8 was measured over WiFi with `Network.txPowerSetting` capped to 8 dBm (the brown-out fix, see below); at 128×128 it's bound by ArtNet over WiFi at reduced TX power (~93 ms of the ~164 ms tick), which is why it trails the Ethernet boards despite a faster core. (The S3 now also supports W5500 SPI Ethernet, which sidesteps that WiFi bottleneck on boards wired for it.) The board's niche is PSRAM headroom (8 MB) for large pixel buffers; use an Ethernet board when frame rate matters. ### Free heap Each cell is **free internal RAM / largest contiguous internal-RAM block**. Internal RAM is the scarce, comparable resource across all boards, so for PSRAM boards (the S3) this is internal-only, NOT the PSRAM-merged total (we assume the 8 MB PSRAM pool is large enough that it isn't the constraint). The block size is the memory-pressure signal that matters: free RAM can be ample while fragmentation leaves no single block big enough for the next allocation. -| Grid | Desktop | Olimex `esp32-eth-wifi` | Olimex `esp32-eth` | LOLIN S3 N16R8 `esp32s3-n16r8` | +| Grid | Desktop | Olimex `esp32` | Olimex `esp32-eth` | LOLIN S3 N16R8 `esp32s3-n16r8` | |---|---:|---:|---:|---:| | 16×16 | unlimited | 139 KB / 52 KB | 178 KB / 100 KB | 238 KB / 160 KB | | 32×32 | unlimited | 132 KB / 50 KB | 172 KB / 92 KB | 240 KB / 152 KB | @@ -80,7 +80,7 @@ Each cell is **free internal RAM / largest contiguous internal-RAM block**. Inte The S3's internal-free stays flat across grid sizes because its Layer buffer + LUT live in PSRAM: growing the grid consumes PSRAM, not internal RAM. The Olimex boards hold those buffers in internal RAM, so their free heap drops as the grid grows. -Build variants differ structurally: `esp32-eth-wifi` includes the WiFi stack (~270 KB flash, ~28 KB heap). `esp32-eth` drops WiFi for more free heap, at the cost of slightly slower tick on large grids (lwIP buffer-pool sizing is tuned for the eth-wifi sdkconfig). The right variant depends on whether the deployment needs WiFi, Ethernet, or large buffers. +Build variants differ structurally: the default `esp32` includes the WiFi stack (~270 KB flash, ~28 KB heap) alongside Ethernet. `esp32-eth` drops WiFi for more free heap, at the cost of slightly slower tick on large grids (lwIP buffer-pool sizing is tuned for the WiFi+Ethernet sdkconfig). The right variant depends on whether the deployment needs WiFi at all, or only Ethernet plus the extra buffers. The numbers above are observations. The **contracts** projectMM commits to, what the device must hit on every CI run, live in [`test/scenarios/*.json`](test/scenarios/) as per-step `contract.` blocks; see [docs/testing.md § Performance contracts](docs/testing.md#performance-contracts-contracttarget) for how they're set and renegotiated. The [docs/performance.md](docs/performance.md) page covers the *why* (WiFi vs Ethernet physics, sizeof tables, build-variant deltas). diff --git a/docs/architecture.md b/docs/architecture.md index 83e31104..841685fe 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -219,11 +219,30 @@ Abstractions are added when a concrete implementation needs them, not pre-design ## Firmware vs board -**Firmware** is the compiled binary: chip target plus which radios/peripherals/sdkconfig fragments are included. Today's variants: `esp32` (WiFi only), `esp32-eth` (Ethernet only, WiFi excluded), `esp32-eth-wifi` (both), `esp32s3-n16r8` (S3 with 16 MB flash + 8 MB PSRAM), `esp32p4-eth` (Waveshare ESP32-P4-NANO, Ethernet only), `esp32p4-eth-wifi` (the same P4 board with WiFi via its on-board ESP32-C6 over esp_hosted). 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 board from boards.json), `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 board 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 board uses is runtime config. Selected by `build_esp32.py --firmware `, reported by `SystemModule.firmware`, used as the contract target key in scenarios. -**Board** is the physical hardware: chip + PCB + on-board peripherals (PHY, USB-serial, PSRAM, antenna). Examples: `Olimex ESP32-Gateway Rev G`, `LOLIN D32`, `Generic ESP32 DevKit`. The device cannot identify its own board (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. The board name is stored on the device by [BoardModule](moonmodules/core/BoardModule.md), a code-wired child of SystemModule that holds a single `board` Text control with the `readonly` UI flag (renders display-only on the device's own UI; HTTP `/api/control` writes still apply). MoonDeck mirrors the picked / deduced value to the device via `POST /api/control` after each discover and after every dropdown change. The catalog of valid board names lives at [docs/install/boards.json](install/boards.json), shared between MoonDeck and the web installer: MoonDeck reads it for its dropdown and HTTP `/api/control` push; the web installer reads it for its picker, pushes the picked board via Improv RPC `SET_BOARD` on first flash, and provides an HTTP fallback (Inject button on *Your devices*) when Improv isn't available on the firmware variant. +**Board** is the physical hardware: chip + PCB + on-board peripherals (PHY, USB-serial, PSRAM, antenna). Examples: `Olimex ESP32-Gateway Rev G`, `LOLIN D32`, `Generic ESP32 Dev`. The device cannot identify its own board (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. The board name is stored on the device by [BoardModule](moonmodules/core/BoardModule.md), a code-wired child of SystemModule that holds a single `board` Text control with the `readonly` UI flag (renders display-only on the device's own UI; HTTP `/api/control` writes still apply). MoonDeck mirrors the picked / deduced value to the device via `POST /api/control` after each discover and after every dropdown change. The catalog of valid board names lives at [docs/install/boards.json](install/boards.json), shared between MoonDeck and the web installer: MoonDeck reads it for its dropdown and HTTP `/api/control` push; the web installer reads it for its picker, pushes the picked board via Improv RPC `SET_BOARD` on first flash, and provides an HTTP fallback (Inject button on *Your devices*) when Improv isn't available on the firmware variant. -A board can run multiple firmwares (the Olimex Gateway runs both `esp32-eth` and `esp32-eth-wifi`); a firmware can run on multiple boards (`esp32` runs on any classic ESP32 dev board). The `esp32s3-n16r8` firmware is S3-only and does not run on the Olimex Gateway or other classic-ESP32 boards. The codebase reserves "board" exclusively for physical hardware and "firmware" exclusively for the compiled binary. +A board can run multiple firmwares (the Olimex Gateway runs both `esp32-eth` and the default `esp32`); a firmware can run on multiple boards (`esp32` runs on any classic ESP32 dev board). The `esp32s3-n16r8` firmware is S3-only and does not run on the Olimex Gateway or other classic-ESP32 boards. The codebase reserves "board" exclusively for physical hardware and "firmware" exclusively for the compiled binary. + +### Config provenance: MCU → Board → Device + +Firmware-vs-board is the first cut of a three-level model for **where a pin or setting default legitimately comes from**. The installer and MoonDeck use it so a user picks their hardware instead of hand-typing every GPIO. Each level may fix some settings, and a default belongs at the level that *fixes* it: + +- **MCU → firmware.** The chip (classic / S3 / P4). Fixes silicon-wired facts: which Ethernet *driver* is compiled in (RMII EMAC on classic/P4, W5500 SPI on S3), native-radio presence, PSRAM — the compile-time `hasI2sMic` / `hasWiFi` / `hasEthernet` constants in `platform_config.h`. The Ethernet *pins/PHY* are no longer MCU-fixed: `platform::ethConfigDefault` is only the per-chip **seed** for the runtime config (overridable per board via `boards.json` → `setEthConfig`), so the actual pin map is decided at the Board/Device level below. The firmware variant *is* the MCU choice. +- **Board → MCU on a PCB.** Fixes board-soldered pins: the onboard status LED, the board's own Ethernet PHY pins, C6 SDIO pins, button pins. A board is a named bundle of "these GPIOs are already committed by the PCB." +- **Device → board + peripherals.** What the *user* soldered on (the mic, the LED strands, a loopback jumper). The same board yields different devices; these are user choices, so they default to **unset** — any guess can drive a pin the user wired elsewhere. + +**The governing rule — "default only at the level that actually fixes it"** — is the [`assign defaults only when they cannot do harm`](history/decisions.md) rule applied to pin provenance. MCU- and Board-level pins are hardware-fixed, so a default cannot do harm (and *omitting* it harms: a board with un-defaulted Ethernet pins can never connect). Device-level pins are user-soldered, so any default is a guess that can drive a pin the user wired elsewhere — it stays empty until set. So the shipping empty Device-level defaults (AudioModule mic pins, LED-driver pins) are the correct baseline a device profile *fills* from confirmed hardware, not one it *overrides*. + +The rule covers **settings, not just pins**. `txPowerSetting` is the worked example: WiFi TX power is a Device-level setting, not MCU or Board. The radio can do 21 dBm (an MCU fact), but whether the assembled rig can *sustain* the current spike of full-power TX depends on the board's power regulation and how the user powers it (USB port, PSU, cable) — a brownout property of the whole device. In-tree, [`boards.json`](install/boards.json) sets `Network.txPowerSetting: 8` for the ESP32-S3 N16R8 Dev, which browns out at full power on typical USB; a board entry *suggests* the safe floor, and the device can lower it further for a weak supply. The general lesson: any setting whose safe value depends on the physical assembly or its power, not the chip, defaults at the Device level. (The catalog that carries these per-board/device values is [`install/boards.json`](install/boards.json); see the [installer README](install/README.md) for its schema.) + +**Board vs Device — two kinds in one catalog (decided direction; not yet split).** The catalog entries fall into two natural kinds, and as the list grows this is how they group: + +- A **dev board / devkit** is a bare MCU on a PCB with broken-out GPIO headers — the user solders their own strands, mic, etc. Its Device-level pins are therefore *unset* (user choice). Examples: `Generic ESP32 Dev`, `LOLIN D32`, `ESP32-S3 N16R8 Dev`, `Waveshare ESP32-P4-NANO`. +- A **device** is a finished product with *fixed* wiring — a purpose-built LED controller (often a carrier or shield sitting on a devkit) whose LED-data pins, relays, mic, etc. are known. Examples: `QuinLED Dig-2-Go`, the MHC / Serg / LightCrafter boards, `ABC-WLED ESP32-P4 shield`. Here the Device-level pins *are* known and shipped in the entry, because the product fixes them. + +So "board = devkit, device = product" is the same MCU→Board→Device spectrum seen from the catalog: a device is a board with its Device level filled in. **Today both kinds live in one flat [`boards.json`](install/boards.json)** — a device is just an entry with more `modules`/`controls` set. A separate `devices.json` (or a `kind: devkit|product` tag + the reserved [`extends`](install/README.md) carrier→device composition) is the way to formalise the grouping *once the catalog is large enough that the flat list is unwieldy* — per the sequencing rule, that split lands when it earns its keep, not before. Recording the direction here so the kind distinction is decided, not re-litigated, when that point comes. ## Peripherals diff --git a/docs/assets/boards/abc-wled-esp32-p4-shield.jpg b/docs/assets/boards/abc-wled-esp32-p4-shield.jpg new file mode 100644 index 00000000..fffe9637 Binary files /dev/null and b/docs/assets/boards/abc-wled-esp32-p4-shield.jpg differ diff --git a/docs/assets/boards/ch9102.jpg b/docs/assets/boards/ch9102.jpg new file mode 100644 index 00000000..fc6454d6 Binary files /dev/null and b/docs/assets/boards/ch9102.jpg differ diff --git a/docs/assets/boards/cp210x-ch34x.jpg b/docs/assets/boards/cp210x-ch34x.jpg new file mode 100644 index 00000000..b4367510 Binary files /dev/null and b/docs/assets/boards/cp210x-ch34x.jpg differ diff --git a/docs/assets/boards/esp32-c3-supermini.jpg b/docs/assets/boards/esp32-c3-supermini.jpg new file mode 100644 index 00000000..2c514c70 Binary files /dev/null and b/docs/assets/boards/esp32-c3-supermini.jpg differ diff --git a/docs/assets/boards/esp32-c3.jpg b/docs/assets/boards/esp32-c3.jpg new file mode 100644 index 00000000..06553c67 Binary files /dev/null and b/docs/assets/boards/esp32-c3.jpg differ diff --git a/docs/assets/boards/esp32-d0-pico2.jpg b/docs/assets/boards/esp32-d0-pico2.jpg new file mode 100644 index 00000000..397466b5 Binary files /dev/null and b/docs/assets/boards/esp32-d0-pico2.jpg differ diff --git a/docs/assets/boards/esp32-d0-wrover.jpg b/docs/assets/boards/esp32-d0-wrover.jpg new file mode 100644 index 00000000..9ea844b8 Binary files /dev/null and b/docs/assets/boards/esp32-d0-wrover.jpg differ diff --git a/docs/assets/boards/esp32-p4-eth.png b/docs/assets/boards/esp32-p4-eth.png new file mode 100644 index 00000000..c8118bb6 Binary files /dev/null and b/docs/assets/boards/esp32-p4-eth.png differ diff --git a/docs/assets/boards/esp32-p4-olimex.jpg b/docs/assets/boards/esp32-p4-olimex.jpg new file mode 100644 index 00000000..65290a4d Binary files /dev/null and b/docs/assets/boards/esp32-p4-olimex.jpg differ diff --git a/docs/assets/boards/esp32-p4.jpg b/docs/assets/boards/esp32-p4.jpg new file mode 100644 index 00000000..23facbc4 Binary files /dev/null and b/docs/assets/boards/esp32-p4.jpg differ diff --git a/docs/assets/boards/esp32-s3-atoms3r.jpg b/docs/assets/boards/esp32-s3-atoms3r.jpg new file mode 100644 index 00000000..fb98d395 Binary files /dev/null and b/docs/assets/boards/esp32-s3-atoms3r.jpg differ diff --git a/docs/assets/boards/esp32-s3-n16r8v.jpg b/docs/assets/boards/esp32-s3-n16r8-dev.jpg similarity index 100% rename from docs/assets/boards/esp32-s3-n16r8v.jpg rename to docs/assets/boards/esp32-s3-n16r8-dev.jpg diff --git a/docs/assets/boards/esp32-s3-n8r8-dev.jpg b/docs/assets/boards/esp32-s3-n8r8-dev.jpg new file mode 100644 index 00000000..588d133d Binary files /dev/null and b/docs/assets/boards/esp32-s3-n8r8-dev.jpg differ diff --git a/docs/assets/boards/esp32-s3-seeed_xiao.png b/docs/assets/boards/esp32-s3-seeed_xiao.png new file mode 100644 index 00000000..ee92cce5 Binary files /dev/null and b/docs/assets/boards/esp32-s3-seeed_xiao.png differ diff --git a/docs/assets/boards/esp32-s3-stephanelec-16p.jpg b/docs/assets/boards/esp32-s3-stephanelec-16p.jpg new file mode 100644 index 00000000..c9412a12 Binary files /dev/null and b/docs/assets/boards/esp32-s3-stephanelec-16p.jpg differ diff --git a/docs/assets/boards/esp32-s3-zero-n4r2.jpg b/docs/assets/boards/esp32-s3-zero-n4r2.jpg new file mode 100644 index 00000000..92c4db36 Binary files /dev/null and b/docs/assets/boards/esp32-s3-zero-n4r2.jpg differ diff --git a/docs/assets/boards/generic-esp32-dev.jpg b/docs/assets/boards/generic-esp32-dev.jpg new file mode 100644 index 00000000..428a3af8 Binary files /dev/null and b/docs/assets/boards/generic-esp32-dev.jpg differ diff --git a/docs/assets/boards/lightcrafter-16.jpg b/docs/assets/boards/lightcrafter-16.jpg new file mode 100644 index 00000000..db5528b1 Binary files /dev/null and b/docs/assets/boards/lightcrafter-16.jpg differ diff --git a/docs/assets/boards/lolin-d32.jpg b/docs/assets/boards/lolin-d32.jpg new file mode 100644 index 00000000..800bf75f Binary files /dev/null and b/docs/assets/boards/lolin-d32.jpg differ diff --git a/docs/assets/boards/mhc-v57-pro.jpg b/docs/assets/boards/mhc-v57-pro.jpg new file mode 100644 index 00000000..e109b755 Binary files /dev/null and b/docs/assets/boards/mhc-v57-pro.jpg differ diff --git a/docs/assets/boards/olimex-esp32-gateway-rev-g.jpg b/docs/assets/boards/olimex-esp32-gateway-rev-g.jpg new file mode 100644 index 00000000..0fd1207d Binary files /dev/null and b/docs/assets/boards/olimex-esp32-gateway-rev-g.jpg differ diff --git a/docs/assets/boards/others.jpg b/docs/assets/boards/others.jpg new file mode 100644 index 00000000..2856ca53 Binary files /dev/null and b/docs/assets/boards/others.jpg differ diff --git a/docs/assets/boards/quinled-dig-2-go.jpg b/docs/assets/boards/quinled-dig-2-go.jpg new file mode 100644 index 00000000..b306e229 Binary files /dev/null and b/docs/assets/boards/quinled-dig-2-go.jpg differ diff --git a/docs/assets/boards/quinled-dig-next-2.jpg b/docs/assets/boards/quinled-dig-next-2.jpg new file mode 100644 index 00000000..9816a863 Binary files /dev/null and b/docs/assets/boards/quinled-dig-next-2.jpg differ diff --git a/docs/assets/boards/quinled-dig-octa-32-8l.jpg b/docs/assets/boards/quinled-dig-octa-32-8l.jpg new file mode 100644 index 00000000..1c73a64c Binary files /dev/null and b/docs/assets/boards/quinled-dig-octa-32-8l.jpg differ diff --git a/docs/assets/boards/quinled-dig-quad-v3.jpg b/docs/assets/boards/quinled-dig-quad-v3.jpg new file mode 100644 index 00000000..a1d63de1 Binary files /dev/null and b/docs/assets/boards/quinled-dig-quad-v3.jpg differ diff --git a/docs/assets/boards/quinled-dig-uno-v3.jpg b/docs/assets/boards/quinled-dig-uno-v3.jpg new file mode 100644 index 00000000..4bf8abd5 Binary files /dev/null and b/docs/assets/boards/quinled-dig-uno-v3.jpg differ diff --git a/docs/assets/boards/serg-minishield.jpg b/docs/assets/boards/serg-minishield.jpg new file mode 100644 index 00000000..7d5162e9 Binary files /dev/null and b/docs/assets/boards/serg-minishield.jpg differ diff --git a/docs/assets/boards/serg-unishield-v5.jpg b/docs/assets/boards/serg-unishield-v5.jpg new file mode 100644 index 00000000..8734156a Binary files /dev/null and b/docs/assets/boards/serg-unishield-v5.jpg differ diff --git a/docs/assets/boards/esp32-p4-nano.jpg b/docs/assets/boards/waveshare-esp32-p4-nano.jpg similarity index 100% rename from docs/assets/boards/esp32-p4-nano.jpg rename to docs/assets/boards/waveshare-esp32-p4-nano.jpg diff --git a/docs/assets/screenshots/installer-board-picker-collapsed.png b/docs/assets/screenshots/installer-board-picker-collapsed.png new file mode 100644 index 00000000..cdac9678 Binary files /dev/null and b/docs/assets/screenshots/installer-board-picker-collapsed.png differ diff --git a/docs/assets/screenshots/installer-board-picker-expanded.png b/docs/assets/screenshots/installer-board-picker-expanded.png new file mode 100644 index 00000000..7b91cdf0 Binary files /dev/null and b/docs/assets/screenshots/installer-board-picker-expanded.png differ diff --git a/docs/backlog/README.md b/docs/backlog/README.md index 9a4d2039..03c82ee7 100644 --- a/docs/backlog/README.md +++ b/docs/backlog/README.md @@ -20,3 +20,4 @@ One-off research documents that informed a future direction, kept for the reason - [leddriver-analysis-bottom-up.md](leddriver-analysis-bottom-up.md) — the companion landscape survey: catalogues the existing LED-driver libraries across ESP32, Teensy, Raspberry Pi, and PC, and recommends a path. - [leddriver-increment-1-plan.md](leddriver-increment-1-plan.md) — the concrete first-increment plan distilled from the two analyses: RMT/WS2812B on classic ESP32, the unified one-base driver hierarchy (ArtNet + LED + Preview as peer interpreters of the light preset), the platform seam, and the loopback + host-encoder test strategy. Locked product-owner decisions at the top. - [leddriver-increment-2-plan.md](leddriver-increment-2-plan.md) — the second increment: 2a multi-pin RMT (implemented; classic + S3 via SOC capability constants) and 2b parallel LCD_CAM on the S3 (open: lane count and LEDs-per-lane targets). Locked decisions and the deferred per-driver buffer window at the top. +- [installer-3layer-plan.md](installer-3layer-plan.md) — the **remaining** installer increments (firmwares.json generator, release-asset dedup, annotated-pin images, shared-client code, firmware-variant collapse, EIM coordination) + build order. Durable parts already shipped have moved to permanent homes (the MCU→Board→Device model → `architecture.md`; the catalog schema → `install/README.md`; the hard-won principles → `history/decisions.md`); this doc shrinks toward empty as the rest lands. diff --git a/docs/backlog/backlog.md b/docs/backlog/backlog.md index 2ffadb09..5642f4dc 100644 --- a/docs/backlog/backlog.md +++ b/docs/backlog/backlog.md @@ -16,12 +16,7 @@ Completed items are removed. This file is deleted when empty. - **Raspberry Pi** — ARM64, cross-built or native. - **macOS code-signing** — drops the Gatekeeper "downloaded from internet" prompt. - **Windows code-signing** — drops the SmartScreen warning on first run of `projectMM.exe`. Same shape as macOS signing; needs an EV / OV code-signing certificate (Microsoft Trusted Signing is the cheapest current option). Until then, the README notes the SmartScreen prompt. -- **Runtime PHY / pin config for Ethernet** — replaces build-time Olimex-pin baking in `sdkconfig.defaults.eth` with a runtime picker via `platform::ethPresent()` / `platform::wifiPresent()`. Once landed, `esp32-eth*` variants stop being Olimex-specific; NetworkModule's `onBuildControls()` flips the `hidden` flag on absent-interface controls. **This unblocks the firmware-variant collapse below — it is the prerequisite.** - - **Target end-state once runtime PHY config lands — collapse the three classic variants to two.** Today there are three (`esp32` = WiFi-only, `esp32-eth` = Eth-only-Olimex, `esp32-eth-wifi` = both-Olimex), because the `.eth` fragment *bakes in Olimex's RMII pins* (clock GPIO17, reset GPIO5, LAN8720, addr 0 — see `sdkconfig.defaults.eth` + `ethInit()` in `platform_esp32.cpp`). So Ethernet can't be in the default build: a LOLIN/QuinLED/Generic board flashing it would drive Olimex's pins as RMII lines and stall at boot waiting for a PHY that isn't there. Once `ethInit()` selects pins/PHY at runtime (and no-ops when no PHY is present), the variants become: - - **`esp32`** — the default: WiFi **and** Ethernet, Eth brought up only when a PHY is detected/configured. Replaces both `esp32` and `esp32-eth-wifi`. - - **`esp32-eth-only`** (today's `esp32-eth`) — Eth only, WiFi compiled out via `EXCLUDE_COMPONENTS`. Kept for two real reasons: it saves flash, and it guarantees no WiFi stack is present to interfere (relevant to the [WiFi ArtNet performance](#wifi-artnet-performance-pending-investigation) and the eth-flapping investigation). Only the Eth-only case earns a separate firmware — so this is variant *reduction*, not explosion. - - **PSRAM boards still get the default `esp32`/`esp32s3-*` build, Ethernet-capable, no extra firmware.** The **ESP32-S3 has no built-in EMAC**, but that does *not* mean a separate firmware: an external **SPI PHY (W5500) wired to GPIO pins** gives the S3 Ethernet, and which PHY/pins (RMII vs SPI, the W5500 MISO/MOSI/SCK/CS/IRQ above) is **runtime config**, exactly the runtime-PHY mechanism this item adds — the same way MoonLight handles it (`ethernetType: BoardDefault / LAN8720-RMII / W5500-SPI` per board). So the default firmware ships **both** the RMII and the SPI-PHY driver; whether Ethernet comes up, and over which PHY, is selected at runtime from the board/pin config. Classic ESP32 uses its internal RMII MAC; S3 uses an SPI PHY; neither needs its own firmware variant. (Product-owner proposal, recorded on PR #16.) +- **Live RMII Ethernet reconfigure** — runtime PHY/pin config shipped (`ethType` + pin controls in NetworkModule, per-board defaults in `boards.json`, `platform::setEthConfig`/`ethInit` dispatch). W5500 (SPI) on S3 applies **live** — `ethStop()` tears down the SPI bus and `ethInit()` re-runs on the next `loop1s()` with no reboot. RMII (classic/P4 internal EMAC) still saves config and asks for a restart to apply, because the EMAC bring-up is fiddlier to hot-cycle cleanly. Make RMII live too: a hot `esp_eth_stop` + EMAC/netif teardown + re-init on config change, matching the W5500 path, so every interface honours the no-reboot principle. - **Installer UX polish** — clear "Pre-release (beta)" warning on RC/latest picks, yank-by-asset-tag instead of yank-by-release-deletion. --- @@ -49,7 +44,7 @@ NetworkReceiveEffect accepts E1.31 via unicast only — the same scope MoonLight - WiFi STA 64×64 (4K LEDs, 24 universes) - WiFi STA 32×32 (1K LEDs, 6 universes) -This determines the practical LED limit for WiFi-only boards. Until the `sdkconfig.defaults` TX-buffer fix lands (identified in the build-variant table), **use `esp32-eth-wifi` for any ArtNet workload on classic ESP32** even if Ethernet isn't physically connected. +This determines the practical LED limit for WiFi-only boards. Until the `sdkconfig.defaults` TX-buffer fix lands (identified in the build-variant table), **prefer wired Ethernet for any ArtNet workload on classic ESP32** — the default `esp32` build carries both stacks, so Ethernet is available even when the original measurements were taken on the old `esp32-eth-wifi` variant. ### Network round-trip test — drop/reorder measurement (deferred) @@ -122,6 +117,10 @@ Two improvements when this matters: Not blocking — MoonDeck is a developer tool, not a production server. Pick this up when MoonDeck is in scope for hardening. +### CI: pin GitHub Actions to commit SHAs (supply-chain hardening) + +`.github/workflows/release.yml` references all 9 action types by mutable `@vN` tag (`actions/checkout@v4`, `astral-sh/setup-uv@v3`, `softprops/action-gh-release@v2`, `espressif/esp-idf-ci-action@v1`, …). A mutable tag can be force-moved to malicious code by a compromised publisher; pinning each `uses:` to a full commit SHA (with a `# vN` trailing comment) removes that vector. **Done already (cheaper half):** `persist-credentials: false` on every checkout that doesn't push, so the `GITHUB_TOKEN` isn't left in `.git/config` for later steps to read (the `release` job keeps it — it force-pushes the `latest` tag). **Not done (this item):** SHA-pinning, because it carries an ongoing cost — pinned SHAs go stale and miss security patches, so it only pays for itself **alongside Dependabot** (or a Renovate config) to auto-bump them. Pick this up as a deliberate "CI hardening + Dependabot" pass, not piecemeal. Low risk today: every action pinned is a first-party `actions/*` or a well-known publisher (astral, espressif, softprops), not an obscure third-party action. + ### mDNS toggle (evaluate) Added as a diagnostic tool during performance investigation; testing showed mDNS has zero FPS impact. Evaluate whether to keep (useful for debugging on other boards) or remove (unnecessary complexity). Decide after WiFi performance testing above. @@ -178,9 +177,17 @@ No FreeRTOS tasks are pinned today. At 16K LEDs the render task takes ~52 ms/tic The LcdLedDriver (S3 LCD_CAM i80) and ParlioLedDriver (P4 Parlio) share ~245 of 362 lines, and their platform-side loopback capture+verify is ~100 lines byte-for-byte identical (`platform_esp32_parlio.cpp` even notes "The RX capture half is byte-for-byte identical" to the LCD one). The status-string lifecycle (`failBuf_` / `configErr_` / `clearFailBuf` / `clearConfigErr`) is triplicated across all three LED drivers (RMT/LCD/Parlio), ~60 lines. The branch deliberately extracted the *encoders* (`LcdSlots.h` shared by i80+Parlio, `RmtSymbol.h`, `PinList.h`) on the "extract when the second user lands" rule, but stopped at the lifecycle/loopback scaffolding. **Accepted for this merge** (the reviewer agreed driver-level extraction can wait): the duplication is in mechanical lifecycle/test scaffolding, not domain logic, and a DriverBase-level refactor touching three drivers is riskier than the duplication it removes. **Do it when the third parallel backend arrives** (16-lane widening, or Teensy FlexIO), at which point the pattern is proven three ways: (a) a `detail::` platform helper for capture+verify (the only per-peripheral difference is the transmit call, pass a callback, beside the already-shared `loopbackJumperOk`), and (b) a small owned-status helper or DriverBase members for the fail/config strings. Until then the cost is line count, not correctness. +### 1..8-pin LCD output (future) — would let S3 default to LCD + +`LcdLedDriver` requires **all 8** i80 data lanes (`kExactLaneCount = true`, `LcdLedDriver.h`): the ESP-IDF `esp_lcd` i80 bus configures every data line of the bus width and rejects a partial set, so even a few WS2812 strands claim 8 GPIOs. That's why **S3 boards default to `RmtLedDriver`** in `boards.json` (RMT runs one channel per pin, 1..N) rather than LCD — a board with fewer than 8 strips can't sensibly use the LCD driver, and the 8-lane LCD bench wiring (`1,2,4,5,6,7,8,9`) collides with common peripheral pins (e.g. the mic on 4/5/6). A **1..8-pin LCD mode** (drive only the lanes named in `pins`, leave the rest unclaimed — matching Parlio's flexibility) would let the parallel S3 path run any lane count, at which point an S3 board entry could choose LCD vs RMT by intent. Parlio already does this (`kExactLaneCount = false`, 1..8 lanes), so the P4 default *is* the parallel driver. Until LCD gains the same flexibility, S3 stays on RMT by default. Low priority — RMT covers the few-strip S3 case today. + +### Classic ESP32 I2S 16-lane parallel LED driver (future) — beyond RMT's 8 channels + +The **classic ESP32 has 8 RMT TX channels** (`platform_config.h`: "8 on classic ESP32, 4 on the S3 and P4"), so RMT covers up to 8 parallel outputs on classic ESP32 — e.g. the 8-output QuinLED Dig-Octa runs fine on `RmtLedDriver`. For **more than 8 lanes on classic ESP32**, the established trick drives the **I2S peripheral in LCD/parallel mode** (the hpwit [I2SClocklessLedDriver](https://github.com/hpwit/I2SClocklessLedDriver) / FastLED I2S lineage), clocking out up to **16 lanes** from one autonomous DMA transfer. This is the classic ESP32's high-lane-count path, distinct from the S3 (LCD_CAM → `LcdLedDriver`, plus the [1..8-pin LCD item](#18-pin-lcd-output-future--would-let-s3-default-to-lcd) above) and the P4 (Parlio). No catalog board needs it today (none exceeds 8 outputs), so no board's `planned` list points at it yet; it's the marker for a future ≥9-output classic-ESP32 board. Studied under *Industry standards, our own code* — carry the idea, write our own against the project architecture (host-testable encoder in `src/light/`, peripheral seam in `src/platform/esp32/`). Prior art: hpwit's I2SClockless lineage and FastLED's I2S driver; the same parallel-DMA lineage is already credited in [LcdLedDriver.md § Prior art](../moonmodules/light/drivers/LcdLedDriver.md#prior-art). + ### Runtime board presets (multi-commit, partially landed) -The firmware-vs-board separation is now in place across the codebase (see [architecture.md § Firmware vs board](../architecture.md#firmware-vs-board)). `build_esp32.py --firmware ` picks the compiled binary; MoonDeck deduces the physical board where the firmware uniquely identifies hardware (`esp32-eth*` ⇒ `olimex-esp32-gateway-rev-g`) and lets the user pick from a short hardcoded list otherwise. Firmware variants stay separate — `esp32-eth` saves ~670 KB flash + ~30 KB DRAM vs `esp32-eth-wifi` (measured); merging would erase that win. +The firmware-vs-board separation is now in place across the codebase (see [architecture.md § Firmware vs board](../architecture.md#firmware-vs-board)). `build_esp32.py --firmware ` picks the compiled binary; MoonDeck deduces the physical board where the firmware uniquely identifies hardware (`esp32-eth*` ⇒ `olimex-esp32-gateway-rev-g`) and lets the user pick from a short hardcoded list otherwise. Firmware variants stay separate — `esp32-eth` saves ~670 KB flash + ~30 KB DRAM vs the default `esp32` (WiFi+Ethernet, measured); merging would erase that win. What still needs separation: the eth variants hardcode Olimex Gateway RMII pins in `src/platform/esp32/platform_esp32.cpp::ethInit()`, so they only work on that one PCB. As we add boards with different pins (LOLIN D32 tested 2026-06-02, QuinLED variants planned), runtime pin configuration becomes the next step. @@ -413,17 +420,15 @@ The build IDF is `v6.1-dev-399-gd1b91b79b5`, a dev-branch snapshot (2025-11-05) ### Three-level device model: MCU → Board → Device (config provenance) -A first-class hierarchy for *where a pin default legitimately comes from*, surfaced through the installer and MoonDeck so a user picks their hardware instead of hand-typing every GPIO. Three levels, each a layer that may fix some settings: - -- **MCU → firmware.** The chip (esp32 classic / S3 / P4). Fixes the silicon-wired pins: the RMII **Ethernet** pin map, native-radio presence, PSRAM. These are chip-facts — already today's compile-time `platform::ethPins` / `hasI2sMic` / `hasWiFi` constants in `platform_config.h`. The firmware variant IS the MCU choice. -- **Board → MCU on a PCB.** e.g. Wemos D1-mini, **Waveshare ESP32-P4-Nano** (https://www.waveshare.com/esp32-p4-nano.htm). Fixes board-soldered pins: the onboard status LED, the board's own Ethernet PHY pins, the C6 co-processor SDIO pins, button pins. A board is a named bundle of "these GPIOs are already committed by the PCB." Carry a board catalog (id, name, image, **product link next to the image**, the pins it fixes). -- **Device → board + peripherals.** board + what the *user* soldered on: the mic, the LED strands, a 4↔5 rxtx loopback jumper. These are **user choices** — the same board yields different devices. A device is a saved profile of Device-level pin assignments the user confirmed once. +The model itself is now a shipped design — see [architecture.md § Config provenance](../architecture.md#config-provenance-mcu--board--device) (the three levels + the `txPowerSetting` example + "default only at the level that fixes it"). The catalog that carries it is [`install/boards.json`](../install/boards.json) ([schema](../install/README.md)). **MoonDeck device-profile save/restore is shipped** — capture a device's pin/peripheral config (`/api/save-profile`) and re-apply it after a reflash or to a clone (`/api/apply-profile`), stored per-device in `moondeck.json`. The remaining forward-looking pieces — a `devices.json`/MCU-layer split and annotated-pin images — stay gated by the sequencing rule (no catalog field ahead of a consumer); see the closed [installer-3layer-plan.md](installer-3layer-plan.md) for their status. -**The provenance rule applies to *settings*, not just pins — `txPowerSetting` is the worked example.** WiFi TX power is a Device-level setting, not MCU or Board: the radio chip can do the full 21 dBm (an MCU fact), but whether the assembled rig can *sustain* the current spike of full-power TX depends on the board's power regulation **and how the user powers it** (USB port, PSU, cable) — a brownout property of the whole device, not the silicon. Confirmed in-tree: [`boards.json`](../install/boards.json) injects `"Network": { "txPowerSetting": 8 }` for the **LOLIN S3 N16R8** specifically (the chip's default is 21), because that board browns out at full power on typical USB supply — a per-board floor the user may still need to lower further on a weak supply. So in the model, a board profile *suggests* a safe `txPowerSetting` (like it suggests board-fixed pins), and the device profile can override it for the actual power source. The general lesson: any setting whose safe value depends on the physical assembly or its power, not on the chip, defaults at the Device level under the same "default only when it cannot do harm" rule. +### Persistence overlay: partial-save / schema-change audit (backlog) -**Why this is the right model — and it's the runtime form of the pin-default rule we just landed.** The rule "**assign defaults only when they cannot do harm**" ([decisions.md](../history/decisions.md)) is exactly "**a pin may be defaulted only at the level that actually fixes it.**" MCU- and Board-level pins are hardware-fixed → a default cannot do harm (and *omitting* it harms: a no-WiFi board with un-defaulted Ethernet pins can never connect). Device-level pins are user-soldered → any default is a guess that *can* drive a pin the user wired elsewhere → must stay empty until set. So the empty Device-level defaults shipping now (AudioModule mic pins, the LED-driver pins) are the **correct baseline a device profile layers onto** — a profile *fills* blanks from confirmed hardware, it doesn't *override* a wrong guess. Had we kept bench defaults, every profile would start by undoing a guess. This backlog item is the natural home for those Device-level values: not hard-coded, but picked. - -**Installer + MoonDeck support (the deliverable).** The web installer's board picker (`docs/install/devices.js`, `src/ui/install-picker.js`) grows from "which firmware" to "which **board**" (auto-selecting the firmware/MCU), with product links beside each board image. MoonDeck gains a device-profile concept: save/restore the Device-level pin set for a named device, so re-flashing or adding a second identical rig doesn't re-type GPIOs. `moondeck.json` (the device registry) is the obvious store. Sequencing: the **Distribution → Runtime PHY/pin config** item above is the MCU/Board-level prerequisite (runtime Ethernet-pin selection); this item is the Device-level layer on top. Own branch — not now. +The absent-key fix (`json::hasKey` guard in `applyControlValue`, so a saved file omitting a key no longer zeroes the control's default — the P4 `ethType` no-DHCP root cause) closed the acute hole. A broader audit would harden the overlay against the rest of the schema-drift surface, now that controls carry meaningful non-zero defaults: +- **Type-change migration.** `ethType` changed `int16` → `uint8` (Select) this branch. A persisted file written under the old type still loads (flat parser reads the number either way), but confirm every type transition is safe — especially Text/Password/IPv4 buffer-size changes and a numeric control narrowing its range (Clamp handles range, but a renamed control silently drops its old value with no warning, unlike `migrateRenamedConfigs` for whole files). +- **Per-control rename path.** `FilesystemModule::migrateRenamedConfigs` only renames whole *files* (by type). A *control* rename within a module has no migration — the old key is silently ignored (now preserved-as-default rather than zeroed, which is better, but the user's saved value is still lost). Decide whether control-level renames need a migration map or are rare enough to accept the loss with a logged warning. +- **Test coverage.** `unit_Control_apply_absent_key` pins the absent-key contract; extend with a type-change round-trip (save int16, load into uint8 Select) and a narrowed-range clamp case so a future schema change can't regress silently. +This is hardening, not a known bug — the shipped fix is correct for the cases that occur today. ### ESP32-P4 support — rounds 3-4 (in progress) @@ -436,10 +441,12 @@ Rounds 1 (board + Ethernet-only) and 2 (Parlio LED driver) have landed. Remainin **Open issues before this is done:** 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.)** - 2. **Co-processor components compile into `esp32p4-eth` (the WiFi-less P4) at build-time only.** The `rules: target == esp32p4` gate in `idf_component.yml` pulls `esp_hosted` / `esp_wifi_remote` / `eppp_link` / `wifi_remote_over_eppp` for *any* esp32p4 build (manifest rules can't see our eth-only flag), and `EXCLUDE_COMPONENTS` does not drop a managed dependency. Impact is **build-time only**: the linker dead-strips them (their `.text` is `0x0` in the eth-only `.map`, because `coprocessorWifi()` is the empty stub there), so **~0 flash bytes** are added to `esp32p4-eth`. Left as-is. If the build-time cost matters later, the fix is a manifest-level conditional the component manager doesn't currently expose simply, or a separate component set per firmware. The `wifiCoproc` read-out itself is compile-gated on `platform::hasWifiCoprocessor` (= `isEsp32P4 && hasWiFi`), so on classic/S3/desktop/eth-only-P4 the control, its buffer fills, and the esp_hosted query all vanish via `if constexpr` (verified: classic `esp32` build pulls no esp_hosted). + 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`). 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 — Parlio loopback + real strip.** A Parlio rx/tx (or RMT-RX-captured) loopback self-test like the RMT/LCD ones, then a real WS2812 strip proven on hardware. + **Dev-loop note — reading the P4's runtime log over USB.** The P4-NANO's primary console is **UART on GPIO 37/38** (`CONFIG_ESP_CONSOLE_UART_DEFAULT`), not the USB port, so `ESP_LOGI` / `mm_net` lines are *not* visible over `/dev/cu.usbmodem*` by default — only the ROM boot banner and `std::printf`-to-stdout (which routes to the **secondary** USB-Serial-JTAG console) come through. Two workarounds when you need the runtime log over USB: (a) temporarily set `CONFIG_ESP_CONSOLE_USB_SERIAL_JTAG=y` (note the JTAG endpoint re-enumerates when the app starts, so a reader must reconnect across the drop — `idf.py monitor` handles it; a plain fixed `pyserial` handle dies); or (b) hang a USB-UART adapter on GPIO 37/38. This cost real time during the P4 no-DHCP hunt; the fastest signal there turned out to be a `printf` of the runtime struct (stdout → secondary JTAG console) plus a `git worktree` bisect (build an old commit, flash, check LAN reachability) to prove code-vs-hardware without needing the log at all. + ### Drop the i80 WR/DC sacrificial pins (S3 LcdLedDriver) via direct LCD_CAM The S3 i80 LED path costs **two GPIOs the LEDs never use**: the IDF `esp_lcd` i80 bus hard-requires a WR (pixel clock) and a DC pin on real GPIOs (`esp_lcd_panel_io_i80.c`: `wr_gpio_num >= 0 && dc_gpio_num >= 0`), even though WS2812 strands ignore both. Today `LcdLedDriver` keeps overridable defaults (clockPin=10, dcPin=11) — peripheral-required, not user-strand wiring, so a default cannot do harm. **Two ways to reclaim the pins, neither trivial:** @@ -454,4 +461,4 @@ For driving **lots of LEDs**, internal SRAM is the scarce resource and the paral ### WiFi runtime disable (backlog) -Compile-time answer already ships: `--firmware esp32-eth` excludes the WiFi stack. This item is the runtime variant — a single `esp32-eth-wifi` binary that skips WiFi init when Ethernet hardware is present. Prerequisite: `platform::ethPresent()` / `platform::wifiPresent()` (listed under Release 2.0 above). Defer until that API lands. +Compile-time answer already ships: `--firmware esp32-eth` excludes the WiFi stack. The default `esp32` already *cascades* — `ethInit()` runs first, WiFi only comes up if no PHY responds — so a wired board never associates over WiFi. What's still missing is reclaiming WiFi's **heap**: even when Ethernet wins the cascade, `esp_wifi_init`'s RX buffers stay allocated. This item skips that init entirely once Ethernet is up, freeing ~16 KB. Defer until the heap saving is worth the teardown-ordering risk. diff --git a/docs/backlog/installer-3layer-plan.md b/docs/backlog/installer-3layer-plan.md new file mode 100644 index 00000000..e3d7a2a2 --- /dev/null +++ b/docs/backlog/installer-3layer-plan.md @@ -0,0 +1,50 @@ +# Installer / 3-layer device model — initiative plan (closed) + +This initiative is **complete for the `next-iteration` branch**. It built the +catalog-driven installer end to end; the durable content has moved to permanent +homes, and the only remaining pieces are explicitly gated future work tracked in +[backlog.md](backlog.md). This doc is a closing index — keep it until the branch +merges, then it can be deleted. + +## Shipped + +| # | What | Where it lives now | +|---|------|--------------------| +| 1 | Partition-table dedup (three 16 MB CSVs → `ota_16mb.csv`) | `esp32/partitions/` | +| 2 | `firmwares.json` generator (`FIRMWARES` → generated, drift-guarded; CI + MoonDeck read it) | [generate_firmwares.py](../../scripts/build/generate_firmwares.py), [firmwares.json](../install/firmwares.json) | +| 3 | Catalog-driven module provisioning (`modules` units, add-then-configure, drivers catalog-added, `loopbackTxPin`) | [install/README.md](../install/README.md), [decisions.md](../history/decisions.md) | +| 5 | Picture board picker — **folded into `/install/`** (the picture grid IS the installer; `install-alt/` deleted, the build-beside swap done) | [install/index.html](../install/index.html), [README § Picture board picker](../install/README.md#picture-board-picker) | +| 8 | Release-asset dedup (`shared-ota-data.bin` ×1, `partition-table-.bin` ×3) | [generate_manifest.py](../../scripts/build/generate_manifest.py), `release.yml` | +| 10 | Detected-chip → family normalisation (fixes the false flash-mismatch warning + board filter) — resolved as **Option A** (normalise at compare time; `chipFamily()` + the single `TARGET_TO_FAMILY` map) | [install-orchestrator.js](../install/install-orchestrator.js), [build_esp32.py](../../scripts/build/build_esp32.py) | +| 4 | MoonDeck device-profile (save/restore a device's pin set) | [moondeck.py](../../scripts/moondeck.py) | + +Durable concepts already in permanent homes: the **MCU → Board → Device provenance +model** → [architecture.md § Config provenance](../architecture.md#config-provenance-mcu--board--device); +the **catalog schema** → [install/README.md](../install/README.md); the **hard-won +lessons** → [decisions.md](../history/decisions.md). + +## Deferred (gated future work — tracked in backlog.md) + +These are **not** finishable now by design; each has a permanent home in +[backlog.md](backlog.md): + +- **#4 remainder — annotated-pin images, `devices.json`/MCU layer.** Gated by the + sequencing rule (*a catalog field earns its keep only once a consumer reads it*): + annotated-pin images need an annotation approach + a UI consumer; the `devices.json` + split lands only when the flat catalog gets unwieldy. **The direction is decided** — + "board = devkit, device = product (a board with its Device level filled in)", split + via a `kind` tag or `devices.json` + `extends` when scale forces it; see + [architecture.md § Board vs Device](../architecture.md#config-provenance-mcu--board--device). + → backlog [§ Runtime board presets](backlog.md#runtime-board-presets-multi-commit-partially-landed). +- **#6 — shared JS helpers across device-UI and web-installer.** Worth the build-glue + (`embed_ui.cmake` + generator + routing + staging, ~5 files) only when ≥2 helpers + earn it. → backlog [§ shared JS helpers](backlog.md#open-follow-up-shared-js-helpers-across-device-ui-and-web-installer). +- **#7 — firmware-variant collapse (classic + P4).** Hard-blocked on runtime PHY/pin + config (the `.eth` fragment bakes in Olimex RMII pins; collapsing without runtime PHY + would silently grab GPIO 5 on every WiFi-only board). → backlog + [§ Release 2.0 / Runtime PHY](backlog.md#release-20--distribution-catches-up-to-the-source-tree). +- **#9 — EIM coordination.** A build-side track (ESP-IDF Installation Manager) kept + coordinated with this end-user installer as the firmware matrix grows. → + [building.md § ESP-IDF version](../building.md#esp-idf-version). +- **CI hardening — pin GitHub Actions to commit SHAs** (the `persist-credentials: false` + half is done). → backlog [§ CI: pin GitHub Actions to commit SHAs](backlog.md). diff --git a/docs/backlog/leddriver-increment-2-plan.md b/docs/backlog/leddriver-increment-2-plan.md index 8bbca291..61058031 100644 --- a/docs/backlog/leddriver-increment-2-plan.md +++ b/docs/backlog/leddriver-increment-2-plan.md @@ -2,7 +2,7 @@ The second LED-driver increment, building on [leddriver-increment-1-plan.md](leddriver-increment-1-plan.md) (RMT single-strand, shipped on branch `led-driver-rmt`) and the analysis docs ([top-down](leddriver-analysis-top-down.md) for peripheral choice and WiFi coexistence, [bottom-up](leddriver-analysis-bottom-up.md) for the build-vs-borrow survey). This file is the *plan*; the analysis docs are the *why*. Forward-looking — exempt from the present-tense rule like the rest of `backlog/`. -**Status:** decisions locked (product owner, planning session 2026-06-10): counts via a parallel `ledsPerPin` list (not even-split-only); per-driver buffer window **deferred** out of 2a; bench board is the **LOLIN S3 N16R8**; sequence **2a → 2b** as proposed. **2a is implemented** on branch `led-driver-rmt` (multi-pin `RmtLedDriver`, S3 enabled via SOC capability constants, transmit-all/wait-all platform seam) — pending hardware test. **2b is implemented** (`LcdLedDriver`, 8 lanes via the esp_lcd i80 bus, product-owner choice 2026-06-10) with two recorded deviations from the sketch below: (1) the **core-1 driver task is deferred again** — the implementation pre-encodes the whole frame into one DMA buffer and ships it as one autonomous GDMA transfer, so no refill deadlines exist for WiFi to miss (the failure mode the task was meant to mitigate); the task returns when an install outgrows internal DRAM (~1500+ lights on a single lane) and forces PSRAM streaming. (2) The **fused-loop helper extraction trigger fired and was declined**: the LCD driver iterates row×lane while RMT/NetworkSend iterate linearly — a shared per-light helper fits two of three users and hides the hot loop (30-second rule); the `PinList.h` parser extraction (the other second-user trigger) DID land. **2b is hardware-proven** (bench session 2026-06-11: loopback bit-exact over the full frame, real strip animating on the LOLIN S3), with three more deviations discovered on the bench: (3) the i80 peripheral requires **all 8 data GPIOs** — partial buses are rejected at `esp_lcd_new_i80_bus`, so `pins` takes exactly 8 entries and unused lanes get 0 in `ledsPerPin`; (4) the slot clock is **2.67 MHz (375 ns), not the lineage's 2.4 MHz** — 412 ns "0" pulses exceed newer WS2812B revisions' T0H max and washed the strip white on a direct 3.3 V data line; (5) the loopback self-test transmits the **real frame** (full size, back-to-back) and bit-verifies the whole capture, after the synthetic-burst version passed while the strip failed. Lessons recorded in [docs/history/decisions.md](../history/decisions.md). +**Status:** decisions locked (product owner, planning session 2026-06-10): counts via a parallel `ledsPerPin` list (not even-split-only); per-driver buffer window **deferred** out of 2a; bench board is the **ESP32-S3 N16R8 Dev**; sequence **2a → 2b** as proposed. **2a is implemented** on branch `led-driver-rmt` (multi-pin `RmtLedDriver`, S3 enabled via SOC capability constants, transmit-all/wait-all platform seam) — pending hardware test. **2b is implemented** (`LcdLedDriver`, 8 lanes via the esp_lcd i80 bus, product-owner choice 2026-06-10) with two recorded deviations from the sketch below: (1) the **core-1 driver task is deferred again** — the implementation pre-encodes the whole frame into one DMA buffer and ships it as one autonomous GDMA transfer, so no refill deadlines exist for WiFi to miss (the failure mode the task was meant to mitigate); the task returns when an install outgrows internal DRAM (~1500+ lights on a single lane) and forces PSRAM streaming. (2) The **fused-loop helper extraction trigger fired and was declined**: the LCD driver iterates row×lane while RMT/NetworkSend iterate linearly — a shared per-light helper fits two of three users and hides the hot loop (30-second rule); the `PinList.h` parser extraction (the other second-user trigger) DID land. **2b is hardware-proven** (bench session 2026-06-11: loopback bit-exact over the full frame, real strip animating on the ESP32-S3 N16R8 Dev), with three more deviations discovered on the bench: (3) the i80 peripheral requires **all 8 data GPIOs** — partial buses are rejected at `esp_lcd_new_i80_bus`, so `pins` takes exactly 8 entries and unused lanes get 0 in `ledsPerPin`; (4) the slot clock is **2.67 MHz (375 ns), not the lineage's 2.4 MHz** — 412 ns "0" pulses exceed newer WS2812B revisions' T0H max and washed the strip white on a direct 3.3 V data line; (5) the loopback self-test transmits the **real frame** (full size, back-to-back) and bit-verifies the whole capture, after the synthetic-burst version passed while the strip failed. Lessons recorded in [docs/history/decisions.md](../history/decisions.md). ## Shape: two sub-increments diff --git a/docs/building.md b/docs/building.md index c9dbde28..c3988b31 100644 --- a/docs/building.md +++ b/docs/building.md @@ -176,24 +176,19 @@ Tracked in [backlog § ESP-IDF version pinning](backlog/backlog.md#esp-idf-versi ### Firmware variants -`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 board is a separate concept — see [architecture.md § Firmware vs board](architecture.md#firmware-vs-board).) `build_esp32.py --help` lists the full set; the main ones: +`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 board is a separate concept — see [architecture.md § Firmware vs board](architecture.md#firmware-vs-board).) `build_esp32.py --help` lists the full set. -| `--firmware` | IDF target | `SDKCONFIG_DEFAULTS` | What's in the image | -|---|---|---|---| -| `esp32` | `esp32` | `sdkconfig.defaults` | WiFi only. No RMII pins reserved. | -| `esp32-eth` | `esp32` | `sdkconfig.defaults;sdkconfig.defaults.eth` | Ethernet only. WiFi components dropped via `-DEXCLUDE_COMPONENTS=esp_wifi;wpa_supplicant;esp_coex` and `-DMM_ETH_ONLY=1`. Smaller image, more free RAM. Olimex ESP32-Gateway pins baked in (LAN8720 @ MDIO 0, PHY RST GPIO 5). | -| `esp32-eth-wifi` | `esp32` | `sdkconfig.defaults;sdkconfig.defaults.eth` | Ethernet + WiFi. Same Olimex pin map. Full Ethernet → WiFi STA → WiFi AP cascade. | -| `esp32s3-n16r8` | `esp32s3` | `sdkconfig.defaults;sdkconfig.defaults.esp32s3-n16r8` | ESP32-S3 DevKitC-1 with the N16R8 module (16 MB flash, 8 MB octal PSRAM). WiFi only. | -| `esp32p4-eth` | `esp32p4` | `sdkconfig.defaults;sdkconfig.defaults.esp32p4-eth` | [Waveshare ESP32-P4-NANO](https://www.waveshare.com/wiki/ESP32-P4-Nano), Ethernet only (IP101 PHY — pins in the `ethPins` config, not sdkconfig). The WiFi-less fallback. Pulls the `espressif/ip101` managed PHY component. | -| `esp32p4-eth-wifi` | `esp32p4` | `…;sdkconfig.defaults.esp32p4-eth;sdkconfig.defaults.esp32p4-eth-wifi` | Same NANO board, Ethernet + WiFi. The P4 has no native radio; WiFi runs on the on-board **[ESP32-C6](https://www.espressif.com/en/products/socs/esp32-c6)** over SDIO via the [`esp_wifi_remote`](https://components.espressif.com/components/espressif/esp_wifi_remote) + [`esp_hosted`](https://github.com/espressif/esp-hosted-mcu) managed components (pulled P4-only). These are a deliberate [v6.0-floor exception](#esp-idf-version). The `esp_wifi_*` API is unchanged; the platform layer only adds an `esp_hosted` bring-up before `esp_wifi_init`. First build is longer (managed-component fetch). | +The canonical list is the **`FIRMWARES` dict** in [`scripts/build/build_esp32.py`](../scripts/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 [`docs/install/firmwares.json`](install/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. -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; `esp32-eth-wifi` keeps everything compiled in and uses the runtime cascade in `NetworkModule`. +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). Each firmware has its own build dir at `build/esp32-/`, so all variants can coexist on disk. `build_esp32.py` points `idf.py -B` at the per-firmware dir; switching firmwares is just a different `--firmware` argument, no clean rebuild penalty. Same-firmware rebuilds stay incremental, as before. Disk usage scales with the number of firmwares built (≈100 MB each), and a future rename would orphan the old dir — clean with `scripts/build/clean_esp32.py --firmware ` or `--all`. +If a firmware *key* changes its feature set (e.g. the classic `esp32` collapse turned a WiFi-only key into WiFi+Ethernet), its existing build dir would otherwise keep the old `MM_NO_ETH` / `MM_ETH_ONLY` in `CMakeCache.txt` — CMake `-D` flags are written to the cache, and omitting one on a later configure does *not* clear it, so the stale value would silently build the old feature set (Ethernet stubbed out, no link, no LED, and a flash erase wouldn't help because it's a compile-time define). `build_esp32.py` guards this: before reusing a dir it compares the cached feature flags to what the firmware wants and wipes the dir for a clean reconfigure on a mismatch (printing the reason). Same-feature rebuilds are untouched, so the incremental fast path is preserved. + Each ESP32-S3 SKU has its own firmware key because the sdkconfig fragment encodes flash size, partition table, and PSRAM mode — flashing an `n16r8` binary onto a different module (e.g. N8R2) either misaligns the partition table (boot loop) or fails PSRAM init. New SKUs become new keys (e.g. `esp32s3-n8r8`); there is no generic `esp32s3` shortcut. -The Ethernet pin map is baked into the build, verified on the [Olimex ESP32-Gateway](https://www.olimex.com/Products/IoT/ESP32/ESP32-GATEWAY/open-source-hardware). Boards with the same LAN8720 PHY but different pinouts (e.g. WT32-ETH01 with reset on GPIO 16) require a local rebuild with adjusted pin defaults. +The Ethernet PHY type and pin map are runtime config, not baked into the build: each firmware carries the driver(s) its chip can host (RMII EMAC for classic/P4, W5500 SPI for S3), and `boards.json` supplies the per-board PHY/pins (pushed into NetworkModule's eth controls at provision). The Olimex pins are the classic chip default, so a board with the same LAN8720 PHY but different pinout (e.g. WT32-ETH01 with reset on GPIO 16) just needs a different `boards.json` entry — no rebuild. `--profile` is accepted one release for migration: `--profile default` → `--firmware esp32`, `--profile eth-only` → `--firmware esp32-eth`. The legacy `build_esp32_ethonly.py` wrapper still works (it now forwards `--firmware esp32-eth`). diff --git a/docs/history/decisions.md b/docs/history/decisions.md index 20166e7b..88cfd9cd 100644 --- a/docs/history/decisions.md +++ b/docs/history/decisions.md @@ -627,7 +627,7 @@ Plan-09 attempted ~1700 LOC of JSON-based persistence that was fully abandoned. The web installer's `boards.json` catalog ships per-board control values that the orchestrator pushes to the device. SET_BOARD over Improv-Serial carries only the board name (one vendor RPC, one Text control on BoardModule); every other field in `controls.*` ships via HTTP after WiFi association. -This split works because every per-board control we ship today applies *post-association*: `Network.txPowerSetting` (LOLIN brown-out fix), and the future Ethernet pin maps, default-config overrides, etc. The radio briefly runs at the wrong setting for the ~1 s between association and HTTP fan-out completion — acceptable for power capping, would be unacceptable for: +This split works because every per-board control we ship today applies *post-association*: `Network.txPowerSetting` (the weak-power brown-out cap), and the future Ethernet pin maps, default-config overrides, etc. The radio briefly runs at the wrong setting for the ~1 s between association and HTTP fan-out completion — acceptable for power capping, would be unacceptable for: - **Country code.** Governs which channels the radio scans; a wrong code at scan time picks wrong channels. - **Antenna selector.** Wrong RF path at radio init makes the device deaf. @@ -640,11 +640,11 @@ If we ever add such a control, **don't extend SET_BOARD's wire format to carry i Pick (1) for user-configurable controls; (2) for truly fixed-per-board values. Avoid the implicit option C ("just push it earlier in SET_BOARD") — it tangles the lifecycle. -## Lessons from the LOLIN S3 N16R8 enablement branch +## Lessons from the ESP32-S3 N16R8 (DevKitC) enablement branch Three non-obvious failures showed up while adding native-USB S3 support, all with the same diagnostic shape ("symptom looks like X, root cause is somewhere completely different"). Record them so a future S3 / native-USB addition doesn't repeat the dig. -1. **USB-Serial-JTAG ≠ UART0 on ESP32-S3.** The LOLIN S3 N16R8's USB-C port wires through the ESP32-S3's built-in USB-Serial-JTAG peripheral, NOT through an external USB-Serial bridge to UART0. ESP-IDF's secondary-console feature (`CONFIG_ESP_CONSOLE_SECONDARY_USB_SERIAL_JTAG=y`, on by default for S3) mirrors stdio out BOTH paths, so the developer sees boot logs and assumes UART0 is wired and working. It isn't. Improv-listener-on-UART0 was deaf because the host was talking to USB-Serial-JTAG. **Fix:** install BOTH drivers and read from both transports — `#if SOC_USB_SERIAL_JTAG_SUPPORTED` keeps ESP32-classic builds free of the JTAG path. Don't make this compile-time-per-board ("the LOLIN firmware") — the same binary should work on a board with either wiring. +1. **USB-Serial-JTAG ≠ UART0 on ESP32-S3.** The ESP32-S3 N16R8 Dev's USB-C port wires through the ESP32-S3's built-in USB-Serial-JTAG peripheral, NOT through an external USB-Serial bridge to UART0. ESP-IDF's secondary-console feature (`CONFIG_ESP_CONSOLE_SECONDARY_USB_SERIAL_JTAG=y`, on by default for S3) mirrors stdio out BOTH paths, so the developer sees boot logs and assumes UART0 is wired and working. It isn't. Improv-listener-on-UART0 was deaf because the host was talking to USB-Serial-JTAG. **Fix:** install BOTH drivers and read from both transports — `#if SOC_USB_SERIAL_JTAG_SUPPORTED` keeps ESP32-classic builds free of the JTAG path. Don't make this compile-time-per-board ("the DevKit firmware") — the same binary should work on a board with either wiring. 2. **CORS preflight is silent on the client side.** Every cross-origin POST from a browser with `Content-Type: application/json` (everything the web installer does) triggers an OPTIONS preflight. If the device's HTTP server returns 405 to OPTIONS, the browser **silently drops the subsequent POST** — no error surfaces in client code, no network tab line, no console message in the install log. The symptom is "the API write didn't happen" with no diagnostic to follow. Burned an entire session diagnosing what looked like a board-injection-fan-out bug; the root cause was an unhandled HTTP verb. **Fix:** always implement OPTIONS in any device-side HTTP server that's reachable cross-origin. Return 204 with `Access-Control-Allow-Origin: *`, `Access-Control-Allow-Methods`, `Access-Control-Allow-Headers: Content-Type`. Verify with `curl -X OPTIONS -H "Origin: ..." -H "Access-Control-Request-Method: POST" -H "Access-Control-Request-Headers: content-type" http://device/api/control` — must return 204 with the headers, not 405. @@ -688,7 +688,7 @@ The harder lesson is about *verification*: the agent's hardware test asserted th ## Lessons from the repo-transfer + v1.0.0 release branch -Moving the repo `ewowi/projectMM → MoonModules/projectMM` and cutting the first stable release surfaced three CI failures that had nothing to do with the transfer's code changes — they came from the *infrastructure around* the release (GitHub's hosted runners, GitHub Pages environment rules). Same diagnostic shape as the LOLIN branch: the red X appears on a release/build, but the cause is a platform behaviour we pinned against, not our diff. Record them so the next release doesn't re-dig. +Moving the repo `ewowi/projectMM → MoonModules/projectMM` and cutting the first stable release surfaced three CI failures that had nothing to do with the transfer's code changes — they came from the *infrastructure around* the release (GitHub's hosted runners, GitHub Pages environment rules). Same diagnostic shape as the S3-DevKit branch: the red X appears on a release/build, but the cause is a platform behaviour we pinned against, not our diff. Record them so the next release doesn't re-dig. 1. **Don't pin GitHub-runner toolchain specifics — the `windows-latest` image migrates underneath you.** Mid-release, GitHub began redirecting `windows-latest` from the VS 2022 image to `windows-2025-vs2026` (the run's own annotation warned: "redirected to windows-2025-vs2026 by June 15, 2026"). Two breakages followed on the *same source tree* that had been green the day before: (a) `package_desktop.py` hard-coded `-G "Visual Studio 17 2022"`, and the new image has no VS 2022 → CMake failed at configure ("could not find any instance of Visual Studio"), a 21-second fast-fail before any compile; (b) once the generator pin was dropped (let CMake auto-detect the installed VS), the build reached compilation and the *new* VS 2026 MSVC STL emitted **C5285** ("specializing `std::tuple` is forbidden") on the vendored `doctest.h`'s tuple forward-declaration, and `/WX` made it fatal. **Fixes:** drop the generator pin (auto-detect survives image migration); add `/wd5285` to the existing MSVC suppression list (it's third-party header code we don't own; GCC/Clang never warn). **Generalisable:** anything pinned to a hosted-runner's bundled toolchain version (CMake generator, compiler path, SDK version) is a time-bomb — the image rotates on GitHub's schedule, not yours. Prefer auto-detection; when a pin is unavoidable, expect to update it and don't treat a sudden Windows-only failure on an unchanged tree as your regression. @@ -702,13 +702,13 @@ Bringing the 8-lane LCD_CAM driver from "compiles and ticks" to "strip actually 1. **The i80 peripheral requires ALL `bus_width` data GPIOs — a partial bus never exists.** `esp_lcd_new_i80_bus` validates every `data_gpio_nums[0..bus_width)` and rejects `GPIO_NUM_NC` entries (`esp_lcd_panel_io_i80.c`, "configure GPIO failed"). A 1-pin config therefore never initialized: no bus, no transmit, dark strip — while the UI showed a configured, enabled driver. **Fixes:** the driver demands exactly 8 pins and reports `LCD bus needs exactly 8 pins` in the status slot (unused lanes take `0` in `ledsPerPin` and idle LOW); the loopback builds its private bus full-width from the driver's real pin set. **Generalisable:** when a peripheral claims a *group* of pins, surface the group contract in the control's validation — don't let a config that the hardware layer will reject look valid in the UI. -2. **"Capacity still fits" is not "config unchanged" — a resize-optimisation early-return swallowed pin changes.** `reinit()` skipped the bus rebuild when the existing DMA buffer was big enough — correct for grid resizes, wrong for pin edits: moving lane 0 from GPIO 13 to 18 keeps the frame size identical, so the bus kept clocking out on the OLD pins and the strip pin carried nothing. **Fix:** record the pin set (data + WR + DC) the live bus was built with and compare on every reinit; any difference forces the rebuild. **Generalisable:** an "is the existing resource still good?" fast path must compare *identity* (what the resource is bound to), not just *capacity* (how big it is). Same family as the LOLIN branch's stale-cache lesson: the cached check was true about the wrong invariant. +2. **"Capacity still fits" is not "config unchanged" — a resize-optimisation early-return swallowed pin changes.** `reinit()` skipped the bus rebuild when the existing DMA buffer was big enough — correct for grid resizes, wrong for pin edits: moving lane 0 from GPIO 13 to 18 keeps the frame size identical, so the bus kept clocking out on the OLD pins and the strip pin carried nothing. **Fix:** record the pin set (data + WR + DC) the live bus was built with and compare on every reinit; any difference forces the rebuild. **Generalisable:** an "is the existing resource still good?" fast path must compare *identity* (what the resource is bound to), not just *capacity* (how big it is). Same family as the S3-DevKit branch's stale-cache lesson: the cached check was true about the wrong invariant. 3. **Gate the LCD driver on `CONFIG_SOC_LCDCAM_I80_LCD_SUPPORTED`, NOT `CONFIG_SOC_LCD_I80_SUPPORTED` — the names look interchangeable and are not.** `lcdLanes` (and the whole driver wiring) first gated on `SOC_LCD_I80_SUPPORTED`, on the assumption it meant "has the S3-style LCD_CAM i80 bus." It doesn't: **the classic ESP32 also defines `SOC_LCD_I80_SUPPORTED=1`** for its unrelated *I2S-LCD* peripheral. So on the classic chip `lcdLanes` became 8, the `LcdLedDriver` got wired at boot, and `esp_lcd_new_i80_bus()` hung the watchdog trying to init an LCD_CAM bus the chip lacks → **boot-loop on every classic ESP32** shipped with that gate. The correct macro is **`SOC_LCDCAM_I80_LCD_SUPPORTED`**, defined only on chips with the actual LCD_CAM peripheral (S3 / P4) — which is what `esp_lcd`'s i80 driver requires. Bisect-proven (the prior commit booted, the LCD-driver commit looped) and hardware-verified after the fix (classic boots with LcdLed absent, S3/P4 keep it). The gate lives in `src/platform/esp32/platform_config.h` (`lcdLanes`) and the `#if` in `platform_esp32_lcd.cpp`. **Generalisable:** SOC capability macros with near-identical names can describe *different peripherals* on different chips — verify the macro is actually defined where you expect (and only there) before gating on it; a "supported" flag that's true on a chip you didn't mean is worse than a missing one, because it silently activates code on the wrong hardware. -3. **A self-test that passes while the device fails is telling you what the test doesn't cover — close the gap before theorising.** The first loopback transmitted a 3-byte synthetic pattern through a 136-byte transfer and PASSED while the real strip washed out max-white. The differences between test and reality were the suspect list: frame size (5.4 KB, multi-descriptor GDMA chain), sustained back-to-back cadence, and pulse timing as seen by a real WS2812 rather than an RMT RX capture. Upgrading the test to transmit the driver's REAL frame (same size, same chain, repeated like the render loop) and bit-verify the WHOLE capture (RMT RX with the DMA backend, ~1536 symbols) eliminated the first two suspects in one run — leaving timing as the only remaining difference, which was the answer. **Generalisable:** when test and reality disagree, enumerate what the test abstracts away and make the test transmit the genuine article; each closed gap either finds the bug or eliminates a theory with proof instead of speculation. +4. **A self-test that passes while the device fails is telling you what the test doesn't cover — close the gap before theorising.** The first loopback transmitted a 3-byte synthetic pattern through a 136-byte transfer and PASSED while the real strip washed out max-white. The differences between test and reality were the suspect list: frame size (5.4 KB, multi-descriptor GDMA chain), sustained back-to-back cadence, and pulse timing as seen by a real WS2812 rather than an RMT RX capture. Upgrading the test to transmit the driver's REAL frame (same size, same chain, repeated like the render loop) and bit-verify the WHOLE capture (RMT RX with the DMA backend, ~1536 symbols) eliminated the first two suspects in one run — leaving timing as the only remaining difference, which was the answer. **Generalisable:** when test and reality disagree, enumerate what the test abstracts away and make the test transmit the genuine article; each closed gap either finds the bug or eliminates a theory with proof instead of speculation. -4. **Modern WS2812B reads a 412 ns "0" as "1" — T0H max is ~380 ns on newer revisions, and 3.3 V direct drive eats the remaining margin.** The classic 3-slots-at-2.4 MHz encoding (hpwit / FastLED lineage, slot ≈ 416 ns) produced a waveform the RMT RX decoded perfectly — and the strip rendered as max white with flicker: most "0" pulses sampled as "1" (mostly-ones ≈ white; the animation's actual 1-bits flicker through). The same strip on the same pin ran clean from the RMT driver's 350 ns zeros, which isolated timing as the only variable. **Fix:** pclk 2.67 MHz → 375 ns slots ("0" = 375 ns, "1" = 750 ns, bit 1125 ns), inside every WS2812B revision's window; latch pad resized to keep ≥300 µs. The lineage gets away with 416 ns because those rigs typically sit behind a 74HCT level shifter that restores threshold margin. **Generalisable:** datasheet timing windows shrank across WS2812B revisions — design new encoders against the NEWEST revision's T0H max (≤380 ns), and treat "RMT-captured waveform is correct but the strip disagrees" as a threshold/margin problem, not a logic problem. +5. **Modern WS2812B reads a 412 ns "0" as "1" — T0H max is ~380 ns on newer revisions, and 3.3 V direct drive eats the remaining margin.** The classic 3-slots-at-2.4 MHz encoding (hpwit / FastLED lineage, slot ≈ 416 ns) produced a waveform the RMT RX decoded perfectly — and the strip rendered as max white with flicker: most "0" pulses sampled as "1" (mostly-ones ≈ white; the animation's actual 1-bits flicker through). The same strip on the same pin ran clean from the RMT driver's 350 ns zeros, which isolated timing as the only variable. **Fix:** pclk 2.67 MHz → 375 ns slots ("0" = 375 ns, "1" = 750 ns, bit 1125 ns), inside every WS2812B revision's window; latch pad resized to keep ≥300 µs. The lineage gets away with 416 ns because those rigs typically sit behind a 74HCT level shifter that restores threshold margin. **Generalisable:** datasheet timing windows shrank across WS2812B revisions — design new encoders against the NEWEST revision's T0H max (≤380 ns), and treat "RMT-captured waveform is correct but the strip disagrees" as a threshold/margin problem, not a logic problem. Bench-procedure notes worth keeping: a board that drops STA mid-session falls back to softAP silently — poll the device's reachability before interpreting an unanswered API call as a wedge (one STA beacon-timeout drop was observed seconds after the LCD bus first went live; not reproduced since, watch for recurrence). And the differential test that cracked the case twice: drive the same pin/strip with the already-proven RMT driver — it exonerates wiring, power, and the strip in one move. @@ -797,7 +797,7 @@ A mic-less **classic ESP32** boot-looped (TG1WDT_SYS_RESET at ~736 ms, no panic - **Chip-/board-fixed pins → default them** (and you *must*): the RMII **Ethernet** pin map is silicon-/PCB-wired, so a default cannot do harm — and *omitting* it does, because a no-WiFi board with un-defaulted Ethernet pins can never connect to be configured (a chicken-and-egg lockout). This is why `platform::ethPins` is a compile-time-per-target constant, never a user-blank control — and it stays that way. - **User-soldered pins → leave them empty**: a MEMS mic or an LED strand goes wherever the user ran the wire, so any default is a guess that can drive a pin the user committed to something else. Empty until set; idle with a "set pins" status meanwhile (the robustness rule: degraded is fine, crashed is not). -The LED drivers (Rmt/Lcd/Parlio) are the same Device-level case — user-soldered, so their pin defaults follow the same rule. This rule is the runtime face of a three-level **MCU → Board → Device** config-provenance model (backlogged): a pin may be defaulted only at the level that actually fixes it, and the empty Device-level defaults are the correct baseline a saved device profile later *fills* rather than *overrides*. Lesson: a hard-coded pin default is a claim about the user's hardware — make that claim only when the hardware, not the user's soldering iron, decides the pin; and never auto-run a peripheral whose init can block on absent hardware. +The LED drivers (Rmt/Lcd/Parlio) are the same Device-level case — user-soldered, so their pin defaults follow the same rule. This rule is the runtime face of the three-level **MCU → Board → Device** config-provenance model ([architecture.md § Config provenance](../architecture.md#config-provenance-mcu--board--device)): a pin may be defaulted only at the level that actually fixes it, and the empty Device-level defaults are the correct baseline a saved device profile later *fills* rather than *overrides*. Lesson: a hard-coded pin default is a claim about the user's hardware — make that claim only when the hardware, not the user's soldering iron, decides the pin; and never auto-run a peripheral whose init can block on absent hardware. ## Live reconfiguration falls out of the prepare-pass for free — MoonLight's "initless" goal, a different mechanism @@ -806,3 +806,23 @@ projectMM has a property most LED-controller firmware lacks: **every module reco **The lineage is MoonLight's "initless drivers."** The product owner's earlier project ([MoonLight nodes.md § Initless drivers](https://github.com/MoonModules/MoonLight/blob/main/docs/develop/nodes.md)) set the same no-reboot goal at the LED-driver level, named *initless*: a driver with **no `addLeds` (FastLED) / `initLed` (Parallel LED Driver) step** — it reads a mutable Context at `show()` time, so pin allocation, leds-per-pin, RGB/RGBW and light type all change live without a restart or recompile. **projectMM reaches the same outcome by a different mechanism, so the word doesn't transfer.** Our drivers *do* have an explicit rebuild — `RmtLedDriver::reinit()` re-creates the RMT channels, the i80/Parlio drivers rebuild the DMA bus — so they are not "initless" in MoonLight's no-`initLed` sense. What makes the behaviour universal here is that the rebuild is driven by the **generic tier-3 `onBuildState()` sweep** ([§ Event triggering](../architecture.md#event-triggering-between-modules)), not hand-built per driver: any module that returns `true` from `controlChangeTriggersBuildState` inherits live-reconfig for free, which is why it spans drivers, the audio peripheral, effects, layouts, modifiers and network I/O alike. Lesson: credit the lineage for the *idea* (MoonLight's initless drivers), but name the property by what the user sees (*live, no-reboot reconfiguration*) when the mechanism differs — overloading a prior project's term onto a different implementation misleads. And: a generic prepare-pass buys breadth a per-driver technique can't — the same three tiers that rebuild a mapping LUT also re-target a GPIO, so the property generalised itself. + +## Lessons from the catalog-driven installer branch (3-layer device model) + +The installer was reworked so a board catalog ([`boards.json`](../install/boards.json)) sets a device up for its hardware at install time. The mechanics and schema live in the [installer README](../install/README.md); these are the hard-won principles worth keeping. + +**Inject from data, don't bury in code (vs MoonLight's `ModuleIO.h`).** MoonLight hardcoded ~20 boards' pin presets in firmware C++ (`setBoardPresetDefaults()`, behind `#ifdef CONFIG_IDF_TARGET_*`); adding a board is a recompile, and every binary ships every board's table. projectMM instead **injects** the same information from the catalog after flash — the firmware is a generic engine that knows nothing about QuinLED/Serg/Olimex, and the *data* specialises it. Adding a board is a JSON edit, binaries carry no board tables, and board definitions become community-contributable data. This is the *domain-neutral core* principle: the specialisation is data, not code. + +**Investigate before building — the device side was already done.** The original plan was a new `POST /api/preset` batch endpoint with a device-side consumer. Investigation overturned it: three install clients already fan a catalog's controls out as `POST /api/control` calls. The *real* gap was that the fan-out can't configure a module that doesn't exist on a fresh flash (a control write 404s) — solved by the **already-existing, idempotent `POST /api/modules`** the clients simply weren't driving. Lesson: the reflex to add an endpoint hid that the mechanism existed; a day of mapping the existing code replaced a new core endpoint (and a forbidden JSON-array parser) with a small client change. *Default to subtraction.* + +**`loopbackTxPin` — verify the claim against the code before deciding twice.** A driver loopback self-test transmits on `pins[0]`, and the bench's loopback TX jumper is a *different* pin from the operational LED pins — so the test forced retyping `pins`. A `loopbackTxPin` override control was proposed, then **dropped** on the reasoning "it only fits RMT's single pin, not Parlio/Lcd's lane array," then **re-added** after reading the code: the Parlio/Lcd loopback only drives **lane 0** with the test pattern, so a single TX override substituting for lane 0 works uniformly on all three drivers. Lesson: the "doesn't fit lane arrays" objection was an assumption about the loopback, not a fact — checking `ParallelLedDriver::runLoopbackSelfTest` (lane-0-only) settled it. Verify the mechanism before a design call that rests on how it behaves. + +**Board vs Device is a completeness spectrum, not two schemas.** The carrier/shield pattern (a PCB an ESP32 *module* plugs into) is a **Board** — it fixes the LED/relay pins, the MCU and strips are chosen separately. A vendor-finished all-in-one with peripherals soldered (QuinLED Dig-2-Go, Dig-Next-2 with built-in mics) is a **Device**. But they are the *same* catalog entry shape — a "Device" is just a Board entry with more of its optional `modules`/`controls` filled in (verified: every `boards.json` entry shares one schema). So **no *separate-schema* `devices.json`**: one entry type, not two. (This rejects a *second schema*, not a future *same-schema* grouping — architecture.md leaves the door open to splitting the flat list into a `devices.json` / `kind:` tag purely for organisation once it's large enough to be unwieldy; that's a file-layout choice under the sequencing rule, not a second entry type.) A board's pins fall into three categories, not two: *always-fixed* (LED outputs, status LED — default freely), *board-optional* (a populated-or-not W5500/IR/power-monitor — an opt-in peripheral block, can't default blind because the same board name ships with and without), and *user-soldered* (always unset). The optional-peripheral case (e.g. SE 16 with a W5500) is the runtime SPI-PHY mechanism, not a new one. + +**Per-board capability spec'n'test loop.** Each board's pin-layout `image` and product-page `url` are the *inputs* to a repeatable build loop, not just decoration: (1) read the board's capabilities off the image + link (LEDs, mic, IR, power monitor, …); (2) for a capability we already offer (an I²S mic → `AudioModule`), wire it into the entry's `modules`/`controls` with real pins; (3) for one we don't offer yet (IR receive), write a proposal against the architecture, spec it, **create test scripts** (host unit + scenario, hardware loopback where applicable), iterate until it works, *then* add it; (4) the entry grows only as far as each capability is spec'n'tested — an un-implemented capability is a recorded proposal, not a half-wired control. A fully-implemented board entry is the *output* of running this loop; the image/link tell you what to aim for. This is *Specs before code* at board granularity. + +**Drivers became catalog-added, with an OTA nuance.** LED/network drivers stopped being boot-wired (only `Preview` stays, since it needs the HTTP broadcaster the catalog can't supply); each board declares its driver(s) in `modules`. A fresh-erased board boots with `Drivers = [Preview]` only — the deliberate explicit-add model. **The nuance hardware surfaced:** an OTA update *without* erase keeps a device's previously-persisted drivers (they're saved config the new firmware reloads as user-added children), so the clean Preview-only state applies only to a fresh/erased flash. That's correct — an update shouldn't wipe a user's configured drivers — but it means "out-of-box" and "after-update" differ, which isn't obvious until you flash a non-erased board. + +**A persistence overlay must distinguish "key absent" from "value 0".** The runtime-Ethernet-PHY work moved pin/PHY config from a compile-time `constexpr ethPins` into persisted NetworkModule controls (`ethType`, pin GPIOs, …) with **non-zero per-chip defaults** (P4 IP101 = `ethType` 2). That exposed a latent bug in `applyControlValue` (the persistence load path): it used `json::parseInt(json,key)`, which returns 0 for an *absent* key — indistinguishable from a real 0 — and then wrote that 0 into the control under the Clamp policy. So loading an older/partial `.json` that omitted a key **clobbered the control's default with 0**. On the ESP32-P4 this zeroed `ethType` (2 → 0 = none), so `ethInit()` dispatched to "no Ethernet": link LEDs on, but no DHCP. It was invisible on classic/Olimex (their eth defaults are mostly 0 anyway) and on `main` (which still read the `constexpr ethPins` directly), so it only bit once eth config became persisted controls with meaningful non-zero defaults. **Fix:** a `json::hasKey()` guard in `applyControlValue` — an absent key leaves the control untouched (preserves its default); a present key (even value 0) still applies. Lesson: any "control resets to its default/0 after reboot" symptom is a persistence-overlay smell, not a control-init bug; a flat JSON parser that returns a zero sentinel for missing keys MUST be paired with a presence check before the value is applied as authoritative. The decisive debugging move was a `std::printf` of the runtime struct over the P4's *secondary* USB-Serial-JTAG console (stdout reaches USB even when ESP_LOG/UART is on GPIO 37/38), after a `git worktree` bisect (round-1 ✓, main ✓, uncommitted ✗) proved it was our code, not hardware or IDF. + +**A GPIO pin is its own control type (`ControlType::Pin`), not an overloaded int16.** Pins were first added as `addInt16` with a `-1..48` range, which the UI rendered as a *slider* — meaningless for a GPIO, and the cap wrongly excluded the P4's high pins (MDIO 52, clk 50). Dropping the range didn't help: the UI's `int16` case *always* draws a slider (an unbounded int16 falls back to a −100..200 percentage slider that Layer start/end positions rely on), so int16 couldn't be made to mean both "position slider" and "pin number." The fix is a dedicated `Pin` type: `int8_t` storage (one byte — a GPIO never exceeds ~54, and on a DRAM-scarce ESP32 the per-pin byte matters across many pin controls), −1 = unused, the UI always renders a plain number input keyed off the `"pin"` type string, and min/max are a server-side write-clamp guard only. Serializes/parses as a plain integer (same as int16). This also serves every future pin control (LED-driver clockPin/dcPin, GyroDriver SDA/SCL, board pins) — they migrate to `addPin` for free. Lesson: when one control type is doing two jobs with different UX (slider vs number), that's the smell for a new type, not a range hack; and pick the smallest storage that fits the domain (int8 for a pin). diff --git a/docs/install/README.md b/docs/install/README.md index ad9cf6e1..844683ea 100644 --- a/docs/install/README.md +++ b/docs/install/README.md @@ -27,14 +27,201 @@ end-to-end, no ESP Web Tools dependency on the install path. user provisioned from this page so they can re-visit / erase / forget them. Renders a dedicated *Inject* button next to Visit for every entry with a `board` field; the button opens `/?board=` and the - device UI fetches the matching `boards.json` entry from Pages to fan out - each `controls.*` field via `/api/control`. Idempotent — safe to re-click - after a popup-blocker rejection or a follow-up catalog edit. + device UI fetches the matching `boards.json` entry from Pages and, for each of + its `modules`, adds the module (`/api/modules`) then sets its nested controls + (`/api/control`) — add-then-configure; see the schema below. Idempotent — safe + to re-click after a popup-blocker rejection or a follow-up catalog edit. - [`boards.json`](boards.json) — the board catalog (name → firmware - variants) the picker fetches and the BoardModule injector writes from. + variants + the modules/controls to inject) the picker fetches and the + installer / device-UI / MoonDeck injectors write from. Schema below. - [`favicon.png`](favicon.png) — moon-man, same as the device UI. - [`README.md`](README.md) — this file. +## Picture board picker + +The board picker is a visual card grid driven by each board's `image` and +(optional) `url` catalog fields (see the schema below — both are optional; a card +without an `image` just shows no photo, without a `url` shows no product link) plus +its `supported`/`planned` capability chips. It reuses the installer's flash machinery unchanged — the grid drives the +shared [`install-picker.js`](../../src/ui/install-picker.js) through a hidden board +` + (#rp-board) — we keep it (so its change-listener wires) but hide its row; + the picture grid above drives it. The row is the .control-row that + contains #rp-board. */ + .control-row:has(#rp-board) { display: none; } + + /* Picture board grid — collapsed by default (a control-row field), expands + on click. The summary button is the row's field, so it flexes like the + selects (flex: 1) to line up with USB Port / Release / Firmware. */ + #board-summary { + flex: 1; display: flex; align-items: center; justify-content: space-between; + gap: 12px; padding: 10px 12px; background: var(--bg); color: var(--fg); + border: 1px solid var(--border); border-radius: 6px; font: inherit; + cursor: pointer; text-align: left; + } + #board-summary:hover { border-color: var(--accent); } + .board-summary-left { display: flex; align-items: center; gap: 10px; min-width: 0; } + .board-summary-thumb { + width: 36px; height: 24px; border-radius: 3px; flex-shrink: 0; + background: #0e1020 center/contain no-repeat; border: 1px solid var(--border); + } + #board-summary-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + .board-summary-caret { color: var(--muted); transition: transform .15s; flex-shrink: 0; } + #board-summary[aria-expanded="true"] .board-summary-caret { transform: rotate(180deg); } + /* The expanded grid breaks out full-width below the row (aligns with the + field column by offsetting the label width + gap). */ + #board-expand { margin: 0 0 10px 92px; } + .board-grid-controls { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; flex-wrap: wrap; } + #board-search { + flex: 1; min-width: 160px; padding: 8px 10px; background: var(--bg); + color: var(--fg); border: 1px solid var(--border); border-radius: 6px; font: inherit; + } + .board-clear { + background: transparent; color: var(--muted); border: 1px solid var(--border); + border-radius: 6px; padding: 8px 12px; font: inherit; font-size: 13px; cursor: pointer; + } + .board-clear:hover { color: var(--fg); border-color: var(--accent); } + .board-filter-notice { color: var(--muted); font-size: 12px; margin-bottom: 10px; } + .board-filter-notice button { + background: none; border: none; color: var(--accent); font: inherit; font-size: 12px; + cursor: pointer; padding: 0; text-decoration: underline; + } + #board-grid { max-height: 420px; overflow-y: auto; } /* expanded grid scrolls, not the page */ + #board-grid { + display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px; + } + .bg-chip-label { + grid-column: 1 / -1; color: var(--muted); font-size: 11px; text-transform: uppercase; + letter-spacing: .06em; margin: 6px 0 0; + } + .bg-card { + background: var(--bg); border: 1px solid var(--border); border-radius: 8px; + overflow: hidden; cursor: pointer; transition: border-color .12s, background .12s; + display: flex; flex-direction: column; + } + .bg-card:hover { border-color: var(--accent); } + .bg-card.selected { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent) inset; } + .bg-thumb { + aspect-ratio: 16 / 10; background: #0e1020 center/contain no-repeat; + display: flex; align-items: center; justify-content: center; + color: var(--muted); font-size: 10px; border-bottom: 1px solid var(--border); + } + .bg-thumb.noimg::after { content: "no photo"; } + .bg-body { padding: 8px 9px; display: flex; flex-direction: column; gap: 3px; } + .bg-name { font-weight: 600; font-size: 12px; line-height: 1.2; } + .bg-meta { color: var(--muted); font-size: 11px; } + /* Capability chips: supported (green) vs planned (orange) — distinguished by + colour, not by extra text. Labels are kept short in boards.json so every + chip fits the ~150px card; the full label + state is in the chip's title + tooltip. */ + .bg-caps { display: flex; flex-wrap: wrap; gap: 3px; margin-top: 3px; } + .bg-cap { + font-size: 9px; line-height: 1.5; padding: 0 5px; border-radius: 999px; + max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + } + .bg-cap.sup { background: color-mix(in srgb, var(--ok) 18%, transparent); color: var(--ok); } + .bg-cap.plan { background: color-mix(in srgb, var(--plan) 20%, transparent); color: var(--plan); } + .bg-link { color: var(--accent); font-size: 11px; text-decoration: none; } + .bg-link:hover { text-decoration: underline; } + .action-btn { background: var(--accent); color: var(--bg); @@ -414,10 +497,45 @@

projectMM Installer