diff --git a/backend/app/api/routes/compile.py b/backend/app/api/routes/compile.py index 7440f449..9fca1efd 100644 --- a/backend/app/api/routes/compile.py +++ b/backend/app/api/routes/compile.py @@ -62,6 +62,7 @@ def _job_key( spiffs_files: list[dict] | None = None, libraries: list[str] | None = None, owner_id: str | None = None, + language: str | None = None, ) -> str: """Stable content hash of (files, board, options, spiffs, libraries, owner) used as the deduplication key. @@ -111,6 +112,13 @@ def _job_key( h.update(b"owner:") h.update(owner_id.encode()) h.update(b"\0") + if language and language != "arduino": + # Pure ESP-IDF mode produces a different binary from the same bytes — + # never dedup across language modes. Guarded so 'arduino' (explicit or + # omitted) keeps the historical key. + h.update(b"lang:") + h.update(language.encode()) + h.update(b"\0") return h.hexdigest() @@ -168,6 +176,11 @@ class CompileRequest(BaseModel): # merged only if it's declared here, so a sketch never picks up an unrelated # library from the shared dir. None / omitted = legacy scan-all (unchanged). libraries: list[str] | None = None + # Pure ESP-IDF language mode (issue #139). 'espidf' compiles the files as + # a pure ESP-IDF project: the user provides app_main() and IDF APIs, and + # the arduino-esp32 component is left out of the build entirely. None / + # 'arduino' = classic Arduino sketch compile. ESP32 boards only. + language: str | None = None class CompileResponse(BaseModel): @@ -298,8 +311,27 @@ async def _run_compile( else: allowed_libraries, owner_id = scope + pure_idf = request.language == "espidf" + if pure_idf and not request.board_fqbn.startswith("esp32:"): + return CompileResponse( + success=False, + stdout="", + stderr="", + error="ESP-IDF language mode is only supported on ESP32 boards.", + ) + if pure_idf and not espidf_compiler.available: + return CompileResponse( + success=False, + stdout="", + stderr="", + error="ESP-IDF toolchain is not available on this server.", + ) + if request.board_fqbn.startswith("esp32:") and espidf_compiler.available: - logger.info(f"[compile] Using ESP-IDF for {request.board_fqbn}") + logger.info( + f"[compile] Using ESP-IDF for {request.board_fqbn}" + + (" (pure ESP-IDF mode)" if pure_idf else "") + ) spiffs_dicts = ( [f.model_dump() for f in request.spiffs_files] if request.spiffs_files else None @@ -311,6 +343,7 @@ async def _run_compile( spiffs_files=spiffs_dicts, allowed_libraries=allowed_libraries, owner_id=owner_id, + pure_idf=pure_idf, ) return CompileResponse( success=result["success"], @@ -608,6 +641,7 @@ async def compile_start( files, request.board_fqbn, request.board_options, spiffs_dicts, sorted(allowed_libraries) if allowed_libraries else None, owner_id if allowed_libraries else None, + language=request.language, ) existing_id = JOB_BY_KEY.get(key) if existing_id is not None: diff --git a/backend/app/services/esp-idf-template/main/CMakeLists.txt b/backend/app/services/esp-idf-template/main/CMakeLists.txt index 4107eaa6..4b91217b 100644 --- a/backend/app/services/esp-idf-template/main/CMakeLists.txt +++ b/backend/app/services/esp-idf-template/main/CMakeLists.txt @@ -1,7 +1,37 @@ +# Pure ESP-IDF language mode (issue #139): the user's files are the main +# component sources — they provide app_main() and use IDF APIs directly. +# espidf_compiler sets VELXIO_PURE_SKETCH (and drops ARDUINO_ESP32_PATH from +# the build env) when the frontend requests language='espidf'. As the `main` +# component, ESP-IDF gives these sources an implicit dependency on every +# other component in the build, so driver/, esp_wifi, esp_http_server etc. +# resolve without an explicit REQUIRES. +if(DEFINED ENV{VELXIO_PURE_SKETCH}) + # No CONFIGURE_DEPENDS — see the comment on the Arduino glob below. + file(GLOB _user_srcs + "${CMAKE_CURRENT_LIST_DIR}/*.cpp" + "${CMAKE_CURRENT_LIST_DIR}/*.c") + + idf_component_register( + SRCS ${_user_srcs} + INCLUDE_DIRS "." + ) + + # Same -Werror relaxations as the Arduino branch: IDF's defaults are + # ruthless for user code pasted from tutorials. + target_compile_options(${COMPONENT_LIB} PRIVATE + -Wno-error=comment + -Wno-error=parentheses + -Wno-error=sign-compare + -Wno-error=narrowing + -Wno-error=write-strings + -Wno-error=missing-field-initializers + -Wno-error=reorder + -Wno-error=unused-variable + -Wno-error=unused-but-set-variable) # If Arduino component is available, use C++ main and link Arduino. # The component name matches the directory basename of ARDUINO_ESP32_PATH # (e.g. "arduino-esp32" when cloned from GitHub). -if(DEFINED ENV{ARDUINO_ESP32_PATH}) +elseif(DEFINED ENV{ARDUINO_ESP32_PATH}) get_filename_component(_arduino_comp_name $ENV{ARDUINO_ESP32_PATH} NAME) # arduino-esp32 ships esp32-camera as a precompiled static lib but does diff --git a/backend/app/services/espidf_compiler.py b/backend/app/services/espidf_compiler.py index af84f40c..0dcc8d80 100644 --- a/backend/app/services/espidf_compiler.py +++ b/backend/app/services/espidf_compiler.py @@ -339,6 +339,36 @@ class ESPIDFCompiler: """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( @@ -1211,13 +1241,22 @@ class ESPIDFCompiler: ) return safe_name - def _build_env(self, idf_target: str) -> dict: - """Build environment dict for ESP-IDF subprocess.""" + def _build_env(self, idf_target: str, pure_idf: bool = False) -> dict: + """Build environment dict for ESP-IDF subprocess. + + pure_idf: omit ARDUINO_ESP32_PATH (the template CMake only pulls the + arduino-esp32 component when that var is set) and raise + VELXIO_PURE_SKETCH so main/CMakeLists.txt compiles the user's own + app_main() sources instead of the Arduino sketch wrapper. + """ env = os.environ.copy() env['IDF_PATH'] = self.idf_path env['IDF_TARGET'] = idf_target - if self.has_arduino: + if pure_idf: + env.pop('ARDUINO_ESP32_PATH', None) + env['VELXIO_PURE_SKETCH'] = '1' + elif self.has_arduino: env['ARDUINO_ESP32_PATH'] = self.arduino_path # On Windows, ESP-IDF uses its own Python venv @@ -1745,6 +1784,7 @@ class ESPIDFCompiler: 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. @@ -1777,6 +1817,12 @@ class ESPIDFCompiler: 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 { @@ -1842,8 +1888,12 @@ class ESPIDFCompiler: ('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).encode() + (options_hash + '|' + _libs_token + '|i:' + _ext_inc_token + _lang_token).encode() ).hexdigest()[:12] try: if _USE_PERSISTENT_DIR: @@ -1853,6 +1903,7 @@ class ESPIDFCompiler: project_dir, files, idf_target, is_c3, progress_callback, normalized_opts, spiffs_files, allowed_libraries=allowed, libraries_dir=scope_dir, + pure_idf=pure_idf, ) with tempfile.TemporaryDirectory(prefix='espidf_') as temp_dir: project_dir = Path(temp_dir) / 'project' @@ -1862,6 +1913,7 @@ class ESPIDFCompiler: project_dir, files, idf_target, is_c3, progress_callback, normalized_opts, spiffs_files, allowed_libraries=allowed, libraries_dir=scope_dir, + pure_idf=pure_idf, ) finally: if scope_dir is not None: @@ -1882,7 +1934,10 @@ class ESPIDFCompiler: return r2 return r - result = await _attempt_safe(allowed_libraries) + # 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) @@ -1891,7 +1946,7 @@ class ESPIDFCompiler: # 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 result.get('success'): + 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( @@ -1919,11 +1974,17 @@ class ESPIDFCompiler: spiffs_files: list[dict] | None = None, allowed_libraries: set[str] | None = None, libraries_dir: Path | None = None, + pure_idf: bool = False, ) -> 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). + + 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. @@ -1935,6 +1996,15 @@ class ESPIDFCompiler: # 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) + 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 @@ -1969,10 +2039,44 @@ class ESPIDFCompiler: # 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. - has_wifi = self._detect_wifi_usage(main_content) - main_content = self._normalize_wifi_for_qemu(main_content) + 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) - if self.has_arduino: + 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 self.has_arduino: # Arduino-as-component mode: copy sketch as .cpp sketch_cpp = project_dir / 'main' / 'sketch.ino.cpp' # Prepend Arduino.h + velxio_compat.h if not already included. @@ -2088,7 +2192,7 @@ class ESPIDFCompiler: build_dir = project_dir / 'build' build_dir.mkdir(exist_ok=True) - env = self._build_env(idf_target) + env = self._build_env(idf_target, pure_idf=pure_idf) # Step 1: cmake configure cmake_cmd = [ diff --git a/frontend/src/__tests__/issue-139-espidf-mode.test.ts b/frontend/src/__tests__/issue-139-espidf-mode.test.ts new file mode 100644 index 00000000..22bc01a4 --- /dev/null +++ b/frontend/src/__tests__/issue-139-espidf-mode.test.ts @@ -0,0 +1,105 @@ +/** + * Tests for GitHub issue #139 — pure ESP-IDF language mode + * https://github.com/davidmonterocrespo24/velxio/issues/139 + * + * The ESP32 family gains a third entry in the language selector next to + * Arduino C++ and MicroPython: "ESP-IDF". In that mode the user writes a + * plain IDF project (app_main() entry point, FreeRTOS + driver APIs) and + * the backend compiles it through the same ESP-IDF toolchain it already + * uses for Arduino sketches — just without the arduino-esp32 component. + * + * Pure Vitest unit tests — no QEMU, no network, no DOM. + */ + +import { describe, it, expect } from 'vitest'; +import { BOARD_SUPPORTS_ESPIDF, BOARD_SUPPORTS_MICROPYTHON } from '../types/board'; +import type { BoardKind } from '../types/board'; +import { useEditorStore } from '../store/useEditorStore'; +import { exampleProjects } from '../data/examples'; + +describe('issue #139 — BOARD_SUPPORTS_ESPIDF', () => { + const ESP32_KINDS: BoardKind[] = [ + 'esp32', + 'esp32-devkit-c-v4', + 'esp32-cam', + 'wemos-lolin32-lite', + 'esp32-s3', + 'xiao-esp32-s3', + 'arduino-nano-esp32', + 'esp32-c3', + 'xiao-esp32-c3', + 'aitewinrobot-esp32c3-supermini', + ]; + + for (const kind of ESP32_KINDS) { + it(`${kind} supports ESP-IDF mode`, () => { + expect(BOARD_SUPPORTS_ESPIDF.has(kind)).toBe(true); + }); + } + + it('non-ESP32 boards do NOT support ESP-IDF mode', () => { + const NON_ESP32: BoardKind[] = [ + 'arduino-uno', + 'raspberry-pi-pico', + 'pi-pico-w', + 'raspberry-pi-3', + 'stm32-bluepill', + 'attiny85', + ]; + for (const kind of NON_ESP32) { + expect(BOARD_SUPPORTS_ESPIDF.has(kind)).toBe(false); + } + }); + + it('every ESP-IDF board also offers the language selector (MicroPython set)', () => { + // The toolbar renders the selector when BOARD_SUPPORTS_MICROPYTHON + // matches and adds the ESP-IDF option when BOARD_SUPPORTS_ESPIDF also + // matches — so every espidf-capable board must be in the outer set or + // the option would be unreachable. + for (const kind of BOARD_SUPPORTS_ESPIDF) { + expect(BOARD_SUPPORTS_MICROPYTHON.has(kind)).toBe(true); + } + }); +}); + +describe('issue #139 — espidf file group defaults', () => { + it("createFileGroup(groupId, 'espidf') seeds main.c with app_main()", () => { + const groupId = 'group-esp32-espidf-test'; + useEditorStore.getState().createFileGroup(groupId, 'espidf'); + const files = useEditorStore.getState().getGroupFiles(groupId); + expect(files).toHaveLength(1); + expect(files[0].name).toBe('main.c'); + expect(files[0].content).toContain('app_main'); + expect(files[0].content).toContain('freertos/FreeRTOS.h'); + expect(files[0].content).toContain('driver/gpio.h'); + // Must NOT be an Arduino sketch — that's the whole point of the mode. + expect(files[0].content).not.toContain('setup()'); + expect(files[0].content).not.toContain('Arduino.h'); + useEditorStore.getState().deleteFileGroup(groupId); + }); + + it("createFileGroup(groupId, 'arduino') still seeds sketch.ino (regression)", () => { + const groupId = 'group-esp32-arduino-test'; + useEditorStore.getState().createFileGroup(groupId, 'arduino'); + const files = useEditorStore.getState().getGroupFiles(groupId); + expect(files[0].name).toBe('sketch.ino'); + useEditorStore.getState().deleteFileGroup(groupId); + }); +}); + +describe('issue #139 — esp32-idf-blink gallery example', () => { + const example = exampleProjects.find((e) => e.id === 'esp32-idf-blink'); + + it('exists and targets an ESP32 board in espidf mode', () => { + expect(example).toBeDefined(); + expect(example?.boardType).toBe('esp32'); + expect(example?.languageMode).toBe('espidf'); + }); + + it('ships a main.c with an app_main() entry point', () => { + const mainFile = example?.files?.find((f) => f.name === 'main.c'); + expect(mainFile).toBeDefined(); + expect(mainFile?.content).toContain('void app_main(void)'); + expect(mainFile?.content).not.toContain('Arduino.h'); + }); +}); diff --git a/frontend/src/components/editor/EditorToolbar.tsx b/frontend/src/components/editor/EditorToolbar.tsx index 429860fd..759c38cb 100644 --- a/frontend/src/components/editor/EditorToolbar.tsx +++ b/frontend/src/components/editor/EditorToolbar.tsx @@ -7,7 +7,7 @@ import { type VerificationResult } from '../../simulation/verify/circuitVerifier import { verifyCircuitFromStore } from '../../simulation/verify/verifyFromStore'; import { CircuitVerificationModal } from '../simulator/CircuitVerificationModal'; import type { BoardKind, LanguageMode } from '../../types/board'; -import { BOARD_KIND_FQBN, BOARD_SUPPORTS_MICROPYTHON, isPiBoardKind, boardDisplayName } from '../../types/board'; +import { BOARD_KIND_FQBN, BOARD_SUPPORTS_ESPIDF, BOARD_SUPPORTS_MICROPYTHON, isPiBoardKind, boardDisplayName } from '../../types/board'; import { compileCode } from '../../services/compilation'; import { compileRom, @@ -588,6 +588,9 @@ export const EditorToolbar = ({ // P2.4 — THIS board's declared manifest (compile scope). Per-board so // two boards can use different libraries without clashing. libraries: activeBoard?.libraries?.length ? activeBoard.libraries : null, + // Pure ESP-IDF mode (issue #139): tell the backend to compile the + // user's app_main() sources without the arduino-esp32 component. + language: activeBoard?.languageMode === 'espidf' ? 'espidf' : undefined, }, ); @@ -1050,7 +1053,7 @@ export const EditorToolbar = ({ })), ]); }, - { boardOptions: board.boardOptions, spiffsFiles: board.spiffsFiles, libraries: board.libraries?.length ? board.libraries : null }, + { boardOptions: board.boardOptions, spiffsFiles: board.spiffsFiles, libraries: board.libraries?.length ? board.libraries : null, language: board.languageMode === 'espidf' ? 'espidf' : undefined }, ); const resultLogs = parseCompileResult(result, label, boardTarget); @@ -1354,9 +1357,11 @@ export const EditorToolbar = ({ <>