From 537aceb063888524bec54630309605c6805069cd Mon Sep 17 00:00:00 2001 From: David Montero Date: Thu, 15 Oct 2026 22:10:08 +0200 Subject: [PATCH 1/3] feat(examples): an example's SD files can name a URL instead of inline bytes A gallery example can pre-load a board's built-in microSD (board.sdFiles). Inline base64 is fine for a text file, but a music player's MP3s would ride in the main bundle every visitor downloads. An entry may now name a `url` (same origin, e.g. /examples-media/...): loadExample fetches it before the canvas changes and stores the bytes on the board exactly like an inline file or an SD Card panel upload. A file that cannot be fetched is left off the card and logged, so one missing asset costs that file, not the example. --- .../__tests__/example-sd-media-url.test.ts | 64 +++++++++++++++++++ frontend/src/data/examples.ts | 15 ++++- frontend/src/utils/loadExample.ts | 13 +++- frontend/src/utils/sdCardFiles.ts | 29 +++++++++ 4 files changed, 116 insertions(+), 5 deletions(-) create mode 100644 frontend/src/__tests__/example-sd-media-url.test.ts diff --git a/frontend/src/__tests__/example-sd-media-url.test.ts b/frontend/src/__tests__/example-sd-media-url.test.ts new file mode 100644 index 00000000..4a88c267 --- /dev/null +++ b/frontend/src/__tests__/example-sd-media-url.test.ts @@ -0,0 +1,64 @@ +/** + * Example SD files named by URL. + * + * A gallery example can pre-load a board's built-in microSD. Inline base64 + * is fine for a text file, but a music player's MP3s would ride in the main + * bundle of every visitor; so an entry may name a `url` instead, and the + * loader fetches it when the example opens, storing the bytes on the board in + * the same shape an inline file (or the SD Card panel) produces. + */ +import { describe, expect, it, vi } from 'vitest'; +import { resolveExampleSdFiles, b64ToBytes } from '../utils/sdCardFiles'; + +const respond = (bytes: number[], status = 200) => + Promise.resolve(new Response(new Uint8Array(bytes), { status })); + +describe('resolveExampleSdFiles', () => { + it('passes inline files through untouched', async () => { + const fetcher = vi.fn(); + const out = await resolveExampleSdFiles([{ name: 'a.txt', contentB64: 'aGk=' }], fetcher); + expect(out).toEqual([{ name: 'a.txt', contentB64: 'aGk=' }]); + expect(fetcher).not.toHaveBeenCalled(); + }); + + it('fetches a url entry and stores its bytes as base64', async () => { + const fetcher = vi.fn(() => respond([0x49, 0x44, 0x33, 0x00, 0xff])); + const out = await resolveExampleSdFiles([{ name: 'SONG.MP3', url: '/examples-media/x/SONG.MP3' }], fetcher); + expect(fetcher).toHaveBeenCalledWith('/examples-media/x/SONG.MP3'); + expect(out).toHaveLength(1); + expect(out[0].name).toBe('SONG.MP3'); + expect(Array.from(b64ToBytes(out[0].contentB64))).toEqual([0x49, 0x44, 0x33, 0x00, 0xff]); + }); + + it('keeps the order of the entries', async () => { + const fetcher = vi.fn((url: string) => respond(url.endsWith('1') ? [1] : [2])); + const out = await resolveExampleSdFiles( + [ + { name: 'one', url: '/m/1' }, + { name: 'inline', contentB64: 'AA==' }, + { name: 'two', url: '/m/2' }, + ], + fetcher, + ); + expect(out.map((f) => f.name)).toEqual(['one', 'inline', 'two']); + }); + + it('leaves a file that cannot be fetched off the card instead of failing', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const fetcher = vi.fn((url: string) => (url.includes('gone') ? respond([], 404) : respond([7]))); + const out = await resolveExampleSdFiles( + [ + { name: 'gone.mp3', url: '/m/gone.mp3' }, + { name: 'ok.mp3', url: '/m/ok.mp3' }, + ], + fetcher, + ); + expect(out.map((f) => f.name)).toEqual(['ok.mp3']); + expect(warn).toHaveBeenCalled(); + warn.mockRestore(); + }); + + it('is empty for a board with no SD files', async () => { + expect(await resolveExampleSdFiles(undefined)).toEqual([]); + }); +}); diff --git a/frontend/src/data/examples.ts b/frontend/src/data/examples.ts index 6e8c5806..63a2e57f 100644 --- a/frontend/src/data/examples.ts +++ b/frontend/src/data/examples.ts @@ -42,9 +42,18 @@ export interface ExampleBoard { vfsFiles?: Record; /** Uploads pre-loaded onto a board's BUILT-IN microSD slot (a ProBoardDef * with builtInSd, e.g. the XIAO ESP32S3 Sense) — the same shape the - * SD Card panel persists on board.sdFiles. */ - sdFiles?: Array<{ name: string; contentB64: string }>; -} + * SD Card panel persists on board.sdFiles. A file too large to ship inside + * the bundle (audio, images) names a `url` instead of `contentB64`: the + * loader fetches it when the example opens and stores the bytes on the + * board exactly like an inline file. */ + sdFiles?: ExampleSdFile[]; +} + +/** One file on an example board's built-in microSD: inline bytes, or a URL + * (same origin, e.g. /examples-media/...) fetched when the example opens. */ +export type ExampleSdFile = + | { name: string; contentB64: string; url?: undefined } + | { name: string; url: string; contentB64?: undefined }; export interface ExampleProject { id: string; diff --git a/frontend/src/utils/loadExample.ts b/frontend/src/utils/loadExample.ts index 1e53bd7a..b51cf999 100644 --- a/frontend/src/utils/loadExample.ts +++ b/frontend/src/utils/loadExample.ts @@ -16,6 +16,7 @@ import { isBoardComponent } from './boardPinMapping'; import { getInstalledLibraries, installLibrary } from '../services/libraryService'; import { trackOpenExample } from './analytics'; import { stripBrandPrefix } from './exampleToBuildNetlistInput'; +import { resolveExampleSdFiles } from './sdCardFiles'; export interface LibraryInstallProgress { total: number; @@ -136,6 +137,13 @@ export async function loadExample( } } + // SD files an example names by URL are fetched BEFORE the canvas changes + // (and before the store is read below), so a slow download never leaves the + // user looking at half an example. + const sdFilesByBoard = await Promise.all( + (example.boards ?? []).map((eb) => resolveExampleSdFiles(eb.sdFiles)), + ); + const { setComponents, setWires, @@ -192,8 +200,9 @@ export async function loadExample( // Built-in SD slot uploads (XIAO Sense): land on the board exactly as // the SD Card panel would put them, so the Run path finds them. - if (eb.sdFiles?.length) { - useSimulatorStore.getState().updateBoard(boardId, { sdFiles: eb.sdFiles }); + const sdFiles = sdFilesByBoard[idx]; + if (sdFiles.length) { + useSimulatorStore.getState().updateBoard(boardId, { sdFiles }); } // `vfsFiles` IS the declaration that this board's code is a guest diff --git a/frontend/src/utils/sdCardFiles.ts b/frontend/src/utils/sdCardFiles.ts index 55671b28..8455e37f 100644 --- a/frontend/src/utils/sdCardFiles.ts +++ b/frontend/src/utils/sdCardFiles.ts @@ -94,3 +94,32 @@ export function buildProjectSdImage( } return buildFat16Image([...byName.values()], opts); } + +/** + * The SD files an example board ships, as the board stores them. Inline files + * pass through; a file named by `url` is fetched (a binary too big to sit in + * the bundle, like the MP3s of a music player example). A file that cannot be + * fetched is left off the card and reported, so one missing asset costs that + * file and not the whole example. + */ +export async function resolveExampleSdFiles( + files: ReadonlyArray<{ name: string; contentB64?: string; url?: string }> | undefined, + fetcher: (url: string) => Promise = (url) => fetch(url), +): Promise> { + if (!files?.length) return []; + const out = await Promise.all( + files.map(async (f) => { + if (typeof f.contentB64 === 'string') return { name: f.name, contentB64: f.contentB64 }; + if (!f.url) return null; + try { + const r = await fetcher(f.url); + if (!r.ok) throw new Error(`HTTP ${r.status}`); + return { name: f.name, contentB64: bytesToB64(new Uint8Array(await r.arrayBuffer())) }; + } catch (e) { + console.warn(`[sd] example file ${f.name} (${f.url}) could not be fetched:`, e); + return null; + } + }), + ); + return out.filter((f): f is { name: string; contentB64: string } => f !== null); +} From 7c4f3c99b0c754c3edd4e0e37df2d4f731d3b8df Mon Sep 17 00:00:00 2001 From: David Montero Date: Thu, 15 Oct 2026 22:10:08 +0200 Subject: [PATCH 2/3] fix(spice): drop a publish window recorded on a deck a rebuild replaced "Circuit solver problem: no such vector undefined" on Run, intermittently, on a circuit with SPI-driven pads. closeWindow() closes the pad timeline's window and queues its publish on the solver chain; when a rebuild was already queued there, the publish ran after it, and the window's levels, indexed by the OLD deck's pads, were paired with the NEW deck's sources. The new deck had one pad more, so ngspice got `alter V__D10 dc undefined`. The publish now runs only while its timeline is still the current one. The rebuild already published the state the pads hold, and edges recorded during it went onto the new timeline, so a stale window has nothing left to say. Regression test reproduces the race with a fake solver (fails before: one alter with an undefined level). --- .../circuit-simulation-service.test.ts | 47 +++++++++++++++++++ .../spice/CircuitSimulationService.ts | 12 ++++- 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/frontend/src/__tests__/circuit-simulation-service.test.ts b/frontend/src/__tests__/circuit-simulation-service.test.ts index 929c1bfe..eae49da7 100644 --- a/frontend/src/__tests__/circuit-simulation-service.test.ts +++ b/frontend/src/__tests__/circuit-simulation-service.test.ts @@ -792,6 +792,53 @@ describe('the publish window keeps every state the pads went through', () => { }); }); +describe('a publish window never outlives the deck it was recorded on', () => { + it('a window that closes behind a rebuild is dropped, not solved with the new pad order', async () => { + // XIAO Pod (velxio-prod, 2026-10): "Circuit solver problem: no such + // vector undefined" on Run. A window closed while a rebuild was already + // queued on the solver; it ran after the rebuild, and its levels (indexed + // by the OLD deck's pads) were paired with the NEW deck's sources. The + // new deck had one pad more, so that pad's level was undefined and + // ngspice got `alter V_uno_10 dc undefined`. + const fake = new FakeSolverAdapter({ vectors: { 'v(vcc_rail)': 5 }, solveDelayMs: 40 }); + __setSchedulerSolverFactoryForTests(() => fake); + let pads: Record = { '9': { type: 'digital', v: 0 } }; + const sim = makeSimStore({ + components: [ + { id: 'ra', metadataId: 'resistor', properties: { value: '1k' } }, + { id: 'rb', metadataId: 'resistor', properties: { value: '1k' } }, + ], + wires: [ + { id: 'w1', start: { componentId: 'uno', pinName: '9' }, end: { componentId: 'ra', pinName: '1' } }, + { id: 'w2', start: { componentId: 'ra', pinName: '2' }, end: { componentId: 'uno', pinName: 'GND' } }, + { id: 'w3', start: { componentId: 'uno', pinName: '10' }, end: { componentId: 'rb', pinName: '1' } }, + { id: 'w4', start: { componentId: 'rb', pinName: '2' }, end: { componentId: 'uno', pinName: 'GND' } }, + ], + boards: [{ id: 'uno', boardKind: 'arduino-uno' }], + }); + const elec = makeElectricalStore(); + const service = new CircuitSimulationService( + sim.port, elec.port, + getMixedModeScheduler() as unknown as MixedModeSchedulerPort, + { collectBoardPinStates: () => pads }, + ); + startTracked(service); + await new Promise((r) => setTimeout(r, 150)); // initial deck: one pad + + pads = { '9': { type: 'digital', v: 5 }, '10': { type: 'digital', v: 0 } }; + void service.handleMcuEdge('uno', '9', true, 5); // opens a window, solves 40 ms + // A canvas change queues a rebuild behind that solve; the window closes + // (33 ms) after it is queued, so its publish runs after the rebuild. + sim.set({ components: [...sim.port.getState().components] }); + await new Promise((r) => setTimeout(r, 400)); + + const bad = fake.calls.alterSource.filter(([, v]) => !Number.isFinite(v)); + expect(bad).toEqual([]); + // The new deck really did carry both pads, so the race was on. + expect(fake.calls.loadCircuit.at(-1) ?? '').toMatch(/V_uno_10 /); + }); +}); + describe('CircuitSimulationService — error handling', () => { it('logs but does not throw when solver fails', async () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); diff --git a/frontend/src/simulation/spice/CircuitSimulationService.ts b/frontend/src/simulation/spice/CircuitSimulationService.ts index 4f476bad..03e4ace8 100644 --- a/frontend/src/simulation/spice/CircuitSimulationService.ts +++ b/frontend/src/simulation/spice/CircuitSimulationService.ts @@ -471,9 +471,17 @@ export class CircuitSimulationService { private closeWindow(): void { this.windowTimer = null; if (this.stopped || !this.timeline) return; - const w = this.timeline.close(nowMs()); + const timeline = this.timeline; + const w = timeline.close(nowMs()); this.windowCount++; - void this.run(() => this.publishWindow(w)); + // The publish waits behind whatever the solver queue already holds, and + // that can be a rebuild. A rebuild replaces the pads (loadPads), and the + // window's levels are indexed by the OLD ones: solved against the new + // deck they pair the wrong source with each level, and a pad the new deck + // added gets `undefined` ("no such vector undefined"). The rebuild already + // published the state the pads hold, and anything recorded during it went + // onto the new timeline, so a stale window has nothing left to say. + void this.run(() => (this.timeline === timeline ? this.publishWindow(w) : Promise.resolve())); } private async publishWindow(w: WindowResult): Promise { From ac83b6519040da82021ffaa323ccd8c9e982765e Mon Sep 17 00:00:00 2001 From: David Montero Date: Thu, 15 Oct 2026 22:10:08 +0200 Subject: [PATCH 3/3] feat(catalog): advertise the PCM5102A I2S DAC as an online-only part --- frontend/src/lib/onlineOnlyBoards.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/frontend/src/lib/onlineOnlyBoards.ts b/frontend/src/lib/onlineOnlyBoards.ts index 6f2eee6d..a7e0e420 100644 --- a/frontend/src/lib/onlineOnlyBoards.ts +++ b/frontend/src/lib/onlineOnlyBoards.ts @@ -411,6 +411,15 @@ export const ONLINE_ONLY_COMPONENT_ADS: OnlineOnlyComponentAd[] = [ thumbnailSvg: "3008", }, + { + id: 'pro-pcm5102a', + label: 'PCM5102A I2S Stereo DAC', + description: + "Stereo I2S DAC with line out: an ESP32-S3 sketch's audio plays on your computer's speakers - available in the online editor", + category: 'output', + thumbnailSvg: + "", + }, { id: 'pro-pi-camera', label: 'Raspberry Pi Camera Module (CSI)',