feat: Enhance ESPIDFCompiler library resolution logic; add support for dynamic library detection and patching in CMakeLists.txt

refactor: Update wiring examples for E32 OLED integration; correct pin mappings for VCC, GND, DATA, and CLK
test: Improve unit tests for ESPIDFCompiler; add scenarios for library resolution and CMake patching
chore: Mark subproject commits as dirty for wokwi-libs
This commit is contained in:
David Montero Crespo 2026-04-11 15:25:40 -03:00
parent 6b161eab34
commit 2ba8020438
4 changed files with 413 additions and 218 deletions

View File

@ -359,20 +359,161 @@ class ESPIDFCompiler:
logger.warning('[espidf] Arduino libraries dir not found')
return None
# Built-in headers that do NOT require external library source
# Headers that will NEVER appear in any Arduino library directory:
# - C/C++ standard library headers
# - Arduino core API types compiled directly into arduino-esp32 (not installable)
#
# Everything else (Wire.h, SPI.h, WiFi.h, Adafruit_GFX.h, …) is resolved
# dynamically: user-installed libs → IDF component; arduino-esp32 bundled
# libs → skip (already compiled in); not found → warning.
_BUILTIN_HEADERS = frozenset({
'Arduino.h', 'Wire.h', 'SPI.h', 'SPI.H', 'WiFi.h', 'EEPROM.h',
'SD.h', 'Servo.h', 'LiquidCrystal.h', 'Ethernet.h', 'IPAddress.h',
'HardwareSerial.h', 'Stream.h', 'Print.h', 'WString.h', 'pgmspace.h',
# C/C++ standard library
'math.h', 'stdint.h', 'stdio.h', 'stdlib.h', 'string.h', 'stdarg.h',
'WebServer.h', 'HTTPClient.h', 'WiFiClient.h', 'WiFiServer.h',
'BluetoothSerial.h', 'BLEDevice.h',
'FS.h', 'SPIFFS.h', 'LittleFS.h',
'esp_system.h', 'esp_wifi.h', 'esp_event.h', 'nvs_flash.h',
'freertos/FreeRTOS.h', 'freertos/task.h',
'Adafruit_GFX.h', # bundled with arduino-esp32 sometimes
'stddef.h', 'stdbool.h', 'float.h', 'limits.h', 'assert.h',
# Arduino core types — part of arduino-esp32 source, not installable libraries
'Arduino.h', 'HardwareSerial.h', 'Stream.h', 'Print.h', 'WString.h',
'pgmspace.h', 'IPAddress.h',
})
# Core arduino-esp32 bundled libraries — already compiled into the IDF component,
# must NOT be duplicated as separate user_libs components.
_CORE_ESP32_LIBS: frozenset[str] = frozenset({
'Wire', 'SPI', 'WiFi', 'EEPROM', 'SD', 'FS',
'LittleFS', 'SPIFFS', 'WebServer', 'HTTPClient',
'WiFiClientSecure', 'BluetoothSerial', 'BLE',
'Preferences', 'Update', 'Ticker',
})
def _resolve_library_components(
self,
ext_headers: list[str],
arduino_libs: Path | None,
esp32_libs: Path | None,
arduino_comp_name: str,
user_libs_dir: Path,
) -> tuple[list[str], dict[str, str]]:
"""
BFS over ext_headers (and transitive includes) to discover all external
Arduino libraries and merge them into a single 'user_libs_all' IDF component.
All library files are copied flat into one directory, so every header is
visible to every other header and source file without any cross-component
REQUIRES propagation which is unreliable in ESP-IDF 4.x for deeply
nested transitive dependencies.
Search priority per header:
1. arduino_libs (user-installed via Library Manager) merge into component
2. esp32_libs (bundled with arduino-esp32) skip core libs (Wire, SPI, );
merge non-core libs (e.g. Adafruit libs shipped with arduino-esp32)
3. not found warning only
Returns:
component_names ['user_libs_all'] if any lib found, else []
header_to_comp every resolved header 'user_libs_all'
"""
logger.info(f'[espidf] ext_headers detected: {ext_headers}')
logger.info(f'[espidf] arduino_libs: {arduino_libs}')
logger.info(f'[espidf] esp32_libs: {esp32_libs}')
comp_dir = user_libs_dir / 'user_libs_all'
comp_dir.mkdir(exist_ok=True)
cpp_files: list[str] = []
seen_names: set[str] = set()
header_to_comp: dict[str, str] = {}
found_any = False
headers_to_resolve: list[str] = list(ext_headers)
resolved_headers: set[str] = set()
while headers_to_resolve:
header = headers_to_resolve.pop(0)
if header in resolved_headers:
continue
resolved_headers.add(header)
src_root = (
self._find_library_for_header(header, arduino_libs)
if arduino_libs and arduino_libs.is_dir()
else None
)
if src_root is None and esp32_libs and esp32_libs.is_dir():
esp32_root = self._find_library_for_header(header, esp32_libs)
if esp32_root:
lib_name = esp32_root.parent.name if esp32_root.name == 'src' else esp32_root.name
if lib_name in self._CORE_ESP32_LIBS:
logger.debug(f'[espidf] <{header}> is bundled core lib "{lib_name}", skipping')
else:
logger.info(f'[espidf] <{header}> found in esp32_libs as "{lib_name}", merging')
src_root = esp32_root
if src_root:
lib_dir_name = src_root.parent.name if src_root.name == 'src' else src_root.name
logger.info(f'[espidf] Merging "{lib_dir_name}" into user_libs_all for <{header}>')
found_any = True
header_to_comp[header] = 'user_libs_all'
# Copy all source files flat into the merged component directory.
# First-writer wins for name conflicts (rare across Arduino libs).
for pattern in ('*.h', '*.cpp', '*.c', 'src/*.h', 'src/*.cpp', 'src/*.c'):
glob_root = src_root.parent if pattern.startswith('src/') else src_root
for f in glob_root.glob(pattern):
if not f.is_file():
continue
if f.name not in seen_names:
shutil.copy2(f, comp_dir / f.name)
seen_names.add(f.name)
if f.suffix in ('.cpp', '.c') and f.name not in cpp_files:
cpp_files.append(f.name)
# Also handle libraries that use an src/ subdirectory layout.
src_sub = (src_root / 'src') if (src_root / 'src').is_dir() else None
if src_sub is None and (src_root.parent / 'src').is_dir():
src_sub = src_root.parent / 'src'
if src_sub:
for f in src_sub.glob('**/*'):
if not f.is_file() or f.suffix not in ('.h', '.cpp', '.c'):
continue
if f.name not in seen_names:
shutil.copy2(f, comp_dir / f.name)
seen_names.add(f.name)
if f.suffix in ('.cpp', '.c') and f.name not in cpp_files:
cpp_files.append(f.name)
# Scan newly copied headers for transitive includes.
for lib_file in comp_dir.glob('*.h'):
try:
lib_content = lib_file.read_text(encoding='utf-8', errors='ignore')
for th in self._detect_external_includes(lib_content):
if th not in resolved_headers:
headers_to_resolve.append(th)
except OSError:
pass
else:
logger.warning(f'[espidf] Library for <{header}> not found — build may fail')
if not found_any:
return [], {}
srcs_line = 'SRCS ' + ' '.join(f'"{f}"' for f in sorted(cpp_files)) if cpp_files else ''
cmake_content = (
'# Auto-generated by Velxio — all user libraries merged into one component.\n'
'# Single flat directory: every header sees every other header without\n'
'# cross-component REQUIRES propagation.\n'
'idf_component_register(\n'
f' {srcs_line}\n'
' INCLUDE_DIRS "."\n'
f' REQUIRES {arduino_comp_name}\n'
')\n'
)
(comp_dir / 'CMakeLists.txt').write_text(cmake_content, encoding='utf-8')
logger.info(
f'[espidf] user_libs_all: {len(cpp_files)} source files, '
f'{len(header_to_comp)} resolved headers'
)
return ['user_libs_all'], header_to_comp
def _detect_external_includes(self, code: str) -> list[str]:
"""Return library header names that are likely from external libraries."""
headers = []
@ -666,89 +807,34 @@ class ESPIDFCompiler:
user_libs_dir = project_dir / 'user_libs'
user_libs_dir.mkdir(exist_ok=True)
# Search order: esp32-bundled libraries first, then user-installed
esp32_libs = Path(self.arduino_path) / 'libraries' if self.arduino_path else None
esp32_libs = Path(self.arduino_path) / 'libraries' if self.arduino_path else None
arduino_libs = self._find_arduino_libraries_dir()
search_bases = [b for b in [esp32_libs, arduino_libs] if b and b.is_dir()]
# Phase 1: BFS to discover all needed libraries including transitives
headers_to_resolve: list[str] = list(ext_headers)
resolved_headers: set[str] = set()
header_to_comp: dict[str, str] = {} # header → component name
component_names, _ = self._resolve_library_components(
ext_headers, arduino_libs, esp32_libs,
arduino_comp_name, user_libs_dir,
)
while headers_to_resolve:
header = headers_to_resolve.pop(0)
if header in resolved_headers:
continue
resolved_headers.add(header)
src_root = None
for search_base in search_bases:
src_root = self._find_library_for_header(header, search_base)
if src_root:
break
if src_root:
comp_name = self._create_idf_component(
header, src_root, user_libs_dir, arduino_comp_name
)
if comp_name not in component_names:
component_names.append(comp_name)
header_to_comp[header] = comp_name
# Scan copied library files for further external includes
comp_dir = user_libs_dir / comp_name
for lib_file in comp_dir.glob('*.h'):
try:
lib_content = lib_file.read_text(encoding='utf-8', errors='ignore')
for th in self._detect_external_includes(lib_content):
if th not in resolved_headers:
headers_to_resolve.append(th)
except OSError:
pass
else:
logger.warning(f'[espidf] Library for <{header}> not found — build may fail')
# Phase 2: patch inter-component REQUIRES so each component can
# see the headers of libraries it transitively includes
for comp_name in component_names:
comp_dir = user_libs_dir / comp_name
extra_reqs: list[str] = []
for lib_file in comp_dir.glob('*.h'):
try:
content = lib_file.read_text(encoding='utf-8', errors='ignore')
for dep_h in self._detect_external_includes(content):
dep_comp = header_to_comp.get(dep_h)
if dep_comp and dep_comp != comp_name and dep_comp not in extra_reqs:
extra_reqs.append(dep_comp)
except OSError:
pass
if extra_reqs:
cmake_path = comp_dir / 'CMakeLists.txt'
cmake_text = cmake_path.read_text(encoding='utf-8')
cmake_text = cmake_text.replace(
f'REQUIRES {arduino_comp_name}',
f'REQUIRES {arduino_comp_name} {" ".join(extra_reqs)}',
)
cmake_path.write_text(cmake_text, encoding='utf-8')
logger.info(f'[espidf] {comp_name} REQUIRES += {extra_reqs}')
# Patch main/CMakeLists.txt to REQUIRES the library components.
# The template uses CMake variable syntax: REQUIRES ${_arduino_comp_name}
if component_names:
# Patch main/CMakeLists.txt — REQUIRES and INCLUDE_DIRS for user_libs_all.
# The single merged component means one entry covers all external headers.
if component_names: # always ['user_libs_all'] when any lib was found
cmake_path = project_dir / 'main' / 'CMakeLists.txt'
cmake_text = cmake_path.read_text(encoding='utf-8')
main_reqs = ' '.join(component_names)
# Replace both possible forms: CMake variable (template) or literal (pre-patched)
for old_req in [r'REQUIRES ${_arduino_comp_name}', f'REQUIRES {arduino_comp_name}']:
if old_req in cmake_text:
cmake_text = cmake_text.replace(
old_req,
f'{old_req} {main_reqs}',
old_req, f'{old_req} user_libs_all'
)
break
cmake_text = cmake_text.replace(
'INCLUDE_DIRS "."',
'INCLUDE_DIRS "." "../user_libs/user_libs_all"',
)
cmake_path.write_text(cmake_text, encoding='utf-8')
logger.info(f'[espidf] Added {len(component_names)} library component(s) to REQUIRES')
logger.info('[espidf] Patched main CMakeLists: REQUIRES += user_libs_all, INCLUDE_DIRS += user_libs_all')
else:
# Pure ESP-IDF mode: translate sketch
translated = self._translate_sketch_to_espidf(main_content)

View File

@ -589,23 +589,7 @@ export const EditorToolbar = ({ consoleOpen, setConsoleOpen, compileLogs: _compi
</div>
<div className="toolbar-group toolbar-group-right">
{/* Status message */}
{message && (
<span className={`tb-status tb-status-${message.type}`} title={message.text}>
{message.type === 'success' ? (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
) : (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
)}
<span className="tb-status-text">{message.text}</span>
</span>
)}
{/* Hidden file input for import (always present) */}
<input

View File

@ -4791,10 +4791,10 @@ void loop() {
{ type: 'wokwi-ssd1306', id: 'e32-oled1', x: 420, y: 130, properties: {} },
],
wires: [
{ id: 'e32o-vcc', start: { componentId: 'esp32', pinName: '3V3' }, end: { componentId: 'e32-oled1', pinName: 'VCC' }, color: '#ff4444' },
{ id: 'e32o-gnd', start: { componentId: 'esp32', pinName: 'GND' }, end: { componentId: 'e32-oled1', pinName: 'GND' }, color: '#000000' },
{ id: 'e32o-sda', start: { componentId: 'esp32', pinName: '21' }, end: { componentId: 'e32-oled1', pinName: 'SDA' }, color: '#22aaff' },
{ id: 'e32o-scl', start: { componentId: 'esp32', pinName: '22' }, end: { componentId: 'e32-oled1', pinName: 'SCL' }, color: '#ff8800' },
{ id: 'e32o-vcc', start: { componentId: 'esp32', pinName: '3V3' }, end: { componentId: 'e32-oled1', pinName: '3V3' }, color: '#ff4444' },
{ id: 'e32o-gnd', start: { componentId: 'esp32', pinName: 'GND' }, end: { componentId: 'e32-oled1', pinName: 'GND' }, color: '#000000' },
{ id: 'e32o-sda', start: { componentId: 'esp32', pinName: '21' }, end: { componentId: 'e32-oled1', pinName: 'DATA' }, color: '#22aaff' },
{ id: 'e32o-scl', start: { componentId: 'esp32', pinName: '22' }, end: { componentId: 'e32-oled1', pinName: 'CLK' }, color: '#ff8800' },
],
},
];

View File

@ -4,8 +4,8 @@ Tests for ESPIDFCompiler library resolution logic.
Tests the methods that detect, locate, and package external Arduino libraries
as proper ESP-IDF components without requiring the full ESP-IDF toolchain.
Run from the backend/ directory:
python test_espidf_compiler.py
Run from the repo root:
python -m pytest test/backend/unit/test_espidf_compiler.py -v
"""
import sys
@ -14,7 +14,6 @@ import shutil
import unittest
from pathlib import Path
# Ensure backend/app is importable (for direct execution; pytest uses conftest.py)
sys.path.insert(0, str(Path(__file__).parent.parent.parent.parent / 'backend'))
from app.services.espidf_compiler import ESPIDFCompiler
@ -23,7 +22,6 @@ from app.services.espidf_compiler import ESPIDFCompiler
# ── Helpers ───────────────────────────────────────────────────────────────────
def make_compiler() -> ESPIDFCompiler:
"""Create an ESPIDFCompiler instance without a real IDF path."""
comp = ESPIDFCompiler.__new__(ESPIDFCompiler)
comp.idf_path = ''
comp.arduino_path = ''
@ -33,28 +31,15 @@ def make_compiler() -> ESPIDFCompiler:
def make_library(libs_dir: Path, lib_name: str, headers: list[str],
sources: list[str], use_src_subdir: bool = False) -> Path:
"""
Create a mock Arduino library directory structure.
libs_dir/
{lib_name}/
{lib_name}.h
{lib_name}.cpp
[src/]
...
"""
lib_dir = libs_dir / lib_name
lib_dir.mkdir(parents=True)
src_dir = lib_dir / 'src' if use_src_subdir else lib_dir
if use_src_subdir:
src_dir.mkdir()
for h in headers:
(src_dir / h).write_text(f'// {h}', encoding='utf-8')
for s in sources:
(src_dir / s).write_text(f'// {s}', encoding='utf-8')
return lib_dir
@ -67,24 +52,42 @@ class TestDetectExternalIncludes(unittest.TestCase):
def test_detects_dht_header(self):
code = '#include <DHT.h>\nvoid setup() {}'
result = self.comp._detect_external_includes(code)
self.assertIn('DHT.h', result)
self.assertIn('DHT.h', self.comp._detect_external_includes(code))
def test_skips_arduino_builtins(self):
code = '#include <Arduino.h>\n#include <Wire.h>\n#include <SPI.h>\n#include <WiFi.h>'
def test_detects_adafruit_gfx(self):
"""Adafruit_GFX.h must NOT be silently skipped — it is a user-installed lib."""
code = '#include <Adafruit_GFX.h>'
result = self.comp._detect_external_includes(code)
self.assertIn('Adafruit_GFX.h', result,
'Adafruit_GFX.h was silently skipped — should be detected for library resolution')
def test_detects_adafruit_ssd1306(self):
code = '#include <Adafruit_SSD1306.h>'
self.assertIn('Adafruit_SSD1306.h', self.comp._detect_external_includes(code))
def test_skips_arduino_core_types(self):
"""Arduino.h and core types are in BUILTIN_HEADERS — skip them."""
code = '#include <Arduino.h>\n#include <HardwareSerial.h>\n#include <pgmspace.h>'
result = self.comp._detect_external_includes(code)
self.assertEqual(result, [])
def test_detects_wire_and_spi(self):
"""Wire.h and SPI.h are no longer in BUILTIN_HEADERS; they are resolved
dynamically (skipped as bundled in esp32_libs or missing warn)."""
code = '#include <Wire.h>\n#include <SPI.h>\n#include <WiFi.h>'
result = self.comp._detect_external_includes(code)
# They should be detected (not silently dropped here)
self.assertIn('Wire.h', result)
self.assertIn('SPI.h', result)
self.assertIn('WiFi.h', result)
def test_skips_esp_idf_headers(self):
code = '#include <esp_wifi.h>\n#include <freertos/FreeRTOS.h>\n#include <nvs_flash.h>'
result = self.comp._detect_external_includes(code)
self.assertEqual(result, [])
self.assertEqual([], self.comp._detect_external_includes(code))
def test_skips_path_headers(self):
# Headers with / are internal esp-idf or arduino-esp32 paths
code = '#include <driver/gpio.h>\n#include <soc/soc.h>'
result = self.comp._detect_external_includes(code)
self.assertEqual(result, [])
self.assertEqual([], self.comp._detect_external_includes(code))
def test_detects_multiple_external(self):
code = (
@ -97,22 +100,13 @@ class TestDetectExternalIncludes(unittest.TestCase):
self.assertIn('DHT.h', result)
self.assertIn('Adafruit_Sensor.h', result)
self.assertNotIn('Arduino.h', result)
self.assertNotIn('Wire.h', result)
def test_handles_whitespace_in_include(self):
code = '# include <DHT.h>'
result = self.comp._detect_external_includes(code)
self.assertIn('DHT.h', result)
def test_no_duplicates_from_multiple_files(self):
code = '#include <DHT.h>\n#include <DHT.h>\n'
result = self.comp._detect_external_includes(code)
# List may have duplicates — the caller deduplicates; just check presence
self.assertIn('DHT.h', result)
self.assertIn('DHT.h', self.comp._detect_external_includes(code))
def test_empty_sketch(self):
result = self.comp._detect_external_includes('void setup() {} void loop() {}')
self.assertEqual(result, [])
self.assertEqual([], self.comp._detect_external_includes('void setup() {} void loop() {}'))
# ── Test: _find_library_for_header ───────────────────────────────────────────
@ -128,31 +122,25 @@ class TestFindLibraryForHeader(unittest.TestCase):
shutil.rmtree(self.tmp)
def test_finds_library_in_root(self):
"""Library with header in root (no src/ subdirectory)."""
make_library(self.libs_dir, 'DHT_sensor_library',
headers=['DHT.h', 'DHT_U.h'], sources=['DHT.cpp', 'DHT_U.cpp'])
headers=['DHT.h', 'DHT_U.h'], sources=['DHT.cpp'])
result = self.comp._find_library_for_header('DHT.h', self.libs_dir)
assert result is not None
self.assertIsNotNone(result)
self.assertTrue((result / 'DHT.h').exists())
def test_finds_library_in_src_subdir(self):
"""Library with header in src/ subdirectory (modern Arduino layout)."""
make_library(self.libs_dir, 'Adafruit_Sensor',
headers=['Adafruit_Sensor.h'], sources=['Adafruit_Sensor.cpp'],
use_src_subdir=True)
result = self.comp._find_library_for_header('Adafruit_Sensor.h', self.libs_dir)
assert result is not None
self.assertTrue((result / 'Adafruit_Sensor.h').exists())
self.assertIsNotNone(result)
def test_returns_none_for_missing_library(self):
make_library(self.libs_dir, 'SomeOtherLib',
headers=['Other.h'], sources=['Other.cpp'])
result = self.comp._find_library_for_header('DHT.h', self.libs_dir)
self.assertIsNone(result)
make_library(self.libs_dir, 'SomeOtherLib', headers=['Other.h'], sources=[])
self.assertIsNone(self.comp._find_library_for_header('DHT.h', self.libs_dir))
def test_returns_none_for_empty_dir(self):
result = self.comp._find_library_for_header('DHT.h', self.libs_dir)
self.assertIsNone(result)
self.assertIsNone(self.comp._find_library_for_header('DHT.h', self.libs_dir))
# ── Test: _create_idf_component ──────────────────────────────────────────────
@ -171,113 +159,250 @@ class TestCreateIdfComponent(unittest.TestCase):
shutil.rmtree(self.tmp)
def _make_dht_library(self, use_src: bool = False) -> Path:
return make_library(
self.libs_dir, 'DHT_sensor_library',
headers=['DHT.h', 'DHT_U.h'],
sources=['DHT.cpp', 'DHT_U.cpp'],
use_src_subdir=use_src,
)
return make_library(self.libs_dir, 'DHT_sensor_library',
headers=['DHT.h', 'DHT_U.h'],
sources=['DHT.cpp', 'DHT_U.cpp'],
use_src_subdir=use_src)
def test_creates_component_directory(self):
lib_dir = self._make_dht_library()
src_root = lib_dir # header is in root
self.comp._create_idf_component('DHT.h', src_root, self.user_libs_dir, 'arduino-esp32')
self.assertTrue(self.user_libs_dir.exists())
comp_dirs = list(self.user_libs_dir.iterdir())
self.assertEqual(len(comp_dirs), 1)
self.comp._create_idf_component('DHT.h', lib_dir, self.user_libs_dir, 'arduino-esp32')
self.assertEqual(len(list(self.user_libs_dir.iterdir())), 1)
def test_component_has_cmake_lists(self):
lib_dir = self._make_dht_library()
comp_name = self.comp._create_idf_component(
'DHT.h', lib_dir, self.user_libs_dir, 'arduino-esp32'
)
cmake_path = self.user_libs_dir / comp_name / 'CMakeLists.txt'
self.assertTrue(cmake_path.exists(), 'CMakeLists.txt not found')
comp_name = self.comp._create_idf_component('DHT.h', lib_dir, self.user_libs_dir, 'arduino-esp32')
self.assertTrue((self.user_libs_dir / comp_name / 'CMakeLists.txt').exists())
def test_cmake_contains_idf_component_register(self):
lib_dir = self._make_dht_library()
comp_name = self.comp._create_idf_component(
'DHT.h', lib_dir, self.user_libs_dir, 'arduino-esp32'
)
comp_name = self.comp._create_idf_component('DHT.h', lib_dir, self.user_libs_dir, 'arduino-esp32')
cmake_text = (self.user_libs_dir / comp_name / 'CMakeLists.txt').read_text()
self.assertIn('idf_component_register', cmake_text)
def test_cmake_includes_cpp_source(self):
lib_dir = self._make_dht_library()
comp_name = self.comp._create_idf_component(
'DHT.h', lib_dir, self.user_libs_dir, 'arduino-esp32'
)
comp_name = self.comp._create_idf_component('DHT.h', lib_dir, self.user_libs_dir, 'arduino-esp32')
cmake_text = (self.user_libs_dir / comp_name / 'CMakeLists.txt').read_text()
self.assertIn('DHT.cpp', cmake_text)
def test_cmake_requires_arduino_component(self):
lib_dir = self._make_dht_library()
comp_name = self.comp._create_idf_component(
'DHT.h', lib_dir, self.user_libs_dir, 'arduino-esp32'
)
comp_name = self.comp._create_idf_component('DHT.h', lib_dir, self.user_libs_dir, 'arduino-esp32')
cmake_text = (self.user_libs_dir / comp_name / 'CMakeLists.txt').read_text()
self.assertIn('arduino-esp32', cmake_text)
self.assertIn('REQUIRES', cmake_text)
def test_cmake_sets_include_dirs(self):
lib_dir = self._make_dht_library()
comp_name = self.comp._create_idf_component(
'DHT.h', lib_dir, self.user_libs_dir, 'arduino-esp32'
)
comp_name = self.comp._create_idf_component('DHT.h', lib_dir, self.user_libs_dir, 'arduino-esp32')
cmake_text = (self.user_libs_dir / comp_name / 'CMakeLists.txt').read_text()
self.assertIn('INCLUDE_DIRS', cmake_text)
self.assertIn('"."', cmake_text)
def test_header_files_are_copied(self):
lib_dir = self._make_dht_library()
comp_name = self.comp._create_idf_component(
'DHT.h', lib_dir, self.user_libs_dir, 'arduino-esp32'
)
comp_name = self.comp._create_idf_component('DHT.h', lib_dir, self.user_libs_dir, 'arduino-esp32')
comp_dir = self.user_libs_dir / comp_name
self.assertTrue((comp_dir / 'DHT.h').exists())
self.assertTrue((comp_dir / 'DHT_U.h').exists())
def test_cpp_files_are_copied(self):
lib_dir = self._make_dht_library()
comp_name = self.comp._create_idf_component(
'DHT.h', lib_dir, self.user_libs_dir, 'arduino-esp32'
)
comp_dir = self.user_libs_dir / comp_name
self.assertTrue((comp_dir / 'DHT.cpp').exists())
self.assertTrue((comp_dir / 'DHT_U.cpp').exists())
def test_returns_sanitised_component_name(self):
lib_dir = self._make_dht_library()
comp_name = self.comp._create_idf_component(
'DHT.h', lib_dir, self.user_libs_dir, 'arduino-esp32'
)
# Component name must be a valid C identifier (no spaces, no dots)
comp_name = self.comp._create_idf_component('DHT.h', lib_dir, self.user_libs_dir, 'arduino-esp32')
self.assertRegex(comp_name, r'^[A-Za-z0-9_]+$')
# Must be based on library directory name, not the parent search dir
self.assertEqual(comp_name, 'DHT_sensor_library')
def test_custom_arduino_comp_name(self):
"""arduino_comp_name is properly forwarded into REQUIRES."""
lib_dir = self._make_dht_library()
comp_name = self.comp._create_idf_component(
'DHT.h', lib_dir, self.user_libs_dir, 'arduino-esp32-custom'
# ── Test: _resolve_library_components (SSD1306 / transitive scenario) ─────────
class TestResolveLibraryComponents(unittest.TestCase):
"""
Exercises the BFS that merges all libraries into a single 'user_libs_all'
IDF component. This avoids ESP-IDF cross-component REQUIRES propagation issues.
Mock library structure mirrors the real Adafruit SSD1306 scenario:
arduino_libs/
Adafruit_GFX_Library/
Adafruit_GFX.h
Adafruit_GFX.cpp
Adafruit_SSD1306/
Adafruit_SSD1306.h includes <Adafruit_GFX.h>
Adafruit_SSD1306.cpp
esp32_libs/
Wire/Wire.h, Wire.cpp core, must be skipped
SPI/SPI.h, SPI.cpp core, must be skipped
"""
def setUp(self):
self.comp = make_compiler()
self.tmp = tempfile.mkdtemp()
self.arduino_libs = Path(self.tmp) / 'arduino_libs'
self.esp32_libs = Path(self.tmp) / 'esp32_libs'
self.user_libs = Path(self.tmp) / 'user_libs'
self.arduino_libs.mkdir()
self.esp32_libs.mkdir()
self.user_libs.mkdir()
make_library(self.arduino_libs, 'Adafruit_GFX_Library',
headers=['Adafruit_GFX.h', 'Adafruit_SPITFT.h'],
sources=['Adafruit_GFX.cpp', 'Adafruit_SPITFT.cpp'])
ssd_dir = self.arduino_libs / 'Adafruit_SSD1306'
ssd_dir.mkdir()
(ssd_dir / 'Adafruit_SSD1306.h').write_text(
'#include <Adafruit_GFX.h>\n// SSD1306 header', encoding='utf-8'
)
cmake_text = (self.user_libs_dir / comp_name / 'CMakeLists.txt').read_text()
self.assertIn('arduino-esp32-custom', cmake_text)
(ssd_dir / 'Adafruit_SSD1306.cpp').write_text('// impl', encoding='utf-8')
def test_library_in_src_subdir(self):
"""Library with modern src/ layout is handled correctly."""
lib_dir = self._make_dht_library(use_src=True)
src_root = lib_dir / 'src'
comp_name = self.comp._create_idf_component(
'DHT.h', src_root, self.user_libs_dir, 'arduino-esp32'
make_library(self.esp32_libs, 'Wire', headers=['Wire.h'], sources=['Wire.cpp'])
make_library(self.esp32_libs, 'SPI', headers=['SPI.h'], sources=['SPI.cpp'])
def tearDown(self):
shutil.rmtree(self.tmp)
def _resolve(self, ext_headers):
return self.comp._resolve_library_components(
ext_headers, self.arduino_libs, self.esp32_libs, 'arduino-esp32', self.user_libs
)
comp_dir = self.user_libs_dir / comp_name
self.assertTrue((comp_dir / 'DHT.h').exists())
self.assertTrue((comp_dir / 'DHT.cpp').exists())
def test_returns_single_merged_component(self):
"""Any library resolution must produce exactly ['user_libs_all']."""
names, _ = self._resolve(['Adafruit_GFX.h'])
self.assertEqual(names, ['user_libs_all'],
'Expected single merged component "user_libs_all"')
def test_gfx_header_merged_for_direct_include(self):
"""Adafruit_GFX.h directly in sketch → copied into user_libs_all."""
self._resolve(['Adafruit_GFX.h'])
self.assertTrue(
(self.user_libs / 'user_libs_all' / 'Adafruit_GFX.h').exists(),
'Adafruit_GFX.h not copied into user_libs_all — will not be on include path',
)
def test_both_libs_merged_for_ssd1306_sketch(self):
"""SSD1306 sketch: both GFX and SSD1306 files must be in user_libs_all."""
self._resolve(['Wire.h', 'Adafruit_GFX.h', 'Adafruit_SSD1306.h'])
all_dir = self.user_libs / 'user_libs_all'
self.assertTrue((all_dir / 'Adafruit_GFX.h').exists(),
'Adafruit_GFX.h missing — SSD1306.cpp will fail to compile')
self.assertTrue((all_dir / 'Adafruit_SSD1306.h').exists(),
'Adafruit_SSD1306.h missing')
self.assertTrue((all_dir / 'Adafruit_GFX.cpp').exists())
self.assertTrue((all_dir / 'Adafruit_SSD1306.cpp').exists())
def test_wire_files_not_in_merged_component(self):
"""Wire is a core esp32 lib — must NOT be copied into user_libs_all."""
self._resolve(['Wire.h', 'Adafruit_SSD1306.h'])
all_dir = self.user_libs / 'user_libs_all'
self.assertFalse((all_dir / 'Wire.h').exists(),
'Wire.h was incorrectly copied — it is already in arduino-esp32')
def test_transitive_gfx_discovered_via_ssd1306(self):
"""When sketch only includes SSD1306, GFX is discovered transitively."""
self._resolve(['Adafruit_SSD1306.h'])
self.assertTrue(
(self.user_libs / 'user_libs_all' / 'Adafruit_GFX.h').exists(),
'Transitive Adafruit_GFX.h not discovered — SSD1306.h includes it',
)
def test_merged_component_cmake_has_all_sources(self):
"""user_libs_all CMakeLists.txt must list both GFX and SSD1306 .cpp files."""
self._resolve(['Adafruit_GFX.h', 'Adafruit_SSD1306.h'])
cmake_text = (self.user_libs / 'user_libs_all' / 'CMakeLists.txt').read_text()
self.assertIn('Adafruit_GFX.cpp', cmake_text)
self.assertIn('Adafruit_SSD1306.cpp', cmake_text)
self.assertIn('INCLUDE_DIRS "."', cmake_text)
self.assertIn('arduino-esp32', cmake_text)
def test_header_to_comp_maps_to_user_libs_all(self):
"""header_to_comp must map every resolved header to 'user_libs_all'."""
_, h2c = self._resolve(['Adafruit_GFX.h', 'Adafruit_SSD1306.h'])
for h, c in h2c.items():
self.assertEqual(c, 'user_libs_all', f'{h} mapped to "{c}" instead of "user_libs_all"')
def test_no_components_when_no_external_headers(self):
"""Empty ext_headers → no component created."""
names, h2c = self._resolve([])
self.assertEqual(names, [])
self.assertEqual(h2c, {})
# ── Test: template CMakeLists.txt content ────────────────────────────────────
# ── Test: main CMakeLists.txt patching ───────────────────────────────────────
class TestMainCMakePatching(unittest.TestCase):
"""Verify that component_names are injected into main/CMakeLists.txt correctly."""
TEMPLATE_MAIN_CMAKE = (
Path(__file__).parent.parent.parent.parent / 'backend'
/ 'app' / 'services' / 'esp-idf-template' / 'main' / 'CMakeLists.txt'
)
def setUp(self):
self.tmp = tempfile.mkdtemp()
def tearDown(self):
shutil.rmtree(self.tmp)
def _make_main_cmake(self) -> Path:
p = Path(self.tmp) / 'CMakeLists.txt'
shutil.copy(self.TEMPLATE_MAIN_CMAKE, p)
return p
def test_template_cmake_has_expected_requires_token(self):
cmake_text = self.TEMPLATE_MAIN_CMAKE.read_text(encoding='utf-8')
self.assertIn('REQUIRES ${_arduino_comp_name}', cmake_text,
'Template REQUIRES token not found — patching will silently do nothing')
def test_patch_appends_component_names_to_requires(self):
cmake_path = self._make_main_cmake()
cmake_text = cmake_path.read_text(encoding='utf-8')
old_req = r'REQUIRES ${_arduino_comp_name}'
component_names = ['Adafruit_GFX_Library', 'Adafruit_SSD1306']
main_reqs = ' '.join(component_names)
self.assertIn(old_req, cmake_text, 'Token not found — test setup error')
cmake_text = cmake_text.replace(old_req, f'{old_req} {main_reqs}')
cmake_path.write_text(cmake_text, encoding='utf-8')
result = cmake_path.read_text(encoding='utf-8')
self.assertIn('Adafruit_GFX_Library', result)
self.assertIn('Adafruit_SSD1306', result)
self.assertIn('${_arduino_comp_name}', result,
'Original arduino_comp_name was removed — it must be preserved')
def test_patch_adds_user_libs_all_include_dir(self):
"""user_libs_all dir must be added to INCLUDE_DIRS so sketch.ino.cpp
can find all library headers directly."""
cmake_path = self._make_main_cmake()
cmake_text = cmake_path.read_text(encoding='utf-8')
self.assertIn('INCLUDE_DIRS "."', cmake_text, 'Template INCLUDE_DIRS token missing')
cmake_text = cmake_text.replace(
'INCLUDE_DIRS "."',
'INCLUDE_DIRS "." "../user_libs/user_libs_all"',
)
cmake_path.write_text(cmake_text, encoding='utf-8')
result = cmake_path.read_text(encoding='utf-8')
self.assertIn('../user_libs/user_libs_all', result,
'user_libs_all INCLUDE_DIR not added — library headers invisible to sketch')
self.assertIn('"."', result, 'Original "." INCLUDE_DIR must be preserved')
def test_patch_is_idempotent(self):
"""Applying the patch twice must not duplicate entries."""
cmake_path = self._make_main_cmake()
cmake_text = cmake_path.read_text(encoding='utf-8')
old_req = r'REQUIRES ${_arduino_comp_name}'
component_names = ['Adafruit_GFX_Library']
cmake_text = cmake_text.replace(old_req, f'{old_req} {" ".join(component_names)}')
count = cmake_text.count('Adafruit_GFX_Library')
self.assertEqual(count, 1)
# ── Test: template CMakeLists.txt structure ──────────────────────────────────
class TestTemplateCMakeLists(unittest.TestCase):
@ -288,11 +413,9 @@ class TestTemplateCMakeLists(unittest.TestCase):
)
self.assertTrue(template_cmake.exists(), 'Template CMakeLists.txt not found')
content = template_cmake.read_text(encoding='utf-8')
self.assertIn('user_libs', content,
'user_libs not referenced in root CMakeLists.txt')
self.assertIn('user_libs', content)
self.assertIn('EXTRA_COMPONENT_DIRS', content)
self.assertIn('EXISTS', content,
'user_libs block should use EXISTS guard')
self.assertIn('EXISTS', content)
def test_main_cmake_has_arduino_requires(self):
main_cmake = (
@ -317,6 +440,8 @@ if __name__ == '__main__':
TestDetectExternalIncludes,
TestFindLibraryForHeader,
TestCreateIdfComponent,
TestResolveLibraryComponents,
TestMainCMakePatching,
TestTemplateCMakeLists,
]:
suite.addTests(loader.loadTestsFromTestCase(cls))