diff --git a/.github/workflows/validate-boards.yml b/.github/workflows/validate-boards.yml index a56b0249..bcc5a240 100644 --- a/.github/workflows/validate-boards.yml +++ b/.github/workflows/validate-boards.yml @@ -37,3 +37,9 @@ jobs: pio pkg install -g -p "platformio/atmelmegaavr@${PLATFORMIO_ATMELMEGAAVR_VERSION}" - name: Validate board JSONs match PlatformIO run: python ci/validate_boards.py + - name: Validate ESP32 flash offsets match authoritative source + # Cross-checks each esp32*.json bootloader/partitions/firmware offset + # against arduino-esp32 boards.txt (build.bootloader_addr). The ESP32 + # framework is not pre-installed in this job, so --download fetches the + # authoritative boards.txt/platform.txt from the pioarduino release tag. + run: python ci/check_flash_offsets.py --download diff --git a/ci/check_flash_offsets.py b/ci/check_flash_offsets.py new file mode 100644 index 00000000..7d36fa89 --- /dev/null +++ b/ci/check_flash_offsets.py @@ -0,0 +1,398 @@ +#!/usr/bin/env python3 +"""Assert each ESP32 chip config's flash offsets match an authoritative source. + +The second-stage **bootloader offset** (`esptool.flash_offsets.bootloader` in +`crates/fbuild-build/src/esp32/configs/esp32*.json`) is a ROM-defined constant: +get it wrong and the chip's ROM reads garbage at its fixed load address and +enters an `invalid header: 0x...` reboot loop (see #278: esp32p4/esp32c5 +shipped `0x0` instead of `0x2000` and bricked boot). + +These values are hand-maintained, so nothing previously cross-checked them +against an external authority. This script does: + + - Reads every `esp32*.json` config and its `esptool.flash_offsets`. + - Determines the AUTHORITATIVE `build.bootloader_addr` per chip from the + arduino-esp32 `boards.txt` (with the `platform.txt` + `build.bootloader_addr=0x1000` default applied when a chip has no explicit + override). + - HARD-FAILS (non-zero exit) on any mismatch, on any config chip that has no + corresponding `boards.txt` entry, and if it cannot locate an authoritative + source at all (it never silently passes). + - Also asserts `partitions == 0x8000` and `firmware == 0x10000` (fixed in + arduino-esp32 `platform.txt` flash recipes). + +Authoritative source (in priority order): + 1. --boards-txt (explicit boards.txt) + 2. $FBUILD_ESP32_BOARDS_TXT (explicit boards.txt via env) + 3. Installed framework in the fbuild cache + (~/.fbuild/{dev,prod}/cache/platforms/framework-arduinoespressif32/.../ + esp32-core-*/boards.txt), highest version wins. + 4. --download [version] (fetch boards.txt + platform.txt from + the pioarduino/arduino-esp32 release + tag; requires internet). This is what + CI uses, since the framework is not + pre-installed in the validate job. + +Reference for maintainers: the offset is `ESP_BOOTLOADER_OFFSET` in ESP-IDF and +is exposed as `.build.bootloader_addr` in arduino-esp32 `boards.txt`. + +Usage: + python ci/check_flash_offsets.py # auto-discover source + python ci/check_flash_offsets.py --boards-txt PATH # explicit boards.txt + python ci/check_flash_offsets.py --download # fetch from pioarduino + python ci/check_flash_offsets.py --download 3.3.7 # fetch a specific tag +""" + +from __future__ import annotations + +import json +import os +import re +import sys +import urllib.error +import urllib.request +from pathlib import Path + +# Default pioarduino arduino-esp32 release tag used when --download has no +# explicit version. Bump alongside the framework version fbuild ships. +DEFAULT_DOWNLOAD_VERSION = "3.3.7" + +# arduino-esp32 platform.txt default for chips that don't override the offset. +DEFAULT_BOOTLOADER_ADDR = "0x1000" + +# Fixed offsets from arduino-esp32 platform.txt flash recipes (not per-chip). +EXPECTED_PARTITIONS = "0x8000" +EXPECTED_FIRMWARE = "0x10000" + +PIOARDUINO_RAW = "https://raw.githubusercontent.com/pioarduino/arduino-esp32/refs/tags/{version}/{name}" + + +def home_dir() -> Path: + home = os.environ.get("USERPROFILE") if sys.platform == "win32" else os.environ.get("HOME") + return Path(home or "") + + +def configs_dir() -> Path: + """Locate the fbuild esp32 config directory relative to this script.""" + return ( + Path(__file__).resolve().parent.parent + / "crates" + / "fbuild-build" + / "src" + / "esp32" + / "configs" + ) + + +def normalize_offset(value: str) -> str: + """Normalize a hex offset string for comparison ('0x1000' == '0x1000').""" + s = value.strip().lower() + if s.startswith("0x"): + # Drop leading zeros after 0x but keep at least one digit. + digits = s[2:].lstrip("0") or "0" + return "0x" + digits + return s + + +# --------------------------------------------------------------------------- +# Authoritative source discovery +# --------------------------------------------------------------------------- + + +def _version_key(version: str) -> tuple[int, ...]: + parts = re.findall(r"\d+", version) + return tuple(int(p) for p in parts) if parts else (0,) + + +def find_cached_framework() -> tuple[Path, Path] | None: + """Find the highest-version installed arduino-esp32 framework in the cache. + + Returns (boards_txt, platform_txt) or None if not found. Mirrors fbuild's + path layout: ~/.fbuild/{dev,prod}/cache/platforms/ + framework-arduinoespressif32///esp32-core-/. + """ + home = home_dir() + if not home: + return None + + candidates: list[tuple[tuple[int, ...], Path]] = [] + search_roots = [ + home / ".fbuild" / "dev" / "cache" / "platforms" / "framework-arduinoespressif32", + home / ".fbuild" / "prod" / "cache" / "platforms" / "framework-arduinoespressif32", + ] + for root in search_roots: + if not root.exists(): + continue + for boards_txt in root.glob("**/boards.txt"): + platform_txt = boards_txt.with_name("platform.txt") + if not platform_txt.exists(): + continue + # Derive a version key from the path (esp32-core-X.Y.Z). + m = re.search(r"esp32-core-([\d.]+)", str(boards_txt)) + version = m.group(1) if m else "0" + candidates.append((_version_key(version), boards_txt)) + + if not candidates: + return None + + candidates.sort(key=lambda c: c[0]) + best = candidates[-1][1] + return best, best.with_name("platform.txt") + + +def download_authoritative_text(version: str) -> tuple[str, str]: + """Fetch boards.txt and platform.txt from the pioarduino release tag.""" + out: list[str] = [] + for name in ("boards.txt", "platform.txt"): + url = PIOARDUINO_RAW.format(version=version, name=name) + req = urllib.request.Request(url, headers={"User-Agent": "fbuild-check-flash-offsets/1.0"}) + try: + with urllib.request.urlopen(req, timeout=60) as resp: + out.append(resp.read().decode("utf-8")) + except (urllib.error.URLError, OSError) as exc: + raise RuntimeError(f"Failed to download {url}: {exc}") from exc + return out[0], out[1] + + +# --------------------------------------------------------------------------- +# Parsing +# --------------------------------------------------------------------------- + + +def parse_platform_default(platform_text: str) -> str: + """Read `build.bootloader_addr` default from platform.txt, if present.""" + for line in platform_text.splitlines(): + line = line.strip() + if line.startswith("build.bootloader_addr="): + return line.split("=", 1)[1].strip() + return DEFAULT_BOOTLOADER_ADDR + + +def parse_boards_bootloader_addr(boards_text: str) -> dict[str, str]: + """Map chip-id -> explicit build.bootloader_addr from boards.txt. + + Only top-level chip entries (e.g. `esp32c3.build.bootloader_addr=0x0`) are + captured; menu/option-scoped keys (containing `.menu.`) are ignored. + """ + result: dict[str, str] = {} + pattern = re.compile(r"^([A-Za-z0-9_\-]+)\.build\.bootloader_addr=(.+)$") + for raw in boards_text.splitlines(): + line = raw.strip() + if ".menu." in line: + continue + m = pattern.match(line) + if m: + result[m.group(1)] = m.group(2).strip() + return result + + +def parse_known_chips(boards_text: str) -> set[str]: + """Set of chip families arduino-esp32 supports (distinct `build.mcu` values). + + Used to distinguish a chip that relies on the platform.txt default + bootloader offset (known to boards.txt, no explicit override) from a chip + boards.txt has no knowledge of at all (a real coverage gap -> failure). + Menu/option-scoped keys (containing `.menu.`) are ignored. + """ + chips: set[str] = set() + pattern = re.compile(r"^[A-Za-z0-9_\-]+\.build\.mcu=(.+)$") + for raw in boards_text.splitlines(): + line = raw.strip() + if ".menu." in line: + continue + m = pattern.match(line) + if m: + chips.add(m.group(1).strip()) + return chips + + +def authoritative_offset( + chip: str, + boards_addrs: dict[str, str], + known_chips: set[str], + platform_default: str, +) -> str | None: + """Resolve the authoritative bootloader offset for a chip. + + Precedence mirrors how arduino-esp32 itself resolves the value: + 1. explicit `.build.bootloader_addr` in boards.txt, else + 2. the platform.txt default (`build.bootloader_addr`) for a chip that + boards.txt otherwise knows about (e.g. esp32/esp32s2, which don't + override the default), else + 3. `None` -- boards.txt has no knowledge of this chip, so the offset + cannot be verified; the caller treats this as a hard failure. + """ + if chip in boards_addrs: + return boards_addrs[chip] + if chip in known_chips: + return platform_default + return None + + +# --------------------------------------------------------------------------- +# Main check +# --------------------------------------------------------------------------- + + +def load_config_offsets(path: Path) -> tuple[str, dict[str, str]]: + """Return (mcu, flash_offsets) for an esp32 config JSON.""" + data = json.loads(path.read_text(encoding="utf-8")) + mcu = data.get("mcu", path.stem) + offsets = data.get("esptool", {}).get("flash_offsets", {}) + return mcu, offsets + + +def main() -> int: + args = sys.argv[1:] + boards_txt_arg: str | None = None + download_version: str | None = None + i = 0 + while i < len(args): + if args[i] == "--boards-txt" and i + 1 < len(args): + boards_txt_arg = args[i + 1] + i += 2 + elif args[i] == "--download": + # Optional version follows. + if i + 1 < len(args) and not args[i + 1].startswith("-"): + download_version = args[i + 1] + i += 2 + else: + download_version = DEFAULT_DOWNLOAD_VERSION + i += 1 + elif args[i] in ("-h", "--help"): + print(__doc__) + return 0 + else: + print(f"Unknown argument: {args[i]}", file=sys.stderr) + print(__doc__, file=sys.stderr) + return 1 + + cfg_dir = configs_dir() + if not cfg_dir.exists(): + print(f"Error: esp32 configs not found at {cfg_dir}", file=sys.stderr) + return 1 + + # Resolve the authoritative source text. + boards_text: str | None = None + platform_text: str | None = None + source_desc = "" + + env_boards = os.environ.get("FBUILD_ESP32_BOARDS_TXT") + + if download_version is not None: + try: + boards_text, platform_text = download_authoritative_text(download_version) + except RuntimeError as exc: + print(f"Error: {exc}", file=sys.stderr) + return 1 + source_desc = f"pioarduino/arduino-esp32 release tag {download_version} (downloaded)" + elif boards_txt_arg or env_boards: + boards_path = Path(boards_txt_arg or env_boards or "") + if not boards_path.exists(): + print(f"Error: boards.txt not found at {boards_path}", file=sys.stderr) + return 1 + boards_text = boards_path.read_text(encoding="utf-8") + platform_path = boards_path.with_name("platform.txt") + platform_text = ( + platform_path.read_text(encoding="utf-8") if platform_path.exists() else "" + ) + source_desc = f"explicit boards.txt: {boards_path}" + else: + found = find_cached_framework() + if found is not None: + boards_path, platform_path = found + boards_text = boards_path.read_text(encoding="utf-8") + platform_text = platform_path.read_text(encoding="utf-8") + source_desc = f"installed framework: {boards_path}" + + if boards_text is None: + print( + "Error: could not locate an authoritative arduino-esp32 boards.txt.\n" + " Tried: --boards-txt, $FBUILD_ESP32_BOARDS_TXT, and the fbuild cache\n" + " (~/.fbuild/{dev,prod}/cache/platforms/framework-arduinoespressif32/).\n" + " Install the ESP32 framework, pass --boards-txt PATH, or use --download.", + file=sys.stderr, + ) + return 1 + + platform_default = parse_platform_default(platform_text or "") + boards_addrs = parse_boards_bootloader_addr(boards_text) + known_chips = parse_known_chips(boards_text) + + print("Authoritative source: " + source_desc) + print(f"platform.txt default build.bootloader_addr = {platform_default}") + print(f"configs directory: {cfg_dir}") + print() + + config_files = sorted(cfg_dir.glob("esp32*.json")) + if not config_files: + print(f"Error: no esp32*.json configs found in {cfg_dir}", file=sys.stderr) + return 1 + + header = f"{'CHIP':<12} {'CONFIG':<10} {'AUTHORITATIVE':<14} {'PART':<8} {'FW':<10} RESULT" + print(header) + print("-" * len(header)) + + failures: list[str] = [] + + for path in config_files: + mcu, offsets = load_config_offsets(path) + cfg_boot = offsets.get("bootloader") + cfg_part = offsets.get("partitions") + cfg_fw = offsets.get("firmware") + + chip_failures: list[str] = [] + + auth = authoritative_offset(mcu, boards_addrs, known_chips, platform_default) + auth_display = auth if auth is not None else "MISSING" + + if auth is None: + chip_failures.append( + f"{mcu}: chip is unknown to the authoritative boards.txt (no " + f"`{mcu}.build.mcu` or `{mcu}.build.bootloader_addr` entry) -- " + f"cannot verify this chip's bootloader offset" + ) + elif cfg_boot is None: + chip_failures.append(f"{mcu}: config has no esptool.flash_offsets.bootloader") + elif normalize_offset(cfg_boot) != normalize_offset(auth): + chip_failures.append( + f"{mcu}: bootloader offset {cfg_boot!r} != authoritative {auth!r} " + f"(boards.txt build.bootloader_addr)" + ) + + if cfg_part is None or normalize_offset(cfg_part) != normalize_offset(EXPECTED_PARTITIONS): + chip_failures.append( + f"{mcu}: partitions offset {cfg_part!r} != expected {EXPECTED_PARTITIONS!r}" + ) + if cfg_fw is None or normalize_offset(cfg_fw) != normalize_offset(EXPECTED_FIRMWARE): + chip_failures.append( + f"{mcu}: firmware offset {cfg_fw!r} != expected {EXPECTED_FIRMWARE!r}" + ) + + result = "PASS" if not chip_failures else "FAIL" + print( + f"{mcu:<12} {str(cfg_boot):<10} {auth_display:<14} " + f"{str(cfg_part):<8} {str(cfg_fw):<10} {result}" + ) + failures.extend(chip_failures) + + print() + if failures: + print(f"FAILED: {len(failures)} flash-offset problem(s):") + for f in failures: + print(f" - {f}") + print() + print( + "The bootloader offset is a ROM-defined constant. The authoritative value is\n" + "`.build.bootloader_addr` in arduino-esp32 boards.txt (ESP-IDF " + "ESP_BOOTLOADER_OFFSET).\n" + "Fix the config to match the authoritative source; do NOT guess." + ) + return 1 + + print(f"All {len(config_files)} esp32 config(s) match the authoritative flash offsets.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/crates/fbuild-build/src/esp32/configs/esp32c5.json b/crates/fbuild-build/src/esp32/configs/esp32c5.json index a10f9fa4..55a2003d 100644 --- a/crates/fbuild-build/src/esp32/configs/esp32c5.json +++ b/crates/fbuild-build/src/esp32/configs/esp32c5.json @@ -137,7 +137,7 @@ "esptool": { "elf_sha256_offset": "0xb0", "flash_offsets": { - "bootloader": "0x0", + "bootloader": "0x2000", "partitions": "0x8000", "firmware": "0x10000" }, diff --git a/crates/fbuild-build/src/esp32/configs/esp32p4.json b/crates/fbuild-build/src/esp32/configs/esp32p4.json index e15a6fb1..0c0c384d 100644 --- a/crates/fbuild-build/src/esp32/configs/esp32p4.json +++ b/crates/fbuild-build/src/esp32/configs/esp32p4.json @@ -135,7 +135,7 @@ "esptool": { "elf_sha256_offset": "0xb0", "flash_offsets": { - "bootloader": "0x0", + "bootloader": "0x2000", "partitions": "0x8000", "firmware": "0x10000" }, diff --git a/crates/fbuild-build/src/esp32/mcu_config.rs b/crates/fbuild-build/src/esp32/mcu_config.rs index 3224da49..ba1b9db2 100644 --- a/crates/fbuild-build/src/esp32/mcu_config.rs +++ b/crates/fbuild-build/src/esp32/mcu_config.rs @@ -584,6 +584,34 @@ mod tests { .any(|f| f.contains("ilp32f"))); } + #[test] + fn test_bootloader_flash_offsets_match_rom() { + // The second-stage bootloader offset is ROM-defined per chip. Getting it + // wrong makes the ROM read garbage at its fixed load address and reboot-loop + // with `invalid header`. ESP32-P4 and ESP32-C5 load from 0x2000. + let expected = [ + ("esp32", "0x1000"), + ("esp32s2", "0x1000"), + ("esp32s3", "0x0"), + ("esp32c2", "0x0"), + ("esp32c3", "0x0"), + ("esp32c6", "0x0"), + ("esp32h2", "0x0"), + ("esp32c5", "0x2000"), + ("esp32p4", "0x2000"), + ]; + for (mcu, offset) in expected { + let config = get_mcu_config(mcu).unwrap(); + assert_eq!( + config.bootloader_offset(), + offset, + "{} bootloader offset must be {}", + mcu, + offset + ); + } + } + #[test] fn test_linker_flag_counts() { // ESP32 MCUs have 40+ linker flags (including -u symbols) diff --git a/crates/fbuild-build/src/esp32/orchestrator/boot_artifacts.rs b/crates/fbuild-build/src/esp32/orchestrator/boot_artifacts.rs index 7aeba170..47d78090 100644 --- a/crates/fbuild-build/src/esp32/orchestrator/boot_artifacts.rs +++ b/crates/fbuild-build/src/esp32/orchestrator/boot_artifacts.rs @@ -20,8 +20,13 @@ pub(super) fn prepare_boot_artifacts( ) -> Result<()> { let boot_artifacts_started = Instant::now(); perf.checkpoint("boot-artifacts-start"); + // SDK directory selector matching the chip's ROM revision (e.g. `esp32p4_es` + // for ESP32-P4 eco0–eco2). The bootloader ELF must come from the same SDK + // variant the app is linked against, or the ROM jumps into an illegal + // instruction at the bootloader entry point. + let sdk_variant = board.sdk_variant(); let boot_dst = build_dir.join("bootloader.bin"); - let boot_bin_src = framework.get_bootloader_bin(&board.mcu); + let boot_bin_src = framework.get_bootloader_bin(sdk_variant); if boot_bin_src.exists() { // Pre-built bootloader.bin available — just copy std::fs::copy(&boot_bin_src, &boot_dst)?; @@ -59,7 +64,7 @@ pub(super) fn prepare_boot_artifacts( } else { "dio" }; - let boot_elf = framework.get_bootloader_elf(&board.mcu, boot_flash_mode, flash_freq); + let boot_elf = framework.get_bootloader_elf(sdk_variant, boot_flash_mode, flash_freq); if boot_elf.exists() { let boot_elf_str = boot_elf.to_string_lossy(); let boot_dst_str = boot_dst.to_string_lossy(); @@ -112,7 +117,7 @@ pub(super) fn prepare_boot_artifacts( } let parts_dst = build_dir.join("partitions.bin"); - let parts_bin_src = framework.get_partitions_bin(&board.mcu); + let parts_bin_src = framework.get_partitions_bin(sdk_variant); if parts_bin_src.exists() { // Pre-built partitions.bin available — just copy std::fs::copy(&parts_bin_src, &parts_dst)?; diff --git a/crates/fbuild-build/src/esp32/orchestrator/build.rs b/crates/fbuild-build/src/esp32/orchestrator/build.rs index 944fab21..81b7b95d 100644 --- a/crates/fbuild-build/src/esp32/orchestrator/build.rs +++ b/crates/fbuild-build/src/esp32/orchestrator/build.rs @@ -83,10 +83,14 @@ impl BuildOrchestrator for Esp32Orchestrator { let core_build_dir = &ctx.core_build_dir; let src_build_dir = &ctx.src_build_dir; + // SDK directory selector: matches the chip's ROM revision (e.g. + // `esp32p4_es` for ESP32-P4 eco0–eco2). Falls back to `mcu`. + let sdk_variant = ctx.board.sdk_variant().to_string(); + // Read link-affecting config before the expensive include/library/source discovery steps // so the no-op fast path can return early on warm builds. - let sdk_ld_flags = framework.get_sdk_ld_flags(&ctx.board.mcu); - let sdk_defines = framework.get_sdk_defines(&ctx.board.mcu); + let sdk_ld_flags = framework.get_sdk_ld_flags(&sdk_variant); + let sdk_defines = framework.get_sdk_defines(&sdk_variant); if sdk_ld_flags.iter().any(|f| f == "-fno-lto") { mcu_config.disable_lto(); @@ -127,6 +131,7 @@ impl BuildOrchestrator for Esp32Orchestrator { board_core: ctx.board.core.clone(), board_variant: ctx.board.variant.clone(), board_variant_h: ctx.board.variant_h.clone(), + board_chip_variant: ctx.board.chip_variant.clone(), board_extra_flags: ctx.board.extra_flags.clone(), board_upload_protocol: ctx.board.upload_protocol.clone(), board_upload_speed: ctx.board.upload_speed.clone(), @@ -250,7 +255,7 @@ impl BuildOrchestrator for Esp32Orchestrator { } // Add SDK include paths (294+ paths from ESP-IDF) include_dirs - .extend(framework.get_sdk_include_dirs(&ctx.board.mcu, sdk_memory_type.as_deref())); + .extend(framework.get_sdk_include_dirs(&sdk_variant, sdk_memory_type.as_deref())); // Add built-in Arduino library includes (Wire, SPI, WiFi, etc.) let builtin_libs_dir = framework.get_libraries_dir(); @@ -274,11 +279,11 @@ impl BuildOrchestrator for Esp32Orchestrator { include_dirs.extend(toolchain.get_include_dirs()); // Read SDK flags early — needed to check LTO before compiling. - let sdk_ld_flags = framework.get_sdk_ld_flags(&ctx.board.mcu); - let sdk_lib_flags = framework.get_sdk_lib_flags(&ctx.board.mcu, sdk_memory_type.as_deref()); + let sdk_ld_flags = framework.get_sdk_ld_flags(&sdk_variant); + let sdk_lib_flags = framework.get_sdk_lib_flags(&sdk_variant, sdk_memory_type.as_deref()); let sdk_ld_scripts = - LinkerScripts::from_raw_flags(&framework.get_sdk_ld_scripts(&ctx.board.mcu)); - let sdk_defines = framework.get_sdk_defines(&ctx.board.mcu); + LinkerScripts::from_raw_flags(&framework.get_sdk_ld_scripts(&sdk_variant)); + let sdk_defines = framework.get_sdk_defines(&sdk_variant); // If SDK specifies -fno-lto, disable LTO in MCU config profiles to avoid // compiling objects with LTO that the linker can't handle. diff --git a/crates/fbuild-build/src/esp32/orchestrator/fingerprint.rs b/crates/fbuild-build/src/esp32/orchestrator/fingerprint.rs index dfdf8a8a..dee6c78f 100644 --- a/crates/fbuild-build/src/esp32/orchestrator/fingerprint.rs +++ b/crates/fbuild-build/src/esp32/orchestrator/fingerprint.rs @@ -13,6 +13,7 @@ pub(super) struct Esp32FingerprintMetadata { pub board_core: String, pub board_variant: String, pub board_variant_h: Option, + pub board_chip_variant: Option, pub board_extra_flags: Option, pub board_upload_protocol: Option, pub board_upload_speed: Option, diff --git a/crates/fbuild-build/src/zccache.rs b/crates/fbuild-build/src/zccache.rs index 05327ab5..993878dd 100644 --- a/crates/fbuild-build/src/zccache.rs +++ b/crates/fbuild-build/src/zccache.rs @@ -447,7 +447,10 @@ mod tests { fn strip_unc_prefix_removes_extended_length_marker() { let raw = std::path::PathBuf::from(r"\\?\C:\Users\test\.fbuild\cache"); let stripped = strip_unc_prefix(raw); - assert_eq!(stripped, std::path::PathBuf::from(r"C:\Users\test\.fbuild\cache")); + assert_eq!( + stripped, + std::path::PathBuf::from(r"C:\Users\test\.fbuild\cache") + ); } #[test] diff --git a/crates/fbuild-config/assets/boards/json/esp32-p4-evboard.json b/crates/fbuild-config/assets/boards/json/esp32-p4-evboard.json index 3e1a8f7d..c43b93fd 100644 --- a/crates/fbuild-config/assets/boards/json/esp32-p4-evboard.json +++ b/crates/fbuild-config/assets/boards/json/esp32-p4-evboard.json @@ -1,5 +1,6 @@ { "build": { + "chip_variant": "esp32p4_es", "core": "esp32", "extra_flags": "-DBOARD_HAS_PSRAM", "f_cpu": "360000000L", diff --git a/crates/fbuild-config/assets/boards/json/esp32-p4.json b/crates/fbuild-config/assets/boards/json/esp32-p4.json index 328d0695..5a10f93e 100644 --- a/crates/fbuild-config/assets/boards/json/esp32-p4.json +++ b/crates/fbuild-config/assets/boards/json/esp32-p4.json @@ -1,5 +1,6 @@ { "build": { + "chip_variant": "esp32p4_es", "core": "esp32", "extra_flags": "-DBOARD_HAS_PSRAM", "f_cpu": "360000000L", diff --git a/crates/fbuild-config/assets/boards/json/esp32-p4_r3.json b/crates/fbuild-config/assets/boards/json/esp32-p4_r3.json index 37218e26..c905a8c0 100644 --- a/crates/fbuild-config/assets/boards/json/esp32-p4_r3.json +++ b/crates/fbuild-config/assets/boards/json/esp32-p4_r3.json @@ -1,5 +1,6 @@ { "build": { + "chip_variant": "esp32p4", "core": "esp32", "extra_flags": "-DBOARD_HAS_PSRAM", "f_cpu": "400000000L", diff --git a/crates/fbuild-config/assets/boards/json/m5stack-tab5-p4.json b/crates/fbuild-config/assets/boards/json/m5stack-tab5-p4.json index af69c551..75dae656 100644 --- a/crates/fbuild-config/assets/boards/json/m5stack-tab5-p4.json +++ b/crates/fbuild-config/assets/boards/json/m5stack-tab5-p4.json @@ -1,5 +1,6 @@ { "build": { + "chip_variant": "esp32p4_es", "core": "esp32", "extra_flags": "-DBOARD_HAS_PSRAM -DARDUINO_USB_CDC_ON_BOOT=1 -DARDUINO_USB_MODE=1", "f_cpu": "360000000L", diff --git a/crates/fbuild-config/src/board/db.rs b/crates/fbuild-config/src/board/db.rs index 615e1923..ae54d3b1 100644 --- a/crates/fbuild-config/src/board/db.rs +++ b/crates/fbuild-config/src/board/db.rs @@ -151,6 +151,9 @@ pub(super) fn get_board_defaults(board_id: &str) -> Option` SDK directory to link against. + /// + /// Returns `build.chip_variant` when the board declares it, otherwise falls + /// back to `mcu`. This selects the prebuilt libraries, linker scripts, and + /// bootloader matching the chip's ROM revision — e.g. `esp32p4_es` for + /// ESP32-P4 eco0–eco2 silicon vs. `esp32p4` for eco5+. The MCU family used + /// for compiler flags and esptool `--chip` stays `mcu`. + pub fn sdk_variant(&self) -> &str { + self.chip_variant.as_deref().unwrap_or(&self.mcu) + } + /// Check whether this board supports a specific emulator tool. pub fn has_emulator(&self, tool_name: &str) -> bool { self.debug_tools diff --git a/crates/fbuild-config/src/board/tests.rs b/crates/fbuild-config/src/board/tests.rs index e32fb47e..77790405 100644 --- a/crates/fbuild-config/src/board/tests.rs +++ b/crates/fbuild-config/src/board/tests.rs @@ -354,6 +354,33 @@ fn test_esp32_effective_memory_type_tracks_effective_flash_mode() { ); } +#[test] +fn test_esp32p4_evboard_uses_es_chip_variant() { + // The ESP32-P4 Function EV Board ships eco0–eco2 silicon ("ES pre rev.300"). + // It must link against the `esp32p4_es` SDK (base ROM), not `esp32p4` (eco5 + // ROM) — otherwise the bootloader panics on an illegal instruction. + let config = BoardConfig::from_board_id("esp32-p4-evboard", &HashMap::new()).unwrap(); + assert_eq!(config.mcu, "esp32p4"); + assert_eq!(config.chip_variant, Some("esp32p4_es".to_string())); + assert_eq!(config.sdk_variant(), "esp32p4_es"); +} + +#[test] +fn test_esp32p4_r3_uses_eco5_chip_variant() { + // The rev.300 board targets eco5 silicon and links the `esp32p4` SDK. + let config = BoardConfig::from_board_id("esp32-p4_r3", &HashMap::new()).unwrap(); + assert_eq!(config.chip_variant, Some("esp32p4".to_string())); + assert_eq!(config.sdk_variant(), "esp32p4"); +} + +#[test] +fn test_sdk_variant_falls_back_to_mcu() { + // Boards without an explicit chip_variant resolve the SDK dir from the MCU. + let config = BoardConfig::from_board_id("esp32c3", &HashMap::new()).unwrap(); + assert_eq!(config.chip_variant, None); + assert_eq!(config.sdk_variant(), "esp32c3"); +} + #[test] fn test_esp32_effective_memory_type_preserves_opi_flash_profiles() { let config = BoardConfig::from_board_id("esp32-s3-devkitc-1-n32r8v", &HashMap::new()).unwrap(); diff --git a/crates/fbuild-config/src/board/types.rs b/crates/fbuild-config/src/board/types.rs index 4652255f..6f424d1c 100644 --- a/crates/fbuild-config/src/board/types.rs +++ b/crates/fbuild-config/src/board/types.rs @@ -40,6 +40,16 @@ pub struct BoardConfig { pub variant: String, /// Variant header override for frameworks that use `#include VARIANT_H` pub variant_h: Option, + /// ESP32 chip-variant SDK selector (Arduino `build.chip_variant`). + /// + /// Names the `esp32-arduino-libs/` directory whose prebuilt + /// libraries, linker scripts, and bootloader are linked against a specific + /// ROM revision. When `None`, the SDK directory falls back to `mcu`. + /// ESP32-P4 needs this: `esp32p4_es` targets chip rev v0.x–v1.x (eco0–eco2), + /// while `esp32p4` targets rev v3.x (eco5+). Linking the wrong one boots + /// into an illegal-instruction panic at the bootloader entry point. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub chip_variant: Option, /// USB vendor ID (optional) pub vid: Option, /// USB product ID (optional) diff --git a/crates/fbuild-daemon/src/handlers/operations/deploy.rs b/crates/fbuild-daemon/src/handlers/operations/deploy.rs index 9c15b39d..cdb07032 100644 --- a/crates/fbuild-daemon/src/handlers/operations/deploy.rs +++ b/crates/fbuild-daemon/src/handlers/operations/deploy.rs @@ -377,10 +377,18 @@ pub async fn deploy( .unwrap() }); // Load MCU config to get flash offsets and esptool defaults. + // Fail loudly on an unknown MCU instead of silently falling + // back to esp32's `0x1000` bootloader offset — that offset is + // wrong for RISC-V variants (need `0x0`) and C5/P4 (need + // `0x2000`), so the device would never boot (`invalid header` + // reboot loop). The build path propagates this error too. let mcu_config = fbuild_build::esp32::mcu_config::get_mcu_config(&board_config.mcu) - .unwrap_or_else(|_| { - fbuild_build::esp32::mcu_config::get_mcu_config("esp32").unwrap() - }); + .map_err(|e| { + fbuild_core::FbuildError::DeployFailed(format!( + "unsupported ESP32 MCU '{}' for board '{}': {} — cannot determine flash offsets", + board_config.mcu, board_id, e + )) + })?; // Flash mode: `board_config.flash_mode` is `None` for ESP32 // chips unless the user explicitly set `board_build.flash_mode` // in their `[env:X]` section (see `BoardConfig::from_board_id` diff --git a/crates/fbuild-python/Cargo.toml b/crates/fbuild-python/Cargo.toml index 2dadcf03..9451a749 100644 --- a/crates/fbuild-python/Cargo.toml +++ b/crates/fbuild-python/Cargo.toml @@ -13,6 +13,10 @@ extension-module = ["pyo3/extension-module"] [lib] name = "_native" crate-type = ["cdylib", "rlib"] +# PyO3 extension-module crates cannot be doctested: rustdoc links the doctest +# harness without libpython, so the pyo3 macros fail to resolve. There are no +# doc examples here anyway. +doctest = false [build-dependencies] pyo3-build-config = "0.22" diff --git a/tests/platform/esp32p4/platformio.ini b/tests/platform/esp32p4/platformio.ini index 81131622..6812534d 100644 --- a/tests/platform/esp32p4/platformio.ini +++ b/tests/platform/esp32p4/platformio.ini @@ -3,3 +3,7 @@ platform = espressif32 board = esp32-p4-evboard framework = arduino board_build.partitions = huge_app.csv +; Route Arduino `Serial` to the board's USB-Serial/JTAG console so sketch output +; is visible on the same USB port used for flashing (COM-on-Windows). Without +; this, `Serial` defaults to UART0 and nothing reaches the host. +build_flags = -DARDUINO_USB_CDC_ON_BOOT=1 -DARDUINO_USB_MODE=1