velxio/docs/wiki/esp32-external-library-comp...

265 lines
9.7 KiB
Markdown

# ESP32 External Arduino Library Compilation — IDF Component Approach
> **Scope**: This document covers the full investigation and implementation of automatic
> external Arduino library inclusion when compiling ESP32 sketches via ESP-IDF.
> Target audience: future maintainers who need to understand *why* the library resolution
> works the way it does and what bugs were discovered during real compilation testing.
---
## Table of Contents
1. [Problem Statement](#problem-statement)
2. [Root Cause](#root-cause)
3. [Solution Architecture](#solution-architecture)
4. [Implementation Details](#implementation-details)
5. [Bugs Found During Real Compilation](#bugs-found-during-real-compilation)
6. [Error Visibility Improvements](#error-visibility-improvements)
7. [Test Coverage](#test-coverage)
8. [Files Changed](#files-changed)
---
## Problem Statement
When compiling an ESP32 sketch that uses an external library (e.g. the DHT22 temperature
sensor example with `#include <DHT.h>`), the build failed with:
```
fatal error: DHT.h: No such file or directory
```
For **Arduino UNO**, `arduino-cli` automatically scans `~/Arduino/libraries/` and adds
the correct `-I` flags. For **ESP32 via ESP-IDF**, that path is never scanned — ESP-IDF
only knows about components explicitly listed in `EXTRA_COMPONENT_DIRS`.
Additionally, even when an error did occur, it was **invisible in the UI**: ninja build
errors go to **stdout** (not stderr), so the frontend was classifying them as grey `info`
lines instead of red `error` lines.
---
## Root Cause
ESP-IDF uses a CMake-based component system. Every unit of code must be a registered
**IDF component** with its own `CMakeLists.txt` calling `idf_component_register()`.
The `arduino-esp32` library itself is included this way. External Arduino libraries
(installed via `arduino-cli` Library Manager into `~/Arduino/libraries/`) have no
`idf_component_register()` and are therefore invisible to the build system.
The naive fix of copying `.h`/`.cpp` files flat into `main/` breaks libraries that use
subdirectory structures for internal includes (e.g. `#include "utility/xyz.h"`).
---
## Solution Architecture
Each external Arduino library is wrapped as a proper ESP-IDF component:
```
project/
CMakeLists.txt ← adds user_libs/ to EXTRA_COMPONENT_DIRS
user_libs/
DHT_sensor_library/
CMakeLists.txt ← idf_component_register(SRCS ... REQUIRES arduino-esp32)
DHT.h
DHT.cpp
DHT_U.h
DHT_U.cpp
Adafruit_Unified_Sensor/ ← transitive dependency, auto-discovered
CMakeLists.txt
Adafruit_Sensor.h
Adafruit_Sensor.cpp
main/
CMakeLists.txt ← REQUIRES arduino-esp32 DHT_sensor_library Adafruit_Unified_Sensor
main.cpp
sketch.ino.cpp
```
ESP-IDF automatically compiles every subdirectory of `EXTRA_COMPONENT_DIRS` that contains
an `idf_component_register()` call. The `INCLUDE_DIRS "."` inside each component makes its
headers available to anything that `REQUIRES` it.
### Library Search Order
1. `$ARDUINO_ESP32_PATH/libraries/` — ESP32-native libs bundled with arduino-esp32 (WiFi,
BLE, EEPROM…) — already compiled as part of arduino-esp32 component, so usually skipped
2. `~/Documents/Arduino/libraries/` — user-installed libraries (Windows primary path)
3. `~/Arduino/libraries/` — alternate user path
4. `/root/Arduino/libraries/` — Docker / CI root user path
---
## Implementation Details
### `_detect_external_includes(code)`
Scans source code for `#include <Header.h>` directives and returns those that are NOT:
- Arduino/ESP32 built-ins (e.g. `Arduino.h`, `Wire.h`, `WiFi.h`, `esp_wifi.h`)
- Headers with `/` (ESP-IDF internal paths like `freertos/FreeRTOS.h`)
- ESP-IDF pattern prefixes (`esp_`, `driver/`, `soc/`, `hal/`, `nvs`, `rom/`)
### `_find_library_for_header(header, libs_dir)`
Iterates subdirectories of `libs_dir`, checking both the root and `src/` subdirectory
for the requested header. Returns the **source root** (either `lib_dir/` or `lib_dir/src/`).
### `_create_idf_component(header, src_root, user_libs_dir, arduino_comp_name)`
Creates `user_libs/<safe_name>/` with:
- All `.h`, `.cpp`, `.c` files copied flat from `src_root`
- A generated `CMakeLists.txt`:
```cmake
idf_component_register(
SRCS "DHT.cpp" "DHT_U.cpp"
INCLUDE_DIRS "."
REQUIRES arduino-esp32
)
```
- Returns the component directory name (e.g. `DHT_sensor_library`)
### Transitive Dependency Resolution (BFS)
The library resolution loop uses **breadth-first search**:
1. **Phase 1 — BFS discovery**: Start with headers found in the user sketch. After creating
each component, scan its copied `.h` files for further external includes. Enqueue any
new headers not yet resolved. Repeat until the queue is empty.
A `header_to_comp` dict tracks which component provides each header.
2. **Phase 2 — inter-component REQUIRES**: After all components are created, scan each
component's headers again. For any dependency that maps to another component in
`header_to_comp`, patch that component's `CMakeLists.txt` to add the dep to `REQUIRES`.
This ensures `DHT_sensor_library/CMakeLists.txt` ends up with:
```cmake
REQUIRES arduino-esp32 Adafruit_Unified_Sensor
```
### Main `CMakeLists.txt` Patching
The template `main/CMakeLists.txt` uses a CMake variable:
```cmake
REQUIRES ${_arduino_comp_name}
```
The Python patch looks for this exact string (not the resolved literal `arduino-esp32`)
and appends the user library component names:
```cmake
REQUIRES ${_arduino_comp_name} DHT_sensor_library Adafruit_Unified_Sensor
```
---
## Bugs Found During Real Compilation
Three bugs were discovered when running an actual ESP32 compile (vs. unit tests with mocks):
### Bug 1 — Wrong Component Name (`libraries` instead of `DHT_sensor_library`)
**Symptom**: Build step showed `esp-idf/libraries/CMakeFiles/...` instead of
`esp-idf/DHT_sensor_library/CMakeFiles/...`.
**Cause**: `_create_idf_component` used `src_root.parent.name` to get the library name.
When `_find_library_for_header` returns the library root (no `src/` subdir), `src_root`
*is* the library directory, so `.parent.name` gives `libraries` (the parent search dir).
**Fix**:
```python
# Before
lib_dir_name = src_root.parent.name
# After
lib_dir_name = src_root.parent.name if src_root.name == 'src' else src_root.name
```
### Bug 2 — Missing Transitive Dependency (`Adafruit_Sensor.h`)
**Symptom**: After fixing Bug 1, the build failed with:
```
DHT_U.h:36:10: fatal error: Adafruit_Sensor.h: No such file or directory
```
**Cause**: `DHT_U.h` (part of DHT library) `#include`s `Adafruit_Sensor.h` from the
`Adafruit_Unified_Sensor` library. The original code only scanned the user sketch for
external includes, not the library headers themselves.
**Fix**: Added BFS transitive dependency resolution (Phase 1 + Phase 2 described above).
### Bug 3 — CMake Template Variable Mismatch
**Symptom**: After fixing Bugs 1 and 2, the `main` component still couldn't find `DHT.h`:
```
sketch.ino.cpp:3:10: fatal error: DHT.h: No such file or directory
```
**Cause**: The Python patch looked for `REQUIRES arduino-esp32` (literal) in
`main/CMakeLists.txt`, but the template uses `REQUIRES ${_arduino_comp_name}` (CMake
variable). The replacement was silently a no-op.
**Fix**: Updated the patch to match the CMake variable syntax:
```python
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}')
break
```
---
## Error Visibility Improvements
Two frontend/backend changes were made to ensure compilation errors are clearly visible:
### Backend — Ninja Error Extraction
When ESP-IDF/ninja fails, compiler errors go to **stdout** (not stderr). The backend now
detects `FAILED:` blocks in stdout and moves them to the `stderr` field of the response,
so the frontend classifies them as errors:
```python
if stripped.startswith('FAILED:') or stripped == 'ninja: build stopped: subcommand failed.':
in_failed_block = True
# ... extract and move to stderr
```
### Frontend — Compilation Console Auto-Filter
`CompilationConsole.tsx` now:
- Tracks previous log count with `prevLogsLenRef`
- Detects newly arrived error logs via `useEffect`
- Automatically switches the filter to **"Errors"** view when new errors arrive
`compilationLogger.ts` classifies stdout lines in ninja `FAILED:` blocks as `'error'`
type (not `'info'`), using a state machine (`inFailedBlock` flag).
---
## Test Coverage
`backend/test_espidf_compiler.py` — 25 unit tests, no ESP-IDF toolchain required:
| Class | Tests | What it covers |
|---|---|---|
| `TestDetectExternalIncludes` | 8 | DHT.h detected; Arduino.h, Wire.h, esp_* skipped; path headers skipped |
| `TestFindLibraryForHeader` | 4 | Root layout, `src/` layout, missing library, empty dir |
| `TestCreateIdfComponent` | 11 | Dir created, CMakeLists.txt content, files copied, name sanitization, correct library name |
| `TestTemplateCMakeLists` | 2 | Template files contain `user_libs` block and `REQUIRES` placeholder |
Run from `backend/`:
```bash
python test_espidf_compiler.py
```
---
## Files Changed
| File | Change |
|---|---|
| `backend/app/services/espidf_compiler.py` | Added `_detect_external_includes`, `_find_library_for_header`, `_create_idf_component`; BFS transitive dep resolution; CMake patching fixes |
| `backend/app/services/esp-idf-template/CMakeLists.txt` | Added `user_libs/` to `EXTRA_COMPONENT_DIRS` via `EXISTS` guard |
| `frontend/src/utils/compilationLogger.ts` | Ninja `FAILED:` block state machine; classifies lines as `'error'` |
| `frontend/src/components/editor/CompilationConsole.tsx` | Auto-switches to Errors filter when new errors arrive |
| `backend/test_espidf_compiler.py` | 25-test suite covering all library resolution logic |