""" ESP-IDF Compilation Service for ESP32 targets. Replaces arduino-cli for ESP32/ESP32-C3 compilation. User Arduino sketches are compiled using ESP-IDF (with optional Arduino-as-component) to produce firmware that boots reliably in the lcgamboa QEMU fork. The key difference vs arduino-cli: ESP-IDF gives control over bootloader, sdkconfig, and flash mapping — all of which must be QEMU-compatible. Two compilation modes: 1. Arduino-as-component: Full Arduino API (WiFi.h, WebServer.h, etc.) compiled through idf.py. Requires ARDUINO_ESP32_PATH env var. 2. Pure ESP-IDF: Translates common Arduino patterns to ESP-IDF C APIs. Fallback when Arduino component is not installed. """ import asyncio import base64 import hashlib import json import logging import os import re import shutil import string import subprocess import tempfile import threading from dataclasses import dataclass from pathlib import Path, PurePosixPath from typing import Callable, Optional from app.core.hooks import materialize_library_scope logger = logging.getLogger(__name__) # Location of the ESP-IDF project template (relative to this file) _TEMPLATE_DIR = Path(__file__).parent / 'esp-idf-template' # ── Persistent build dir ───────────────────────────────────────────────────── # Cold ESP-IDF compiles rebuild ~1480 base objects (FreeRTOS, lwIP, esp_wifi, # libsodium, …). The default tempfile.TemporaryDirectory flow gave each compile # a fresh path under /tmp/espidf_/, which baked into -I and # -fmacro-prefix-map flags and made ccache 0% effective (different cwd → hash # miss every time). With the persistent dir, /var/lib/velxio-build// # is a stable anchor: ninja's incremental cache + ccache hits combine to bring # warm compiles down to ~5-30s. # # Concurrent compiles to the SAME target would corrupt the shared build dir; # they're serialised by the per-target asyncio.Lock in routes/compile.py. # Different targets get different subdirs and run in parallel. # # Set VELXIO_PERSISTENT_BUILD_DIR=0 to fall back to the legacy tempfile flow # without rebuilding the image (escape hatch if the persistent dir misbehaves # in production). _BUILD_ROOT = Path(os.environ.get('VELXIO_BUILD_ROOT', '/var/lib/velxio-build')) _USE_PERSISTENT_DIR = ( os.environ.get('VELXIO_PERSISTENT_BUILD_DIR', '1') not in ('0', 'false', 'False', '') ) def _idf_version_signature() -> str: """Snapshot of the ESP-IDF + arduino-esp32 toolchain version. Used to invalidate persistent build dirs after an upstream submodule bump (the cached object files on disk are no longer ABI-compatible).""" parts = [] idf_version_file = Path('/opt/esp-idf/version.txt') if idf_version_file.exists(): parts.append(idf_version_file.read_text(encoding='utf-8').strip()) arduino_version_file = Path('/opt/arduino-esp32/version.txt') if arduino_version_file.exists(): parts.append(arduino_version_file.read_text(encoding='utf-8').strip()) if not parts: # Fall back to mtime of the IDF tree root — coarse but stable per # image build. try: parts.append(str(int(Path('/opt/esp-idf').stat().st_mtime))) except OSError: parts.append('unknown') return '|'.join(parts) # Type for live progress callback. Called from a worker thread for every # stdout/stderr line as the build runs. Implementations should be cheap and # thread-safe (callers commonly stash lines into a dict shared with the main # event loop). Exceptions raised from the callback are swallowed so a faulty # UI hook can never break the build. ProgressCallback = Callable[[str], None] @dataclass class _RunResult: """Drop-in replacement for the fields we read off subprocess.CompletedProcess.""" returncode: int stdout: str stderr: str def _run_with_streaming( cmd: list[str], *, cwd: str, env: dict[str, str], timeout: float, progress_callback: Optional[ProgressCallback], ) -> _RunResult: """Run `cmd` synchronously and stream stdout + stderr line-by-line. Behaves like subprocess.run(capture_output=True, text=True) but invokes `progress_callback(line)` for every line as it arrives. When progress_callback is None this falls back to a single subprocess.run call so we don't pay the threading cost on the unit-test path that doesn't care about live output. Raises subprocess.TimeoutExpired on timeout (matches the existing flow). """ if progress_callback is None: cp = subprocess.run( cmd, cwd=cwd, env=env, capture_output=True, text=True, timeout=timeout, ) return _RunResult(returncode=cp.returncode, stdout=cp.stdout, stderr=cp.stderr) proc = subprocess.Popen( cmd, cwd=cwd, env=env, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, bufsize=1, # line-buffered ) stdout_lines: list[str] = [] stderr_lines: list[str] = [] def _drain(stream, sink: list[str]) -> None: try: for line in iter(stream.readline, ''): sink.append(line) try: progress_callback(line) except Exception: # A faulty progress sink must never break the build. pass finally: try: stream.close() except Exception: pass t_out = threading.Thread(target=_drain, args=(proc.stdout, stdout_lines), daemon=True) t_err = threading.Thread(target=_drain, args=(proc.stderr, stderr_lines), daemon=True) t_out.start() t_err.start() try: proc.wait(timeout=timeout) except subprocess.TimeoutExpired: proc.kill() # Give drain threads a chance to flush before we raise. t_out.join(timeout=2) t_err.join(timeout=2) raise t_out.join(timeout=5) t_err.join(timeout=5) return _RunResult( returncode=proc.returncode, stdout=''.join(stdout_lines), stderr=''.join(stderr_lines), ) # How many distinct build variants to keep per target before LRU-evicting the # coldest. Each variant is a full ESP-IDF build tree (~240 MB). Distinct # variants = distinct (board options x resolved library set). The global ccache # means an evicted-then-rebuilt variant warms up in seconds. _MAX_BUILD_VARIANTS = 12 def _evict_cold_variants(target_dir: Path, keep: int) -> None: """LRU-evict variant dirs (``v_*``) beyond ``keep``, coldest mtime first.""" try: variants = [ d for d in target_dir.iterdir() if d.is_dir() and d.name.startswith('v_') ] except OSError: return if len(variants) <= keep: return variants.sort(key=lambda d: d.stat().st_mtime if d.exists() else 0.0) for d in variants[:len(variants) - keep]: logger.info(f'[espidf] LRU-evicting cold build variant {d.name}') shutil.rmtree(d, ignore_errors=True) def _prepare_persistent_project_dir( idf_target: str, variant_key: str = 'default', ) -> Path: """Return a persistent project dir for (idf_target, variant_key), created from the template on first use and with the per-compile parts (main/, user_libs/) reset each call so a previous sketch doesn't leak into the next. Each distinct ``variant_key`` gets its OWN dir with its OWN ``build/`` that is NEVER wiped/reconfigured for a different config. The caller folds the board options AND the resolved library set into the key, so two compiles that would produce a different ESP-IDF component graph never share a build/. Sharing one build/ across configs caused intermittent cmake-configure failures, stale-object false positives, and nested-build breakage (a wiped+ reconfigured dir loses ESP-IDF managed-component temp files). Same variant -> same dir -> warm ninja incremental + ccache. Cold variant -> fresh build, fast via the global ccache. The variant set is LRU-bounded per target. """ target_dir = _BUILD_ROOT / idf_target target_dir.mkdir(parents=True, exist_ok=True) # Wipe ALL variants for this target if the toolchain changed (cached .o # files are no longer ABI-compatible). sentinel = target_dir / '.idf_version' current_signature = _idf_version_signature() if sentinel.exists() and sentinel.read_text(encoding='utf-8').strip() != current_signature: logger.info(f'[espidf] toolchain version changed; wiping {target_dir}') shutil.rmtree(target_dir, ignore_errors=True) target_dir.mkdir(parents=True, exist_ok=True) sentinel.write_text(current_signature, encoding='utf-8') # One-time cleanup of the pre-variant layout (target_dir/project) so it # doesn't orphan ~240 MB after the upgrade to per-variant dirs. legacy_project = target_dir / 'project' if legacy_project.exists(): shutil.rmtree(legacy_project, ignore_errors=True) (target_dir / '.options_hash').unlink(missing_ok=True) safe = ''.join(c for c in variant_key if c.isalnum() or c in '-_')[:40] or 'default' variant_dir = target_dir / ('v_' + safe) project_dir = variant_dir / 'project' if not project_dir.exists(): variant_dir.mkdir(parents=True, exist_ok=True) shutil.copytree(_TEMPLATE_DIR, project_dir) else: # Reset per-compile parts only; keep build/ (warm for this variant). shutil.rmtree(project_dir / 'main', ignore_errors=True) shutil.copytree(_TEMPLATE_DIR / 'main', project_dir / 'main') shutil.rmtree(project_dir / 'user_libs', ignore_errors=True) # Mark this variant most-recently-used, then LRU-evict the coldest. try: os.utime(variant_dir, None) except OSError: pass _evict_cold_variants(target_dir, keep=_MAX_BUILD_VARIANTS) return project_dir # Static IP that matches slirp DHCP range (first client = x.x.x.15) _STATIC_IP = '192.168.4.15' _GATEWAY_IP = '192.168.4.2' _NETMASK = '255.255.255.0' # SSID the QEMU WiFi AP broadcasts. # Must match one of the access_point_info entries in esp32_wifi_ap.c # (the lcgamboa QEMU fork). "Espressif" is on channel 5 in that array. _QEMU_WIFI_SSID = 'Espressif' _QEMU_WIFI_CHANNEL = 5 class ESPIDFCompiler: """Compile Arduino sketches using ESP-IDF for QEMU-compatible output.""" # Instance attributes resolved in __init__ (declared here so type # checkers see them without inferring through the discovery logic). idf_path: str # pinned ESP-IDF v4.4.7 tree (Arduino fallback) idf5_path: str # ESP-IDF v5.x tree (family default + only C6) arduino_path: str # arduino-esp32 2.x core (IDF 4.4-based) arduino5_path: str # arduino-esp32 3.x core (IDF 5.x-based) has_arduino: bool # a usable 2.x core was found has_arduino5: bool # a usable 3.x core was found def __init__(self): self.idf_path = os.environ.get('IDF_PATH', '') self.arduino_path = os.environ.get('ARDUINO_ESP32_PATH', '') self.has_arduino = bool(self.arduino_path) and os.path.isdir(self.arduino_path) # Try common locations on Windows dev machines if not self.idf_path: for candidate in [ r'C:\Espressif\frameworks\esp-idf-v4.4.7', r'C:\esp\esp-idf', '/opt/esp-idf', ]: if os.path.isdir(candidate): self.idf_path = candidate break # Auto-detect the 2.x Arduino core (IDF 4.4-based) if not explicitly # set. This is the core used for Arduino sketches on the v4.4.7 # fallback path (esp32/s2/s3/c3). if self.idf_path and not self.has_arduino: for candidate in [ r'C:\Espressif\components\arduino-esp32', os.path.join(self.idf_path, '..', 'components', 'arduino-esp32'), '/opt/arduino-esp32', ]: if os.path.isdir(candidate): self.arduino_path = os.path.abspath(candidate) self.has_arduino = True break # ── ESP-IDF v5.x root (IDF5-only targets: ESP32-C6) ───────────── # The pinned v4.4.7 tree has no esp32c6 target, so C6 builds resolve # a separate IDF v5.x install. Kept independent of self.idf_path so # the classic targets keep building against the known-good 4.4 tree. self.idf5_path = os.environ.get('IDF5_PATH', '') if not self.idf5_path: candidates5: list[str] = [] win55 = Path(r'C:\Espressif5.5\frameworks') if win55.is_dir(): # Newest first when several v5 frameworks are installed. candidates5 += sorted( (str(p) for p in win55.glob('esp-idf-v5*')), reverse=True ) candidates5.append('/opt/esp-idf-v5') for candidate in candidates5: if os.path.isdir(candidate): self.idf5_path = candidate break if self.idf_path: logger.info(f'[espidf] IDF_PATH={self.idf_path}') if self.has_arduino: logger.info(f'[espidf] Arduino component: yes ({self.arduino_path})') else: logger.info('[espidf] Arduino component: no (pure ESP-IDF fallback)') else: logger.warning('[espidf] IDF_PATH not set — ESP-IDF compilation unavailable') if self.idf5_path: logger.info(f'[espidf] IDF v5.x (esp32c6): {self.idf5_path}') else: logger.info('[espidf] IDF v5.x not found — esp32c6 compilation unavailable') # ── arduino-esp32 3.x core (IDF 5.x-based) ────────────────────── # A separate install used for Arduino sketches on the v5.x path — # the ONLY core that can build esp32c6 .ino sketches. Discovered # under the v5 install's components/ (or ARDUINO_ESP32_5_PATH). # Kept independent of the 2.x core so both remain available and the # pipeline picks per build. self.arduino5_path = os.environ.get('ARDUINO_ESP32_5_PATH', '') if not self.arduino5_path: for candidate in [ r'C:\Espressif5.5\components\arduino-esp32', (os.path.join(os.path.dirname(self.idf5_path), '..', 'components', 'arduino-esp32') if self.idf5_path else ''), '/opt/arduino-esp32-3', ]: if candidate and os.path.isdir(candidate): self.arduino5_path = os.path.abspath(candidate) break self.has_arduino5 = ( bool(self.arduino5_path) and os.path.isdir(self.arduino5_path) ) if self.has_arduino5: logger.info(f'[espidf] arduino-esp32 3.x core (IDF5): {self.arduino5_path}') else: logger.info('[espidf] arduino-esp32 3.x core: no (C6 .ino unavailable)') @property def available(self) -> bool: """Whether ESP-IDF toolchain is available.""" return bool(self.idf_path) and os.path.isdir(self.idf_path) # Targets that only exist in ESP-IDF v5.x — the pinned v4.4.7 tree # predates them, so they build against idf5_path instead of idf_path. _IDF5_TARGETS: frozenset[str] = frozenset({'esp32c6'}) # Flash offset where each chip's boot ROM expects the 2nd-stage # bootloader. ESP32 / ESP32-S2 use 0x1000; every newer chip (S3, C2, # C3, C6, H2) boots from 0x0, so that is the default for unlisted # targets. (A future P4/C5 entry would be 0x2000.) _BOOTLOADER_OFFSETS: dict[str, int] = { 'esp32': 0x1000, 'esp32s2': 0x1000, } def _is_esp32c3(self, board_fqbn: str) -> bool: """Return True if FQBN targets ESP32-C3 (RISC-V).""" return 'esp32c3' in board_fqbn or 'esp32-c3' in board_fqbn def _is_esp32c6(self, board_fqbn: str) -> bool: """Return True if FQBN targets ESP32-C6 (RISC-V, IDF v5.x only).""" f = board_fqbn.lower() return 'esp32c6' in f or 'esp32-c6' in f def _idf_root(self, use_idf5: bool) -> str: """The ESP-IDF tree a compile builds against.""" return self.idf5_path if use_idf5 else self.idf_path def _use_idf5(self, idf_target: str, arduino_mode: bool) -> bool: """Decide which IDF major this compile builds against. Policy: IDF v5.x is THE toolchain for the whole ESP32 family (esp32 / s2 / s3 / c3 / c6); the pinned v4.4.7 tree is legacy. - esp32c6 only exists in v5.x → always v5. - Arduino-as-component builds are tied to the core's IDF. When an arduino-esp32 3.x core (IDF 5.x based — 3.3.x pairs with IDF 5.5) is installed, Arduino sketches build on v5 with it. Without a 3.x core they FALL BACK to v4.4.7 + the 2.x core (which is IDF 4.4-based). Escape hatches: VELXIO_ARDUINO_IDF5=0 forces the v4.4 path even when a 3.x core exists; =1 forces v5. - Pure-IDF builds (user code defines app_main) use v5 whenever the v5 install exists, and fall back to v4.4 when it doesn't (OSS self-host without the v5 install). """ if idf_target in self._IDF5_TARGETS: return True has_idf5 = bool(self.idf5_path) and os.path.isdir(self.idf5_path) if arduino_mode: override = os.environ.get('VELXIO_ARDUINO_IDF5', '') if override in ('1', 'true', 'True'): return has_idf5 if override in ('0', 'false', 'False'): return False # Auto: prefer v5 + the 3.x core when both are present. return has_idf5 and self.has_arduino5 return has_idf5 def _arduino_path_for(self, use_idf5: bool) -> str: """The arduino-esp32 core a build uses: the 3.x core on v5, the 2.x core on v4.4 — falling back to whichever exists if the preferred one is absent.""" if use_idf5: return self.arduino5_path or self.arduino_path return self.arduino_path or self.arduino5_path def _arduino_supports_target(self, idf_target: str) -> bool: """True when SOME installed arduino-esp32 core can build this IDF target as Arduino-as-component. IDF5-only targets (esp32c6) need an arduino-esp32 3.x core (IDF 5.x based) — the 2.x core has no such target. Classic targets (esp32/s2/s3/c3) build with either core. When no usable core exists for the target the sketch is refused with a structured error rather than being pattern-translated into something it isn't. """ if idf_target in self._IDF5_TARGETS: return self.has_arduino5 return self.has_arduino or self.has_arduino5 def _arduino_core_version(self, arduino_path: Optional[str] = None) -> Optional[str]: """Best-effort version of an arduino-esp32 core (package.json ``version``), for user-facing messages. Defaults to the 2.x core.""" path = arduino_path or self.arduino_path if not path: return None try: with open( os.path.join(path, 'package.json'), encoding='utf-8' ) as fh: return json.load(fh).get('version') except (OSError, ValueError): return None @staticmethod def _contains_app_main(code: str) -> bool: """True when the code defines its own ESP-IDF entry point (``void app_main(void)``) — i.e. it is a plain ESP-IDF program rather than an Arduino setup()/loop() sketch.""" return bool(re.search(r'\bvoid\s+app_main\s*\(', code)) def _is_esp32s3(self, board_fqbn: str) -> bool: """Return True if FQBN targets ESP32-S3 (Xtensa LX7). Most S3 FQBNs contain 'esp32s3' (esp32s3, XIAO_ESP32S3 uppercase — the lowercased substring test still matches), but a few S3 boards use a variant name that doesn't: Arduino Nano ESP32 = nano_nora, M5 Cardputer/ StampS3 = m5stack_cardputer / m5stack_stamps3, so those are matched explicitly. Without this every S3 board compiled for the 'esp32' (LX6) target and could not boot the esp32s3 QEMU machine. """ f = board_fqbn.lower() if 'esp32s3' in f: return True return any(v in f for v in ('nano_nora', 'm5stack_cardputer', 'm5stack_stamps3', 'm5stamp_s3')) # Cache: boards.txt is a few hundred KB and never changes at runtime. _variant_cache: dict = {} def _arduino_variant(self, board_fqbn: str, idf_target: str) -> str: """The Arduino VARIANT for this FQBN, from arduino-esp32's boards.txt. Without this every board builds against ``variants//``, i.e. as a generic dev-kit, and the board's own pin names never exist: a XIAO sketch dies on ``D1``, an M5 sketch on its silk names. Since those are exactly the sketches people copy from vendor wikis, the emulator has to get this right to be believable. boards.txt is the authority (``.build.variant=``), so it is read rather than guessed; anything not found falls back to the chip, which is the previous behaviour. """ board_id = board_fqbn.split(':')[-1].split('?')[0].strip() if not board_id: return idf_target key = (board_id, idf_target) if key in self._variant_cache: return self._variant_cache[key] variant = idf_target # The 3.x core first: it is the one the IDF5 targets build against. roots = [ r for r in ( os.environ.get('ARDUINO_ESP32_V3_PATH', '/opt/arduino-esp32-3'), os.environ.get('ARDUINO_ESP32_PATH', '/opt/arduino-esp32'), ) if r ] for root in roots: boards_txt = Path(root) / 'boards.txt' if not boards_txt.is_file(): continue try: text = boards_txt.read_text(encoding='utf-8', errors='ignore') except OSError: continue needle = f'{board_id}.build.variant=' for line in text.splitlines(): if line.startswith(needle): found = line.split('=', 1)[1].strip() if found: variant = found break if variant != idf_target: break if variant == idf_target and board_id != idf_target: logger.info( f'[espidf] no build.variant for {board_id} in boards.txt; ' f'falling back to {idf_target}' ) self._variant_cache[key] = variant return variant _board_flags_cache: dict = {} def _arduino_board_flags(self, board_fqbn: str) -> dict: """USB/identity flags for this FQBN from arduino-esp32's boards.txt. Returns {'board': str|None, 'cdc_on_boot': bool}: - ``board``: the ``.build.board`` name — arduino-cli defines ``ARDUINO_`` from it and vendor sketches #ifdef on it. - ``cdc_on_boot``: ``.build.cdc_on_boot=1`` — on real hardware ``Serial`` is then the USB CDC, not UART0. Boards like the XIAO ESP32S3 expose UART0's default RX pin (GPIO44) as a plain Dx pin, so building them with Serial on UART0 is not just unfaithful: a pinMode() on that pin tears the UART driver down mid-sketch. """ board_id = board_fqbn.split(':')[-1].split('?')[0].strip() if board_id in self._board_flags_cache: return self._board_flags_cache[board_id] flags = {'board': None, 'cdc_on_boot': False} roots = [ r for r in ( os.environ.get('ARDUINO_ESP32_V3_PATH', '/opt/arduino-esp32-3'), os.environ.get('ARDUINO_ESP32_PATH', '/opt/arduino-esp32'), ) if r ] for root in roots: boards_txt = Path(root) / 'boards.txt' if not boards_txt.is_file(): continue try: text = boards_txt.read_text(encoding='utf-8', errors='ignore') except OSError: continue for line in text.splitlines(): if line.startswith(f'{board_id}.build.board='): flags['board'] = line.split('=', 1)[1].strip() or None elif line.startswith(f'{board_id}.build.cdc_on_boot='): flags['cdc_on_boot'] = line.split('=', 1)[1].strip() == '1' if flags['board'] is not None: break self._board_flags_cache[board_id] = flags return flags def _idf_target(self, board_fqbn: str) -> str: """Map FQBN to IDF_TARGET.""" if self._is_esp32c3(board_fqbn): return 'esp32c3' if self._is_esp32c6(board_fqbn): return 'esp32c6' if self._is_esp32s3(board_fqbn): return 'esp32s3' # Default to esp32 (Xtensa LX6) for the original ESP32 / ESP32-S2 return 'esp32' def _add_managed_components(self, project_dir: Path, deps: dict) -> None: """Declare extra ESP-IDF managed components for the `main` component. The template ships no idf_component.yml, so every managed component the build sees today arrives transitively through arduino-esp32's own manifest. Anything it does not depend on has to be asked for explicitly; the component manager then fetches it into managed_components/ during the cmake configure and caches it in the build dir for later runs. """ if not deps: return manifest = project_dir / 'main' / 'idf_component.yml' lines = ['# Auto-generated by Velxio — components the sketch needs that the', '# Arduino core does not pull in by itself.', 'dependencies:'] for name, version in deps.items(): lines.append(f' {name}: "{version}"') manifest.write_text('\n'.join(lines) + '\n', encoding='utf-8') logger.info( '[espidf] Declared managed components: %s', ', '.join(sorted(deps)) ) def _detect_camera_usage(self, code: str) -> bool: """Does the sketch use the ESP32 camera driver? `esp_camera.h` does NOT ship with the Arduino core: it lives in the `espressif/esp32-camera` managed component, which arduino-esp32's own idf_component.yml does not depend on. So a camera sketch compiles fine everywhere else and dies here with "esp_camera.h: No such file or directory" — which is exactly what both ESP32-CAM examples did. """ return bool(re.search(r'#include\s*[<"]esp_camera\.h[">]', code)) def _detect_wifi_usage(self, code: str) -> bool: """Check if sketch uses WiFi.""" return bool(re.search(r'#include\s*[<"]WiFi\.h[">]|WiFi\.begin\(', code)) def _detect_idf_wifi_usage(self, code: str) -> bool: """Pure ESP-IDF mode: does the project use the esp_wifi stack?""" return bool(re.search(r'#include\s*[<"]esp_wifi\.h[">]|esp_wifi_init\s*\(', code)) def _normalize_wifi_for_qemu_idf(self, code: str) -> str: """ Pure ESP-IDF variant of _normalize_wifi_for_qemu. IDF projects set credentials via `#define WIFI_SSID "..."` or wifi_config_t designated initializers (`.ssid = "..."`, `.password = "..."`). Rewrite the common literal forms so the firmware associates with the open AP the QEMU fork broadcasts (_QEMU_WIFI_SSID); anything more dynamic (strcpy into the struct at runtime) is left alone and simply won't connect. """ code = re.sub( r'(#define\s+\w*SSID\w*\s+)"[^"]*"', rf'\1"{_QEMU_WIFI_SSID}"', code, flags=re.IGNORECASE, ) code = re.sub( r'(#define\s+\w*(?:PASS|PASSWORD|PSK)\w*\s+)"[^"]*"', r'\1""', code, flags=re.IGNORECASE, ) code = re.sub(r'(\.ssid\s*=\s*)"[^"]*"', rf'\1"{_QEMU_WIFI_SSID}"', code) code = re.sub(r'(\.password\s*=\s*)"[^"]*"', r'\1""', code) return code def _detect_webserver_usage(self, code: str) -> bool: """Check if sketch uses WebServer.""" return bool(re.search( r'#include\s*[<"]WebServer\.h[">]|#include\s*[<"]ESP8266WebServer\.h[">]|WebServer\s+\w+', code )) def _normalize_wifi_for_qemu(self, code: str) -> str: """ Normalize WiFi SSID/password/channel in Arduino sketches for QEMU. QEMU's WiFi AP broadcasts _QEMU_WIFI_SSID on _QEMU_WIFI_CHANNEL with open auth. This method rewrites the user's sketch so that: - Any SSID string literal → _QEMU_WIFI_SSID - Password → "" (open auth) - Channel → _QEMU_WIFI_CHANNEL The user's editor still shows their original code; only the compiled binary is modified. """ if not self._detect_wifi_usage(code): return code # 1) Replace SSID variable definitions: # const char* ssid = "anything" → _QEMU_WIFI_SSID # char ssid[] = "anything" → _QEMU_WIFI_SSID # #define WIFI_SSID "anything" → _QEMU_WIFI_SSID code = re.sub( r'((?:const\s+)?char\s*\*?\s*ssid\s*\[?\]?\s*=\s*)"[^"]*"', rf'\1"{_QEMU_WIFI_SSID}"', code, flags=re.IGNORECASE ) code = re.sub( r'(#define\s+\w*SSID\w*\s+)"[^"]*"', rf'\1"{_QEMU_WIFI_SSID}"', code, flags=re.IGNORECASE ) # 2) Normalize WiFi.begin() calls: # WiFi.begin("X") → WiFi.begin(_QEMU_WIFI_SSID, "", _QEMU_WIFI_CHANNEL) # WiFi.begin("X", "pass") → WiFi.begin(_QEMU_WIFI_SSID, "", _QEMU_WIFI_CHANNEL) # WiFi.begin(ssid, pass, N) → WiFi.begin(ssid, "", _QEMU_WIFI_CHANNEL) # WiFi.begin(ssid) → WiFi.begin(ssid, "", _QEMU_WIFI_CHANNEL) def _rewrite_wifi_begin(m: re.Match) -> str: args = m.group(1) parts = [a.strip() for a in args.split(',')] ssid_arg = parts[0] # If SSID is a string literal, force to _QEMU_WIFI_SSID if ssid_arg.startswith('"'): ssid_arg = f'"{_QEMU_WIFI_SSID}"' return f'WiFi.begin({ssid_arg}, "", {_QEMU_WIFI_CHANNEL})' code = re.sub( r'WiFi\.begin\s*\(([^)]+)\)', _rewrite_wifi_begin, code ) logger.info('[espidf] WiFi normalized: SSID→%s, channel→%d, open auth', _QEMU_WIFI_SSID, _QEMU_WIFI_CHANNEL) return code def _translate_sketch_to_espidf(self, sketch_code: str) -> str: """ Translate an Arduino WiFi+WebServer sketch to pure ESP-IDF C code. This handles the common pattern: - WiFi.begin("ssid", "pass") → esp_wifi_start() with static IP - WebServer server(80) + server.on("/", handler) → esp_http_server - digitalWrite/pinMode → gpio_set_level/gpio_set_direction Returns C source code for sketch_translated.c """ uses_wifi = self._detect_wifi_usage(sketch_code) uses_webserver = self._detect_webserver_usage(sketch_code) # Extract route handlers from server.on() calls routes = [] handler_bodies = {} if uses_webserver: # Match: server.on("/path", handler_func) # or: server.on("/path", HTTP_GET, handler_func) for m in re.finditer( r'server\.on\(\s*"([^"]+)"\s*,\s*(?:HTTP_\w+\s*,\s*)?(\w+)\s*\)', sketch_code ): routes.append((m.group(1), m.group(2))) # Extract handler function bodies # Match: void handler_name() { ... server.send(...) ... } handler_bodies = {} for m in re.finditer( r'void\s+(\w+)\s*\(\s*\)\s*\{([^}]*(?:\{[^}]*\}[^}]*)*)\}', sketch_code, re.DOTALL ): fname = m.group(1) body = m.group(2) # Extract server.send() content send_match = re.search( r'server\.send\s*\(\s*(\d+)\s*,\s*"([^"]+)"\s*,\s*"([^"]*)"', body ) if not send_match: # Try multi-line string or variable send_match = re.search( r'server\.send\s*\(\s*(\d+)\s*,\s*"([^"]+)"\s*,\s*(\w+)', body ) if send_match: handler_bodies[fname] = { 'status': send_match.group(1), 'content_type': send_match.group(2), 'content': send_match.group(3), } # Build the translated C source lines = [] lines.append('/* Auto-translated from Arduino sketch to ESP-IDF */') lines.append('') if uses_wifi: lines.append(f'#define WIFI_SSID "{_QEMU_WIFI_SSID}"') lines.append('#define WIFI_PASS ""') lines.append(f'#define STATIC_IP "{_STATIC_IP}"') lines.append(f'#define GATEWAY_IP "{_GATEWAY_IP}"') lines.append(f'#define NETMASK "{_NETMASK}"') lines.append('') # Generate HTML content variables from handler bodies for fname, info in handler_bodies.items(): content = info['content'] if content.startswith('"') or content.startswith("'"): content = content.strip('"').strip("'") lines.append(f'static const char *{fname}_html = "{content}";') lines.append('') # Generate ESP-IDF HTTP handlers if uses_webserver: for path, handler_name in routes: info = handler_bodies.get(handler_name, {}) ct = info.get('content_type', 'text/html') lines.append(f'static esp_err_t {handler_name}_handler(httpd_req_t *req) {{') lines.append(f' httpd_resp_set_type(req, "{ct}");') if handler_name in handler_bodies: lines.append(f' return httpd_resp_send(req, {handler_name}_html, HTTPD_RESP_USE_STRLEN);') else: lines.append(f' return httpd_resp_send(req, "OK", 2);') lines.append('}') lines.append('') # Generate webserver start function if uses_webserver: lines.append('static void start_webserver(void) {') lines.append(' httpd_config_t config = HTTPD_DEFAULT_CONFIG();') lines.append(' httpd_handle_t server = NULL;') lines.append(' if (httpd_start(&server, &config) == ESP_OK) {') for path, handler_name in routes: uri_var = handler_name + '_uri' lines.append(f' httpd_uri_t {uri_var} = {{') lines.append(f' .uri = "{path}",') lines.append(f' .method = HTTP_GET,') lines.append(f' .handler = {handler_name}_handler') lines.append(f' }};') lines.append(f' httpd_register_uri_handler(server, &{uri_var});') lines.append(' }') lines.append('}') lines.append('') # WiFi event handler + init if uses_wifi: lines.append('static EventGroupHandle_t s_wifi_event_group;') lines.append('#define WIFI_CONNECTED_BIT BIT0') lines.append('') lines.append('static void wifi_event_handler(void *arg, esp_event_base_t base,') lines.append(' int32_t id, void *data) {') lines.append(' if (base == WIFI_EVENT && id == WIFI_EVENT_STA_START)') lines.append(' esp_wifi_connect();') lines.append(' else if (base == WIFI_EVENT && id == WIFI_EVENT_STA_DISCONNECTED)') lines.append(' esp_wifi_connect();') lines.append(' else if (base == IP_EVENT && id == IP_EVENT_STA_GOT_IP)') lines.append(' xEventGroupSetBits(s_wifi_event_group, WIFI_CONNECTED_BIT);') lines.append('}') lines.append('') lines.append('static void wifi_init_sta(void) {') lines.append(' s_wifi_event_group = xEventGroupCreate();') lines.append(' esp_netif_init();') lines.append(' esp_event_loop_create_default();') lines.append(' esp_netif_t *sta = esp_netif_create_default_wifi_sta();') lines.append(' esp_netif_dhcpc_stop(sta);') lines.append(' esp_netif_ip_info_t ip_info;') lines.append(' ip_info.ip.addr = ipaddr_addr(STATIC_IP);') lines.append(' ip_info.gw.addr = ipaddr_addr(GATEWAY_IP);') lines.append(' ip_info.netmask.addr = ipaddr_addr(NETMASK);') lines.append(' esp_netif_set_ip_info(sta, &ip_info);') lines.append(' wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();') lines.append(' esp_wifi_init(&cfg);') lines.append(' esp_event_handler_instance_register(WIFI_EVENT, ESP_EVENT_ANY_ID,') lines.append(' &wifi_event_handler, NULL, NULL);') lines.append(' esp_event_handler_instance_register(IP_EVENT, IP_EVENT_STA_GOT_IP,') lines.append(' &wifi_event_handler, NULL, NULL);') lines.append(' wifi_config_t wifi_config = {') lines.append(' .sta = {') lines.append(' .ssid = WIFI_SSID,') lines.append(' .password = WIFI_PASS,') lines.append(' .threshold.authmode = WIFI_AUTH_OPEN,') lines.append(' },') lines.append(' };') lines.append(' esp_wifi_set_mode(WIFI_MODE_STA);') lines.append(' esp_wifi_set_config(WIFI_IF_STA, &wifi_config);') lines.append(' esp_wifi_start();') lines.append('}') lines.append('') # app_main lines.append('void app_main(void) {') if uses_wifi: lines.append(' esp_err_t ret = nvs_flash_init();') lines.append(' if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND) {') lines.append(' nvs_flash_erase();') lines.append(' nvs_flash_init();') lines.append(' }') lines.append(' wifi_init_sta();') lines.append(' vTaskDelay(pdMS_TO_TICKS(3000));') if uses_webserver: lines.append(' start_webserver();') lines.append(' while (1) {') lines.append(' vTaskDelay(pdMS_TO_TICKS(1000));') lines.append(' }') lines.append('}') return '\n'.join(lines) + '\n' def _find_arduino_libraries_dir(self) -> Path | None: """Find the Arduino global user-libraries directory (installed via arduino-cli). P2.1h: when the pro overlay sets VELXIO_FALLBACK_LIBRARIES_DIR (the content-addressed cache root, itself a valid libraries dir whose children are library folders), prefer it — so the no-manifest scan + the scan-all retry resolve from the cache instead of the shared global volume, letting the global volume be retired. Unset (OSS self-host) -> legacy global. """ candidates: list[Path] = [] _fb = os.environ.get('VELXIO_FALLBACK_LIBRARIES_DIR') if _fb: candidates.append(Path(_fb)) candidates += [ Path.home() / 'Arduino' / 'libraries', Path.home() / 'Documents' / 'Arduino' / 'libraries', Path('/root/Arduino/libraries'), # Docker / CI as root Path('/home/user/Arduino/libraries'), Path('/Arduino/libraries'), ] # Also check arduino-cli's data directory for base in [ Path.home() / '.arduino15', Path('/root/.arduino15'), Path('/home/user/.arduino15'), ]: candidates.append(base / 'libraries') for c in candidates: if c.is_dir(): logger.info(f'[espidf] Arduino libraries dir: {c}') return c logger.warning('[espidf] Arduino libraries dir not found') return None # Headers that will NEVER appear in any Arduino library directory: # - C/C++ standard library headers # - Arduino core API types compiled directly into arduino-esp32 (not installable) # # Everything else (Wire.h, SPI.h, WiFi.h, Adafruit_GFX.h, …) is resolved # dynamically: user-installed libs → IDF component; arduino-esp32 bundled # libs → skip (already compiled in); not found → warning. _BUILTIN_HEADERS = frozenset({ # C standard library 'math.h', 'stdint.h', 'stdio.h', 'stdlib.h', 'string.h', 'stdarg.h', 'stddef.h', 'stdbool.h', 'float.h', 'limits.h', 'assert.h', 'ctype.h', 'errno.h', 'inttypes.h', 'locale.h', 'setjmp.h', 'signal.h', 'time.h', 'wchar.h', 'wctype.h', # C++ standard library wrappers and STL. # MUST be marked built-in — otherwise the user_libs bundler resolves # them against /root/Arduino/libraries/ArduinoSTL (an AVR-only # uClibc++ port) and drags in complex.cpp / vector.cpp / …, none of # which compile against ESP-IDF's libstdc++. 'cstdint', 'cstddef', 'cstdio', 'cstdlib', 'cstring', 'cmath', 'cassert', 'cctype', 'cerrno', 'cfloat', 'climits', 'clocale', 'csetjmp', 'csignal', 'cstdarg', 'ctime', 'cwchar', 'cwctype', 'cinttypes', 'algorithm', 'array', 'atomic', 'bitset', 'chrono', 'codecvt', 'complex', 'condition_variable', 'deque', 'exception', 'fstream', 'functional', 'future', 'initializer_list', 'iomanip', 'ios', 'iosfwd', 'iostream', 'istream', 'iterator', 'limits', 'list', 'locale', 'map', 'memory', 'mutex', 'new', 'numeric', 'optional', 'ostream', 'queue', 'random', 'ratio', 'regex', 'set', 'sstream', 'stack', 'stdexcept', 'streambuf', 'string', 'string_view', 'system_error', 'thread', 'tuple', 'type_traits', 'typeindex', 'typeinfo', 'unordered_map', 'unordered_set', 'utility', 'valarray', 'variant', 'vector', 'any', # Arduino core types — part of arduino-esp32 source, not installable libraries 'Arduino.h', 'HardwareSerial.h', 'Stream.h', 'Print.h', 'WString.h', 'pgmspace.h', 'IPAddress.h', }) # Core arduino-esp32 bundled libraries — already compiled into the IDF component, # must NOT be duplicated as separate user_libs components. _CORE_ESP32_LIBS: frozenset[str] = frozenset({ 'Wire', 'SPI', 'WiFi', 'EEPROM', 'SD', 'FS', 'LittleFS', 'SPIFFS', 'WebServer', 'HTTPClient', 'WiFiClientSecure', 'BluetoothSerial', 'BLE', 'Preferences', 'Update', 'Ticker', }) # Headers that ship inside the arduino-esp32 core but don't live in a # standalone library dir (so _find_library_for_header can't resolve # them). They're already compiled into the core — pulled transitively # by WiFi/WebServer/etc. — so a "not found" warning for them is a # false positive. Treated as core-provided, not "may fail". _CORE_ESP32_HEADERS: frozenset[str] = frozenset({ 'Udp.h', 'IPAddress.h', 'Client.h', 'Server.h', 'Stream.h', 'Print.h', 'Printable.h', 'WiFiUdp.h', 'WiFiClient.h', 'WiFiServer.h', 'WiFiType.h', 'esp_wifi.h', }) # Basenames of C/C++ standard headers. A user library that ships a private # header with one of these names (e.g. LovyanGFX's src/lgfx/internal/ # limits.h, algorithm.h, memory.h, alloca.h) must NOT put that directory on # the global -I path: doing so shadows the toolchain header for the WHOLE # build. Concretely, FreeRTOS/portmacro.h does `#include ` inside # an `extern "C"` block (reached via Arduino.h), and if it resolves to # LovyanGFX's limits.h — which pulls the C++ — the compile dies # with "template specialization with C linkage". Libraries reach these # private headers through file-relative includes (`#include "../internal/ # limits.h"`), so the directory is never needed on -I. See # `_generate_merged_component` where dirs holding these are excluded. _SHADOW_STD_HEADERS: frozenset[str] = frozenset({ 'limits.h', 'stdio.h', 'stdlib.h', 'string.h', 'math.h', 'assert.h', 'ctype.h', 'errno.h', 'time.h', 'stddef.h', 'stdint.h', 'setjmp.h', 'signal.h', 'locale.h', 'wchar.h', 'stdbool.h', 'stdarg.h', 'float.h', 'algorithm.h', 'memory.h', 'alloca.h', 'new.h', }) # arduino-esp32 uses a single library architecture id ("esp32") across # every chip variant (esp32 / esp32c3 / esp32s3 ...). A library whose # library.properties declares architectures= without "esp32" or "*" is # built for another platform and must not be pulled into an ESP32 build. _ESP32_LIB_ARCH = 'esp32' def _core_provided_headers(self) -> frozenset[str]: """Header filenames provided by the arduino-esp32 core itself (its `cores/` tree plus every bundled library under `libraries/`). These are compiled into the arduino-esp32 IDF component, so a user library must NEVER shadow them — even when a lib installed in ~/Arduino/libraries happens to ship a file by the same name. The canonical break this guards against: `WiFiEspAT/src/WiFi.h` (an ESP8266 AT-modem library) shadowing the core `WiFi.h`, which drags `EspAtDrv.cpp` into the build where its `const char OK[]` / `const char STATUS[]` collide with ESP-IDF's `enum STATUS { ...OK... }` in rom/ets_sys.h and the compile fails. Computed once from the core tree and cached. Always unions the static `_CORE_ESP32_HEADERS` fallback so the guard still holds even when the core path is unknown (e.g. translation-only mode). """ cached = getattr(self, '_core_headers_cache', None) if cached is not None: return cached headers: set[str] = set(self._CORE_ESP32_HEADERS) # Union both installed cores (2.x and 3.x) so the guard holds # whichever one a given build selects. for core_path in {self.arduino_path, getattr(self, 'arduino5_path', '')}: root = Path(core_path) if core_path else None if not (root and root.is_dir()): continue for sub in ('cores', 'libraries'): base = root / sub if not base.is_dir(): continue for pattern in ('*.h', '*.hpp'): for f in base.rglob(pattern): headers.add(f.name) result = frozenset(headers) self._core_headers_cache = result logger.info('[espidf] core-provided header set: %d headers', len(result)) return result @staticmethod def _parse_library_properties(lib_root: Path) -> dict[str, str]: """Best-effort parse of an Arduino library.properties into a dict.""" props: dict[str, str] = {} try: text = (lib_root / 'library.properties').read_text( encoding='utf-8', errors='ignore' ) except OSError: return props for line in text.splitlines(): line = line.strip() if not line or line.startswith('#') or '=' not in line: continue key, _, value = line.partition('=') props[key.strip().lower()] = value.strip() return props def _library_supports_esp32(self, lib_root: Path) -> bool: """True if the library may be used on the ESP32 platform. A missing/empty `architectures` field means "all architectures" (the Arduino default), so we allow it. Only libraries that explicitly enumerate architectures WITHOUT esp32/* are rejected — those are written for another platform and would not compile. """ arch = self._parse_library_properties(lib_root).get('architectures', '').strip() if not arch: return True arches = {a.strip().lower() for a in arch.split(',') if a.strip()} return '*' in arches or self._ESP32_LIB_ARCH in arches @staticmethod def _norm_lib_name(name: str) -> str: """Normalise a library name for manifest matching: lowercased, only alphanumerics. So "Adafruit GFX Library", "Adafruit_GFX_Library" and "adafruitgfxlibrary" all compare equal — the Library Manager display name and the on-disk folder name differ only by separators/case.""" return ''.join(ch for ch in (name or '').lower() if ch.isalnum()) def _library_in_manifest(self, lib_root: Path, allowed_norm: set[str]) -> bool: """True if this library is in the project's declared manifest. Matches on the on-disk folder name OR the library.properties `name=` (the Library Manager display name), both normalised.""" if self._norm_lib_name(lib_root.name) in allowed_norm: return True props_name = self._parse_library_properties(lib_root).get('name', '') return bool(props_name) and self._norm_lib_name(props_name) in allowed_norm def _find_manifest_library_for_header( self, header: str, libs_dir: Path, allowed_norm: set[str] ) -> Path | None: """Like _find_library_for_header, but returns the source root of the first library that provides `header` AND is in the project manifest. Returns None when no DECLARED library provides the header — so a stray same-named lib in the shared dir is never picked up.""" for lib_dir in sorted(libs_dir.iterdir()): if not lib_dir.is_dir(): continue for src_root in (lib_dir, lib_dir / 'src'): if (src_root / header).exists() and self._library_in_manifest( lib_dir, allowed_norm ): return src_root return None @staticmethod def _missing_library_headers(result: dict) -> list[str]: """Extract the header filenames a failed compile reported as missing (`fatal error: X.h: No such file or directory`). De-duped, basename only. Used to decide whether a manifest-scoped failure is a missing- dependency case worth retrying with scan-all.""" text = '\n'.join( str(result.get(k) or '') for k in ('error', 'stderr', 'stdout') ) headers: list[str] = [] for m in re.finditer( r'fatal error:\s*([A-Za-z0-9_./+-]+\.h(?:pp)?)\s*:\s*No such file', text, ): h = m.group(1).split('/')[-1] if h not in headers: headers.append(h) return headers @staticmethod def _is_transient_build_failure(result: dict) -> bool: """True if a failed compile looks like infrastructure flakiness (cmake configure, ESP-IDF nested bootloader / managed-components, a missing generated sdkconfig.h, a failed CORE esp-idf object) rather than the user's sketch. Such failures hit occasionally on a cold variant build and clear on a retry — and are NEVER caused by user code, so retrying is safe.""" if result.get('success'): return False text = ( str(result.get('error') or '') + '\n' + str(result.get('stderr') or '') + '\n' + str(result.get('stdout') or '') ).lower() markers = ( 'managed_components_list.temp.cmake', 'cmake configure failed', 'sdkconfig.h: no such file', 'ninja: error', 'esp-idf/bootloader', 'cmake error', ) return any(m in text for m in markers) def _suggest_libraries_for_headers(self, headers: list[str]) -> dict: """For each missing header, the installed libraries that provide it (by Library Manager display name, else folder name). Returns {header: [candidate names]} so a manifest can be completed.""" arduino_libs = self._find_arduino_libraries_dir() out: dict[str, list[str]] = {} if not arduino_libs or not arduino_libs.is_dir(): return out for h in headers: cands: list[str] = [] for lib_dir in sorted(arduino_libs.iterdir()): if not lib_dir.is_dir(): continue for src_root in (lib_dir, lib_dir / 'src'): if (src_root / h).exists(): name = self._parse_library_properties(lib_dir).get('name') or lib_dir.name if name not in cands: cands.append(name) break if cands: out[h] = cands return out def _resolve_library_components( self, ext_headers: list[str], arduino_libs: Path | None, esp32_libs: Path | None, arduino_comp_name: str, user_libs_dir: Path, allowed_libraries: set[str] | None = None, ) -> tuple[list[str], dict[str, str]]: """ BFS over ext_headers (and transitive includes) to discover all external Arduino libraries and merge them into a single 'user_libs_all' IDF component. `allowed_libraries` (P2 — project library manifest / scope): when not None, a USER-installed library (from arduino_libs) is merged only if its name is in this set. This makes the project's declared manifest the resolution SCOPE — the compiler never picks up an unrelated library from the shared dir (another user's install, or a same-named clash). When None (no manifest supplied) the behaviour is the legacy scan-all, so existing callers and un-migrated projects are unaffected. Core arduino-esp32 libs and bundled esp32_libs are always allowed (they are platform-provided, not user installs). All library files are copied flat into one directory, so every header is visible to every other header and source file without any cross-component REQUIRES propagation — which is unreliable in ESP-IDF 4.x for deeply nested transitive dependencies. Search priority per header: 1. arduino_libs (user-installed via Library Manager) → merge into component 2. esp32_libs (bundled with arduino-esp32) → skip core libs (Wire, SPI, …); merge non-core libs (e.g. Adafruit libs shipped with arduino-esp32) 3. not found → warning only Returns: component_names — ['user_libs_all'] if any lib found, else [] header_to_comp — every resolved header → 'user_libs_all' """ logger.info(f'[espidf] ext_headers detected: {ext_headers}') logger.info(f'[espidf] arduino_libs: {arduino_libs}') logger.info(f'[espidf] esp32_libs: {esp32_libs}') # P2 manifest scope: normalise the allowed set once (None = scan-all). allowed_norm: set[str] | None = ( {self._norm_lib_name(a) for a in allowed_libraries} if allowed_libraries is not None else None ) if allowed_norm is not None: # `or set()` only narrows the Optional for type checkers — # allowed_norm being non-None implies allowed_libraries is too. logger.info( f'[espidf] library manifest scope active: ' f'{sorted(allowed_libraries or set())}' ) comp_dir = user_libs_dir / 'user_libs_all' comp_dir.mkdir(exist_ok=True) cpp_files: list[str] = [] seen_names: set[str] = set() header_to_comp: dict[str, str] = {} found_any = False headers_to_resolve: list[str] = list(ext_headers) resolved_headers: set[str] = set() while headers_to_resolve: header = headers_to_resolve.pop(0) if header in resolved_headers: continue resolved_headers.add(header) # Core-first. arduino-esp32 core headers (WiFi.h, Wire.h, SPI.h, # WebServer.h, HTTPClient.h, ...) are compiled into the # arduino-esp32 component. They must NEVER resolve to a user # library, even when an installed lib ships a same-named file # (e.g. WiFiEspAT/src/WiFi.h). Resolving to it would merge that # foreign library and break the build. Skip resolution entirely # and let the core provide the header. if header in self._core_provided_headers(): logger.info( f'[espidf] <{header}> is provided by the arduino-esp32 core ' f'— never resolving against user libraries' ) continue # P2 manifest scope. When a manifest is supplied, resolve the header # to the DECLARED library that provides it — not the first- # alphabetical lib in the shared dir. Several installed libs may # ship the same header name (e.g. DHT118266, DHT_sensor_library, # servodht11 all have DHT.h); the legacy first-match would pick a # stray. The manifest both picks the right lib AND excludes # undeclared ones (another user's install, a clash). No manifest = # legacy first-match (unchanged). if not (arduino_libs and arduino_libs.is_dir()): src_root = None elif allowed_norm is not None: src_root = self._find_manifest_library_for_header( header, arduino_libs, allowed_norm ) else: src_root = self._find_library_for_header(header, arduino_libs) # Architecture guard. A user lib that resolves the header but # whose library.properties declares architectures= without # esp32/* is written for a different platform (AVR-only AT-modem # shims, etc.) and would not compile against ESP-IDF. Drop it. if src_root is not None: _lib_root = src_root.parent if src_root.name == 'src' else src_root if not self._library_supports_esp32(_lib_root): logger.warning( f'[espidf] <{header}> resolved to "{_lib_root.name}" but its ' f'library.properties architectures exclude esp32 — skipping' ) src_root = None # Tracks the "resolved to a core lib that's already compiled into # the arduino-esp32 component" case, so we don't fall through to # the scary "not found — build may fail" warning below for a # header that WAS found (just not as a mergeable user lib). is_core_provided = False if src_root is None and esp32_libs and esp32_libs.is_dir(): esp32_root = self._find_library_for_header(header, esp32_libs) if esp32_root: lib_name = esp32_root.parent.name if esp32_root.name == 'src' else esp32_root.name if lib_name in self._CORE_ESP32_LIBS: is_core_provided = True logger.info( f'[espidf] <{header}> provided by arduino-esp32 core ' f'("{lib_name}") — already compiled in, not merging' ) else: logger.info(f'[espidf] <{header}> found in esp32_libs as "{lib_name}", merging') src_root = esp32_root if src_root: lib_dir_name = src_root.parent.name if src_root.name == 'src' else src_root.name logger.info(f'[espidf] Merging "{lib_dir_name}" into user_libs_all for <{header}>') found_any = True header_to_comp[header] = 'user_libs_all' # Preserve directory structure while merging libraries. # Skip non-buildable directories like examples, tests, docs. lib_root = src_root.parent if src_root.name == 'src' else src_root has_src_layout = (lib_root / 'src').is_dir() excluded_dirs = { '.git', '.github', '.vscode', '__pycache__', 'docs', 'doc', 'example', 'examples', 'test', 'tests', 'extras', 'ci', 'fuzz', 'fuzzing', 'benchmark', 'benchmarks', } def _should_include(rel_path: Path) -> bool: parts = rel_path.parts if any(part.lower() in excluded_dirs for part in parts[:-1]): return False # Copy compiled sources (.c/.cpp) AND every flavour of header # or text-included fragment. `.inl`/`.inc`/`.ipp`/`.tcc` are # `#include`d verbatim from a sibling source (e.g. M5Unified's # `#include "BMI270_config.inl"`), so dropping them breaks the # build with "No such file or directory" even though the file # ships with the library. if rel_path.suffix.lower() not in ( '.h', '.hpp', '.hh', '.hxx', '.c', '.cpp', '.inl', '.inc', '.ipp', '.tcc', ): return False if has_src_layout: return parts[0] == 'src' or len(parts) == 1 # Non-src-layout libs (Adafruit_GFX, etc.) keep auxiliary # headers in subdirs like Fonts/ or gfxfont/. Anything not # already excluded (docs/examples/tests handled above) is # presumed to be buildable source. return True for f in lib_root.rglob('*'): if not f.is_file(): continue rel_path = f.relative_to(lib_root) if not _should_include(rel_path): continue # Track file by its relative path to preserve structure file_key = str(rel_path).replace('\\', '/') if file_key not in seen_names: dest = comp_dir / rel_path dest.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(f, dest) seen_names.add(file_key) if f.suffix in ('.cpp', '.c') and file_key not in cpp_files: cpp_files.append(file_key) # Scan newly copied headers for transitive includes. # Use rglob so libs with `src/` layout (e.g. GxEPD2, ArduinoJson) # are scanned recursively — otherwise their headers live under # `src/`/subdirs and we'd miss every transitive include. Scan # ALL header extensions (.h/.hpp/.hh/.hxx/.inc): C++ libs like # M5Unified put the transitive `#include ` in a .hpp, # so a `*.h`-only scan silently drops that dependency. for lib_file in comp_dir.rglob('*'): if lib_file.suffix.lower() not in ('.h', '.hpp', '.hh', '.hxx', '.inc'): continue try: lib_content = lib_file.read_text(encoding='utf-8', errors='ignore') for th in self._detect_external_includes(lib_content): if th not in resolved_headers: headers_to_resolve.append(th) except OSError: pass elif is_core_provided or header in self._CORE_ESP32_HEADERS: # Resolved to an arduino-esp32 core lib, or a known core # header that lives inside the core (not a standalone lib # dir). Already compiled in — not a "build may fail" case. if header in self._CORE_ESP32_HEADERS: logger.info( f'[espidf] <{header}> is an arduino-esp32 core header — ' f'already compiled in, not merging' ) else: logger.warning(f'[espidf] Library for <{header}> not found — build may fail') if not found_any: return [], {} srcs_line = 'SRCS ' + ' '.join(f'"{f}"' for f in sorted(cpp_files)) if cpp_files else '' # Generate INCLUDE_DIRS from the directory structure of copied files. # Use PurePosixPath so paths stay forward-slashed on Windows — CMake # parses backslashes as string escapes (e.g. "src\bitmaps" → invalid \b). # Directories that ship a header shadowing a C/C++ standard header must # be kept OFF the global -I path (see `_SHADOW_STD_HEADERS`) — otherwise # a stray `#include ` deep in FreeRTOS resolves to the # library's copy and breaks the build. The files stay copied so the # owning library's file-relative includes still resolve. shadow_dirs: set[str] = set() for file_key in seen_names: if PurePosixPath(file_key).name.lower() in self._SHADOW_STD_HEADERS: parent = str(PurePosixPath(file_key).parent) if parent and parent != '.': shadow_dirs.add(parent) include_dirs: set[str] = {'.'} for file_key in seen_names: parent = str(PurePosixPath(file_key).parent) if parent and parent != '.' and parent not in shadow_dirs: include_dirs.add(parent) if shadow_dirs: logger.info( f'[espidf] kept {len(shadow_dirs)} dir(s) off -I to avoid ' f'shadowing standard headers: {sorted(shadow_dirs)}' ) include_dirs_line = 'INCLUDE_DIRS ' + ' '.join(f'"{d}"' for d in sorted(include_dirs)) # LovyanGFX's Bus_EPD.cpp (e-paper bus, compiled unconditionally as part # of the M5GFX merge) includes , which is provided by # the IDF `esp_lcd` component. Add it to REQUIRES so its headers land on # the include path. Guard on the component existing so builds on an IDF # without it (esp_lcd landed in IDF 4.4) are unaffected. extra_requires = '' if self.idf_path and os.path.isdir( os.path.join(self.idf_path, 'components', 'esp_lcd') ): extra_requires = ' esp_lcd' # Arduino libraries include IDF headers directly (, # , , ...). Under IDF 4.4 those arrived # transitively through the arduino component's PUBLIC requires; the # arduino-esp32 3.x / IDF v5 pairing keeps most of its requires # PRIVATE, so the merged user-libs component died one missing header # at a time (nvs.h, then esp_efuse.h, ... — M5GFX/M5Unified touch # several). Require the standard set Arduino-facing libraries lean # on, guarded on existence so both IDF generations stay happy. for _comp in ('nvs_flash', 'efuse', 'esp_timer', 'driver', 'spi_flash', 'esp_adc', 'esp_wifi', 'esp_event', 'esp_netif', 'esp_partition'): for _root in filter(None, (self.idf5_path, self.idf_path)): if os.path.isdir(os.path.join(_root, 'components', _comp)): extra_requires += f' {_comp}' break cmake_content = ( '# Auto-generated by Velxio — all user libraries merged into one component.\n' '# Directory structure preserved for libraries like ArduinoJson with src/ layout.\n' 'idf_component_register(\n' f' {srcs_line}\n' f' {include_dirs_line}\n' f' REQUIRES {arduino_comp_name}{extra_requires}\n' ')\n' '# LovyanGFX (the engine inside M5GFX) uses alloca()/memcpy_P/\n' '# memcmp_P without a prior declaration — its C utility files\n' '# (lgfx_pngle.c, qoi …) call the PROGMEM aliases but never include\n' '# pgmspace.h, so the flat IDF-component merge trips over an implicit\n' '# declaration. Map them to always-available equivalents (identical\n' '# to pgmspace.h, so redefinition is benign) so C++ graphics libs\n' '# compile as a merged component.\n' 'target_compile_definitions(${COMPONENT_LIB} PRIVATE' ' alloca=__builtin_alloca memcpy_P=memcpy memcmp_P=memcmp)\n' ) (comp_dir / 'CMakeLists.txt').write_text(cmake_content, encoding='utf-8') logger.info( f'[espidf] user_libs_all: {len(cpp_files)} source files, ' f'{len(header_to_comp)} resolved headers' ) return ['user_libs_all'], header_to_comp def _detect_external_includes( self, code: str, own_files: set[str] | None = None ) -> list[str]: """Return library header names that are likely from external libraries. BOTH include forms count. Arduino treats `#include "Lib.h"` and `#include ` alike for libraries, and vendors' own examples lean on the quoted form — M5Stack ships `#include "M5Cardputer.h"` in theirs. Only scanning the angled form meant such a sketch never reached the library resolver at all and died on `fatal error: M5Cardputer.h: No such file`, while the very same sketch with angle brackets built fine. `own_files` are the sketch's own file names; a quoted include naming one of them is a project-local header, not a library. """ headers = [] own = own_files or set() for m in re.finditer(r'#\s*include\s*(?:<([^>]+)>|"([^"]+)")', code): h = m.group(1) or m.group(2) if h in own: continue if h in self._BUILTIN_HEADERS: continue # Skip paths with / (esp-idf internal headers like freertos/FreeRTOS.h) if '/' in h: continue # Skip headers that look like esp-idf internal (prefix pattern) if re.match(r'^(esp_|driver/|soc/|hal/|nvs|rom/)', h): continue headers.append(h) return headers def _find_library_for_header(self, header: str, libs_dir: Path) -> Path | None: """ Search libs_dir for a library that provides `header`. Returns the source root of the library (root or src/ subdirectory). """ for lib_dir in sorted(libs_dir.iterdir()): if not lib_dir.is_dir(): continue for src_root in [lib_dir, lib_dir / 'src']: if (src_root / header).exists(): return src_root return None def _create_idf_component( self, header: str, src_root: Path, user_libs_dir: Path, arduino_comp_name: str, ) -> str: """ Create a proper ESP-IDF component for a library in user_libs_dir. Each library becomes user_libs// with its own CMakeLists.txt that calls idf_component_register(). This is the correct ESP-IDF way to include third-party code and properly handles include paths so that internal library includes like #include "utility/xyz.h" work correctly. Returns the component directory name (used in REQUIRES of main). """ # Sanitise name: use the library directory name, not the header name # src_root may be the library root OR lib/src/ — handle both cases lib_dir_name = src_root.parent.name if src_root.name == 'src' else src_root.name safe_name = re.sub(r'[^A-Za-z0-9_]', '_', lib_dir_name) comp_dir = user_libs_dir / safe_name comp_dir.mkdir(parents=True, exist_ok=True) # Preserve the original library layout for actual buildable library code # while skipping repo-only content such as examples, tests, and CI files. lib_root = src_root.parent if src_root.name == 'src' else src_root include_dirs: set[str] = {'.'} cpp_files: set[str] = set() copied_any = False has_src_layout = (lib_root / 'src').is_dir() excluded_dirs = { '.git', '.github', '.vscode', '__pycache__', 'docs', 'doc', 'example', 'examples', 'test', 'tests', 'extras', 'ci', 'fuzz', 'fuzzing', 'benchmark', 'benchmarks', } def should_include(relative_path: Path) -> bool: parts = relative_path.parts if any(part.lower() in excluded_dirs for part in parts[:-1]): return False if relative_path.suffix not in ('.h', '.hpp', '.c', '.cpp'): return False if has_src_layout: return parts[0] == 'src' or len(parts) == 1 return len(parts) == 1 or parts[0].lower() == 'utility' for f in lib_root.rglob('*'): if not f.is_file(): continue rel_path = f.relative_to(lib_root) if not should_include(rel_path): continue dest = comp_dir / rel_path dest.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(f, dest) copied_any = True include_dirs.add(str(rel_path.parent).replace('\\', '/')) if f.suffix in ('.cpp', '.c'): cpp_files.add(str(rel_path).replace('\\', '/')) if not copied_any: raise ValueError(f'No buildable source files found in library {lib_dir_name}') # Generate CMakeLists.txt for this component include_dirs.discard('.') ordered_include_dirs = ['.'] + sorted(d for d in include_dirs if d and d != '.') if cpp_files: srcs_line = 'SRCS ' + ' '.join(f'"{f}"' for f in sorted(cpp_files)) else: srcs_line = '# header-only library' include_dirs_line = 'INCLUDE_DIRS ' + ' '.join( f'"{include_dir}"' for include_dir in ordered_include_dirs ) cmake_content = ( f'# Auto-generated by Velxio for library: {lib_dir_name}\n' f'idf_component_register(\n' f' {srcs_line}\n' f' {include_dirs_line}\n' f' REQUIRES {arduino_comp_name}\n' f')\n' ) (comp_dir / 'CMakeLists.txt').write_text(cmake_content, encoding='utf-8') logger.info( f'[espidf] Created IDF component "{safe_name}" for <{header}>' f' ({len(cpp_files)} source file(s))' ) return safe_name def _discover_espressif_win( self, idf_path: Optional[str] = None ) -> tuple[str | None, str | None]: """Locate the Espressif install (tools root + registered Python venv). The official Windows offline installer writes ``esp_idf.json`` at the install root recording ``idfToolsPath`` and the exact python venv for each installed IDF version. Parsing it lets a backend launched from ANY shell (no ``export.ps1``) still find the cross-compilers and the confgen venv. Returns ``(tools_root, python_env_dir)``; either may be ``None`` if it can't be resolved. Windows only. ``idf_path`` selects WHICH install to discover (each IDF root ships its own ``esp_idf.json`` two levels up); defaults to the pinned 4.4 tree for backwards compatibility. """ idf_path = idf_path or self.idf_path candidate_roots: list[str] = [] if idf_path: # Installer layout: /frameworks/esp-idf-vX → candidate_roots.append( os.path.abspath(os.path.join(idf_path, '..', '..')) ) candidate_roots.append(r'C:\Espressif') for root in candidate_roots: cfg = os.path.join(root, 'esp_idf.json') if not os.path.isfile(cfg): continue try: with open(cfg, 'r', encoding='utf-8') as fh: data = json.load(fh) except Exception as e: # noqa: BLE001 — best-effort discovery logger.warning(f'[espidf] could not parse {cfg}: {e}') continue tools_root = data.get('idfToolsPath') or root py_env: str | None = None installed = data.get('idfInstalled') or {} want = os.path.normcase(os.path.abspath(idf_path)) if idf_path else None for info in installed.values(): ipath = (info.get('path') or '').rstrip('/\\') pexe = info.get('python') or '' if not pexe: continue # python.exe lives at /Scripts/python.exe → env_dir = os.path.dirname(os.path.dirname(pexe)) if want and ipath and os.path.normcase(os.path.abspath(ipath)) == want: py_env = env_dir # exact IDF match wins break if py_env is None: py_env = env_dir # first registered as fallback return tools_root, py_env # No metadata found: derive the root from IDF_PATH. if idf_path: return os.path.abspath(os.path.join(idf_path, '..', '..')), None return r'C:\Espressif', None def _build_env( self, idf_target: str, use_idf5: Optional[bool] = None, arduino_mode: Optional[bool] = None, pure_idf: bool = False, ) -> dict: """Build environment dict for ESP-IDF subprocess. Per-compile IDF selection (see _use_idf5): pure-IDF builds default to the v5.x install for the whole family; Arduino-as-component builds fall back to the pinned v4.4.7 install their 2.x core is built for. Everything derived from the IDF root — tools root, python venv, toolchain PATH — follows that choice. ``pure_idf`` is the user's LANGUAGE mode (issue #139), a different axis from ``arduino_mode``: it raises VELXIO_PURE_SKETCH so main/CMakeLists.txt compiles the user's own app_main() sources instead of the Arduino sketch wrapper. Both drop ARDUINO_ESP32_PATH, but a target that merely lacks an arduino-esp32 core (arduino_mode False) still hands a SKETCH to the legacy translator — it must not take the pure glob. """ # A pure-IDF build is never Arduino-as-component, whatever the target # supports: the template CMake pulls the arduino-esp32 component in as # soon as ARDUINO_ESP32_PATH exists, so leaving it set would compile # the Arduino core into a build that has no sketch at all. if pure_idf: arduino_mode = False if arduino_mode is None: arduino_mode = self._arduino_supports_target(idf_target) if use_idf5 is None: use_idf5 = self._use_idf5(idf_target, arduino_mode) env = os.environ.copy() idf_path = self._idf_root(use_idf5) or self.idf_path is_idf5 = use_idf5 env['IDF_PATH'] = idf_path env['IDF_TARGET'] = idf_target if arduino_mode: # Pick the core matching the chosen IDF major: 3.x on v5, # 2.x on v4.4. env['ARDUINO_ESP32_PATH'] = self._arduino_path_for(use_idf5) # arduino-esp32 3.x refuses to configure unless the IDF version # is within its supported window; the 3.3.x/IDF-5.5.4 pair is, # but keep this defensive so a future minor bump doesn't hard- # fail the configure before we notice. env['ARDUINO_SKIP_IDF_VERSION_CHECK'] = '1' else: # The project template switches to Arduino-as-component whenever # ARDUINO_ESP32_PATH is defined — make sure an ambient value # can't drag a pure-IDF build (or an unsupported target such as # esp32c6 with no 3.x core) into that mode. env.pop('ARDUINO_ESP32_PATH', None) if pure_idf: # Only the explicit language mode takes the pure glob; see the # docstring for why arduino_mode alone must not. env['VELXIO_PURE_SKETCH'] = '1' # On Windows, ESP-IDF uses its own Python venv and out-of-PATH tools. # We self-configure so a backend launched from a plain shell (no # export.ps1) still compiles. The tricky part on multi-install boxes: # ambient IDF_TOOLS_PATH / IDF_PYTHON_ENV_PATH can point at a DIFFERENT # IDF version than IDF_PATH (e.g. IDF_TOOLS_PATH=C:\Espressif5.5 while we # build with the 4.4 install under C:\Espressif). So we trust the # per-install metadata (esp_idf.json) and VALIDATE every candidate root # actually contains a matching xtensa toolchain before using it. # os.name goes through str() ONLY so type checkers pinned to a # single platform (typeshed types os.name as Literal['nt'] under # Windows analysis) keep the Linux/Docker branch below reachable — # this backend deploys on both. Runtime behaviour is identical. if str(os.name) == 'nt': # A backend launched from Git Bash inherits MSYSTEM, and ESP-IDF # 5.x's idf_tools.py fatals on its mere presence ("MSys/Mingw is # not supported") during cmake's python-dependency check. The # build subprocess is a plain Windows process either way — scrub # the variable so the launching shell can't poison the build. env.pop('MSYSTEM', None) discovered_root, registered_py = self._discover_espressif_win(idf_path) # ── Python venv (confgen / kconfiglib) ── # The installer-registered venv is version-matched and authoritative; # prefer it over an ambient IDF_PYTHON_ENV_PATH that may be stale # (this box's env var points at the broken py3.10). Only fall back to # the override / derived paths when no registered venv exists. if is_idf5: # Known-good recipe for the v5.x install: it ships NO # python_env of its own (idfInstalled is empty in its # esp_idf.json), and the 4.4 py3.10 venv on this layout has # the v5.x core requirements pip-installed into it. Ambient # IDF_PYTHON_ENV_PATH is not trusted here — it points at # whatever the 4.4 flow last used. py_candidates: tuple[str | None, ...] = ( os.environ.get('IDF5_PYTHON_ENV_PATH'), registered_py, r'C:\Espressif\python_env\idf4.4_py3.10_env', ) else: py_candidates = ( registered_py, os.environ.get('IDF_PYTHON_ENV_PATH'), os.path.join(os.path.dirname(idf_path), '..', 'python_env', 'idf4.4_py3.10_env'), r'C:\Espressif\python_env\idf4.4_py3.10_env', ) for cand in py_candidates: if cand and os.path.isdir(cand): py_venv = cand break else: py_venv = None if py_venv: env['PATH'] = os.path.join(py_venv, 'Scripts') + os.pathsep + env.get('PATH', '') env['VIRTUAL_ENV'] = py_venv if is_idf5: # IDF 5.x cmake honours IDF_PYTHON_ENV_PATH when picking # its interpreter — pin it so an ambient 4.4-era value # can't win over the resolved venv. env['IDF_PYTHON_ENV_PATH'] = py_venv # ── Cross-compilers + build tools ── # Pick the first candidate root that actually holds the toolchain # THIS target compiles with (validates against version mismatch); # if none does, take the first existing dir so at least host tools # resolve. The old xtensa-only glob wrongly rejected RISC-V-only # roots such as the v5.x install used for esp32c6. xtensa_glob = 'tools/xtensa-esp32-elf/*/xtensa-esp32-elf/bin' if idf_target in ('esp32c3', 'esp32c6'): required_globs: tuple[str, ...] = ( 'tools/riscv32-esp-elf/*/riscv32-esp-elf/bin', ) else: # IDF 4.4 ships per-chip xtensa toolchains; 5.x unifies them # under xtensa-esp-elf. required_globs = ( xtensa_glob, 'tools/xtensa-esp-elf/*/xtensa-esp-elf/bin', ) if is_idf5: # v5.x targets must resolve against the v5.x root only: # ambient IDF_TOOLS_PATH / C:\Espressif hold 4.4-era # toolchains that would pass the riscv glob but cannot # build IDF 5.x sources. root_candidates = [ discovered_root, os.path.abspath(os.path.join(idf_path, '..', '..')) if idf_path else None, ] else: root_candidates = [ discovered_root, os.environ.get('IDF_TOOLS_PATH'), os.path.abspath(os.path.join(idf_path, '..', '..')) if idf_path else None, r'C:\Espressif', os.path.expanduser(r'~\.espressif'), ] tools_path = None first_existing = None for cand in root_candidates: if not cand or not os.path.isdir(cand): continue if first_existing is None: first_existing = cand if any(any(Path(cand).glob(g)) for g in required_globs): tools_path = cand break tools_path = tools_path or first_existing if tools_path: env['IDF_TOOLS_PATH'] = tools_path # Note: ninja and ccache live directly under tools/// # (no bin/ subdir), so the generic tools/*/*/bin glob misses # them — enumerate them explicitly. Order: put toolchains first. prepend: list[str] = [] for pattern in ( xtensa_glob, 'tools/xtensa-esp-elf/*/xtensa-esp-elf/bin', 'tools/riscv32-esp-elf/*/riscv32-esp-elf/bin', 'tools/cmake/*/bin', 'tools/ninja/*', 'tools/ccache/*', 'tools/ccache/*/*', # windows: ccache//ccache--windows-64/ 'tools/*/*/bin', ): for d in Path(tools_path).glob(pattern): if d.is_dir() and (any(d.glob('*.exe')) or pattern != 'tools/ccache/*/*'): prepend.append(str(d)) # De-duplicate while preserving order. seen: set[str] = set() ordered = [d for d in prepend if not (d in seen or seen.add(d))] if ordered: env['PATH'] = os.pathsep.join(ordered) + os.pathsep + env['PATH'] else: # Linux/Docker: explicitly add toolchain bin dirs to PATH so cmake # can find the cross-compilers even when the process wasn't started # with export.sh (e.g. after a uvicorn restart or in tests). tools_path = os.environ.get('IDF_TOOLS_PATH', os.path.expanduser('~/.espressif')) env['IDF_TOOLS_PATH'] = tools_path if os.path.isdir(tools_path): # Pin the IDF Python venv. Without IDF_PYTHON_ENV_PATH, IDF's # cmake derives the venv dir name from the SYSTEM python # version — which in the docker image can differ from the # python the venv was built with (the image ships # idf5.5_py3.10_env; a py3.12 system python makes cmake look # for idf5.5_py3.12_env and fail with "python doesn't # exist"). Glob the env matching the chosen IDF major # instead of trusting the name derivation. ver_prefix = 'idf5.' if is_idf5 else 'idf4.' # Newest first: when several envs exist (e.g. a stale one # from an older base image beside the freshly created one), # the highest python version is the one the image can run. for venv in sorted(Path(tools_path).glob(f'python_env/{ver_prefix}*_env'), reverse=True): if (venv / 'bin' / 'python').exists(): env['IDF_PYTHON_ENV_PATH'] = str(venv) # We invoke cmake DIRECTLY (not through idf.py), and # IDF's cmake resolves its PYTHON property as the bare # `python` from PATH — IDF_PYTHON_ENV_PATH alone is # ignored in that flow. Prepend the venv's bin so the # bare name resolves to the venv interpreter (this is # what made "interface_version: invalid choice" persist: # the SYSTEM python's ancient idf-component-manager was # answering instead of the venv's). env['PATH'] = (str(venv / 'bin') + os.pathsep + env.get('PATH', '')) env['VIRTUAL_ENV'] = str(venv) break extra_paths: list[str] = [] # Xtensa toolchain: ESP32/S2 -> xtensa-esp32-elf, # ESP32-S3 -> xtensa-esp32s3-elf (IDF 4.4), unified on 5.x. for tc_dir in Path(tools_path).glob('tools/xtensa-esp32-elf/*/xtensa-esp32-elf/bin'): extra_paths.append(str(tc_dir)) for tc_dir in Path(tools_path).glob('tools/xtensa-esp32s3-elf/*/xtensa-esp32s3-elf/bin'): extra_paths.append(str(tc_dir)) for tc_dir in Path(tools_path).glob('tools/xtensa-esp-elf/*/xtensa-esp-elf/bin'): extra_paths.append(str(tc_dir)) # RISC-V toolchain (ESP32-C3) for tc_dir in Path(tools_path).glob('tools/riscv32-esp-elf/*/riscv32-esp-elf/bin'): extra_paths.append(str(tc_dir)) # cmake / ninja / ccache — ninja and ccache have no bin/ subdir, # so the tools/*/*/bin glob below misses them; add explicitly. for tool_dir in Path(tools_path).glob('tools/cmake/*/bin'): extra_paths.append(str(tool_dir)) for tool_dir in Path(tools_path).glob('tools/ninja/*'): if tool_dir.is_dir(): extra_paths.append(str(tool_dir)) for tool_dir in Path(tools_path).glob('tools/ccache/*'): if tool_dir.is_dir(): extra_paths.append(str(tool_dir)) for tool_dir in Path(tools_path).glob('tools/ccache/*/*'): if tool_dir.is_dir() and any(tool_dir.glob('ccache*')): extra_paths.append(str(tool_dir)) # ESP-IDF host tools (esptool, partition_table, etc.) for tool_dir in Path(tools_path).glob('tools/*/*/bin'): extra_paths.append(str(tool_dir)) # De-duplicate while preserving order. _seen: set[str] = set() extra_paths = [d for d in extra_paths if not (d in _seen or _seen.add(d))] # Both IDF generations install their toolchains under the SAME # tools root (riscv32-esp-elf/esp-2021r2... for 4.4 next to # esp-14.2.0... for 5.5). Whichever glob order put first won # the PATH race — the C6 build failed its tool-version check # against the 8.4 GCC, and classic builds died mid-compile on # newlib header mismatches. Order by the versions the CHOSEN # IDF tree declares in its tools.json instead. preferred_versions: set[str] = set() try: with open(os.path.join(idf_path, 'tools', 'tools.json'), encoding='utf-8') as fh: for tool in json.load(fh).get('tools', []): for v in tool.get('versions', []): if v.get('status') == 'recommended' and v.get('name'): preferred_versions.add(str(v['name'])) except (OSError, ValueError): pass if preferred_versions: extra_paths.sort( key=lambda p: 0 if any(v in p for v in preferred_versions) else 1) if extra_paths: env['PATH'] = os.pathsep.join(extra_paths) + os.pathsep + env.get('PATH', '') return env # ── Board options → sdkconfig / partition translation ─────────────── # Per-board ESP32 build options arrive as a loose dict from the # frontend. _normalize_options validates the known keys and fills in # defaults; _render_sdkconfig and _render_partition_csv then turn the # normalised dict into the two files cmake reads at configure time. # Schemes available in the UI. Each CSV is a verbatim copy of the # arduino-esp32 partition table layout for that name. Keys must match # ESP32PartitionScheme on the frontend. _PARTITION_CSVS: dict[str, str] = { # Velxio's historical default: single huge factory app, no OTA. # Picking this as the fallback keeps pre-feature projects byte-for-byte # compatible (compiled apps stayed under 3 MB). 'huge_app': ( '# Name, Type, SubType, Offset, Size, Flags\n' 'nvs, data, nvs, 0x9000, 0x5000,\n' 'otadata, data, ota, 0xe000, 0x2000,\n' 'app0, app, ota_0, 0x10000, 0x300000,\n' 'spiffs, data, spiffs, 0x310000,0xE0000,\n' 'coredump, data, coredump,0x3F0000,0x10000,\n' ), 'default': ( '# Name, Type, SubType, Offset, Size, Flags\n' 'nvs, data, nvs, 0x9000, 0x5000,\n' 'otadata, data, ota, 0xe000, 0x2000,\n' 'app0, app, ota_0, 0x10000, 0x140000,\n' 'app1, app, ota_1, 0x150000,0x140000,\n' 'spiffs, data, spiffs, 0x290000,0x150000,\n' 'coredump, data, coredump,0x3E0000,0x10000,\n' ), 'defaults_ffat': ( '# Name, Type, SubType, Offset, Size, Flags\n' 'nvs, data, nvs, 0x9000, 0x5000,\n' 'otadata, data, ota, 0xe000, 0x2000,\n' 'app0, app, ota_0, 0x10000, 0x140000,\n' 'app1, app, ota_1, 0x150000,0x140000,\n' 'ffat, data, fat, 0x290000,0x150000,\n' 'coredump, data, coredump,0x3E0000,0x10000,\n' ), 'min_spiffs': ( '# Name, Type, SubType, Offset, Size, Flags\n' 'nvs, data, nvs, 0x9000, 0x5000,\n' 'otadata, data, ota, 0xe000, 0x2000,\n' 'app0, app, ota_0, 0x10000, 0x1E0000,\n' 'app1, app, ota_1, 0x1F0000,0x1E0000,\n' 'spiffs, data, spiffs, 0x3D0000,0x20000,\n' 'coredump, data, coredump,0x3F0000,0x10000,\n' ), 'min_ffat': ( '# Name, Type, SubType, Offset, Size, Flags\n' 'nvs, data, nvs, 0x9000, 0x5000,\n' 'otadata, data, ota, 0xe000, 0x2000,\n' 'app0, app, ota_0, 0x10000, 0x1E0000,\n' 'app1, app, ota_1, 0x1F0000,0x1E0000,\n' 'ffat, data, fat, 0x3D0000,0x20000,\n' 'coredump, data, coredump,0x3F0000,0x10000,\n' ), 'no_ota': ( '# Name, Type, SubType, Offset, Size, Flags\n' 'nvs, data, nvs, 0x9000, 0x5000,\n' 'otadata, data, ota, 0xe000, 0x2000,\n' 'app0, app, ota_0, 0x10000, 0x200000,\n' 'spiffs, data, spiffs, 0x210000,0x1E0000,\n' 'coredump, data, coredump,0x3F0000,0x10000,\n' ), 'no_fs': ( '# Name, Type, SubType, Offset, Size, Flags\n' 'nvs, data, nvs, 0x9000, 0x5000,\n' 'otadata, data, ota, 0xe000, 0x2000,\n' 'app0, app, ota_0, 0x10000, 0x1F0000,\n' 'app1, app, ota_1, 0x200000,0x1F0000,\n' 'coredump, data, coredump,0x3F0000,0x10000,\n' ), 'large_spiffs': ( '# Name, Type, SubType, Offset, Size, Flags\n' 'nvs, data, nvs, 0x9000, 0x5000,\n' 'otadata, data, ota, 0xe000, 0x2000,\n' 'app0, app, ota_0, 0x10000, 0x1E0000,\n' 'spiffs, data, spiffs, 0x1F0000,0x200000,\n' 'coredump, data, coredump,0x3F0000,0x10000,\n' ), 'rainmaker': ( '# Name, Type, SubType, Offset, Size, Flags\n' 'nvs, data, nvs, 0x9000, 0x4000,\n' 'otadata, data, ota, 0xd000, 0x2000,\n' 'phy_init, data, phy, 0xf000, 0x1000,\n' 'app0, app, ota_0, 0x10000, 0x1E0000,\n' 'app1, app, ota_1, 0x1F0000,0x1E0000,\n' 'fctry, data, nvs, 0x3D0000,0x6000,\n' 'coredump, data, coredump,0x3F0000,0x10000,\n' ), } # Known option defaults — used to backfill missing keys so older clients # (or callers without the feature wired up) still get a build. _DEFAULT_OPTIONS: dict[str, str | int | bool] = { 'partitionScheme': 'huge_app', # historical Velxio default 'cpuFreqMHz': 240, 'flashMode': 'dio', 'flashSize': '4MB', 'flashFreqMHz': '40', 'psram': 'disabled', 'coreDebugLevel': 'none', 'eraseFlashOnUpload': False, 'eventsRunOnCore': 1, 'arduinoRunsOnCore': 1, } _VALID_VALUES: dict[str, set] = { 'partitionScheme': set(_PARTITION_CSVS.keys()), 'cpuFreqMHz': {240, 160, 80, 40, 20, 10}, 'flashMode': {'qio', 'dio', 'qout', 'dout'}, 'flashSize': {'4MB', '8MB', '16MB'}, 'flashFreqMHz': {'80', '40'}, 'psram': {'disabled', 'enabled', 'opi'}, 'coreDebugLevel': {'none', 'error', 'warn', 'info', 'debug', 'verbose'}, 'eventsRunOnCore': {0, 1}, 'arduinoRunsOnCore': {0, 1}, } _DEBUG_LEVEL_NUMBER: dict[str, int] = { 'none': 0, 'error': 1, 'warn': 2, 'info': 3, 'debug': 4, 'verbose': 5, } _FLASH_SIZE_BYTES: dict[str, int] = { '4MB': 4 * 1024 * 1024, '8MB': 8 * 1024 * 1024, '16MB': 16 * 1024 * 1024, } def _normalize_options( self, opts: dict | None, idf_target: str, board_fqbn: str | None = None, ) -> dict: """Fill missing keys with defaults, validate enums, strip target-incompatible keys (e.g. PSRAM on C3). Raises ValueError on an unknown enum value — caller turns this into a user-visible compile error. """ normalized: dict = {**self._DEFAULT_OPTIONS} if opts: for k, v in opts.items(): if k not in self._DEFAULT_OPTIONS: continue if k in self._VALID_VALUES and v not in self._VALID_VALUES[k]: raise ValueError( f"Invalid board option {k}={v!r}; " f"expected one of {sorted(self._VALID_VALUES[k])}" ) normalized[k] = v # ESP32-C3 / ESP32-C6 have no external PSRAM controller — silently # disable so a stale field from an upgraded project doesn't trip up # the build. if idf_target in ('esp32c3', 'esp32c6'): normalized['psram'] = 'disabled' # ESP32-C3 / ESP32-C6 top out at 160 MHz — clamp the historical 240 # default (and any stale higher value) instead of failing the build. # (On C3 the 240 choice was always an unknown kconfig symbol that # 4.4's kconfgen silently ignored, leaving the chip at its 160 # default — the clamp makes that explicit and v5.x-clean.) if idf_target in ('esp32c3', 'esp32c6') and int(normalized['cpuFreqMHz']) > 160: normalized['cpuFreqMHz'] = 160 # OPI PSRAM (octal) is an S3-only mode. Downgrade to 'enabled' on # classic Xtensa so users who switched boards mid-project don't get # a stuck build. if normalized['psram'] == 'opi' and idf_target != 'esp32s3': normalized['psram'] = 'enabled' # The Arduino VARIANT this board builds against (F7.6). Without it the # sdkconfig template falls back to the generic chip variant, and boards # like the XIAO ESP32S3 lose their identity: no Dx pin names, and # CDC_ON_BOOT stays off so `Serial` lands on UART0 — whose default RX # is GPIO44, the very pin the XIAO exposes as D7. A sketch doing # pinMode(D7, ...) then has the peripheral manager tear the UART0 # driver down and every print after it is silently dropped (measured # on the Round Display clock example: setup prints out, loop mute). if board_fqbn: normalized['arduinoVariant'] = self._arduino_variant(board_fqbn, idf_target) return normalized # sdkconfig.defaults.in is written against IDF v4.4 + the 2.x Arduino # core. On a v5 PURE-IDF build the Arduino component options, the ARDUHAL # log level, and the Bluedroid stack (only ever needed by Arduino BLE # sketches) aren't wanted, and IDF 5.0 renamed ESP_TASK_WDT → # ESP_TASK_WDT_EN. Dropping them keeps kconfgen output clean instead of # spraying "unknown kconfig symbol" warnings into every build log. On a # v5 ARDUINO build (3.x core present) the Arduino/BT symbols ARE valid # (the 3.x component's Kconfig defines them) so only the WDT rename and # the target-specific drops apply. _IDF5_PUREIDF_DROP_PREFIXES: tuple[str, ...] = ( 'CONFIG_ARDUHAL_', 'CONFIG_AUTOSTART_ARDUINO', 'CONFIG_ARDUINO_', 'CONFIG_BT_', 'CONFIG_BTDM_', ) def _idf5_drop_prefixes_for( self, idf_target: str, arduino_mode: bool = False ) -> tuple[str, ...]: """Prefixes to strip from the rendered defaults for a v5.x build. Pure-IDF builds drop the Arduino/Bluedroid options (invalid without the component). Per-target: C3/C6 have no PSRAM controller and top out at 160 MHz (single-core → the Arduino running-core options also don't exist); the 26 MHz flash-frequency choice only exists on classic ESP32. """ drops: tuple[str, ...] = () if not arduino_mode: drops += self._IDF5_PUREIDF_DROP_PREFIXES elif idf_target in ('esp32c3', 'esp32c6'): # Arduino v5 on a single-core RISC-V chip: the running-core # options are hidden by `depends on !FREERTOS_UNICORE`, so drop # just those two (keep the rest of the Arduino/BT symbols). drops += ( 'CONFIG_ARDUINO_RUNNING_CORE', 'CONFIG_ARDUINO_EVENT_RUNNING_CORE', ) if idf_target in ('esp32c3', 'esp32c6'): drops += ( 'CONFIG_SPIRAM', 'CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_240', ) if idf_target != 'esp32': drops += ('CONFIG_ESPTOOLPY_FLASHFREQ_26M',) return drops def _fixup_sdkconfig_for_idf5( self, rendered: str, idf_target: str, arduino_mode: bool = False ) -> str: """Adapt the rendered v4.4-era defaults to an IDF v5.x build (see ``_idf5_drop_prefixes_for``).""" drops = self._idf5_drop_prefixes_for(idf_target, arduino_mode) lines: list[str] = [] for line in rendered.splitlines(): if line.startswith(drops): continue # IDF 5.0 renamed ESP_TASK_WDT → ESP_TASK_WDT_EN. if line.startswith('CONFIG_ESP_TASK_WDT='): line = line.replace( 'CONFIG_ESP_TASK_WDT=', 'CONFIG_ESP_TASK_WDT_EN=', 1 ) lines.append(line) return '\n'.join(lines) + '\n' def _render_sdkconfig( self, normalized: dict, template_dir: Path, idf_target: str = 'esp32', use_idf5: Optional[bool] = None, arduino_mode: bool = False, ) -> str: """Render sdkconfig.defaults from the .in template + normalised opts.""" template_path = template_dir / 'sdkconfig.defaults.in' template_text = template_path.read_text(encoding='utf-8') # ── Flash mode (exactly one of QIO/DIO/QOUT/DOUT) ───────────── flash_mode = normalized['flashMode'] flash_mode_lines = '\n'.join( f'CONFIG_ESPTOOLPY_FLASHMODE_{m.upper()}={"y" if m == flash_mode else "n"}' for m in ('qio', 'dio', 'qout', 'dout') ) # ── Flash frequency ──────────────────────────────────────────── flash_freq = normalized['flashFreqMHz'] flash_freq_lines = '\n'.join( f'CONFIG_ESPTOOLPY_FLASHFREQ_{f}M={"y" if f == flash_freq else "n"}' for f in ('80', '40', '26', '20') ) # ── Flash size ───────────────────────────────────────────────── flash_size = normalized['flashSize'] flash_size_lines_list = [ f'CONFIG_ESPTOOLPY_FLASHSIZE_{s}={"y" if s == flash_size else "n"}' for s in ('2MB', '4MB', '8MB', '16MB') ] flash_size_lines_list.append(f'CONFIG_ESPTOOLPY_FLASHSIZE="{flash_size}"') flash_size_lines = '\n'.join(flash_size_lines_list) # ── CPU frequency ────────────────────────────────────────────── cpu_freq = int(normalized['cpuFreqMHz']) cpu_lines = [ f'CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ_{f}=' f'{"y" if f == cpu_freq else "n"}' for f in (240, 160, 80, 40) ] cpu_lines.append(f'CONFIG_ESP_DEFAULT_CPU_FREQ_MHZ={cpu_freq}') cpu_freq_lines = '\n'.join(cpu_lines) # ── PSRAM ────────────────────────────────────────────────────── psram_mode = normalized['psram'] psram_chunks: list[str] = [] if psram_mode == 'disabled': psram_chunks.append('CONFIG_SPIRAM=n') else: psram_chunks.append('CONFIG_SPIRAM=y') psram_chunks.append('CONFIG_SPIRAM_USE_MALLOC=y') psram_chunks.append('CONFIG_SPIRAM_SPEED_80M=y') if psram_mode == 'opi': psram_chunks.append('CONFIG_SPIRAM_MODE_OCT=y') else: psram_chunks.append('CONFIG_SPIRAM_MODE_QUAD=y') psram_lines = '\n'.join(psram_chunks) substitutions = { 'FLASH_MODE_LINES': flash_mode_lines, 'FLASH_FREQ_LINES': flash_freq_lines, 'FLASH_SIZE_LINES': flash_size_lines, 'CPU_FREQ_LINES': cpu_freq_lines, 'PSRAM_LINES': psram_lines, 'ARDUHAL_LOG_LEVEL': str( self._DEBUG_LEVEL_NUMBER[normalized['coreDebugLevel']] ), 'ARDUINO_VARIANT': normalized.get('arduinoVariant') or idf_target, 'ARDUINO_RUNNING_CORE': str(normalized['arduinoRunsOnCore']), 'ARDUINO_EVENT_RUNNING_CORE': str(normalized['eventsRunOnCore']), } rendered = string.Template(template_text).safe_substitute(substitutions) if use_idf5 is None: use_idf5 = idf_target in self._IDF5_TARGETS if use_idf5: rendered = self._fixup_sdkconfig_for_idf5( rendered, idf_target, arduino_mode ) return rendered def _render_partition_csv(self, scheme: str) -> str: """Return the partition table CSV for the given scheme name.""" csv = self._PARTITION_CSVS.get(scheme) if csv is None: # Unknown scheme — should not happen because _normalize_options # validates, but fall back to the historical layout so the build # still succeeds rather than crashing. logger.warning(f'[espidf] Unknown partition scheme {scheme!r}, using huge_app') csv = self._PARTITION_CSVS['huge_app'] return csv @staticmethod def _parse_partition_csv(csv_text: str) -> list[dict]: """Parse a partition table CSV into a list of dicts. Handles the comma-separated arduino-esp32 format with arbitrary whitespace. """ entries: list[dict] = [] for line in csv_text.splitlines(): line = line.strip() if not line or line.startswith('#'): continue cols = [c.strip() for c in line.split(',')] if len(cols) < 5: continue try: offset = int(cols[3], 16) if cols[3] else 0 size = int(cols[4], 16) if cols[4] else 0 except ValueError: continue entries.append({ 'name': cols[0], 'type': cols[1], 'subtype': cols[2], 'offset': offset, 'size': size, }) return entries def _find_filesystem_partition(self, csv_text: str) -> Optional[dict]: """Return the SPIFFS or FATFS partition entry, or None.""" for e in self._parse_partition_csv(csv_text): if e['type'] == 'data' and e['subtype'] in ('spiffs', 'fat'): return e return None def _locate_mkspiffs(self) -> Optional[str]: """Find the mkspiffs binary. Returns None when unavailable so the build can proceed (with an empty FS partition). Search order: 1. $MKSPIFFS_PATH (explicit override) 2. $IDF_TOOLS_PATH/tools/mkspiffs/*/mkspiffs/mkspiffs[.exe] 3. shutil.which('mkspiffs') """ override = os.environ.get('MKSPIFFS_PATH') if override and os.path.isfile(override): return override idf_tools = os.environ.get('IDF_TOOLS_PATH') if idf_tools: for sub in Path(idf_tools, 'tools', 'mkspiffs').glob('*/mkspiffs/mkspiffs*'): if sub.is_file(): return str(sub) found = shutil.which('mkspiffs') if found: return found return None def _build_spiffs_image( self, project_dir: Path, spiffs_files: list[dict], partition_size_bytes: int, ) -> Optional[Path]: """Materialise uploaded files into a SPIFFS partition image. Returns the path to spiffs.bin, or None if mkspiffs is unavailable / the file set is empty. The caller places the bin at the SPIFFS offset (looked up from partitions.csv) when merging the flash image. Raises ValueError when the inputs are oversized — the route layer turns this into a 4xx-shaped CompileResponse for the UI. """ if not spiffs_files: return None if partition_size_bytes <= 0: raise ValueError( 'Selected partition scheme has no SPIFFS/FATFS region — ' 'remove the uploaded files or pick a scheme with a filesystem.' ) mkspiffs = self._locate_mkspiffs() if mkspiffs is None: logger.warning( '[espidf] mkspiffs not found — uploaded SPIFFS files will be ' 'ignored at flash time. Set MKSPIFFS_PATH or install mkspiffs ' 'into IDF_TOOLS_PATH.' ) return None spiffs_data_dir = project_dir / 'spiffs_data' if spiffs_data_dir.exists(): shutil.rmtree(spiffs_data_dir) spiffs_data_dir.mkdir(parents=True) total_bytes = 0 for entry in spiffs_files: name = entry['name'] data = base64.b64decode(entry['content_b64']) total_bytes += len(data) # mkspiffs uses the on-disk filename as the in-flash path so # write subdirs literally rather than smuggling slashes into # the name. Strip any leading slash to avoid escaping the dir. safe_path = name.lstrip('/').lstrip('\\') dest = spiffs_data_dir / safe_path dest.parent.mkdir(parents=True, exist_ok=True) dest.write_bytes(data) # Block size 4096, page size 256 — matches the arduino-esp32 # defaults so the in-flash image is readable by SPIFFS.begin(). spiffs_bin = project_dir / 'spiffs.bin' cmd = [ mkspiffs, '-c', str(spiffs_data_dir), '-b', '4096', '-p', '256', '-s', str(partition_size_bytes), str(spiffs_bin), ] logger.info( f'[espidf] mkspiffs: {len(spiffs_files)} files, ' f'{total_bytes} bytes payload, {partition_size_bytes} byte partition' ) result = subprocess.run(cmd, capture_output=True, text=True, timeout=60) if result.returncode != 0: raise ValueError( f'mkspiffs failed (rc={result.returncode}): ' f'{result.stderr.strip() or result.stdout.strip()}' ) return spiffs_bin def _merge_flash_image( self, build_dir: Path, idf_target: str, flash_size_bytes: int = 4 * 1024 * 1024, spiffs_bin: Optional[Path] = None, spiffs_offset: int = 0, ) -> Path: """Merge bootloader + partitions + app (+ optional SPIFFS) into a flash image sized to match the user's Flash Size option. The bootloader lands at the per-target ROM offset (0x1000 on classic ESP32/S2, 0x0 on S3/C3/C6 — see ``_BOOTLOADER_OFFSETS``).""" FLASH_SIZE = flash_size_bytes flash = bytearray(b'\xff' * FLASH_SIZE) bootloader_offset = self._BOOTLOADER_OFFSETS.get(idf_target, 0x0) # ESP-IDF build output paths bootloader = build_dir / 'bootloader' / 'bootloader.bin' partitions = build_dir / 'partition_table' / 'partition-table.bin' app = build_dir / 'velxio-sketch.bin' if not app.exists(): # Try alternate names for pattern in ['*.bin']: candidates = [f for f in build_dir.glob(pattern) if 'bootloader' not in f.name and 'partition' not in f.name] if candidates: app = candidates[0] break files_found = { 'bootloader': bootloader.exists(), 'partitions': partitions.exists(), 'app': app.exists(), } logger.info(f'[espidf] Merge files: {files_found}') if not all(files_found.values()): missing = [k for k, v in files_found.items() if not v] raise FileNotFoundError(f'Missing binaries for merge: {missing}') last_used = 0 placements: list[tuple[int, Path]] = [ (bootloader_offset, bootloader), (0x8000, partitions), (0x10000, app), ] if spiffs_bin is not None and spiffs_offset > 0: placements.append((spiffs_offset, spiffs_bin)) for offset, path in placements: data = path.read_bytes() if offset + len(data) > FLASH_SIZE: raise ValueError( f'Partition {path.name} ({len(data)} bytes at 0x{offset:X}) ' f'overflows the selected flash size ({FLASH_SIZE} bytes). ' f'Pick a larger Flash Size or a partition scheme with a ' f'smaller app/data region.' ) flash[offset:offset + len(data)] = data last_used = max(last_used, offset + len(data)) logger.info(f'[espidf] Placed {path.name} at 0x{offset:04X} ({len(data)} bytes)') # Trim the trailing 0xFF padding before serializing. # # Keeping the full 4 MB flash image here gives a ~5.5 MB base64 JSON # response that nginx / Cloudflare can choke on (issue #101 — user # saw "No response from server"). The frontend stores the trimmed # bytes and the backend pads back to the QEMU flash size at the # bridge layer right before mtd attach. Lossless: bytes after # last_used are 0xFF by construction, so re-padding restores the # original image byte-for-byte. merged_path = build_dir / 'merged_flash.bin' merged_path.write_bytes(bytes(flash[:last_used])) logger.info( f'[espidf] Merged flash image (trimmed): {merged_path.stat().st_size} bytes ' f'(would have been {FLASH_SIZE} bytes unpadded)' ) return merged_path async def compile( self, files: list[dict], board_fqbn: str, progress_callback: Optional[ProgressCallback] = None, board_options: dict | None = None, spiffs_files: list[dict] | None = None, allowed_libraries: set[str] | None = None, owner_id: str | None = None, pure_idf: bool = False, ) -> dict: """ Compile Arduino sketch using ESP-IDF. Returns dict compatible with ArduinoCLIService.compile(): success, binary_content (base64), binary_type, stdout, stderr, error Build dir layout: - With VELXIO_PERSISTENT_BUILD_DIR=1 (default): a per-target dir at /var/lib/velxio-build//project/ is reused across compiles. ninja's incremental cache + ccache hits combine to bring warm compiles down to ~5-30s. - With VELXIO_PERSISTENT_BUILD_DIR=0: legacy tempfile.TemporaryDirectory flow. Every compile rebuilds from scratch. The caller (routes/compile.py:_compile_job) holds a per-target asyncio.Lock for the duration of this call, so the persistent dir is never accessed by two compiles at once. progress_callback (optional): if provided, called from a worker thread for every stdout/stderr line as cmake and ninja run. Used by the async compile path to expose live build output to clients polling /api/compile/status/{job_id}. board_options (optional): per-board ESP32 build options from the UI (Partition Scheme, CPU Frequency, Flash Mode, PSRAM, etc.). Missing keys fall back to historical defaults so AVR/RP2040 callers and pre-feature clients still get a working build. Note that SPIFFS files are NOT folded into the cache-invalidation hash — only sdkconfig-affecting options are — because the SPIFFS image is rebuilt on every compile anyway and folding it in would burn the C/C++ ninja cache on every file edit. pure_idf: pure ESP-IDF language mode (issue #139). The user's files ARE the IDF main component sources (they provide app_main()); the arduino-esp32 component is left out of the build entirely and Arduino library resolution is skipped. Gets its own persistent build-dir variant via the eff_hash fold below. """ if not self.available: return { 'success': False, 'error': 'ESP-IDF toolchain not found. Set IDF_PATH environment variable.', 'stdout': '', 'stderr': '', } idf_target = self._idf_target(board_fqbn) # IDF5-only targets (esp32c6) need their own toolchain install — # the pinned v4.4.7 tree that `available` checks has no such target. # (Other targets merely PREFER v5 and quietly fall back to v4.4 when # the v5 install is absent — see _use_idf5.) if idf_target in self._IDF5_TARGETS: idf5 = self.idf5_path if not idf5 or not os.path.isdir(idf5): return { 'success': False, 'error': ( f'{idf_target} requires ESP-IDF v5.x, which is not ' f'installed on this server (the pinned v4.4.7 ' f'toolchain has no {idf_target} target). Install an ' f'ESP-IDF v5.x and point IDF5_PATH at its root.' ), 'stdout': '', 'stderr': '', } logger.info(f'[espidf] Compiling for {idf_target} (FQBN: {board_fqbn})') logger.info(f'[espidf] Files: {[f["name"] for f in files]}') try: normalized_opts = self._normalize_options(board_options, idf_target, board_fqbn) except ValueError as exc: return { 'success': False, 'error': str(exc), 'stdout': '', 'stderr': '', } options_hash = hashlib.sha256( json.dumps(normalized_opts, sort_keys=True).encode() ).hexdigest()[:12] # External-include set of the sketch — a stable proxy for "which # libraries this compile resolves". Folded into the per-attempt build # hash below so the persistent build/ is reset (via the tested # _prepare_persistent_project_dir wipe) whenever the library set # changes between compiles. Without this, the shared build/ caches a # cmake config + ninja graph + ccache objects for the PREVIOUS lib set, # which causes intermittent "cmake configure failed" and stale-object # false positives when a different project/manifest compiles next. _sketch_text = '\n'.join(f.get('content', '') for f in files) _own_names = {Path(str(f.get('name') or '')).name for f in files} _core_hdrs = self._core_provided_headers() _ext_inc_token = ','.join(sorted( h for h in set(self._detect_external_includes(_sketch_text, _own_names)) if h not in _core_hdrs )) # Per-compile mode decision (see _use_idf5). Decided HERE — not in # _compile_in_dir — because it picks the IDF major, which everything # downstream depends on: build env, sdkconfig rendering, and the # build-dir identity (a v4.4 and a v5 build must never share a # configured build/). _primary_src = next( (f['content'] for f in files if f['name'].endswith('.ino')), files[0]['content'] if files else '', ) arduino_mode = ( self._arduino_supports_target(idf_target) and not self._contains_app_main(_primary_src) # The explicit ESP-IDF language mode (#139) is the user SAYING # their code is a pure IDF app; it outranks the app_main sniff. and not pure_idf ) use_idf5 = self._use_idf5(idf_target, arduino_mode) logger.info( f'[espidf] mode={"arduino" if arduino_mode else "pure-idf"} ' f'idf={"v5 (" + self.idf5_path + ")" if use_idf5 else "v4.4 (" + self.idf_path + ")"}' ) async def _attempt(allowed: set[str] | None) -> dict: # P2.1e — materialize a per-compile library scope: the manifest's # libs symlinked from the content-addressed cache (with a legacy-dir # fallback for any not yet cached). A no-op overlay / scan-all # fallback (allowed=None) returns None -> the compiler uses the # single default libraries dir. The scope's content token is folded # into the build-dir hash so a content change (cache vs legacy, or a # cache update) gets its own clean build dir — and the throwaway # scope dir is removed after the attempt (its files were already # copied into the build's user_libs_all by _compile_in_dir). scope = materialize_library_scope(allowed, owner_id) scope_dir = scope[0] if scope else None scope_token = scope[1] if scope else '' # Fold the effective library set + resolved content into the build-dir # hash. A different manifest, the scan-all fallback (allowed=None), or # changed lib CONTENT gets its own clean build dir — resetting at # _prepare time (before any cmake), the well-tested wipe path. _libs_token = ( ('m:' + ','.join(sorted(allowed)) + ('|s:' + scope_token if scope_token else '')) if allowed is not None else 'scanall' ) # Pure ESP-IDF mode gets its own build-dir variant: same bytes # compiled with vs without the arduino-esp32 component produce # entirely different cmake graphs + objects. _lang_token = '|lang:pure' if pure_idf else '' eff_hash = hashlib.sha256( ( options_hash + '|' + _libs_token + '|i:' + _ext_inc_token # A v4.4 and a v5 build (or arduino vs pure-IDF) must # never share a configured build/ — the cmake cache and # every object file are tied to the IDF tree. + f'|idf:{5 if use_idf5 else 4}|ard:{int(arduino_mode)}' + _lang_token ).encode() ).hexdigest()[:12] try: if _USE_PERSISTENT_DIR: project_dir = _prepare_persistent_project_dir(idf_target, eff_hash) logger.info(f'[espidf] Using persistent build dir: {project_dir}') return await self._compile_in_dir( project_dir, files, idf_target, progress_callback, normalized_opts, spiffs_files, allowed_libraries=allowed, libraries_dir=scope_dir, arduino_mode=arduino_mode, use_idf5=use_idf5, pure_idf=pure_idf, board_fqbn=board_fqbn, ) with tempfile.TemporaryDirectory(prefix='espidf_') as temp_dir: project_dir = Path(temp_dir) / 'project' shutil.copytree(_TEMPLATE_DIR, project_dir) logger.info(f'[espidf] Using ephemeral build dir: {project_dir}') return await self._compile_in_dir( project_dir, files, idf_target, progress_callback, normalized_opts, spiffs_files, allowed_libraries=allowed, libraries_dir=scope_dir, arduino_mode=arduino_mode, use_idf5=use_idf5, pure_idf=pure_idf, board_fqbn=board_fqbn, ) finally: if scope_dir is not None: # rmtree unlinks the symlinks, never their cache/legacy targets. shutil.rmtree(scope_dir.parent, ignore_errors=True) async def _attempt_safe(allowed: set[str] | None) -> dict: # Retry ONCE on a clearly-transient infrastructure failure (cmake / # nested bootloader / managed-components / sdkconfig) — never on a # user-code error. These hit occasionally on a cold variant build; # a retry resumes the now-warmer build dir and succeeds. Cheap via # ccache + ninja incremental. r = await _attempt(allowed) if not r.get('success') and self._is_transient_build_failure(r): logger.warning('[espidf] transient build failure; retrying once') r2 = await _attempt(allowed) if r2.get('success') or not self._is_transient_build_failure(r2): return r2 return r # Pure ESP-IDF mode never resolves Arduino libraries — force the # no-manifest path (the manifest names Arduino libs, which don't # exist in a pure IDF build). result = await _attempt_safe(None if pure_idf else allowed_libraries) # Graceful fallback (P2). A manifest-scoped compile that fails because a # header isn't in the manifest (an undeclared / transitive dependency) # retries once with scan-all, so a project with an incomplete manifest # still compiles instead of regressing — and we report the gap so the # manifest can be auto-completed (P2.4) or the user prompted to add the # missing library. The caller holds the per-target lock for this whole # method, so the retry safely reuses the same build dir. if allowed_libraries is not None and not pure_idf and not result.get('success'): missing = self._missing_library_headers(result) if missing: logger.warning( f'[espidf] scoped compile missing {missing} (not in manifest) — ' f'retrying scan-all' ) retry = await _attempt_safe(None) if retry.get('success'): retry['manifest_incomplete'] = True retry['manifest_suggested_libraries'] = ( self._suggest_libraries_for_headers(missing) ) return retry # Both failed: the scoped error is the more informative one. return result async def _compile_in_dir( self, project_dir: Path, files: list[dict], idf_target: str, progress_callback: Optional[ProgressCallback] = None, board_options: dict | None = None, spiffs_files: list[dict] | None = None, allowed_libraries: set[str] | None = None, libraries_dir: Path | None = None, arduino_mode: Optional[bool] = None, use_idf5: Optional[bool] = None, pure_idf: bool = False, board_fqbn: str | None = None, ) -> dict: """Inner compile body: writes sketch + libs into `project_dir`, runs cmake + ninja, merges binaries. Caller is responsible for creating `project_dir` (with the template tree already copied in) and for managing its lifecycle (persistent vs tempfile). ``arduino_mode`` / ``use_idf5`` are decided by compile(); the defensive defaults below only serve direct test callers. pure_idf: the user's files are the IDF main component sources (app_main entry point) — no Arduino wrap, no Arduino libraries. The template's main/CMakeLists.txt picks its pure branch via the VELXIO_PURE_SKETCH env var set in _build_env. """ # board_options is already normalised by compile() — defensive in # case _compile_in_dir is called directly from a test path. if board_options is None: board_options = self._normalize_options(None, idf_target) if arduino_mode is None: arduino_mode = self._arduino_supports_target(idf_target) and not pure_idf if use_idf5 is None: use_idf5 = self._use_idf5(idf_target, arduino_mode) # Render sdkconfig.defaults from the templated .in file using the # user's options. Overwrites the static file copied from the # template tree. Doing this BEFORE cmake configure means the new # CONFIG_* lines reach kconfig on its first read. rendered_sdkconfig = self._render_sdkconfig( board_options, _TEMPLATE_DIR, idf_target, use_idf5=use_idf5, arduino_mode=arduino_mode, ) if pure_idf: # Without the arduino-esp32 component the CONFIG_ARDUINO_* / # CONFIG_AUTOSTART_ARDUINO symbols don't exist. kconfig only # warns on unknown symbols, but strip them so the generated # sdkconfig stays honest about what the build contains. rendered_sdkconfig = '\n'.join( line for line in rendered_sdkconfig.splitlines() if not line.startswith(('CONFIG_ARDUINO', 'CONFIG_AUTOSTART_ARDUINO')) ) + '\n' defaults_path = project_dir / 'sdkconfig.defaults' prev_defaults = ( defaults_path.read_text(encoding='utf-8') if defaults_path.exists() else None ) defaults_path.write_text(rendered_sdkconfig, encoding='utf-8') # ESP-IDF only SEEDS sdkconfig from sdkconfig.defaults when sdkconfig is # ABSENT. Persistent build dirs live in the build volume and keep a # stale sdkconfig across image rebuilds, so a defaults change (a new # CONFIG_* shipped in the template, or different board options) would # otherwise never reach kconfig. Drop the generated sdkconfig when the # rendered defaults change so kconfig re-seeds from them on configure. # NOTHING may run between the defaults write above and this unlink: a # crash in between leaves defaults==rendered with a stale sdkconfig, # and the next run then never re-seeds (measured: the board_fqbn # NameError left CONSOLE_SECONDARY stale in a persistent dir). if prev_defaults is not None and prev_defaults != rendered_sdkconfig: (project_dir / 'sdkconfig').unlink(missing_ok=True) (project_dir / 'sdkconfig.old').unlink(missing_ok=True) # Board identity defines (velxio_board.cmake, included OPTIONAL by the # template's top CMakeLists BEFORE project() so they reach EVERY # component — `Serial`'s mapping is decided inside the arduino core's # own sources, a sketch-only define cannot move it). Going through a # file instead of an env var makes it a configure dependency: CMake # re-runs when it changes, env vars it silently ignores. board_cmake_lines = ['# generated by espidf_compiler — do not edit'] if arduino_mode and not pure_idf and board_fqbn: flags = self._arduino_board_flags(board_fqbn) if flags['board']: macro = re.sub(r'[^A-Z0-9_]', '_', str(flags['board']).upper()) board_cmake_lines.append( f'add_compile_definitions(ARDUINO_{macro})' ) if flags['cdc_on_boot']: # USB_MODE is FORCED to 1 (HWCDC over USB-Serial-JTAG, the # boards.txt "USBMode=hwcdc" menu choice) even when the board's # default is 0 (USB-OTG/TinyUSB): the engine models the # Serial-JTAG console; the OTG device stack it does not. board_cmake_lines.append( 'add_compile_definitions(ARDUINO_USB_CDC_ON_BOOT=1 ARDUINO_USB_MODE=1)' ) board_cmake = '\n'.join(board_cmake_lines) + '\n' board_cmake_path = project_dir / 'velxio_board.cmake' prev_board_cmake = ( board_cmake_path.read_text(encoding='utf-8') if board_cmake_path.exists() else None ) if prev_board_cmake != board_cmake: board_cmake_path.write_text(board_cmake, encoding='utf-8') # Generate partitions.csv per the selected scheme. partition_csv = self._render_partition_csv(board_options['partitionScheme']) (project_dir / 'partitions.csv').write_text(partition_csv, encoding='utf-8') # Get sketch content main_content = '' for f in files: if f['name'].endswith('.ino'): main_content = f['content'] break if not main_content and files: main_content = files[0]['content'] # ── QEMU WiFi compatibility ────────────────────────────────────── # QEMU's WiFi AP broadcasts "Velxio-GUEST" on channel 6. # We normalize ANY user SSID → "Velxio-GUEST", enforce channel 6, # and use open auth (empty password) so the connection always works. # Detect WiFi BEFORE normalization so the flag reflects the original sketch. if pure_idf: _all_text = '\n'.join(f.get('content', '') for f in files) has_wifi = self._detect_idf_wifi_usage(_all_text) else: has_wifi = self._detect_wifi_usage(main_content) main_content = self._normalize_wifi_for_qemu(main_content) # Arduino-as-component only when compile() decided so: the core must # support the target (esp32c6 needs an arduino-esp32 3.x core the # server doesn't ship) AND the code must be an actual sketch (plain # ESP-IDF programs with their own app_main build pure-IDF on v5). # Which arduino core this build uses (3.x on v5, 2.x on v4.4). arduino_core_path = self._arduino_path_for(use_idf5) if arduino_mode else '' if pure_idf: # Pure ESP-IDF mode (issue #139): the user's files ARE the main # component sources — app_main() entry point, IDF APIs, compiled # by the template CMake's VELXIO_PURE_SKETCH glob branch. No # Arduino wrap, no velxio_compat.h, no Arduino libraries. main_dir = project_dir / 'main' # Template / other-mode leftovers must not reach the pure glob # (main.cpp would drag in setup()/loop() references; a stale # sketch.ino.cpp would redefine symbols). Deleted BEFORE writing # so a user file with the same name wins. for leftover in ('main.c', 'main.cpp', 'sketch.ino.cpp', 'sketch_translated.c'): (main_dir / leftover).unlink(missing_ok=True) wrote_any = False for f in files: # basename() the client-supplied name so it can't escape main/ name = PurePosixPath(str(f.get('name') or '').replace('\\', '/')).name if not name: continue content = f.get('content', '') if has_wifi: content = self._normalize_wifi_for_qemu_idf(content) (main_dir / name).write_text(content, encoding='utf-8') wrote_any = True if not wrote_any: return { 'success': False, 'error': 'No source files provided.', 'stdout': '', 'stderr': '', } elif arduino_mode: # Arduino-as-component mode: copy sketch as .cpp sketch_cpp = project_dir / 'main' / 'sketch.ino.cpp' # Prepend Arduino.h, and — ONLY on the 2.x core — velxio_compat.h, # which shims the 3.x LEDC API (ledcAttach, …) onto 2.0.17. On the # 3.x core those APIs are real functions, so including the shim # would REDEFINE ledcAttach (its `#if !defined(ledcAttach)` guard # doesn't fire — a function isn't a macro) and break the build. compat_include = '' if use_idf5 else '#include "velxio_compat.h"\n' if '#include' not in main_content or 'Arduino.h' not in main_content: main_content = ( '#include "Arduino.h"\n' + compat_include + main_content ) elif compat_include: main_content = main_content.replace( '#include "Arduino.h"', '#include "Arduino.h"\n' + compat_include.rstrip('\n'), 1, ) sketch_cpp.write_text(main_content, encoding='utf-8') # Copy additional files (.h, .cpp) for f in files: if not f['name'].endswith('.ino'): (project_dir / 'main' / f['name']).write_text( f['content'], encoding='utf-8' ) # Remove the pure-C main to avoid conflict main_c = project_dir / 'main' / 'main.c' if main_c.exists(): main_c.unlink() sketch_translated = project_dir / 'main' / 'sketch_translated.c' if sketch_translated.exists(): sketch_translated.unlink() # ── Resolve external Arduino libraries as IDF components ────── # arduino-cli installs libraries in ~/Arduino/libraries/ but the # ESP-IDF build system does not scan that path. We create a # user_libs/ directory where each external library becomes a # proper ESP-IDF component with its own CMakeLists.txt and # INCLUDE_DIRS. The root CMakeLists.txt (template) adds user_libs # to EXTRA_COMPONENT_DIRS so ESP-IDF discovers them automatically. # # Scan the .ino AND every user-supplied .h/.hpp/.c/.cpp so # transitive includes inside project headers (e.g. Common.h # → ) are picked up. Previously only main_content # was scanned, so libs only referenced from project headers # never reached _resolve_library_components and the build # died with "fatal error: ESP32Servo.h: No such file". own_names = {PurePosixPath(str(_f.get('name') or '').replace('\\', '/')).name for _f in files} ext_headers_set: set[str] = set( self._detect_external_includes(main_content, own_names) ) for _f in files: if _f.get('name', '').endswith(('.h', '.hpp', '.ino', '.c', '.cpp')): ext_headers_set.update( self._detect_external_includes(_f.get('content', ''), own_names) ) ext_headers = list(ext_headers_set) component_names: list[str] = [] # arduino-esp32 component name (directory basename of ARDUINO_ESP32_PATH) arduino_comp_name = Path(arduino_core_path).name if arduino_core_path else 'arduino-esp32' if ext_headers: user_libs_dir = project_dir / 'user_libs' user_libs_dir.mkdir(exist_ok=True) esp32_libs = Path(arduino_core_path) / 'libraries' if arduino_core_path else None # P2.1e — when a per-compile library scope was materialized (the # manifest's libs symlinked from the content-addressed cache, with # a legacy-dir fallback), resolve from THAT instead of the shared # global volume. Falls back to the single global dir for scan-all # / OSS self-host. arduino_libs = libraries_dir or self._find_arduino_libraries_dir() component_names, _ = self._resolve_library_components( ext_headers, arduino_libs, esp32_libs, arduino_comp_name, user_libs_dir, allowed_libraries=allowed_libraries, ) # Patch main/CMakeLists.txt — REQUIRES and INCLUDE_DIRS for user_libs_all. # The single merged component means one entry covers all external headers. if component_names: # always ['user_libs_all'] when any lib was found cmake_path = project_dir / 'main' / 'CMakeLists.txt' cmake_text = cmake_path.read_text(encoding='utf-8') for old_req in [r'REQUIRES ${_arduino_comp_name}', f'REQUIRES {arduino_comp_name}']: if old_req in cmake_text: cmake_text = cmake_text.replace( old_req, f'{old_req} user_libs_all' ) break cmake_text = cmake_text.replace( 'INCLUDE_DIRS "."', 'INCLUDE_DIRS "." "../user_libs/user_libs_all"', ) cmake_path.write_text(cmake_text, encoding='utf-8') logger.info('[espidf] Patched main CMakeLists: REQUIRES += user_libs_all, INCLUDE_DIRS += user_libs_all') # esp_camera.h used to come free: arduino-esp32 2.x shipped a precompiled # SDK with esp32-camera bundled under tools/sdk//include. The 3.x # component does NOT, and its manifest does not depend on it either, so the # move to 3.x silently broke every camera sketch with "esp_camera.h: No such # file or directory". Ask for it explicitly; the component manager fetches it # during the cmake configure and caches it in the build dir. sketch_src = '\n'.join( f.get('content', '') for f in files if str(f.get('name', '')).endswith( ('.ino', '.cpp', '.c', '.h', '.hpp') ) ) if self._detect_camera_usage(sketch_src): self._add_managed_components( project_dir, {'espressif/esp32-camera': '^2.0.4'} ) else: # Pure ESP-IDF mode (no Arduino component usable for this # target). Remove Arduino main.cpp to avoid conflict. main_cpp = project_dir / 'main' / 'main.cpp' if main_cpp.exists(): main_cpp.unlink() if self._contains_app_main(main_content): # The user's code is already a plain ESP-IDF program — # compile it verbatim (the template's main.c #includes # sketch_translated.c). (project_dir / 'main' / 'sketch_translated.c').write_text( main_content, encoding='utf-8' ) logger.info( '[espidf] app_main detected — compiling as plain ESP-IDF C' ) elif self.has_arduino: # An Arduino core IS installed, it just cannot build this # target (arduino-esp32 2.x has no esp32c6 support). Refuse # honestly instead of pattern-translating the sketch into # something that doesn't do what the user wrote. core_ver = self._arduino_core_version() or 'unknown' return { 'success': False, 'error': ( f'Arduino sketches are not supported on {idf_target} ' f'yet: the installed arduino-esp32 core ' f'(v{core_ver}) has no {idf_target} support — that ' f'needs arduino-esp32 3.x (ESP-IDF 5.x based), which ' f'is not installed on this server. Meanwhile you can ' f'write the program as plain ESP-IDF C: define ' f'`void app_main(void)` and use IDF APIs ' f'(driver/gpio.h, freertos/task.h, ...) — those ' f'compile and run today.' ), 'stdout': '', 'stderr': '', } else: # Legacy fallback: no Arduino component installed at all — # translate the common Arduino WiFi/WebServer patterns. translated = self._translate_sketch_to_espidf(main_content) (project_dir / 'main' / 'sketch_translated.c').write_text( translated, encoding='utf-8' ) # Build using cmake + ninja (more portable than idf.py on Windows) build_dir = project_dir / 'build' build_dir.mkdir(exist_ok=True) env = self._build_env( idf_target, use_idf5=use_idf5, arduino_mode=arduino_mode, pure_idf=pure_idf, ) # Step 1: cmake configure cmake_cmd = [ 'cmake', '-G', 'Ninja', '-Wno-dev', f'-DIDF_TARGET={idf_target}', '-DCMAKE_BUILD_TYPE=Release', f'-DSDKCONFIG_DEFAULTS={project_dir / "sdkconfig.defaults"}', str(project_dir), ] # ccache: ESP-IDF's tools/cmake/project.cmake enables ccache iff # the CMake variable `CCACHE_ENABLE` is truthy. We don't go through # `idf.py` (which would translate the env var for us), so wire it # in here. Default ON; set IDF_CCACHE_ENABLE=0 in the env to # bypass without rebuilding the image. if os.environ.get('IDF_CCACHE_ENABLE', '1') not in ('0', 'false', 'False', ''): cmake_cmd.append('-DCCACHE_ENABLE=1') logger.info(f'[espidf] cmake: {" ".join(cmake_cmd)}') # IDF 5.x configure does more work per cold run (component manager, # per-target tool checks) — give it more headroom than the 4.4 flow. cmake_timeout = 300 if use_idf5 else 120 def _run_cmake(): return _run_with_streaming( cmake_cmd, cwd=str(build_dir), env=env, timeout=cmake_timeout, progress_callback=progress_callback, ) try: cmake_result = await asyncio.to_thread(_run_cmake) except subprocess.TimeoutExpired: return { 'success': False, 'error': f'ESP-IDF cmake configure timed out ({cmake_timeout}s)', 'stdout': '', 'stderr': '', } if cmake_result.returncode != 0: logger.error(f'[espidf] cmake failed:\n{cmake_result.stderr}') return { 'success': False, 'error': 'ESP-IDF cmake configure failed', 'stdout': cmake_result.stdout, 'stderr': cmake_result.stderr, } # Step 2: ninja build ninja_cmd = ['ninja'] logger.info('[espidf] Building with ninja...') # Cold ESP-IDF builds with external Arduino libraries (e.g. Adafruit # BMP280 + BusIO + Unified Sensor → ~1480 build steps) regularly take # 5-7 minutes on modest hardware. 300s used to cut them off at 98%; # bump to 600s so first-run cold compiles complete. Subsequent # builds reuse ninja's cache and finish in seconds. NINJA_TIMEOUT_S = 600 def _run_ninja(): return _run_with_streaming( ninja_cmd, cwd=str(build_dir), env=env, timeout=NINJA_TIMEOUT_S, progress_callback=progress_callback, ) try: ninja_result = await asyncio.to_thread(_run_ninja) except subprocess.TimeoutExpired: return { 'success': False, 'error': f'ESP-IDF build timed out ({NINJA_TIMEOUT_S}s)', 'stdout': '', 'stderr': '', } all_stdout = cmake_result.stdout + '\n' + ninja_result.stdout all_stderr = cmake_result.stderr + '\n' + ninja_result.stderr # Filter out expected but ugly warnings from stderr (e.g. absent git, cmake deprecation) filtered_stderr_lines = [] for line in all_stderr.splitlines(): if 'fatal: not a git repository' in line: continue if 'CMake Deprecation Warning' in line: continue if 'Compatibility with CMake' in line: continue filtered_stderr_lines.append(line) all_stderr = '\n'.join(filtered_stderr_lines) if ninja_result.returncode != 0: # Extract the actual compiler errors from ninja's stdout. # Ninja prints failed job blocks in stdout: # FAILED: path/to/file.obj # # sketch.ino.cpp:5:10: fatal error: DHT.h: No such file or directory # compilation terminated. # ninja: build stopped: subcommand failed. stdout_lines = ninja_result.stdout.split('\n') error_lines: list[str] = [] in_failed_block = False for line in stdout_lines: stripped = line.strip() if stripped.startswith('FAILED:') or stripped == 'ninja: build stopped: subcommand failed.': in_failed_block = True error_lines.append(line) continue # Next [N/M] progress line ends the block if in_failed_block and stripped.startswith('[') and '/' in stripped and ']' in stripped: in_failed_block = False if in_failed_block: error_lines.append(line) elif ': error:' in line or 'fatal error:' in line.lower(): # Explicit compiler error outside a FAILED block error_lines.append(line) extracted = '\n'.join(l for l in error_lines if l.strip()) # First non-FAILED, non-command error line → short summary for toolbar summary = 'ESP-IDF build failed' for l in error_lines: s = l.strip() if s and not s.startswith('FAILED:') and not s.startswith('ninja:') and not s.startswith('/') and 'error:' in s.lower(): summary = s break if summary == 'ESP-IDF build failed' and error_lines: # Fall back to first non-empty error line for l in error_lines: if l.strip() and not l.strip().startswith('FAILED:'): summary = l.strip() break # Put extracted errors in stderr so the console highlights them combined_stderr = (extracted + '\n\n' + all_stderr).strip() if extracted else all_stderr logger.error(f'[espidf] ninja build failed (stdout):\n{ninja_result.stdout[-4000:]}') logger.error(f'[espidf] ninja build failed (stderr):\n{ninja_result.stderr[-2000:]}') return { 'success': False, 'error': summary, 'stdout': all_stdout, 'stderr': combined_stderr, } # Step 3: Build the SPIFFS partition image (if files were uploaded # and the partition scheme has a filesystem region). Skipped silently # when mkspiffs isn't installed — see _build_spiffs_image. flash_size_bytes = self._FLASH_SIZE_BYTES.get( board_options['flashSize'], 4 * 1024 * 1024, ) fs_partition = self._find_filesystem_partition(partition_csv) spiffs_bin: Optional[Path] = None spiffs_offset = 0 if spiffs_files: try: spiffs_bin = self._build_spiffs_image( project_dir, spiffs_files, fs_partition['size'] if fs_partition else 0, ) except ValueError as exc: return { 'success': False, 'error': str(exc), 'stdout': all_stdout, 'stderr': all_stderr, } if spiffs_bin is not None and fs_partition is not None: spiffs_offset = fs_partition['offset'] # Step 4: Merge binaries into flash image try: merged_path = self._merge_flash_image( build_dir, idf_target, flash_size_bytes=flash_size_bytes, spiffs_bin=spiffs_bin, spiffs_offset=spiffs_offset, ) except FileNotFoundError as exc: return { 'success': False, 'error': f'Binary merge failed: {exc}', 'stdout': all_stdout, 'stderr': all_stderr, } except ValueError as exc: return { 'success': False, 'error': str(exc), 'stdout': all_stdout, 'stderr': all_stderr, } binary_b64 = base64.b64encode(merged_path.read_bytes()).decode('ascii') logger.info(f'[espidf] Compilation successful — {len(binary_b64) // 1024} KB (base64), has_wifi={has_wifi}') return { 'success': True, 'hex_content': None, 'binary_content': binary_b64, 'binary_type': 'bin', 'has_wifi': has_wifi, 'stdout': all_stdout, 'stderr': all_stderr, } # Singleton instance espidf_compiler = ESPIDFCompiler()