From 0d9a26d9513c383f5b985a5304a88b5f4ad9ff4a Mon Sep 17 00:00:00 2001 From: David Montero Crespo Date: Mon, 3 Aug 2026 07:26:01 +0200 Subject: [PATCH] fix(espidf): a header-only library broke every ESP32 build Installing ArduinoJson made the project stop compiling, with an error that never mentioned ArduinoJson: CMake Error at user_libs/user_libs_all/CMakeLists.txt:15 (target_compile_definitions): target_compile_definitions may only set INTERFACE properties on INTERFACE targets -- Configuring incomplete, errors occurred! ArduinoJson ships headers only. With no sources, idf_component_register() builds the merged user-libs component as an INTERFACE library, and PRIVATE scope on an INTERFACE target is a hard CMake error - so the configure step died before a single file was compiled. The user saw only a failing build and reported it as "ArduinoJson can't be found even after it has been installed" (#124). Those definitions map alloca/memcpy_P/memcmp_P for library C sources that use them undeclared; with no sources there is nothing to define, so they are emitted only when the component actually compiles something. A library that carries its own .cpp is unchanged - which is why LiquidCrystal_I2C (#257) worked and hid this. --- backend/app/services/espidf_compiler.py | 36 +++++++--- test/backend/unit/test_espidf_compiler.py | 81 +++++++++++++++++++++++ 2 files changed, 108 insertions(+), 9 deletions(-) diff --git a/backend/app/services/espidf_compiler.py b/backend/app/services/espidf_compiler.py index 9b05cbf1..740b2648 100644 --- a/backend/app/services/espidf_compiler.py +++ b/backend/app/services/espidf_compiler.py @@ -1491,6 +1491,32 @@ class ESPIDFCompiler: extra_requires += f' {_comp}' break + # LovyanGFX (the engine inside M5GFX) uses alloca()/memcpy_P/memcmp_P + # without a prior declaration — its C utility files (lgfx_pngle.c, qoi …) + # call the PROGMEM aliases but never include pgmspace.h, so the flat + # IDF-component merge trips over an implicit declaration. Map them to + # always-available equivalents (identical to pgmspace.h, so redefinition + # is benign) so C++ graphics libs compile as a merged component. + # + # ONLY when this component actually compiles something. With no SRCS, + # idf_component_register() builds an INTERFACE library, and PRIVATE (or + # PUBLIC) on an INTERFACE target is a hard CMake error: + # target_compile_definitions may only set INTERFACE properties on + # INTERFACE targets + # which killed the whole configure step before a single file was + # compiled. That is the state a HEADER-ONLY library leaves us in — + # ArduinoJson is the common one — and it is why installing it made every + # ESP32 build fail while a library with its own .cpp (LiquidCrystal_I2C) + # was fine. The definitions exist for this component's own C sources, so + # with no sources there is nothing to define. + defs_block = '' + if cpp_files: + defs_block = ( + '# PROGMEM aliases for library C sources that use them undeclared.\n' + 'target_compile_definitions(${COMPONENT_LIB} PRIVATE' + ' alloca=__builtin_alloca memcpy_P=memcpy memcmp_P=memcmp)\n' + ) + cmake_content = ( '# Auto-generated by Velxio — all user libraries merged into one component.\n' '# Directory structure preserved for libraries like ArduinoJson with src/ layout.\n' @@ -1499,15 +1525,7 @@ class ESPIDFCompiler: f' {include_dirs_line}\n' f' REQUIRES {arduino_comp_name}{extra_requires}\n' ')\n' - '# LovyanGFX (the engine inside M5GFX) uses alloca()/memcpy_P/\n' - '# memcmp_P without a prior declaration — its C utility files\n' - '# (lgfx_pngle.c, qoi …) call the PROGMEM aliases but never include\n' - '# pgmspace.h, so the flat IDF-component merge trips over an implicit\n' - '# declaration. Map them to always-available equivalents (identical\n' - '# to pgmspace.h, so redefinition is benign) so C++ graphics libs\n' - '# compile as a merged component.\n' - 'target_compile_definitions(${COMPONENT_LIB} PRIVATE' - ' alloca=__builtin_alloca memcpy_P=memcpy memcmp_P=memcmp)\n' + f'{defs_block}' ) (comp_dir / 'CMakeLists.txt').write_text(cmake_content, encoding='utf-8') logger.info( diff --git a/test/backend/unit/test_espidf_compiler.py b/test/backend/unit/test_espidf_compiler.py index 7f322221..332b2d37 100644 --- a/test/backend/unit/test_espidf_compiler.py +++ b/test/backend/unit/test_espidf_compiler.py @@ -500,3 +500,84 @@ class TestDetectExternalIncludesQuotedForm(unittest.TestCase): with self.subTest(code=code): self.assertIn('M5Cardputer.h', self.comp._detect_external_includes(code)) + + +# ── Test: header-only libraries (ArduinoJson) ──────────────────────────────── + +class TestHeaderOnlyLibrary(unittest.TestCase): + """ + A library with no .c/.cpp of its own must still produce a CMakeLists.txt + ESP-IDF can configure. + + With no SRCS, idf_component_register() builds the component as an INTERFACE + library, and target_compile_definitions(... PRIVATE ...) on an INTERFACE + target is a hard CMake error: + + target_compile_definitions may only set INTERFACE properties on + INTERFACE targets + + That killed the configure step outright, so installing ArduinoJson broke + every ESP32 build in the project — reported as "ArduinoJson can't be found + even after it has been installed" (issue #124), because the user only ever + saw the build fail. A library carrying its own .cpp was unaffected, which is + why LiquidCrystal_I2C (#257) looked fine. + """ + + 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' + for d in (self.arduino_libs, self.esp32_libs, self.user_libs): + d.mkdir() + # ArduinoJson ships headers only, under src/. + make_library(self.arduino_libs, 'ArduinoJson', + headers=['ArduinoJson.h'], sources=[], use_src_subdir=True) + # A conventional library, for the contrast case. + make_library(self.arduino_libs, 'LiquidCrystal_I2C', + headers=['LiquidCrystal_I2C.h'], + sources=['LiquidCrystal_I2C.cpp']) + + def tearDown(self): + shutil.rmtree(self.tmp) + + def _cmake_for(self, headers: list[str]) -> str: + names, _ = self.comp._resolve_library_components( + headers, self.arduino_libs, self.esp32_libs, 'arduino-esp32', self.user_libs + ) + self.assertEqual(names, ['user_libs_all']) + return (self.user_libs / 'user_libs_all' / 'CMakeLists.txt').read_text(encoding='utf-8') + + def test_header_only_library_emits_no_private_scope(self): + cmake = self._cmake_for(['ArduinoJson.h']) + self.assertNotIn('SRCS', cmake, 'header-only component must register with no SRCS') + self.assertNotIn( + 'PRIVATE', cmake, + 'PRIVATE on a source-less (INTERFACE) component is a CMake configure error', + ) + + def test_header_only_library_still_exposes_its_header(self): + cmake = self._cmake_for(['ArduinoJson.h']) + self.assertIn('idf_component_register', cmake) + # The library's src/ layout is preserved, so the header lands at + # src/ArduinoJson.h and "src" has to be on the include path for the + # sketch's #include to resolve. + merged = self.user_libs / 'user_libs_all' + self.assertTrue( + (merged / 'src' / 'ArduinoJson.h').exists(), + 'ArduinoJson.h not copied — the sketch would fail on "No such file"', + ) + self.assertIn('INCLUDE_DIRS "." "src"', cmake) + + def test_library_with_sources_keeps_the_progmem_definitions(self): + cmake = self._cmake_for(['LiquidCrystal_I2C.h']) + self.assertIn('SRCS', cmake) + self.assertIn('target_compile_definitions(${COMPONENT_LIB} PRIVATE', cmake) + self.assertIn('memcpy_P=memcpy', cmake) + + def test_mixed_libraries_keep_the_definitions(self): + """One library with sources is enough to make the component compile code.""" + cmake = self._cmake_for(['ArduinoJson.h', 'LiquidCrystal_I2C.h']) + self.assertIn('SRCS', cmake) + self.assertIn('PRIVATE', cmake)