diff --git a/backend/app/services/espidf_compiler.py b/backend/app/services/espidf_compiler.py index f9a0d94e..60572c03 100644 --- a/backend/app/services/espidf_compiler.py +++ b/backend/app/services/espidf_compiler.py @@ -487,6 +487,65 @@ class ESPIDFCompiler: return True return any(v in f for v in ('nano_nora', 'm5stack_cardputer', 'm5stack_stamps3', 'm5stamp_s3')) + # Cache: boards.txt is a few hundred KB and never changes at runtime. + _variant_cache: dict = {} + + def _arduino_variant(self, board_fqbn: str, idf_target: str) -> str: + """The Arduino VARIANT for this FQBN, from arduino-esp32's boards.txt. + + Without this every board builds against ``variants//``, i.e. as a + generic dev-kit, and the board's own pin names never exist: a XIAO + sketch dies on ``D1``, an M5 sketch on its silk names. Since those are + exactly the sketches people copy from vendor wikis, the emulator has to + get this right to be believable. + + boards.txt is the authority (``.build.variant=``), so it + is read rather than guessed; anything not found falls back to the chip, + which is the previous behaviour. + """ + board_id = board_fqbn.split(':')[-1].split('?')[0].strip() + if not board_id: + return idf_target + key = (board_id, idf_target) + if key in self._variant_cache: + return self._variant_cache[key] + + variant = idf_target + # The 3.x core first: it is the one the IDF5 targets build against. + roots = [ + r + for r in ( + os.environ.get('ARDUINO_ESP32_V3_PATH', '/opt/arduino-esp32-3'), + os.environ.get('ARDUINO_ESP32_PATH', '/opt/arduino-esp32'), + ) + if r + ] + for root in roots: + boards_txt = Path(root) / 'boards.txt' + if not boards_txt.is_file(): + continue + try: + text = boards_txt.read_text(encoding='utf-8', errors='ignore') + except OSError: + continue + needle = f'{board_id}.build.variant=' + for line in text.splitlines(): + if line.startswith(needle): + found = line.split('=', 1)[1].strip() + if found: + variant = found + break + if variant != idf_target: + break + + if variant == idf_target and board_id != idf_target: + logger.info( + f'[espidf] no build.variant for {board_id} in boards.txt; ' + f'falling back to {idf_target}' + ) + self._variant_cache[key] = variant + return variant + def _idf_target(self, board_fqbn: str) -> str: """Map FQBN to IDF_TARGET.""" if self._is_esp32c3(board_fqbn): @@ -498,6 +557,39 @@ class ESPIDFCompiler: # Default to esp32 (Xtensa LX6) for the original ESP32 / ESP32-S2 return 'esp32' + def _add_managed_components(self, project_dir: Path, deps: dict) -> None: + """Declare extra ESP-IDF managed components for the `main` component. + + The template ships no idf_component.yml, so every managed component the + build sees today arrives transitively through arduino-esp32's own + manifest. Anything it does not depend on has to be asked for explicitly; + the component manager then fetches it into managed_components/ during the + cmake configure and caches it in the build dir for later runs. + """ + if not deps: + return + manifest = project_dir / 'main' / 'idf_component.yml' + lines = ['# Auto-generated by Velxio — components the sketch needs that the', + '# Arduino core does not pull in by itself.', + 'dependencies:'] + for name, version in deps.items(): + lines.append(f' {name}: "{version}"') + manifest.write_text('\n'.join(lines) + '\n', encoding='utf-8') + logger.info( + '[espidf] Declared managed components: %s', ', '.join(sorted(deps)) + ) + + def _detect_camera_usage(self, code: str) -> bool: + """Does the sketch use the ESP32 camera driver? + + `esp_camera.h` does NOT ship with the Arduino core: it lives in the + `espressif/esp32-camera` managed component, which arduino-esp32's own + idf_component.yml does not depend on. So a camera sketch compiles fine + everywhere else and dies here with "esp_camera.h: No such file or + directory" — which is exactly what both ESP32-CAM examples did. + """ + return bool(re.search(r'#include\s*[<"]esp_camera\.h[">]', code)) + def _detect_wifi_usage(self, code: str) -> bool: """Check if sketch uses WiFi.""" return bool(re.search(r'#include\s*[<"]WiFi\.h[">]|WiFi\.begin\(', code)) @@ -2170,6 +2262,7 @@ class ESPIDFCompiler: 'ARDUHAL_LOG_LEVEL': str( self._DEBUG_LEVEL_NUMBER[normalized['coreDebugLevel']] ), + 'ARDUINO_VARIANT': normalized.get('arduinoVariant') or idf_target, 'ARDUINO_RUNNING_CORE': str(normalized['arduinoRunsOnCore']), 'ARDUINO_EVENT_RUNNING_CORE': str(normalized['eventsRunOnCore']), } @@ -2881,6 +2974,22 @@ class ESPIDFCompiler: cmake_path.write_text(cmake_text, encoding='utf-8') logger.info('[espidf] Patched main CMakeLists: REQUIRES += user_libs_all, INCLUDE_DIRS += user_libs_all') + + # esp_camera.h used to come free: arduino-esp32 2.x shipped a precompiled + # SDK with esp32-camera bundled under tools/sdk//include. The 3.x + # component does NOT, and its manifest does not depend on it either, so the + # move to 3.x silently broke every camera sketch with "esp_camera.h: No such + # file or directory". Ask for it explicitly; the component manager fetches it + # during the cmake configure and caches it in the build dir. + sketch_src = '\n'.join( + f.get('content', '') for f in files if str(f.get('name', '')).endswith( + ('.ino', '.cpp', '.c', '.h', '.hpp') + ) + ) + if self._detect_camera_usage(sketch_src): + self._add_managed_components( + project_dir, {'espressif/esp32-camera': '^2.0.4'} + ) else: # Pure ESP-IDF mode (no Arduino component usable for this # target). Remove Arduino main.cpp to avoid conflict.