feat(esp32): pure ESP-IDF language mode for the ESP32 family (#139)

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).
This commit is contained in:
David Montero 2026-07-24 06:37:01 +02:00
parent 4766789632
commit 734b7d0487
12 changed files with 555 additions and 27 deletions

View File

@ -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:

View File

@ -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

View File

@ -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 = [

View File

@ -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');
});
});

View File

@ -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 = ({
<>
<div className="editor-toolbar-wrapper" style={{ position: 'relative' }}>
<div className="editor-toolbar" ref={toolbarRef}>
{/* 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) && (
<select
className="tb-lang-select"
@ -1380,6 +1385,9 @@ export const EditorToolbar = ({
>
<option value="arduino">Arduino C++</option>
<option value="micropython">MicroPython</option>
{BOARD_SUPPORTS_ESPIDF.has(activeBoard.boardKind) && (
<option value="espidf">ESP-IDF</option>
)}
</select>
)}

View File

@ -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 <stdio.h>
#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)',

View File

@ -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 },
);

View File

@ -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 <stdio.h>
#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<EditorState>((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) {

View File

@ -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<SimulatorState>((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);

View File

@ -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<BoardKind>([
'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<BoardKind>([
// 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;

View File

@ -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();

View File

@ -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 <esp_wifi.h>'))
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()