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).
2026-07-24 11:37:01 +07:00
|
|
|
"""
|
|
|
|
|
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
|
2026-07-26 02:52:00 +07:00
|
|
|
# Per-compile IDF selection: _build_env picks the IDF tree and the
|
|
|
|
|
# matching arduino-esp32 core, so a bare __new__ object needs the v5
|
|
|
|
|
# half of that pair too. Only a v4.4 + 2.x core is declared here, which
|
|
|
|
|
# keeps these tests on the legacy path and leaves the choice itself to
|
|
|
|
|
# the tests that actually cover it.
|
|
|
|
|
comp.idf5_path = ''
|
|
|
|
|
comp.arduino5_path = ''
|
|
|
|
|
comp.has_arduino5 = False
|
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).
2026-07-24 11:37:01 +07:00
|
|
|
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()
|