From 734b7d0487818206ff7d57c325dff42a3d683432 Mon Sep 17 00:00:00 2001 From: David Montero Date: Fri, 24 Jul 2026 06:37:01 +0200 Subject: [PATCH] feat(esp32): pure ESP-IDF language mode for the ESP32 family (#139) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a third entry to the board language selector next to Arduino C++ and MicroPython: ESP-IDF. In this mode the user writes a plain ESP-IDF project — app_main() entry point, FreeRTOS + driver APIs — and the backend compiles it through the same ESP-IDF toolchain it already uses for ESP32 Arduino sketches, just without the arduino-esp32 component. Backend: - CompileRequest.language ('espidf') threaded through the sync + async compile paths and folded into the dedup job key (language='arduino' and omitted hash identically so old clients keep dedupping). - espidf_compiler: pure_idf flag. User files are written into main/ as-is (no Arduino.h wrap, no velxio_compat.h, Arduino library resolution skipped), ARDUINO_ESP32_PATH is dropped from the build env and VELXIO_PURE_SKETCH raised so the template CMake compiles the user's own sources via a glob branch. Pure builds get their own persistent build-dir variant through the eff_hash fold. - QEMU WiFi compat for IDF-style code: esp_wifi.h/esp_wifi_init detection sets has_wifi, and literal #define SSID/PASS plus wifi_config_t designated initializers are normalized to the QEMU AP. - CONFIG_ARDUINO_* lines are stripped from sdkconfig.defaults in pure mode (the symbols don't exist without the arduino component). Frontend: - LanguageMode gains 'espidf'; BOARD_SUPPORTS_ESPIDF covers the ESP32 family (Xtensa, S3, C3). Toolbar shows the option only for those. - Switching modes seeds a main.c blink skeleton (app_main + gpio driver), mirroring the MicroPython main.py flow. - compileCode sends language='espidf'; run/stop paths are unchanged (the QEMU worker consumes the same merged flash image). - New gallery example: esp32-idf-blink (LED + resistor on GPIO 2). Tests: unit coverage for the build-env switch, IDF wifi normalization, job-key variance, file-group seeding and the new example; verified end-to-end in a container from the prod image (pure build produces a bootable flash image; Arduino-mode build unchanged, same variant hash). --- backend/app/api/routes/compile.py | 36 ++++- .../esp-idf-template/main/CMakeLists.txt | 32 ++++- backend/app/services/espidf_compiler.py | 124 ++++++++++++++-- .../__tests__/issue-139-espidf-mode.test.ts | 105 ++++++++++++++ .../src/components/editor/EditorToolbar.tsx | 18 ++- frontend/src/data/examples.ts | 62 +++++++- frontend/src/services/compilation.ts | 5 + frontend/src/store/useEditorStore.ts | 30 +++- frontend/src/store/useSimulatorStore.ts | 5 +- frontend/src/types/board.ts | 24 +++- frontend/src/utils/loadExample.ts | 7 +- test/backend/unit/test_espidf_pure_mode.py | 134 ++++++++++++++++++ 12 files changed, 555 insertions(+), 27 deletions(-) create mode 100644 frontend/src/__tests__/issue-139-espidf-mode.test.ts create mode 100644 test/backend/unit/test_espidf_pure_mode.py 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 = ({ <>
- {/* MicroPython language selector — only when active board supports it. - The board context pill that used to live here was removed: it - duplicated the BoardSelector dropdown elsewhere in the toolbar. */} + {/* Language selector — only when active board supports an + alternative to Arduino C++ (MicroPython on Pico/ESP32 boards, + pure ESP-IDF on the ESP32 family — issue #139). The board + context pill that used to live here was removed: it duplicated + the BoardSelector dropdown elsewhere in the toolbar. */} {activeBoard && BOARD_SUPPORTS_MICROPYTHON.has(activeBoard.boardKind) && ( )} diff --git a/frontend/src/data/examples.ts b/frontend/src/data/examples.ts index 8072a6fe..55e2807e 100644 --- a/frontend/src/data/examples.ts +++ b/frontend/src/data/examples.ts @@ -60,10 +60,11 @@ export interface ExampleProject { /** Code for single-board examples (ignored when boards[] is set, or when files[] is provided). */ code: string; /** - * Optional language mode for the active board. When 'micropython', loadExample - * switches the board into MicroPython mode before populating files. + * Optional language mode for the active board. When 'micropython' or + * 'espidf', loadExample switches the board into that mode before + * populating files. */ - languageMode?: 'arduino' | 'micropython'; + languageMode?: 'arduino' | 'micropython' | 'espidf'; /** * Optional multi-file payload for single-board examples. When present it * overrides ``code`` — every entry is loaded into the active file group as-is. @@ -213,6 +214,61 @@ void loop() { { id: 'w-scl', start: { componentId: 'esp32', pinName: '22' }, end: { componentId: 'oled', pinName: 'SCL' }, color: '#ff8800' }, ], }, + // ── Pure ESP-IDF (issue #139): user app_main(), no Arduino core. ── + { + id: 'esp32-idf-blink', + title: 'ESP32: Pure ESP-IDF Blink', + description: + 'Blink an LED from a pure ESP-IDF project — app_main(), FreeRTOS delays and the GPIO driver, no Arduino core. Pick "ESP-IDF" in the language selector to write more projects like this.', + category: 'basics', + difficulty: 'intermediate', + boardType: 'esp32', + languageMode: 'espidf', + tags: ['esp32', 'esp-idf', 'freertos', 'gpio'], + code: '', + files: [ + { + name: 'main.c', + content: `// Pure ESP-IDF blink — no Arduino core, just IDF APIs. +// app_main() is the ESP-IDF entry point (instead of setup()/loop()). +#include +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "driver/gpio.h" +#include "esp_log.h" + +#define LED_PIN GPIO_NUM_2 + +static const char *TAG = "blink"; + +void app_main(void) +{ + ESP_LOGI(TAG, "ESP-IDF blink starting"); + gpio_reset_pin(LED_PIN); + gpio_set_direction(LED_PIN, GPIO_MODE_OUTPUT); + + int level = 0; + while (1) { + level = !level; + gpio_set_level(LED_PIN, level); + ESP_LOGI(TAG, "LED %s", level ? "on" : "off"); + vTaskDelay(pdMS_TO_TICKS(1000)); + } +} +`, + }, + ], + components: [ + // 220R keeps the LED under its 20 mA rating on a 3.3 V GPIO. + { type: 'wokwi-resistor', id: 'r-led', x: 340, y: 120, properties: { value: '220' } }, + { type: 'wokwi-led', id: 'led-ext', x: 470, y: 100, properties: { color: 'red' } }, + ], + wires: [ + { id: 'w-gpio2-r', start: { componentId: 'esp32', pinName: '2' }, end: { componentId: 'r-led', pinName: '1' }, color: '#e74c3c' }, + { id: 'w-r-led', start: { componentId: 'r-led', pinName: '2' }, end: { componentId: 'led-ext', pinName: 'A' }, color: '#e74c3c' }, + { id: 'w-gnd', start: { componentId: 'led-ext', pinName: 'C' }, end: { componentId: 'esp32', pinName: 'GND' }, color: '#2c3e50' }, + ], + }, { id: 'pico-oled-4pin-i2c', title: 'Raspberry Pi Pico: SSD1306 OLED (4-pin I2C)', diff --git a/frontend/src/services/compilation.ts b/frontend/src/services/compilation.ts index fcc445bd..e4458403 100644 --- a/frontend/src/services/compilation.ts +++ b/frontend/src/services/compilation.ts @@ -20,6 +20,10 @@ export interface CompileExtras { // Sent as the ESP-IDF resolution SCOPE; null/omitted = legacy scan-all. // Ignored by the backend for non-ESP32 (arduino-cli) boards. libraries?: string[] | null; + // Pure ESP-IDF mode (issue #139): 'espidf' compiles the files as a pure + // ESP-IDF project (user app_main, no arduino-esp32 component). Omitted / + // undefined = classic Arduino sketch compile. ESP32 boards only. + language?: 'espidf'; } export interface CompileResult { @@ -113,6 +117,7 @@ export async function compileCode( board_options, spiffs_files, libraries, + language: extras?.language ?? null, }, { withCredentials: true, timeout: 30000 }, ); diff --git a/frontend/src/store/useEditorStore.ts b/frontend/src/store/useEditorStore.ts index 48d98e62..a43f7603 100644 --- a/frontend/src/store/useEditorStore.ts +++ b/frontend/src/store/useEditorStore.ts @@ -50,6 +50,31 @@ while True: time.sleep(1) `; +// Pure ESP-IDF mode (issue #139): the user's own app_main(), compiled by the +// backend's ESP-IDF toolchain WITHOUT the arduino-esp32 component. GPIO 2 is +// the built-in LED on most ESP32 dev boards. +const DEFAULT_ESPIDF_CONTENT = `// ESP-IDF Blink Example +#include +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "driver/gpio.h" + +#define LED_PIN GPIO_NUM_2 + +void app_main(void) +{ + gpio_reset_pin(LED_PIN); + gpio_set_direction(LED_PIN, GPIO_MODE_OUTPUT); + + while (1) { + gpio_set_level(LED_PIN, 1); + vTaskDelay(pdMS_TO_TICKS(1000)); + gpio_set_level(LED_PIN, 0); + vTaskDelay(pdMS_TO_TICKS(1000)); + } +} +`; + const DEFAULT_PY_CONTENT = `import RPi.GPIO as GPIO import time @@ -369,7 +394,10 @@ export const useEditorStore = create((set, get) => ({ let fileName: string; let content: string; const isEsp32 = groupId.includes('esp32'); - if (isMicroPython && isEsp32) { + if (languageMode === 'espidf') { + fileName = 'main.c'; + content = DEFAULT_ESPIDF_CONTENT; + } else if (isMicroPython && isEsp32) { fileName = 'main.py'; content = DEFAULT_ESP32_MICROPYTHON_CONTENT; } else if (isMicroPython) { diff --git a/frontend/src/store/useSimulatorStore.ts b/frontend/src/store/useSimulatorStore.ts index 703014de..d26eb169 100644 --- a/frontend/src/store/useSimulatorStore.ts +++ b/frontend/src/store/useSimulatorStore.ts @@ -18,7 +18,7 @@ import type { I2CDevice } from '../simulation/I2CBusManager'; import type { RP2040I2CDevice } from '../simulation/RP2040Simulator'; import type { Wire, WireInProgress, WireEndpoint } from '../types/wire'; import type { BoardKind, BoardInstance, LanguageMode, WifiStatus } from '../types/board'; -import { BOARD_SUPPORTS_MICROPYTHON, isPiBoardKind, isStm32BoardKind } from '../types/board'; +import { BOARD_SUPPORTS_ESPIDF, BOARD_SUPPORTS_MICROPYTHON, isPiBoardKind, isStm32BoardKind } from '../types/board'; import { boardGateDecision, proBoardFeatureName, triggerProUpgradePrompt } from '../lib/proBoardGate'; import { calculatePinPosition } from '../utils/pinPositionCalculator'; import { useOscilloscopeStore } from './useOscilloscopeStore'; @@ -1701,8 +1701,9 @@ export const useSimulatorStore = create((set, get) => { const board = get().boards.find((b) => b.id === boardId); if (!board) return; - // Only allow MicroPython for supported boards + // Only allow MicroPython / ESP-IDF for supported boards if (mode === 'micropython' && !BOARD_SUPPORTS_MICROPYTHON.has(board.boardKind)) return; + if (mode === 'espidf' && !BOARD_SUPPORTS_ESPIDF.has(board.boardKind)) return; // Stop any running simulation if (board.running) get().stopBoard(boardId); diff --git a/frontend/src/types/board.ts b/frontend/src/types/board.ts index 96ef65bb..dc69f6d1 100644 --- a/frontend/src/types/board.ts +++ b/frontend/src/types/board.ts @@ -30,7 +30,7 @@ export type BoardKind = | 'stm32-netduino2' // Netduino 2 (F205, Cortex-M3), QEMU (serial until F205 GPIO wired) | 'attiny85'; // AVR ATtiny85, browser emulation (avr8js) -export type LanguageMode = 'arduino' | 'micropython'; +export type LanguageMode = 'arduino' | 'micropython' | 'espidf'; /** True for every Raspberry Pi backed by the QEMU bridge (Zero, 1, 2, 3, 4, 5). * Excludes the Pico boards (RP2040, browser emulation). */ @@ -63,6 +63,26 @@ export const BOARD_SUPPORTS_MICROPYTHON = new Set([ 'aitewinrobot-esp32c3-supermini', ]); +/** Boards that can run pure ESP-IDF projects (app_main entry point, IDF + * APIs only — no Arduino core). The backend compiles them through the same + * ESP-IDF toolchain it already uses for ESP32 Arduino sketches, just + * without the arduino-esp32 component. ESP32 family only (issue #139). */ +export const BOARD_SUPPORTS_ESPIDF = new Set([ + // ESP32 Xtensa (QEMU bridge) + 'esp32', + 'esp32-devkit-c-v4', + 'esp32-cam', + 'wemos-lolin32-lite', + // ESP32-S3 Xtensa (QEMU bridge) + 'esp32-s3', + 'xiao-esp32-s3', + 'arduino-nano-esp32', + // ESP32-C3 RISC-V (QEMU bridge) + 'esp32-c3', + 'xiao-esp32-c3', + 'aitewinrobot-esp32c3-supermini', +]); + export interface WifiStatus { status: string; // 'initializing' | 'connected' | 'got_ip' | 'disconnected' ssid?: string; @@ -94,7 +114,7 @@ export interface BoardInstance { serialBaudRate: number; serialMonitorOpen: boolean; activeFileGroupId: string; - languageMode: LanguageMode; // 'arduino' (default) or 'micropython' + languageMode: LanguageMode; // 'arduino' (default), 'micropython' or 'espidf' hasWifi?: boolean; // set by compiler — true when sketch uses WiFi wifiStatus?: WifiStatus; bleStatus?: BleStatus; diff --git a/frontend/src/utils/loadExample.ts b/frontend/src/utils/loadExample.ts index c62ba038..86c81701 100644 --- a/frontend/src/utils/loadExample.ts +++ b/frontend/src/utils/loadExample.ts @@ -277,8 +277,11 @@ export async function loadExample( .getState() .boards.find((b) => b.id === liveBoardId); - if (example.languageMode === 'micropython' && liveBoard) { - setBoardLanguageMode(liveBoard.id, 'micropython'); + if ( + (example.languageMode === 'micropython' || example.languageMode === 'espidf') && + liveBoard + ) { + setBoardLanguageMode(liveBoard.id, example.languageMode); } const editorStore = useEditorStore.getState(); diff --git a/test/backend/unit/test_espidf_pure_mode.py b/test/backend/unit/test_espidf_pure_mode.py new file mode 100644 index 00000000..34ccf468 --- /dev/null +++ b/test/backend/unit/test_espidf_pure_mode.py @@ -0,0 +1,134 @@ +""" +Tests for the pure ESP-IDF language mode (issue #139). + +Covers the pieces that don't need the ESP-IDF toolchain: the build env +switch, the QEMU WiFi normalization for IDF-style code, the IDF wifi +detection, and the compile-job dedup key variance. + +Run from the repo root: + python -m pytest test/backend/unit/test_espidf_pure_mode.py -v +""" + +import os +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / 'backend')) + +from app.services.espidf_compiler import ESPIDFCompiler, _QEMU_WIFI_SSID +from app.api.routes import compile as compile_module + + +def make_compiler(has_arduino: bool = True) -> ESPIDFCompiler: + comp = ESPIDFCompiler.__new__(ESPIDFCompiler) + comp.idf_path = '/opt/esp-idf' + comp.arduino_path = '/opt/arduino-esp32' if has_arduino else '' + comp.has_arduino = has_arduino + return comp + + +class TestBuildEnvPureMode(unittest.TestCase): + + def setUp(self): + self.comp = make_compiler(has_arduino=True) + + def test_arduino_mode_sets_arduino_path(self): + env = self.comp._build_env('esp32') + self.assertEqual(env.get('ARDUINO_ESP32_PATH'), '/opt/arduino-esp32') + self.assertNotIn('VELXIO_PURE_SKETCH', env) + + def test_pure_mode_drops_arduino_and_flags_pure(self): + env = self.comp._build_env('esp32', pure_idf=True) + self.assertNotIn('ARDUINO_ESP32_PATH', env) + self.assertEqual(env.get('VELXIO_PURE_SKETCH'), '1') + + def test_pure_mode_overrides_inherited_arduino_path(self): + """The uvicorn process env carries ARDUINO_ESP32_PATH in Docker — + pure mode must strip the inherited copy too, or the template CMake + would still pick the Arduino branch.""" + os.environ['ARDUINO_ESP32_PATH'] = '/opt/arduino-esp32' + try: + env = self.comp._build_env('esp32', pure_idf=True) + self.assertNotIn('ARDUINO_ESP32_PATH', env) + finally: + del os.environ['ARDUINO_ESP32_PATH'] + + +class TestIdfWifiDetection(unittest.TestCase): + + def setUp(self): + self.comp = make_compiler() + + def test_detects_esp_wifi_include(self): + self.assertTrue(self.comp._detect_idf_wifi_usage('#include "esp_wifi.h"')) + self.assertTrue(self.comp._detect_idf_wifi_usage('#include ')) + + def test_detects_esp_wifi_init_call(self): + code = 'wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();\nesp_wifi_init(&cfg);' + self.assertTrue(self.comp._detect_idf_wifi_usage(code)) + + def test_ignores_plain_gpio_project(self): + code = '#include "driver/gpio.h"\nvoid app_main(void) {}' + self.assertFalse(self.comp._detect_idf_wifi_usage(code)) + + +class TestIdfWifiNormalization(unittest.TestCase): + + def setUp(self): + self.comp = make_compiler() + + def test_define_ssid_rewritten(self): + code = '#define WIFI_SSID "MyHomeNetwork"\n#define WIFI_PASS "hunter2"' + out = self.comp._normalize_wifi_for_qemu_idf(code) + self.assertIn(f'#define WIFI_SSID "{_QEMU_WIFI_SSID}"', out) + self.assertIn('#define WIFI_PASS ""', out) + + def test_designated_initializers_rewritten(self): + code = ( + 'wifi_config_t wifi_config = {\n' + ' .sta = {\n' + ' .ssid = "MyHomeNetwork",\n' + ' .password = "hunter2",\n' + ' },\n' + '};' + ) + out = self.comp._normalize_wifi_for_qemu_idf(code) + self.assertIn(f'.ssid = "{_QEMU_WIFI_SSID}"', out) + self.assertIn('.password = ""', out) + self.assertNotIn('MyHomeNetwork', out) + self.assertNotIn('hunter2', out) + + def test_non_wifi_code_untouched(self): + code = '#include "driver/gpio.h"\nvoid app_main(void) { }\n' + self.assertEqual(self.comp._normalize_wifi_for_qemu_idf(code), code) + + +class TestJobKeyLanguageVariance(unittest.TestCase): + + FILES = [{'name': 'main.c', 'content': 'void app_main(void) {}'}] + + def test_language_changes_key(self): + k_arduino = compile_module._job_key(self.FILES, 'esp32:esp32:esp32') + k_espidf = compile_module._job_key( + self.FILES, 'esp32:esp32:esp32', language='espidf', + ) + self.assertNotEqual(k_arduino, k_espidf) + + def test_explicit_arduino_keeps_historical_key(self): + """language='arduino' and language=None must hash identically so + pre-feature clients keep dedupping against new-client submissions.""" + k_none = compile_module._job_key(self.FILES, 'esp32:esp32:esp32') + k_arduino = compile_module._job_key( + self.FILES, 'esp32:esp32:esp32', language='arduino', + ) + self.assertEqual(k_none, k_arduino) + + def test_espidf_key_is_stable(self): + k1 = compile_module._job_key(self.FILES, 'esp32:esp32:esp32', language='espidf') + k2 = compile_module._job_key(self.FILES, 'esp32:esp32:esp32', language='espidf') + self.assertEqual(k1, k2) + + +if __name__ == '__main__': + unittest.main()