From 3348e1fa4815143371493361f791e298401d33fe Mon Sep 17 00:00:00 2001 From: David Montero Date: Tue, 25 Aug 2026 19:22:29 +0200 Subject: [PATCH] fix(boards): a QEMU-Linux overlay board is not a Raspberry Pi Two answers about the UNIHIKER M10 came from the family whose run path it borrows rather than from the board itself. The upgrade prompt told a free user that "Raspberry Pi emulation is a Pro feature" about a DFRobot SBC: proBoardFeatureName named the family, so every piFamily overlay board inherited the Pi's name. It now uses the board's own label whenever the kind is registered; the OSS families keep theirs. loadExample gated the guest-script branch on isPiBoardKind(), which reads a registry the overlay only fills at mount. A direct /example/ link could load before that: the branch was skipped and the editor opened on the board group's Arduino blink default instead of the example's script.py. vfsFiles is itself the declaration that this board's code is a guest script, so the branch no longer consults the registry. The server-side gate message names the two families instead of one board, since the QEMU-Linux lane also carries boards that are not Raspberry Pis. Test covers both, with the unregistered-kind race reproduced. --- backend/app/services/board_access.py | 9 +- .../overlay-pi-family-example.test.ts | 90 +++++++++++++++++++ frontend/src/lib/proBoardGate.ts | 13 ++- frontend/src/utils/loadExample.ts | 9 +- 4 files changed, 117 insertions(+), 4 deletions(-) create mode 100644 frontend/src/__tests__/overlay-pi-family-example.test.ts diff --git a/backend/app/services/board_access.py b/backend/app/services/board_access.py index 3ec3f8ba..df9fd69f 100644 --- a/backend/app/services/board_access.py +++ b/backend/app/services/board_access.py @@ -30,9 +30,14 @@ BoardAccessGate = Callable[[object, str], Awaitable[bool]] _gate: Optional[BoardAccessGate] = None +# Deliberately names the families, not one board: the QEMU-Linux lane also +# carries boards that are not Raspberry Pis (the UNIHIKER M10 boots through +# the same bridge), and telling their user that "Raspberry Pi emulation" is +# gated is a message about a board they did not place. PRO_BOARD_MESSAGE = ( - 'STM32 and Raspberry Pi emulation are Velxio Pro features. ' - 'Use them on velxio.dev with a paid plan, or install Velxio Desktop.' + 'STM32 and QEMU-emulated Linux boards (Raspberry Pi, UNIHIKER) are ' + 'Velxio Pro features. Use them on velxio.dev with a paid plan, or ' + 'install Velxio Desktop.' ) diff --git a/frontend/src/__tests__/overlay-pi-family-example.test.ts b/frontend/src/__tests__/overlay-pi-family-example.test.ts new file mode 100644 index 00000000..90a2ba83 --- /dev/null +++ b/frontend/src/__tests__/overlay-pi-family-example.test.ts @@ -0,0 +1,90 @@ +// @vitest-environment jsdom +/** + * Overlay QEMU-Linux boards (ProBoardDef.piFamily) in the example loader and + * in the Pro upgrade prompt. + * + * Both used to answer as if the board were a Raspberry Pi, because both read + * a registry the overlay only fills at mount: + * + * - `loadExample` gated the guest-script branch on `isPiBoardKind()`. A + * direct /example/ link can load before the overlay registers its + * kinds (the pro overlay registered the partner examples first), so the + * branch was skipped and the editor opened on the board group's Arduino + * blink default instead of the example's script.py. + * - `proBoardFeatureName` named the FAMILY, so a free user clicking Run on + * a UNIHIKER M10 was told "Raspberry Pi emulation is a Pro feature" about + * a board that is not a Raspberry Pi. + */ +import { describe, it, expect, beforeEach } from 'vitest'; +import { useSimulatorStore } from '../store/useSimulatorStore'; +import { useEditorStore } from '../store/useEditorStore'; +import { useElectricalStore } from '../store/useElectricalStore'; +import { registerProBoards } from '../lib/proBoardRegistry'; +import { proBoardFeatureName } from '../lib/proBoardGate'; +import { loadExample } from '../utils/loadExample'; +import type { ExampleProject } from '../data/examples'; +import type { BoardKind } from '../types/board'; + +// Never registered — stands in for a board whose overlay import has not +// landed yet at load time. +const UNREGISTERED = 'linux-test-unregistered'; +// Registered below, as the overlay does at mount. +const REGISTERED = 'linux-test-registered'; + +const SCRIPT = '# guest script\nprint("hello from the guest")\n'; + +const exampleFor = (kind: string): ExampleProject => + ({ + id: `example-${kind}`, + title: `${kind} example`, + description: 'guest script uploaded to the QEMU-Linux board', + category: 'basics', + difficulty: 'beginner', + boardType: kind, + code: '', + boards: [{ boardKind: kind, x: 40, y: 40, vfsFiles: { 'script.py': SCRIPT } }], + components: [], + wires: [], + }) as unknown as ExampleProject; + +function resetStores() { + const sim = useSimulatorStore.getState(); + for (const id of sim.boards.map((b) => b.id)) sim.removeBoard(id); + useElectricalStore.getState().setPaused(false); +} + +describe('overlay QEMU-Linux boards', () => { + beforeEach(resetStores); + + it('loads the example guest script even before the kind is registered', async () => { + await loadExample(exampleFor(UNREGISTERED)); + + const sim = useSimulatorStore.getState(); + expect(sim.boards).toHaveLength(1); + const files = useEditorStore.getState().fileGroups[sim.boards[0].activeFileGroupId] ?? []; + expect(files.map((f) => f.name)).toContain('script.py'); + expect(files.find((f) => f.name === 'script.py')?.content).toBe(SCRIPT); + // The board group's Arduino default must be gone, not sitting next to it. + expect(files.some((f) => (f.content ?? '').includes('Arduino Blink Example'))).toBe(false); + }); + + it('names the board itself in the upgrade prompt, not the family it rides', () => { + registerProBoards([ + { + kind: REGISTERED, + label: 'Test Linux SBC', + fqbn: null, + description: 'overlay QEMU-Linux board', + tag: 'velxio-linux-test', + size: { w: 100, h: 100 }, + piFamily: true, + }, + ]); + + expect(proBoardFeatureName(REGISTERED)).toBe('Test Linux SBC emulation'); + // The OSS families keep their family names — every kind in them is one. + expect(proBoardFeatureName('raspberry-pi-4' as BoardKind)).toBe('Raspberry Pi emulation'); + expect(proBoardFeatureName('stm32-bluepill' as BoardKind)).toBe('STM32 emulation'); + expect(proBoardFeatureName('arduino-uno' as BoardKind)).toBe('this board'); + }); +}); diff --git a/frontend/src/lib/proBoardGate.ts b/frontend/src/lib/proBoardGate.ts index acec4bff..29ddc1ee 100644 --- a/frontend/src/lib/proBoardGate.ts +++ b/frontend/src/lib/proBoardGate.ts @@ -23,6 +23,7 @@ import type { BoardKind } from '../types/board'; import { isPiBoardKind, isStm32BoardKind } from '../types/board'; +import { getProBoard } from './proBoardRegistry'; export type BoardGateDecision = 'allow' | 'block'; @@ -82,8 +83,18 @@ export function triggerProUpgradePrompt(featureName: string): void { ); } -/** Human label for the upgrade prompt, per board family. */ +/** + * Human label for the upgrade prompt. + * + * An overlay board is named by its own label, not by the family whose run + * path it borrows: `piFamily` routes the UNIHIKER M10 through the Raspberry + * Pi bridge, and the prompt told its user that "Raspberry Pi emulation is a + * Pro feature" about a board that is not a Raspberry Pi. The OSS families + * keep their family names — every kind in them really is one. + */ export function proBoardFeatureName(kind: BoardKind | string): string { + const def = typeof kind === 'string' ? getProBoard(kind) : undefined; + if (def) return `${def.label} emulation`; if (isStm32BoardKind(kind)) return 'STM32 emulation'; if (isPiBoardKind(kind)) return 'Raspberry Pi emulation'; return 'this board'; diff --git a/frontend/src/utils/loadExample.ts b/frontend/src/utils/loadExample.ts index 4a405480..4e11cf57 100644 --- a/frontend/src/utils/loadExample.ts +++ b/frontend/src/utils/loadExample.ts @@ -189,7 +189,14 @@ export async function loadExample( useSimulatorStore.getState().updateBoard(boardId, { sdFiles: eb.sdFiles }); } - if (eb.vfsFiles && isPiBoardKind(eb.boardKind)) { + // `vfsFiles` IS the declaration that this board's code is a guest + // script — no isPiBoardKind() check. That guard read the Pi-family + // registry, which an overlay fills at mount: a direct /example/ + // link to an overlay QEMU-Linux board (UNIHIKER M10) could load before + // its kind was registered, the branch was skipped, and the editor + // opened on the board group's Arduino blink default instead of the + // example's script. Only QEMU-Linux examples carry vfsFiles. + if (eb.vfsFiles) { // Pi example scripts go into the board's REGULAR file group — they // are edited in Monaco like any other board's code, and the run // path uploads the group into the guest home. (The old separate