feat(boards): a board can ship the code it opens with

The editor seeded a freshly placed board from its FAMILY: the Arduino
LED_BUILTIN blink, the Pico Pin(25) MicroPython blink, or the Raspberry
Pi RPi.GPIO script. None of those run on a board that has no such LED,
no RPi.GPIO and its own vendor library, so the first Run failed on a
board the user had not touched yet.

ProBoardDef gains defaultFiles (per language mode, plus 'python' for the
QEMU-Linux guests) and defaultLibraries; addBoard and setBoardLanguageMode
prefer the board's own seed over the family default, and the seeded
libraries land in the board's compile manifest.

Also fix the MicroPython family fallback: it recognised an ESP32 by
'esp32' appearing in the group id, so an overlay ESP32 whose kind carries
no such substring (m5stack-core, cardputer-adv) was seeded the Pico's
Pin(25) blink - a dead pin on both. It now consults the board's declared
esp32Family too.
This commit is contained in:
David Montero Crespo 2026-08-18 15:01:52 +02:00
parent 0a0badd10f
commit 189ba355a9
4 changed files with 214 additions and 5 deletions

View File

@ -0,0 +1,140 @@
/**
* Per-board seed code — what a freshly placed board starts with in the editor.
*
* The editor's family defaults (Arduino `LED_BUILTIN` blink, Pico `Pin(25)`
* blink, Raspberry Pi `RPi.GPIO` script) do not run on a board that has no
* such LED and its own vendor library: a placed M5Stack / UNIHIKER / Badger
* used to open with code that could not work on it. A board declares its own
* seed through ProBoardDef.defaultFiles, and addBoard / setBoardLanguageMode
* must prefer it over the family default.
*
* Pure Vitest unit tests — no QEMU, no network, no DOM.
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { registerProBoards, getBoardSeedFiles } from '../lib/proBoardRegistry';
import { useEditorStore } from '../store/useEditorStore';
import { useSimulatorStore } from '../store/useSimulatorStore';
import type { BoardKind } from '../types/board';
const SEEDED_ARDUINO = '// seeded arduino sketch';
const SEEDED_MPY = '# seeded micropython';
const SEEDED_PY = '# seeded guest script';
registerProBoards([
{
kind: 'seed-test-esp32',
label: 'Seed Test ESP32',
fqbn: 'esp32:esp32:esp32',
description: 'test board that ships its own seed code',
tag: 'velxio-seed-test-esp32',
size: { w: 100, h: 100 },
supportsMicroPython: true,
esp32Family: 'esp32',
defaultLibraries: ['SeedLib'],
defaultFiles: {
arduino: [{ name: 'sketch.ino', content: SEEDED_ARDUINO }],
micropython: [{ name: 'main.py', content: SEEDED_MPY }],
},
},
{
kind: 'seed-test-linux',
label: 'Seed Test Linux SBC',
fqbn: null,
description: 'test QEMU-Linux board that ships its own guest script',
tag: 'velxio-seed-test-linux',
size: { w: 100, h: 100 },
piFamily: true,
defaultFiles: { python: [{ name: 'script.py', content: SEEDED_PY }] },
},
{
kind: 'seed-test-plain',
label: 'Seed Test Plain',
fqbn: 'esp32:esp32:esp32',
description: 'test board with no seed of its own',
tag: 'velxio-seed-test-plain',
size: { w: 100, h: 100 },
supportsMicroPython: true,
esp32Family: 'esp32',
},
]);
const groupFiles = (groupId: string) => useEditorStore.getState().getGroupFiles(groupId);
describe('getBoardSeedFiles', () => {
it('returns the board’s own files for a declared mode', () => {
expect(getBoardSeedFiles('seed-test-esp32', 'arduino')?.[0].content).toBe(SEEDED_ARDUINO);
expect(getBoardSeedFiles('seed-test-esp32', 'micropython')?.[0].content).toBe(SEEDED_MPY);
expect(getBoardSeedFiles('seed-test-linux', 'python')?.[0].content).toBe(SEEDED_PY);
});
it('returns undefined for an undeclared mode or an unknown board', () => {
expect(getBoardSeedFiles('seed-test-esp32', 'espidf')).toBeUndefined();
expect(getBoardSeedFiles('seed-test-plain', 'arduino')).toBeUndefined();
expect(getBoardSeedFiles('arduino-uno', 'arduino')).toBeUndefined();
});
});
describe('addBoard seeds the board’s own code', () => {
beforeEach(() => {
for (const b of [...useSimulatorStore.getState().boards]) {
useSimulatorStore.getState().removeBoard(b.id);
}
});
it('a placed board with a seed opens on its own sketch, not the blink default', () => {
const id = useSimulatorStore.getState().addBoard('seed-test-esp32' as BoardKind, 0, 0);
const files = groupFiles(`group-${id}`);
expect(files).toHaveLength(1);
expect(files[0].name).toBe('sketch.ino');
expect(files[0].content).toBe(SEEDED_ARDUINO);
});
it('the seed’s vendor libraries land in the board’s compile manifest', () => {
const id = useSimulatorStore.getState().addBoard('seed-test-esp32' as BoardKind, 0, 0);
const board = useSimulatorStore.getState().boards.find((b) => b.id === id);
expect(board?.libraries).toEqual(['SeedLib']);
});
it('a QEMU-Linux board seeds its guest script instead of the RPi.GPIO default', () => {
const id = useSimulatorStore.getState().addBoard('seed-test-linux' as BoardKind, 0, 0);
const files = groupFiles(`group-${id}`);
expect(files[0].name).toBe('script.py');
expect(files[0].content).toBe(SEEDED_PY);
expect(files[0].content).not.toContain('RPi.GPIO');
});
it('a board without a seed keeps the family default', () => {
const id = useSimulatorStore.getState().addBoard('seed-test-plain' as BoardKind, 0, 0);
const files = groupFiles(`group-${id}`);
expect(files[0].name).toBe('sketch.ino');
expect(files[0].content).toContain('LED_BUILTIN');
});
});
describe('switching language mode re-seeds from the board', () => {
beforeEach(() => {
for (const b of [...useSimulatorStore.getState().boards]) {
useSimulatorStore.getState().removeBoard(b.id);
}
});
it('MicroPython mode uses the board’s own main.py', () => {
const id = useSimulatorStore.getState().addBoard('seed-test-esp32' as BoardKind, 0, 0);
useSimulatorStore.getState().setBoardLanguageMode(id, 'micropython');
const files = groupFiles(`group-${id}`);
expect(files[0].name).toBe('main.py');
expect(files[0].content).toBe(SEEDED_MPY);
});
it('a seedless ESP32-family overlay board gets the ESP32 blink, not the Pico one', () => {
// The fallback used to key off 'esp32' appearing in the group id, so an
// overlay ESP32 whose kind has no 'esp32' in its name (m5stack-core,
// cardputer-adv) was seeded the Pico's Pin(25) blink — a dead pin there.
const id = useSimulatorStore.getState().addBoard('seed-test-plain' as BoardKind, 0, 0);
useSimulatorStore.getState().setBoardLanguageMode(id, 'micropython');
const files = groupFiles(`group-${id}`);
expect(files[0].name).toBe('main.py');
expect(files[0].content).toContain('Pin(2, Pin.OUT)');
});
});

View File

@ -138,6 +138,30 @@ export interface ProBoardDef {
bridge: unknown;
}) => () => void;
/**
* Seed code a freshly placed board starts with, per language mode.
*
* Without this the editor falls back to the family default — the Arduino
* `LED_BUILTIN` blink, the Pico `Pin(25)` blink, or the Raspberry Pi
* `RPi.GPIO` script — and NONE of those run on a board that has no such
* LED, no RPi.GPIO and its own vendor library (an M5Stack needs
* `M5.begin()`, the UNIHIKER needs pinpong/unihiker). The user's very
* first Run then fails on a board they have not touched yet. Keep each
* entry as close to the vendor's own first example as the emulator
* allows, and self-contained: a freshly placed board has nothing wired
* to it, so the code must do something visible on the board itself.
*
* Keys are the board's language modes plus 'python' for QEMU-Linux
* (piFamily) boards, whose seed is the guest script.
*/
defaultFiles?: Partial<
Record<'arduino' | 'micropython' | 'espidf' | 'python', Array<{ name: string; content: string }>>
>;
/** Library manifest seeded together with `defaultFiles.arduino` — the seed
* sketch includes the vendor library, so the board must declare it or the
* first compile resolves against nothing. */
defaultLibraries?: string[];
/** Sidebar / toolbar accents (fall back to a neutral chip icon). */
icon?: string;
color?: string;
@ -226,6 +250,19 @@ export function getBoardBuiltins(kind: string): BuiltinsAttach | undefined {
return registry.get(kind)?.attachBuiltins ?? boardBuiltins.get(kind);
}
/**
* Seed files for a board kind in a given language mode, or undefined when the
* board carries no seed of its own (the editor's family default applies).
* `mode` is 'python' for QEMU-Linux boards — see ProBoardDef.defaultFiles.
*/
export function getBoardSeedFiles(
kind: string,
mode: 'arduino' | 'micropython' | 'espidf' | 'python',
): Array<{ name: string; content: string }> | undefined {
const files = registry.get(kind)?.defaultFiles?.[mode];
return files && files.length ? files : undefined;
}
export function listProBoards(): ProBoardDef[] {
return Array.from(registry.values());
}

View File

@ -1,6 +1,7 @@
import { create } from 'zustand';
import { generateUUID } from '../utils/uuid';
import { isPiBoardKind } from '../types/board';
import { getProBoard } from '../lib/proBoardRegistry';
export interface WorkspaceFile {
id: string;
@ -491,7 +492,14 @@ export const useEditorStore = create<EditorState>((set, get) => ({
const mainId = `${groupId}-main`;
let fileName: string;
let content: string;
const isEsp32 = groupId.includes('esp32');
// ESP32 by id ('group-esp32-c3') or by the overlay board's declared
// family — an overlay kind can be an ESP32 with no 'esp32' in its name
// (m5stack-core, cardputer-adv), and those used to be seeded the Pico's
// `Pin(25)` blink, a dead pin on both.
const isEsp32 =
groupId.includes('esp32') ||
getProBoard(boardIdPart)?.esp32Family !== undefined ||
getProBoard(boardIdPart.replace(/-\d+$/, ''))?.esp32Family !== undefined;
if (languageMode === 'espidf') {
fileName = 'main.c';
content = DEFAULT_ESPIDF_CONTENT;

View File

@ -1,6 +1,7 @@
import { create } from 'zustand';
import { decideEngine, getInstantEngine } from '../lib/instantEngine';
import {
getBoardSeedFiles,
getProBoard,
getGuestSetup,
isProBoardSimulator,
@ -1681,8 +1682,24 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
simulator: simulatorMap.get(nextActive) ?? s.simulator,
};
});
// Create the editor file group for this board
useEditorStore.getState().createFileGroup(`group-${id}`);
// Create the editor file group for this board. A board that ships its
// own seed code (vendor library boards: M5Stack, UNIHIKER, ...) gets it
// here — the editor's family default is an LED_BUILTIN blink / an
// RPi.GPIO script, neither of which runs on such a board, so the very
// first Run on a freshly placed board would fail. QEMU-Linux boards seed
// their guest script ('python'); everything else starts in Arduino mode.
const seedMode = isPiBoardKind(boardKind) ? 'python' : 'arduino';
useEditorStore
.getState()
.createFileGroup(`group-${id}`, getBoardSeedFiles(boardKind, seedMode));
// The seed sketch includes the board's vendor library, so declare it in
// the board's manifest (= the compile resolution scope) right away.
const seedLibs = getProBoard(boardKind)?.defaultLibraries;
if (seedLibs?.length) {
set((s) => ({
boards: s.boards.map((b) => (b.id === id ? { ...b, libraries: [...seedLibs] } : b)),
}));
}
// If this board is now the active one (it's the first board, or the
// previously-active board was removed), point the editor at its file
// group too. The canvas board picker calls addBoard directly WITHOUT
@ -2236,10 +2253,17 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
),
}));
// Replace file group with appropriate default files and activate it
// Replace file group with appropriate default files and activate it.
// A board's own seed for the target mode wins over the family default:
// the MicroPython fallback is a Pico `Pin(25)` blink, which is a dead
// pin on an M5Stack.
const editorStore = useEditorStore.getState();
editorStore.deleteFileGroup(board.activeFileGroupId);
editorStore.createFileGroup(board.activeFileGroupId, mode);
const modeSeed = getBoardSeedFiles(
board.boardKind,
mode === 'micropython' || mode === 'espidf' ? mode : 'arduino',
);
editorStore.createFileGroup(board.activeFileGroupId, modeSeed ?? mode);
editorStore.setActiveGroup(board.activeFileGroupId);
},