feat(espidf): honor CDCOnBoot menu option in 4-part FQBNs

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 <id>.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).
This commit is contained in:
David Montero Crespo 2026-08-19 16:39:59 +02:00
parent 7dc88b45e0
commit 3558d9ba39
2 changed files with 86 additions and 3 deletions

View File

@ -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
(``<id>.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:

View File

@ -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