From 3558d9ba39efcffeaf8f5beb88cc831af0c473ad Mon Sep 17 00:00:00 2001 From: David Montero Crespo Date: Wed, 19 Aug 2026 16:39:59 +0200 Subject: [PATCH] feat(espidf): honor CDCOnBoot menu option in 4-part FQBNs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit arduino-cli FQBNs may carry a menu-option suffix (vendor:arch:board_id:Menu1=val1,...). The espidf lane previously derived cdc_on_boot exclusively from the board id's boards.txt default, so a generic-devkit board could never be built with Serial on the USB Serial/JTAG CDC port. - _fqbn_board_id_and_options(): split board id and menu options, keeping the old last-segment behaviour for plain 3-part FQBNs. - _arduino_board_flags(): CDCOnBoot=cdc overlays cdc_on_boot=1 on the boards.txt base flags (mirroring .menu.CDCOnBoot.cdc.build. cdc_on_boot=1); the per-board cache stores only base flags, overrides are applied on a copy. - _arduino_variant(): board id extraction goes through the same helper so a menu suffix no longer corrupts the variant lookup. First user: the ESP32-C3-LCDkit board def pins esp32:esp32:esp32c3:CDCOnBoot=cdc — the real kit has no UART bridge, its only console is the USB Serial/JTAG port, and the esp32c3js engine now models the always-attached host that HWCDC's connection gate requires (esp32c3js v1.8.0). --- backend/app/services/espidf_compiler.py | 48 +++++++++++++++++++++++-- backend/tests/test_espidf_options.py | 41 +++++++++++++++++++++ 2 files changed, 86 insertions(+), 3 deletions(-) diff --git a/backend/app/services/espidf_compiler.py b/backend/app/services/espidf_compiler.py index 742a169a..ab920a20 100644 --- a/backend/app/services/espidf_compiler.py +++ b/backend/app/services/espidf_compiler.py @@ -657,6 +657,29 @@ class ESPIDFCompiler: # Cache: boards.txt is a few hundred KB and never changes at runtime. _variant_cache: dict = {} + @staticmethod + def _fqbn_board_id_and_options(board_fqbn: str) -> tuple[str, dict]: + """Split an arduino FQBN into (board_id, menu-option dict). + + arduino-cli syntax: ``vendor:arch:board_id[:Menu1=val1,Menu2=val2]``. + The espidf lane must understand the 4-part option-suffix form too, + not just plain 3-part FQBNs — the C3-LCDkit board def pins + ``CDCOnBoot=cdc`` this way (the real kit has no UART bridge; its only + console is the USB Serial/JTAG port). Menu keys a caller does not + recognise are simply ignored. + """ + parts = board_fqbn.split(':') + opts: dict = {} + if len(parts) >= 4 and parts[3]: + for kv in parts[3].split(','): + if '=' in kv: + k, v = kv.split('=', 1) + opts[k.strip()] = v.strip() + board_id = parts[2] + else: + board_id = parts[-1] + return board_id.split('?')[0].strip(), opts + def _arduino_variant(self, board_fqbn: str, idf_target: str) -> str: """The Arduino VARIANT for this FQBN, from arduino-esp32's boards.txt. @@ -670,7 +693,7 @@ class ESPIDFCompiler: 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() + board_id, _ = self._fqbn_board_id_and_options(board_fqbn) if not board_id: return idf_target key = (board_id, idf_target) @@ -726,10 +749,16 @@ class ESPIDFCompiler: ESP32S3 expose UART0's default RX pin (GPIO44) as a plain Dx pin, so building them with Serial on UART0 is not just unfaithful: a pinMode() on that pin tears the UART driver down mid-sketch. + + A ``CDCOnBoot`` menu option in the FQBN suffix overrides the board's + boards.txt default, mirroring arduino-cli + (``.menu.CDCOnBoot.cdc.build.cdc_on_boot=1``): the C3-LCDkit pins + ``esp32:esp32:esp32c3:CDCOnBoot=cdc`` so its console matches the real + kit, which only exposes the USB Serial/JTAG port. """ - board_id = board_fqbn.split(':')[-1].split('?')[0].strip() + board_id, menu_opts = self._fqbn_board_id_and_options(board_fqbn) if board_id in self._board_flags_cache: - return self._board_flags_cache[board_id] + return self._apply_menu_overrides(self._board_flags_cache[board_id], menu_opts) flags = {'board': None, 'cdc_on_boot': False} roots = [ r @@ -755,6 +784,19 @@ class ESPIDFCompiler: if flags['board'] is not None: break self._board_flags_cache[board_id] = flags + return self._apply_menu_overrides(flags, menu_opts) + + @staticmethod + def _apply_menu_overrides(flags: dict, menu_opts: dict) -> dict: + """Overlay FQBN menu options on the boards.txt base flags. + + Only ``CDCOnBoot`` is understood today (``cdc`` -> 1, ``default`` -> + the boards.txt value). Returns a copy so the per-board cache never + holds an override. + """ + cdc = menu_opts.get('CDCOnBoot') + if cdc == 'cdc': + return {**flags, 'cdc_on_boot': True} return flags def _idf_target(self, board_fqbn: str) -> str: diff --git a/backend/tests/test_espidf_options.py b/backend/tests/test_espidf_options.py index ff1a8133..ac86b193 100644 --- a/backend/tests/test_espidf_options.py +++ b/backend/tests/test_espidf_options.py @@ -463,3 +463,44 @@ def test_ninja_log_step_count(tmp_path) -> None: encoding='utf-8', ) assert _ninja_log_step_count(build_dir) == 2 + + +# ── FQBN menu-option suffix (CDCOnBoot) ────────────────────────────────── + + +def test_fqbn_board_id_plain(compiler: ESPIDFCompiler) -> None: + assert compiler._fqbn_board_id_and_options('esp32:esp32:esp32c3') == ( + 'esp32c3', {}, + ) + + +def test_fqbn_board_id_with_menu_suffix(compiler: ESPIDFCompiler) -> None: + board_id, opts = compiler._fqbn_board_id_and_options( + 'esp32:esp32:esp32c3:CDCOnBoot=cdc' + ) + assert board_id == 'esp32c3' + assert opts == {'CDCOnBoot': 'cdc'} + + +def test_fqbn_menu_suffix_multiple_options(compiler: ESPIDFCompiler) -> None: + board_id, opts = compiler._fqbn_board_id_and_options( + 'esp32:esp32:esp32s3:CDCOnBoot=cdc,PSRAM=opi' + ) + assert board_id == 'esp32s3' + assert opts == {'CDCOnBoot': 'cdc', 'PSRAM': 'opi'} + + +def test_menu_override_cdc_flips_flag(compiler: ESPIDFCompiler) -> None: + base = {'board': 'ESP32C3_DEV', 'cdc_on_boot': False} + out = compiler._apply_menu_overrides(base, {'CDCOnBoot': 'cdc'}) + assert out['cdc_on_boot'] is True + # The cache's base dict must never be mutated by an override. + assert base['cdc_on_boot'] is False + + +def test_menu_override_default_keeps_boards_txt_value( + compiler: ESPIDFCompiler, +) -> None: + base = {'board': 'ESP32C3_DEV', 'cdc_on_boot': False} + assert compiler._apply_menu_overrides(base, {'CDCOnBoot': 'default'}) is base + assert compiler._apply_menu_overrides(base, {}) is base