diff --git a/backend/app/services/espidf_compiler.py b/backend/app/services/espidf_compiler.py
index 0a074dea..48587c65 100644
--- a/backend/app/services/espidf_compiler.py
+++ b/backend/app/services/espidf_compiler.py
@@ -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)
diff --git a/frontend/src/components/editor/EditorToolbar.tsx b/frontend/src/components/editor/EditorToolbar.tsx
index 30c7e57a..8ecb8f8d 100644
--- a/frontend/src/components/editor/EditorToolbar.tsx
+++ b/frontend/src/components/editor/EditorToolbar.tsx
@@ -589,23 +589,7 @@ export const EditorToolbar = ({ consoleOpen, setConsoleOpen, compileLogs: _compi
- {/* Status message */}
- {message && (
-
- {message.type === 'success' ? (
-
- ) : (
-
- )}
- {message.text}
-
- )}
+
{/* Hidden file input for import (always present) */}
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
\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 \n#include \n#include \n#include '
+ def test_detects_adafruit_gfx(self):
+ """Adafruit_GFX.h must NOT be silently skipped — it is a user-installed lib."""
+ code = '#include '
+ 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 '
+ 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 \n#include \n#include '
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 \n#include \n#include '
+ 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 \n#include \n#include '
- 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 \n#include '
- 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 '
- result = self.comp._detect_external_includes(code)
- self.assertIn('DHT.h', result)
-
- def test_no_duplicates_from_multiple_files(self):
- code = '#include \n#include \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_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 \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))