From 189ba355a969883d0c735637e272f9b51286700f Mon Sep 17 00:00:00 2001 From: David Montero Crespo Date: Tue, 18 Aug 2026 15:01:52 +0200 Subject: [PATCH] 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. --- .../src/__tests__/board-seed-code.test.ts | 140 ++++++++++++++++++ frontend/src/lib/proBoardRegistry.ts | 37 +++++ frontend/src/store/useEditorStore.ts | 10 +- frontend/src/store/useSimulatorStore.ts | 32 +++- 4 files changed, 214 insertions(+), 5 deletions(-) create mode 100644 frontend/src/__tests__/board-seed-code.test.ts diff --git a/frontend/src/__tests__/board-seed-code.test.ts b/frontend/src/__tests__/board-seed-code.test.ts new file mode 100644 index 00000000..195c4f61 --- /dev/null +++ b/frontend/src/__tests__/board-seed-code.test.ts @@ -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)'); + }); +}); diff --git a/frontend/src/lib/proBoardRegistry.ts b/frontend/src/lib/proBoardRegistry.ts index e9e9e27b..54318b5a 100644 --- a/frontend/src/lib/proBoardRegistry.ts +++ b/frontend/src/lib/proBoardRegistry.ts @@ -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()); } diff --git a/frontend/src/store/useEditorStore.ts b/frontend/src/store/useEditorStore.ts index 9c7a2be4..a5d9ec0d 100644 --- a/frontend/src/store/useEditorStore.ts +++ b/frontend/src/store/useEditorStore.ts @@ -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((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; diff --git a/frontend/src/store/useSimulatorStore.ts b/frontend/src/store/useSimulatorStore.ts index 6b5a2a27..582ff32d 100644 --- a/frontend/src/store/useSimulatorStore.ts +++ b/frontend/src/store/useSimulatorStore.ts @@ -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((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((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); },