diff --git a/.claude/settings.local.cleaned.json b/.claude/settings.local.cleaned.json
index 50b578d..58fcf2d 100644
--- a/.claude/settings.local.cleaned.json
+++ b/.claude/settings.local.cleaned.json
@@ -1,107 +1,40 @@
{
"permissions": {
"allow": [
- "Bash(./build/projectMM:*)",
- "Bash(./build/test/mm_scenarios:*)",
- "Bash(./build/test/mm_tests:*)",
- "Bash(/usr/bin/curl -s --max-time 12 \"https://moonmodules.org/projectMM/install/boards.json\")",
- "Bash(arp -a)",
- "Bash(awk:*)",
- "Bash(board)",
- "Bash(cat:*)",
- "Bash(cd /Users/ewoud/Developer/GitHub/MoonModules/projectMM *)",
- "Bash(clang++:*)",
- "Bash(clang:*)",
- "Bash(cmake:*)",
- "Bash(cp .claude/settings.local.cleaned.json .claude/settings.local.json)",
- "Bash(cp .claude/settings.local.json .claude/settings.local.cleaned.json)",
- "Bash(ctest:*)",
- "Bash(curl -s*github.com/MoonModules*)",
- "Bash(curl -s*raw.githubusercontent.com*)",
- "Bash(curl:*192.168.1.*api*)",
- "Bash(curl:*localhost*)",
- "Bash(echo:*)",
- "Bash(ffmpeg:*)",
- "Bash(ffprobe:*)",
- "Bash(gh api *)",
- "Bash(gh auth *)",
- "Bash(gh pr *)",
- "Bash(gh release *)",
- "Bash(gh run *)",
- "Bash(gh search *)",
- "Bash(gh workflow *)",
- "Bash(git -C ~/esp/esp-idf describe)",
- "Bash(git check-ignore *)",
- "Bash(git fetch *)",
- "Bash(git ls-remote *)",
- "Bash(git mv:*)",
- "Bash(git rm *)",
- "Bash(grep:*)",
- "Bash(gunzip:*)",
- "Bash(gzip -c)",
- "Bash(head:*)",
- "Bash(ipconfig getsummary *)",
- "Bash(ls:*)",
- "Bash(lsof:*)",
- "Bash(networksetup -listallhardwareports)",
- "Bash(node --check:*)",
- "Bash(node --input-type=module -e ' *)",
- "Bash(node --test:*)",
- "Bash(node -c docs/install/install-orchestrator.js)",
- "Bash(node -c src/ui/install-picker.js)",
- "Bash(node -e ' *)",
- "Bash(npm install *)",
- "Bash(npm test *)",
- "Bash(objcopy:*)",
- "Bash(objdump:*)",
- "Bash(otool:*)",
- "Bash(pkill:*)",
- "Bash(ps:*)",
- "Bash(python3 -)",
- "Bash(python3 -c ' *)",
- "Bash(python3 -c *)",
- "Bash(python3 -m json.tool)",
- "Bash(python3)",
- "Bash(sed:*)",
- "Bash(sort -k1)",
- "Bash(sort -t: -k2 -rn)",
- "Bash(system_profiler SPUSBDataType)",
- "Bash(tail:*)",
- "Bash(timeout 5 dns-sd -B _http._tcp local)",
- "Bash(timeout 5 dns-sd -B _http._tcp local.)",
- "Bash(uv run *)",
- "Edit",
- "Read(//Users/ewoud/**)",
- "Read(//Users/ewoud/.cache/**)",
- "Read(//Users/ewoud/.espressif/tools/esp-rom-elfs/**)",
- "Read(//Users/ewoud/Developer/GitHub/MoonModules/**)",
- "Read(//Users/ewoud/Library/Caches/ms-playwright/**)",
- "Read(//Users/ewoud/esp/esp-idf/**)",
- "Read(//Users/ewoud/esp/esp-idf/components/esp_driver_rmt/src/**)",
- "Read(//Users/ewoud/esp/esp-idf/components/esp_lcd/i80/**)",
- "Read(//dev/**)",
- "WebFetch(domain:components.espressif.com)",
- "WebFetch(domain:components101.com)",
- "WebFetch(domain:developer.espressif.com)",
- "WebFetch(domain:devices.esphome.io)",
- "WebFetch(domain:docs.espressif.com)",
- "WebFetch(domain:easyelecmodule.com)",
- "WebFetch(domain:files.waveshare.com)",
- "WebFetch(domain:github.com)",
- "WebFetch(domain:kno.wled.ge)",
- "WebFetch(domain:mm.kno.wled.ge)",
- "WebFetch(domain:moonmodules.org)",
- "WebFetch(domain:raw.githubusercontent.com)",
- "WebFetch(domain:www.digikey.com)",
- "WebFetch(domain:www.farnell.com)",
- "WebFetch(domain:www.waveshare.com)",
- "WebSearch",
- "Write"
- ],
- "additionalDirectories": [
- "/tmp",
- "/Users/ewoud/esp/esp-idf/components/esp_mm/include",
- "/Users/ewoud/Developer/GitHub/MoonModules/projectMM/.claude"
+ "PowerShell(& \"C:\\\\Users\\\\ewoud\\\\.local\\\\bin\\\\uv.exe\" run moondeck/*)",
+ "PowerShell(& \"C:\\\\Users\\\\ewoud\\\\.local\\\\bin\\\\uv.exe\" run --with * python *)",
+ "PowerShell(& \"C:\\\\Users\\\\ewoud\\\\.local\\\\bin\\\\uv.exe\" run python *)",
+ "PowerShell(& \"C:\\\\Users\\\\ewoud\\\\.local\\\\bin\\\\uv.exe\" pip *)",
+ "PowerShell(& \"C:\\\\Program Files\\\\CMake\\\\bin\\\\cmake.exe\" *)",
+ "PowerShell($env:Path = \"C:\\\\Program Files\\\\CMake\\\\bin;\" + $env:Path; *)",
+ "PowerShell($env:Path = \"C:\\\\Program Files\\\\GitHub CLI;\" + $env:Path; *)",
+ "PowerShell(Get-PnpDevice*)",
+ "PowerShell(Get-Item *)",
+ "PowerShell(Get-ItemProperty *)",
+ "PowerShell(Get-ChildItem *)",
+ "PowerShell(Get-Command * -ErrorAction SilentlyContinue*)",
+ "PowerShell(Get-Content *)",
+ "PowerShell(Get-Process *)",
+ "PowerShell(Get-WmiObject *)",
+ "PowerShell(Test-Path *)",
+ "PowerShell(Write-Host *)",
+ "PowerShell(Start-Sleep *)",
+ "PowerShell(Start-Process *)",
+ "PowerShell(New-Item *)",
+ "PowerShell(Remove-Item *)",
+ "PowerShell(Rename-Item *)",
+ "PowerShell(& gh *)",
+ "PowerShell(& git *)",
+ "PowerShell(Invoke-RestMethod *)",
+ "PowerShell(winget install *)",
+ "PowerShell(winget --version)",
+ "PowerShell($venv * python.exe *)",
+ "Bash(grep *)",
+ "Bash(Test-Path *)",
+ "Bash(Start-Sleep *)",
+ "Bash(Write-Host *)",
+ "Read(//c/Users/ewoud/esp/esp-idf/tools/**)",
+ "WebFetch(domain:github.com)"
]
}
-}
\ No newline at end of file
+}
diff --git a/docs/building.md b/docs/building.md
index b8f64d8..7f68e99 100644
--- a/docs/building.md
+++ b/docs/building.md
@@ -128,6 +128,47 @@ The ESP32 tab in MoonDeck wraps the same steps as cards (Setup → Firmware →

+### Windows: USB-serial drivers
+
+Windows ships no drivers for the two USB-serial chips almost every ESP32 dev board uses (WCH CH340/CH341, Silicon Labs CP2102/CP2102N). macOS and Linux do — so a board that Just Works on your Mac may show up on Windows as an `Unknown` device with no `COM*` port allocated at all, in which case both MoonDeck's port dropdown and the web installer's Chrome Web Serial picker come up **empty**. This isn't a projectMM bug; it's the OS.
+
+**How to tell what you're dealing with:**
+
+- **MoonDeck ESP32 tab** — click Refresh; no `COM*` in the dropdown after plugging in a board.
+- **Chrome web installer** — the "Select a serial port" browser dialog says *No serial ports available*.
+- **Device Manager** — the board shows under *Other devices* (yellow triangle) with the chip name (e.g. *CP2102N USB to UART Bridge Controller*) and status *Error*.
+- **PowerShell** — `Get-PnpDevice | Where-Object InstanceId -match "VID_10C4|VID_1A86"` shows the device with `Status: Error` and empty `SERIALCOMM` registry (`Get-ItemProperty "HKLM:\HARDWARE\DEVICEMAP\SERIALCOMM"`).
+
+**Which driver you need**, by the vendor ID (VID) in the InstanceId:
+
+| VID | Chip | Common boards | Driver |
+|---|---|---|---|
+| `1A86` | WCH CH340 / CH341 | LOLIN D32, cheap NodeMCU-ESP32 clones, some Olimex | wch.cn official CH341SER (Windows Update usually pulls it — if not, try `pnputil /scan-devices` from an elevated PowerShell first). |
+| `10C4` | Silicon Labs CP2102 / CP2102N | ESP32-S3 DevKitC, ESP32-S31 CoreBoard, many newer dev kits | Silicon Labs Universal driver — [zip download](https://www.silabs.com/documents/public/software/CP210x_Universal_Windows_Driver.zip). Windows Update rarely has this one, so grab it directly. |
+| `0403` | FTDI FT2232 / FT230X | Some Olimex Gateways, older ESP32-WROVER-KIT | Windows Update usually installs FTDI VCP automatically. |
+
+**Fastest headless install** (works for both CH340 and CP210x — Silicon Labs' zip contains an `.inf` `pnputil` can install directly):
+
+```powershell
+# 1) Download + extract the Silicon Labs Universal driver (CP210x).
+$dst = "$env:TEMP\cp210x_driver"
+Invoke-WebRequest "https://www.silabs.com/documents/public/software/CP210x_Universal_Windows_Driver.zip" `
+ -OutFile "$dst.zip"
+Expand-Archive -Path "$dst.zip" -DestinationPath $dst -Force
+
+# 2) Install (elevated — accept the UAC prompt).
+Start-Process powershell -Verb RunAs -ArgumentList `
+ "pnputil /add-driver $dst\silabser.inf /install"
+```
+
+After the driver installs and Windows finishes binding (a few seconds), the boards appear as `Silicon Labs CP210x USB to UART Bridge (COM*)` with `Status: OK`. Both MoonDeck's port list (stateless registry read, no need to restart the server) and the web installer's Chrome picker populate immediately.
+
+**Related Windows serial gotchas — same class of issue, same page:**
+
+- **Stale "ghost" COM ports** from previous plug attempts (visible in Device Manager under *Show hidden devices*) reserve entries in the `ComDB` registry and can block fresh COM allocations even after replug. Clean them with `pnputil /remove-device "USB\VID_…"` (also elevated) — then replug.
+- **Wedged CH340 driver from an earlier install** (`Status: Unknown` even though the driver is installed) — same pattern: `pnputil /delete-driver oem*.inf /uninstall /force` for the wch.cn driver, replug, let Windows Update supply a fresh one.
+- **Cable / port** — some phone-charging cables carry only VBUS + GND (no data lines) and enumerate as briefly-then-vanish. Try a known-good cable and a rear USB-2 port before spending more time on drivers.
+
### ESP-IDF version
**Pinned to `v6.1-beta1`** (commit `b1d13e9f`, a signed pre-release tag). `setup_esp_idf.py` holds the exact commit in `PINNED_IDF_VERSION`, warns loudly when the installed tree differs, and by default offers to check the pin out so a stray `git pull` or a fresh shallow clone landing on a newer commit converges back rather than silently building against the wrong tree (`--no-checkout` keeps it warn-only). Minimum is ESP-IDF v5.1 (C++20 needs GCC 12+); the project uses v6.x APIs (`esp_eth_phy_new_generic`, the component manager for mDNS, the modern RMT/parlio/LCD drivers) so v5.x would need adjustments.
diff --git a/docs/gettingstarted.md b/docs/gettingstarted.md
index 7bc62e7..ab8455f 100644
--- a/docs/gettingstarted.md
+++ b/docs/gettingstarted.md
@@ -36,6 +36,13 @@ which? Unplug, look at the list, plug back in — the new entry is your device.)

+**Windows users — dialog says "No serial ports available"?** Windows doesn't
+ship drivers for the USB-serial chips most ESP32 boards use (WCH CH340, Silicon
+Labs CP2102). One-time install fixes it for every future flash — full
+step-by-step + the download link is in
+[building.md § Windows: USB-serial drivers](building.md#windows-usb-serial-drivers).
+macOS and Linux ship these drivers built in, so it's a Windows-only step.
+
Once a port is chosen, the installer recognises the chip and tells you how many
devices match it, so you know you're on the right track before you pick one.
diff --git a/docs/history/lessons.md b/docs/history/lessons.md
index e66fa64..cf97e9c 100644
--- a/docs/history/lessons.md
+++ b/docs/history/lessons.md
@@ -242,3 +242,12 @@ HA MQTT discovery's toggle-off publishes an empty retained config (so HA removes
## Raw-HTML `
` is not validated by MkDocs `--strict`, so a wrong `../` depth ships a silent 404
The catalog/summary pages hand-author each card image as a raw `
`, and the build hook moves the tag into a table cell without touching its src. MkDocs `--strict` validates markdown `![]()` links but NOT raw-HTML `
`, so 34 card images with a wrong `../` depth (the catalog pages sit one level shallower than the generated moxygen pages, so they need `../../assets`, not the moxygen pages' `../../../assets`) resolved to a nonexistent repo-root `/assets/` and 404'd on the live site while every gate passed green. The desktop build, ctest, and `--strict` all miss it; only loading the deployed page shows it. Fix + guard: a `test/python` check resolves every catalog `
` on disk (`test_mkdocs_slug.py::test_catalog_card_images_resolve_on_disk`). General: a generated-site check that only validates the syntaxes its linter knows leaves the others as silent-404 territory, verify the actual rendered output, or gate the un-validated syntax explicitly.
+
+## Lessons from this branch (windows-esp32-fixes)
+
+Windows ESP32 bring-up end-to-end plus the beta1 pin-bump aftermath. Four gotchas each layered under a working build.
+
+- **A "private" LL_ HAL symbol can be the de-facto stable interface, while its public SOC_ replacement is transient.** ESP-IDF's HAL docs mark `RMT_LL_TX_CANDIDATES_PER_INST` (`hal/rmt_ll.h`) as internal, but it's shipped consistently across every v5.x and v6.x snapshot this codebase has been built against — except one in-between v6.1-dev commit that briefly removed it in favour of `SOC_RMT_TX_CANDIDATES_PER_GROUP` (`soc/soc_caps.h`). Following the docs and switching to the SOC symbol broke on beta1, which restored `RMT_LL_*` and removed the SOC one. When a codebase already uses an "internal" HAL symbol proven stable across releases, upstream docs calling it internal aren't binding — the moving target may actually be the "public" alternative during a component-split refactor. Cross-check what Espressif *ships*, not what they *document*. The net-zero-code round-trip on `platform_config.h` in this branch is the receipt.
+- **Two `build_esp32.py` invocations against the same project race on `esp32/managed_components/`.** IDF's component manager writes to a project-scope directory, not per-build-dir. Kicking off the S31 build while S3-n16r8 was still resolving components produced a partial `managed_components/` where one component's `CMakeLists.txt` pointed at source files that didn't land. Symptom: `CMake Error: Cannot find source file` mid-configure. Serialize IDF builds — either wait for one to finish before the next, or introduce a lock at wrapper level. `moondeck/build/build_esp32.py` has no queue today; serialisation is the caller's responsibility.
+- **A failed `set-target` leaves the build dir with a fallback `esp32` sdkconfig that the wrapper's fast path trusts silently.** `build_esp32.py`'s "dir exists → skip set-target" fast path assumes the existing dir is coherent for the requested firmware. It isn't when a previous `set-target esp32p4` bailed early (e.g. the RISC-V toolchain wasn't in PATH) after IDF wrote a partial sdkconfig defaulting to esp32. The next invocation skips set-target and builds a classic esp32 binary in a `build/esp32-esp32p4-eth/` directory. Symptom: an esp32p4-labelled artifact esptool refuses to flash to a P4 ("chip is ESP32-P4, not ESP32"). Fix in this branch: `stale_feature_cache()` gains a `chip` param and reads `IDF_TARGET:STRING=` from `CMakeCache.txt` before the existing feature-flag checks; a mismatch (or missing target line) forces the wipe-and-reconfigure path. Generalisation: any fast path that skips work because "the previous run left state" needs to validate that state is coherent, not just present.
+- **Python scripts emitting non-ASCII crash on Windows when stdout is piped.** `sys.stdout` on Windows defaults to cp1252, which has no `⚠`, `→`, box-drawing, or emoji. Direct-to-terminal often works because the Windows terminal negotiates UTF-8 on modern versions, but piping (`Tee-Object`, redirection, CI log capture) uses the raw stdio encoding and raises `UnicodeEncodeError` mid-message. `sys.stdout.reconfigure(encoding="utf-8", errors="replace")` at script entry is a one-line fix, no-op on POSIX. Every projectMM Python script that may emit non-ASCII AND be piped needs the reconfigure — worth generalising into a shared helper when the next script hits the same trap.
diff --git a/moondeck/build/build_esp32.py b/moondeck/build/build_esp32.py
index 92241f4..ec889ac 100644
--- a/moondeck/build/build_esp32.py
+++ b/moondeck/build/build_esp32.py
@@ -24,6 +24,62 @@
Path("/opt/esp-idf"),
]
+# The ESP-IDF commit every target (classic ESP32, S3, P4, S31) has been
+# validated against — the `v6.1-beta1` tag, on the earliest IDF line that
+# carries the esp32s31 preview target. Kept here (not in setup_esp_idf.py) so
+# the pre-build drift check below can share the constant — a stale local IDF is
+# the single most common source of an "it built for me last week" ESP32 build
+# failure, so the check runs on every build_esp32 invocation, not just when the
+# user remembers to re-run setup_esp_idf.py. setup_esp_idf.py imports these
+# two constants.
+PINNED_IDF_COMMIT = "b1d13e9fe441c4f75e240c98a26fd631b7b3232f"
+PINNED_IDF_VERSION = "v6.1-beta1"
+
+
+def installed_idf_commit(idf_path: Path) -> str:
+ """Return the git HEAD SHA of the installed IDF, or '' if unavailable."""
+ try:
+ r = subprocess.run(["git", "rev-parse", "HEAD"], cwd=str(idf_path),
+ capture_output=True, text=True)
+ return r.stdout.strip() if r.returncode == 0 else ""
+ except OSError:
+ return ""
+
+
+def check_idf_pin(idf_path: Path) -> None:
+ """Abort the build if the installed IDF isn't at PINNED_IDF_COMMIT.
+
+ Reason: silently building against a drifted IDF produces the classic
+ "missing symbol" / "renamed cap macro" errors that look like *our* bug but
+ are actually Espressif renaming an API between snapshots. Failing fast with
+ the exact fix command turns 30-minute debugging into a 30-second
+ re-checkout. Skip if HEAD can't be determined (not a git checkout, missing
+ git, etc.) — no false positives.
+
+ Deliberate escape hatch: a dev testing an upcoming IDF release (beta1 → RC,
+ RC → GA) needs to build against a drifted IDF on purpose. Symmetric with
+ setup_esp_idf.py's --no-checkout, that path is `--skip-idf-pin-check`
+ threaded from main(); passed here as `bypass=True`.
+ """
+ installed = installed_idf_commit(idf_path)
+ if not installed or installed == PINNED_IDF_COMMIT:
+ return
+ print(f"\nESP-IDF commit drift: installed {installed[:12]} != "
+ f"pinned {PINNED_IDF_COMMIT[:12]} ({PINNED_IDF_VERSION}).", file=sys.stderr)
+ print("The build was validated against the pinned commit; a drifted IDF is "
+ "the most common source of ESP32 build failures that look like "
+ "projectMM bugs but are actually Espressif renaming a symbol.",
+ file=sys.stderr)
+ print("Fix: re-run `uv run moondeck/build/setup_esp_idf.py` (it will offer "
+ "to check out the pinned commit + resync submodules + reinstall "
+ "toolchains). See docs/building.md § ESP-IDF version for the "
+ "manual command if you'd rather do it by hand.", file=sys.stderr)
+ print("Or pass --skip-idf-pin-check to build anyway (deliberate migration "
+ "to a newer IDF release; re-tests then update PINNED_IDF_COMMIT).",
+ file=sys.stderr)
+ sys.exit(2)
+
+
# Components to drop from an Ethernet-only build. ESP-IDF v6.x has no
# CONFIG_ESP_WIFI_ENABLED switch (the symbol is non-settable, forced y on
# WiFi-capable SoCs), so WiFi is removed via EXCLUDE_COMPONENTS instead.
@@ -415,7 +471,7 @@ def resolve_firmware(args: argparse.Namespace) -> str:
return "esp32"
-def stale_feature_cache(build_dir: Path, extra: list[str]) -> str | None:
+def stale_feature_cache(build_dir: Path, extra: list[str], chip: str) -> str | None:
"""Detect a build dir whose cached feature flags disagree with this firmware.
CMake `-D` flags are written into CMakeCache.txt; *omitting* a flag on a
@@ -426,6 +482,15 @@ def stale_feature_cache(build_dir: Path, extra: list[str]) -> str | None:
WiFi-only dir kept MM_NO_ETH=1 and stubbed Ethernet out (no link, no LED).
Erasing flash doesn't help: it's a compile-time define, not device state.
+ Also catches a mismatched IDF_TARGET: an earlier `set-target` for this
+ firmware may have partially succeeded (interrupted, or ran when the
+ RISC-V toolchain was missing and only got as far as writing sdkconfig for
+ the default `esp32` target), leaving the build dir with the wrong chip.
+ The wrapper's "dir exists → skip set-target" fast-path then trusts that
+ stale state and silently builds for the wrong chip — the P4 case that
+ bit us in the beta1 bring-up. `chip` is the firmware's expected target
+ (per FIRMWARES[firmware]["chip"]).
+
Returns a human-readable reason string when the cache is stale (caller
should clean + reconfigure), or None when it matches.
"""
@@ -433,6 +498,16 @@ def stale_feature_cache(build_dir: Path, extra: list[str]) -> str | None:
if not cache.exists():
return None
text = cache.read_text(encoding="utf-8", errors="replace")
+ # Chip / target mismatch — cheap check, do it first (a mismatch here means
+ # every other cached flag is also for the wrong target, so no point
+ # checking those). CMake writes `IDF_TARGET:STRING=esp32p4`; a fresh
+ # cache without an IDF_TARGET line (partial set-target failure before
+ # configure) also counts as stale.
+ m = re.search(r"^IDF_TARGET:[^=]*=(.*)$", text, re.MULTILINE)
+ cached_target = m.group(1).strip() if m else None
+ if cached_target != chip:
+ return (f"IDF_TARGET cached as {cached_target!r} but this firmware "
+ f"wants {chip!r}")
# The feature toggles whose presence/absence changes which code compiles.
# For each, "wanted" = does this firmware pass the -D, "cached" = is it set
# in the existing cache. A disagreement means a stale dir.
@@ -489,6 +564,13 @@ def main():
"(see compute_version.py): core for a stable tag, "
"'-dev.' for latest. Omit for local builds "
"(library.json applies).")
+ parser.add_argument("--skip-idf-pin-check", action="store_true",
+ help="Build against the currently-checked-out IDF even if "
+ "it differs from PINNED_IDF_COMMIT (for a dev "
+ "deliberately testing a newer IDF release before "
+ "the pin bumps). Symmetric with setup_esp_idf.py "
+ "--no-checkout. Use with care: a drifted IDF is the "
+ "most common source of ESP32 build failures.")
args = parser.parse_args()
firmware = resolve_firmware(args)
@@ -510,6 +592,13 @@ def main():
sys.exit(1)
print(f"Using ESP-IDF at {idf_path}")
+ # Fail-fast on a drifted local IDF before we sink a few minutes into a
+ # build that would end in "SOC_FOO was renamed to SOC_BAR" or similar. See
+ # check_idf_pin above for why this belongs in the build script, not just
+ # setup_esp_idf.py. --skip-idf-pin-check bypasses the check entirely for a
+ # dev deliberately testing an upcoming IDF release.
+ if not args.skip_idf_pin_check:
+ check_idf_pin(idf_path)
env = idf_env(idf_path)
cmd = idf_cmd(idf_path)
@@ -549,7 +638,7 @@ def main():
# Guard against a build dir configured for a different feature set (a stale
# MM_NO_ETH / MM_ETH_ONLY in CMakeCache that a plain reconfigure won't clear).
# Wiping the dir forces the set-target path below, which seeds a clean cache.
- stale = stale_feature_cache(build_dir, extra)
+ stale = stale_feature_cache(build_dir, extra, chip)
if stale:
print(f"Build dir {build_dir.relative_to(ROOT)} has a stale feature "
f"cache ({stale}); removing it for a clean reconfigure.")
diff --git a/moondeck/build/setup_esp_idf.py b/moondeck/build/setup_esp_idf.py
index 5e46afd..99dc038 100644
--- a/moondeck/build/setup_esp_idf.py
+++ b/moondeck/build/setup_esp_idf.py
@@ -6,8 +6,23 @@
import sys
from pathlib import Path
+# Force stdout/stderr to UTF-8 on Windows: the drift warning below (and any
+# future non-ASCII output — file paths with accents, log lines from install.bat)
+# fails with UnicodeEncodeError on cp1252 when the caller pipes / redirects
+# stdout, which is the exact case CI and Tee-Object hit. reconfigure() is a
+# no-op on POSIX where stdio is already UTF-8.
+if sys.stdout is not None:
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
+if sys.stderr is not None:
+ sys.stderr.reconfigure(encoding="utf-8", errors="replace")
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
-from build_esp32 import find_idf, IDF_SEARCH_PATHS
+# PINNED_IDF_COMMIT / PINNED_IDF_VERSION / installed_idf_commit live in
+# build_esp32.py so the pre-build drift check (check_idf_pin, called on every
+# ESP32 build) shares them with this setup script. Single source of truth.
+from build_esp32 import (find_idf, IDF_SEARCH_PATHS,
+ PINNED_IDF_COMMIT, PINNED_IDF_VERSION,
+ installed_idf_commit)
# Espressif ships install.sh for POSIX hosts and install.bat / install.ps1 for
# Windows. Both create the same `~/.espressif/python_env/...` venv and download
@@ -16,28 +31,6 @@
# execution policy unlocked, which we'd rather not assume.
INSTALL_SCRIPT_NAME = "install.bat" if sys.platform == "win32" else "install.sh"
-# The ESP-IDF commit every target (classic ESP32, S3, P4, S31) has been
-# validated against — the `v6.1-beta1` tag, on the earliest IDF line that
-# carries the esp32s31 preview target. The script can't *clone* the
-# IDF for you (the dev does the initial clone per docs/building.md), but when the
-# installed checkout drifts from this pin it offers to move it (git checkout +
-# submodule sync) so a fresh shallow clone landing on a newer dev-branch commit
-# — exactly what turns a green build red with no code change — converges back to
-# the validated commit. Pass --no-checkout to keep the warn-only behaviour (a
-# dev deliberately migrating off this snapshot to a newer release re-tests, not
-# auto-reverts).
-PINNED_IDF_COMMIT = "b1d13e9fe441c4f75e240c98a26fd631b7b3232f"
-PINNED_IDF_VERSION = "v6.1-beta1"
-
-
-def _installed_idf_commit(idf_path: Path) -> str:
- try:
- r = subprocess.run(["git", "rev-parse", "HEAD"], cwd=str(idf_path),
- capture_output=True, text=True)
- return r.stdout.strip() if r.returncode == 0 else ""
- except OSError:
- return ""
-
def _checkout_pinned(idf_path: Path) -> bool:
"""Move the installed IDF onto PINNED_IDF_COMMIT (checkout + submodule sync).
@@ -85,7 +78,7 @@ def main():
# fatal (you may be deliberately migrating), but it must be visible — and by
# default we offer to move the checkout onto the pin so a new dev converges on
# the validated commit. --no-checkout keeps it warn-only.
- installed = _installed_idf_commit(idf_path)
+ installed = installed_idf_commit(idf_path)
if installed and installed != PINNED_IDF_COMMIT:
print(f"\n⚠ IDF commit drift: installed {installed[:12]} != "
f"pinned {PINNED_IDF_COMMIT[:12]} ({PINNED_IDF_VERSION}).")
@@ -118,16 +111,23 @@ def main():
print(f"{INSTALL_SCRIPT_NAME} not found at {install_script}")
sys.exit(1)
- print(f"Running ESP-IDF {INSTALL_SCRIPT_NAME} (creates Python venv)...")
+ print(f"Running ESP-IDF {INSTALL_SCRIPT_NAME} (downloads toolchains + creates Python venv)...")
+ # Pass `all` (not `esp32`) so both toolchain families install: the Xtensa
+ # `xtensa-esp-elf` for classic ESP32 / S2 / S3, and the RISC-V
+ # `riscv32-esp-elf` for P4 and the C-series. Without RISC-V, a P4 configure
+ # fails with `CMAKE_ASM_COMPILER: riscv32-esp-elf-gcc … not found in PATH`.
+ # `all` is the same size increment on Windows as `esp32,esp32p4` (~50 MB
+ # for the RISC-V toolchain) and covers any future chip target the codebase
+ # gains without another setup pass.
# shell=True on Windows so cmd.exe interprets the .bat correctly.
- r = subprocess.run([str(install_script), "esp32"],
+ r = subprocess.run([str(install_script), "all"],
cwd=str(idf_path),
shell=(sys.platform == "win32"))
if r.returncode != 0:
print("Install failed.")
sys.exit(r.returncode)
- print("\nESP-IDF setup complete. You can now build for ESP32.")
+ print("\nESP-IDF setup complete. You can now build for any supported target.")
if __name__ == "__main__":
main()
diff --git a/src/platform/esp32/platform_config.h b/src/platform/esp32/platform_config.h
index 7405df5..e9e9ad9 100644
--- a/src/platform/esp32/platform_config.h
+++ b/src/platform/esp32/platform_config.h
@@ -6,11 +6,14 @@
#include
-// The RMT TX-channel count moved into the RMT HAL's low-level header when the RMT
-// HAL graduated into its own `esp_hal_rmt` component on the v6.1 line (it replaced
-// the public `SOC_RMT_TX_CANDIDATES_PER_GROUP` soc-cap). Included at file scope
-// (not inside the namespace) so the standard headers it pulls land in `::`, and
-// only on RMT-bearing builds.
+// The RMT TX-channel count moved into the RMT HAL's low-level header when the
+// RMT HAL graduated into its own `esp_hal_rmt` component on the v6.1 line (the
+// header re-appears at `hal/rmt_ll.h`, now shipped by `esp_hal_rmt` instead of
+// the monolithic `hal` component). Espressif also removed the public
+// `SOC_RMT_TX_CANDIDATES_PER_GROUP` soc-cap from `soc/soc_caps.h` in the same
+// pass, so the LL symbol is the only per-target definition of the count on
+// beta1. Included at file scope (not inside the namespace) so the standard
+// headers it pulls land in `::`, and only on RMT-bearing builds.
#ifdef CONFIG_SOC_RMT_SUPPORTED
#include "hal/rmt_ll.h"
#endif
@@ -56,7 +59,7 @@ constexpr bool isEsp32S31 = false;
#endif
// RMT TX channels this chip offers (8 on classic ESP32, 4 on the S3 / P4 / S31,
-// straight from the IDF RMT HAL — `RMT_LL_TX_CANDIDATES_PER_INST`, included above).
+// straight from the RMT HAL — `RMT_LL_TX_CANDIDATES_PER_INST`, included above).
// Doubles as the RMT capability flag: the RMT LED driver and its main.cpp
// registration guard on `rmtTxChannels > 0` instead of a chip-family flag, so a
// new RMT-bearing target works untouched.