fix(boards): an overlay board could never enter ESP-IDF mode

BOARD_SUPPORTS_ESPIDF is a literal set of OSS board kinds and it gates
both the toolbar's ESP-IDF option and setBoardLanguageMode, so no overlay
board could ever be switched into ESP-IDF: loadExample called
setBoardLanguageMode(id, 'espidf') for an `languageMode: 'espidf'` gallery
example, the guard returned early, and the example opened in Arduino mode
with the toolbar saying "Arduino C++". It stayed invisible because the
backend sniffs `app_main` and compiles those sketches as pure IDF anyway —
until a board with no Arduino FQBN at all hit it, and the run died in the
browser at "No FQBN for board kind" before any request left the page.

- ProBoardDef.supportsEspIdf registers the kind into BOARD_SUPPORTS_ESPIDF,
  the same way supportsMicroPython already did for MicroPython.
- ProBoardDef.espidfFqbn + BOARD_KIND_ESPIDF_FQBN + fqbnForLanguage give the
  IDF lane a target for a board whose Arduino FQBN is null (the ESP32-C5
  kits: no arduino-esp32 core supports the C5, and the backend refuses that
  target for Arduino on purpose, but idf.py builds it fine). Both compile
  sites resolve through it instead of reading BOARD_KIND_FQBN directly.
- The language select shows when EITHER alternative mode exists (the P4 is
  ESP-IDF but not MicroPython, so it had no select at all), hides options
  the board does not support, and hides Arduino for a board with no FQBN.
- addBoard no longer seeds an Arduino-less board into 'arduino', where it
  could never compile and the select would show a value it does not carry.

Regression test: overlay-espidf-mode.test.ts.
This commit is contained in:
David Montero Crespo 2026-08-24 05:21:41 +02:00
parent c15d099c9e
commit 87101175ce
6 changed files with 210 additions and 9 deletions

View File

@ -0,0 +1,135 @@
/**
* ESP-IDF language mode for OVERLAY boards.
*
* `BOARD_SUPPORTS_ESPIDF` is a literal set of OSS board kinds, and it is what
* gates BOTH the toolbar's ESP-IDF option AND `setBoardLanguageMode`. An
* overlay board (every Espressif devkit) was therefore unable to enter ESP-IDF
* mode at all: `loadExample` called `setBoardLanguageMode(id, 'espidf')`, the
* guard returned early, and an `languageMode: 'espidf'` gallery example opened
* in Arduino mode with the toolbar reporting "Arduino C++". It happened to
* still compile on boards whose sketch the backend could sniff as pure IDF
* (`app_main` present) — but on the ESP32-C5 kit, which has no Arduino FQBN at
* all, the run died in the browser at "No FQBN for board kind".
*
* So: `ProBoardDef.supportsEspIdf` registers the kind, and
* `ProBoardDef.espidfFqbn` gives the IDF lane a target for a board with no
* Arduino FQBN of its own. Pure Vitest — no QEMU, no network, no DOM.
*/
import { describe, it, expect } from 'vitest';
import { registerProBoards } from '../lib/proBoardRegistry';
import { useSimulatorStore } from '../store/useSimulatorStore';
import {
BOARD_SUPPORTS_ESPIDF,
BOARD_SUPPORTS_MICROPYTHON,
fqbnForLanguage,
type BoardKind,
} from '../types/board';
const IDF_ONLY = 'idf-test-c5' as BoardKind; // no Arduino core, ESP-IDF + MPY
const DUAL = 'idf-test-s3' as BoardKind; // Arduino and ESP-IDF both build
const ARDUINO_ONLY = 'idf-test-plain' as BoardKind;
registerProBoards([
{
kind: IDF_ONLY,
label: 'IDF Test C5',
fqbn: null,
espidfFqbn: 'esp32:esp32:esp32c5',
description: 'overlay board with no arduino core',
tag: 'velxio-idf-test-c5',
size: { w: 100, h: 100 },
supportsMicroPython: true,
supportsEspIdf: true,
esp32Family: 'esp32-c5',
},
{
kind: DUAL,
label: 'IDF Test S3',
fqbn: 'esp32:esp32:esp32s3',
description: 'overlay board that builds both ways',
tag: 'velxio-idf-test-s3',
size: { w: 100, h: 100 },
supportsMicroPython: true,
supportsEspIdf: true,
esp32Family: 'esp32-s3',
},
{
kind: ARDUINO_ONLY,
label: 'IDF Test Plain',
fqbn: 'esp32:esp32:esp32',
description: 'overlay board that never declared ESP-IDF',
tag: 'velxio-idf-test-plain',
size: { w: 100, h: 100 },
supportsMicroPython: true,
esp32Family: 'esp32',
},
]);
describe('ProBoardDef.supportsEspIdf', () => {
it('registers the kind so the toolbar and the store both see it', () => {
expect(BOARD_SUPPORTS_ESPIDF.has(IDF_ONLY)).toBe(true);
expect(BOARD_SUPPORTS_ESPIDF.has(DUAL)).toBe(true);
});
it('leaves a board that did not declare it alone', () => {
expect(BOARD_SUPPORTS_ESPIDF.has(ARDUINO_ONLY)).toBe(false);
// …while still being a MicroPython board, so the regression is scoped.
expect(BOARD_SUPPORTS_MICROPYTHON.has(ARDUINO_ONLY)).toBe(true);
});
it('lets setBoardLanguageMode actually switch an overlay board to espidf', () => {
const store = useSimulatorStore.getState();
store.addBoard(DUAL, 0, 0);
const board = useSimulatorStore.getState().boards.find((b) => b.boardKind === DUAL);
expect(board).toBeDefined();
useSimulatorStore.getState().setBoardLanguageMode(board!.id, 'espidf');
expect(
useSimulatorStore.getState().boards.find((b) => b.id === board!.id)?.languageMode,
).toBe('espidf');
});
it('still refuses espidf for a board that never declared it', () => {
const store = useSimulatorStore.getState();
store.addBoard(ARDUINO_ONLY, 0, 0);
const board = useSimulatorStore
.getState()
.boards.find((b) => b.boardKind === ARDUINO_ONLY);
useSimulatorStore.getState().setBoardLanguageMode(board!.id, 'espidf');
expect(
useSimulatorStore.getState().boards.find((b) => b.id === board!.id)?.languageMode,
).not.toBe('espidf');
});
});
describe('fqbnForLanguage', () => {
it('gives the IDF lane a target for a board with no Arduino FQBN', () => {
expect(fqbnForLanguage(IDF_ONLY, 'espidf')).toBe('esp32:esp32:esp32c5');
// Arduino stays impossible on that board — the mode has no core at all.
expect(fqbnForLanguage(IDF_ONLY, 'arduino')).toBeNull();
expect(fqbnForLanguage(IDF_ONLY, undefined)).toBeNull();
});
it('falls through to the Arduino FQBN when no espidf override exists', () => {
expect(fqbnForLanguage(DUAL, 'espidf')).toBe('esp32:esp32:esp32s3');
expect(fqbnForLanguage(DUAL, 'arduino')).toBe('esp32:esp32:esp32s3');
});
});
describe('addBoard language seeding', () => {
it('does not seed a board that cannot compile Arduino into arduino mode', () => {
useSimulatorStore.getState().addBoard(IDF_ONLY, 0, 0);
const board = useSimulatorStore.getState().boards.find((b) => b.boardKind === IDF_ONLY);
expect(board?.languageMode).not.toBe('arduino');
expect(board?.languageMode).toBe('micropython');
});
it('still seeds a normal board into arduino', () => {
useSimulatorStore.getState().addBoard(ARDUINO_ONLY, 0, 0);
const board = useSimulatorStore
.getState()
.boards.filter((b) => b.boardKind === ARDUINO_ONLY)
.pop();
expect(board?.languageMode).toBe('arduino');
});
});

View File

@ -10,7 +10,7 @@ import { type VerificationResult } from '../../simulation/verify/circuitVerifier
import { verifyCircuitFromStore } from '../../simulation/verify/verifyFromStore';
import { CircuitVerificationModal } from '../simulator/CircuitVerificationModal';
import type { BoardKind, LanguageMode } from '../../types/board';
import { BOARD_KIND_FQBN, BOARD_SUPPORTS_ESPIDF, BOARD_SUPPORTS_MICROPYTHON, isKnownBoardKind, isPiBoardKind, boardDisplayName } from '../../types/board';
import { BOARD_KIND_FQBN, BOARD_SUPPORTS_ESPIDF, BOARD_SUPPORTS_MICROPYTHON, fqbnForLanguage, isKnownBoardKind, isPiBoardKind, boardDisplayName } from '../../types/board';
import { compileCode } from '../../services/compilation';
import { compileOptionsForBoard } from '../../utils/boardCompile';
import {
@ -519,7 +519,7 @@ export const EditorToolbar = ({
return;
}
const fqbn = kind ? BOARD_KIND_FQBN[kind] : null;
const fqbn = kind ? fqbnForLanguage(kind, activeBoard?.languageMode) : null;
if (!fqbn) {
blog('error', `No FQBN for board kind: ${kind}`);
@ -1566,7 +1566,9 @@ export const EditorToolbar = ({
pure ESP-IDF on the ESP32 family — issue #139). The board
context pill that used to live here was removed: it duplicated
the BoardSelector dropdown elsewhere in the toolbar. */}
{activeBoard && BOARD_SUPPORTS_MICROPYTHON.has(activeBoard.boardKind) && (
{activeBoard &&
(BOARD_SUPPORTS_MICROPYTHON.has(activeBoard.boardKind) ||
BOARD_SUPPORTS_ESPIDF.has(activeBoard.boardKind)) && (
<select
className="tb-lang-select"
value={activeBoard.languageMode ?? 'arduino'}
@ -1589,8 +1591,16 @@ export const EditorToolbar = ({
marginRight: 4,
}}
>
{/* Arduino only when the kind actually HAS an FQBN: a board
with none (the ESP32-C5 kits — no arduino-esp32 core exists)
cannot compile in this mode, and offering it just lands the
user on "No FQBN for board kind". */}
{!!BOARD_KIND_FQBN[activeBoard.boardKind] && (
<option value="arduino">Arduino C++</option>
)}
{BOARD_SUPPORTS_MICROPYTHON.has(activeBoard.boardKind) && (
<option value="micropython">MicroPython</option>
)}
{BOARD_SUPPORTS_ESPIDF.has(activeBoard.boardKind) && (
<option value="espidf">ESP-IDF</option>
)}

View File

@ -21,6 +21,8 @@ import type { ESP32BoardOptions } from '../types/boardOptions';
import {
BOARD_KIND_LABELS,
BOARD_KIND_FQBN,
BOARD_KIND_ESPIDF_FQBN,
BOARD_SUPPORTS_ESPIDF,
BOARD_SUPPORTS_MICROPYTHON,
registerPiFamilyKind,
type BoardKind,
@ -52,6 +54,17 @@ export interface ProBoardDef {
/** True pixel size of the element (selection ring + pin overlays). */
size: { w: number; h: number };
supportsMicroPython?: boolean;
/** Board can run pure ESP-IDF projects (app_main entry, no Arduino
* core). Registers the kind into BOARD_SUPPORTS_ESPIDF, which is what
* gates the toolbar's ESP-IDF option AND setBoardLanguageMode — an
* overlay board without it silently ignores every attempt to enter
* ESP-IDF mode, so an `languageMode: 'espidf'` gallery example opens
* in Arduino mode instead. ESP32 family only. */
supportsEspIdf?: boolean;
/** FQBN the ESP-IDF lane compiles this board with, when the board has
* no Arduino FQBN of its own (`fqbn: null`) or needs a different one.
* Only used to derive the backend's IDF target. */
espidfFqbn?: string;
/** ESP32 run-path routing: the base chip the board carries. Routes the run
* through the ESP32 bridge path and picks the machine/engine type. Omit for
* boards that provide createSimulator (RP2350 class) or AVR/RP2040. */
@ -246,6 +259,10 @@ export function registerProBoards(defs: ProBoardDef[]): void {
(BOARD_KIND_LABELS as Record<string, string>)[kind] = def.label;
(BOARD_KIND_FQBN as Record<string, string | null>)[kind] = def.fqbn;
if (def.supportsMicroPython) BOARD_SUPPORTS_MICROPYTHON.add(kind);
if (def.supportsEspIdf) BOARD_SUPPORTS_ESPIDF.add(kind);
if (def.espidfFqbn) {
(BOARD_KIND_ESPIDF_FQBN as Record<string, string>)[kind] = def.espidfFqbn;
}
if (def.piFamily) registerPiFamilyKind(def.kind);
}
version++;

View File

@ -27,7 +27,7 @@ import type { I2CDevice } from '../simulation/I2CBusManager';
import type { RP2040I2CDevice } from '../simulation/RP2040Simulator';
import type { Wire, WireInProgress, WireEndpoint } from '../types/wire';
import type { BoardKind, BoardInstance, LanguageMode, WifiStatus } from '../types/board';
import { BOARD_SUPPORTS_ESPIDF, BOARD_SUPPORTS_MICROPYTHON, EMULATED_WIFI_SSIDS, isPiBoardKind, isStm32BoardKind } from '../types/board';
import { BOARD_KIND_FQBN, BOARD_SUPPORTS_ESPIDF, BOARD_SUPPORTS_MICROPYTHON, EMULATED_WIFI_SSIDS, isPiBoardKind, isStm32BoardKind } from '../types/board';
import { boardGateDecision, proBoardFeatureName, triggerProUpgradePrompt } from '../lib/proBoardGate';
import { getSerialTxInterceptor } from '../lib/proHardwareSerial';
import { calculatePinPosition } from '../utils/pinPositionCalculator';
@ -1669,6 +1669,19 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
}
}
// Arduino is the default language everywhere EXCEPT on a board that has
// no FQBN at all: the ESP32-C5 kits have no arduino-esp32 core, so a
// board seeded into 'arduino' can never compile and the toolbar's
// language select (which hides the Arduino option for them) would show
// a value it does not carry. Seed those into a mode they can run.
const seededLanguage: LanguageMode = BOARD_KIND_FQBN[boardKind]
? 'arduino'
: BOARD_SUPPORTS_MICROPYTHON.has(boardKind)
? 'micropython'
: BOARD_SUPPORTS_ESPIDF.has(boardKind)
? 'espidf'
: 'arduino';
const newBoard: BoardInstance = {
id,
boardKind,
@ -1680,7 +1693,7 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
serialBaudRate: 0,
serialMonitorOpen: false,
activeFileGroupId: `group-${id}`,
languageMode: 'arduino',
languageMode: seededLanguage,
};
set((s) => {

View File

@ -284,3 +284,29 @@ export const BOARD_KIND_FQBN: Record<BoardKind, string | null> = {
'stm32-netduino2': 'STMicroelectronics:stm32:GenF2:pnum=GENERIC_F205RGTX',
attiny85: 'ATTinyCore:avr:attinyx5:chip=85,clock=16pll',
};
/**
* FQBN used ONLY by the pure ESP-IDF language mode, for boards where it
* differs from the Arduino one — in practice, boards that have NO Arduino
* FQBN at all. The ESP32-C5 kits are the case: no arduino-esp32 core supports
* the C5 (the backend refuses that target for Arduino on purpose), but the
* ESP-IDF lane builds them fine and only needs the FQBN to derive its IDF
* target. Overlay boards fill this through ProBoardDef.espidfFqbn.
*/
export const BOARD_KIND_ESPIDF_FQBN: Partial<Record<BoardKind, string>> = {};
/**
* The FQBN a compile should use for a board in a given language mode. Every
* compile site must go through this: reading BOARD_KIND_FQBN directly makes an
* Arduino-less board (FQBN null) fail with "No FQBN for board kind" even when
* its ESP-IDF mode is perfectly buildable.
*/
export function fqbnForLanguage(
kind: BoardKind,
mode: LanguageMode | undefined,
): string | null {
if (mode === 'espidf' && BOARD_KIND_ESPIDF_FQBN[kind]) {
return BOARD_KIND_ESPIDF_FQBN[kind] as string;
}
return BOARD_KIND_FQBN[kind] ?? null;
}

View File

@ -8,7 +8,7 @@ import { isChipProgramFile } from '../services/romCompileService';
import { useEditorStore } from '../store/useEditorStore';
import { useSimulatorStore } from '../store/useSimulatorStore';
import { useProjectStore } from '../store/useProjectStore';
import { BOARD_KIND_FQBN, type BoardInstance } from '../types/board';
import { fqbnForLanguage, type BoardInstance } from '../types/board';
import { isNoiseBuildLine } from './compilationLogger';
import { publishCompileOutput } from '../lib/intellisenseRegistry';
import { fingerprintSources } from './sourceFingerprint';
@ -86,7 +86,7 @@ export async function compileBoardForFlash(
onLine: (line: string) => void,
): Promise<BoardCompileOutcome> {
const t0 = performance.now();
const fqbn = BOARD_KIND_FQBN[board.boardKind];
const fqbn = fqbnForLanguage(board.boardKind, board.languageMode);
if (!fqbn) {
return { ok: false, error: `No FQBN for board kind: ${board.boardKind}`, elapsedMs: 0 };
}