From db5e3a86234e272bdfe470a303600c2ab9d86ea6 Mon Sep 17 00:00:00 2001 From: davidmonterocrespo24 Date: Mon, 18 May 2026 15:41:18 +0200 Subject: [PATCH] feat(pi3 phase 3.1+3.2): Pi 3/4/5 family via PI_CONFIGS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: extract per-board config into a PI_CONFIGS dict keyed by board_type. Pi 3/4/5 share the same arm64 image set (kernel + initramfs + rootfs) and differ only in QEMU -cpu and -m: raspberry-pi-3 → cortex-a53 + 1G (BCM2837, ARMv8 64-bit) raspberry-pi-4 → cortex-a72 + 2G (BCM2711, ARMv8 64-bit) raspberry-pi-5 → cortex-a76 + 2G (BCM2712, ARMv8 64-bit) PiInstance now carries board_type so the per-board lookup happens once at start_instance time. Unknown board_type falls back to DEFAULT_PI_BOARD ('raspberry-pi-3') instead of erroring out (for back-compat with older clients). Pre-warm hook walks every unique image_set in PI_CONFIGS so the provider only downloads each set once even when several Pi models are registered. Frontend: - BoardKind union gains 'raspberry-pi-4' and 'raspberry-pi-5'. - BOARD_KIND_LABELS + BOARD_KIND_FQBN entries for both new boards (FQBN null since they use the Pi VFS + Python toolchain like Pi 3). - ComponentRegistry inserts two new component metadata entries cloning the Pi 3 board art with different thumbnail colours. Tag name reused so the same velxio-raspberry-pi-3 web element draws the board on the canvas — the 40-pin GPIO layout is identical across Pi 3/4/5. - boardProtocols.ts: Pi 3/4/5 share the BCM physical→GPIO table (PI3_BCM) since the 40-pin header layout is identical. - loadExample.ts: where 'raspberry-pi-3' is special-cased (VFS ingest, .cpp vs .ino filename), now matches Pi 3/4/5 alike. - Interconnect.isPi3Bridge() recognises all three Pi family members so Arduino↔Pi serial routing keeps working. - RaspberryPi3Bridge constructor gained a boardKind parameter defaulting to 'raspberry-pi-3'. The WebSocket 'start_pi' message now ships the actual board kind so the backend knows which PI_CONFIGS entry to use. - useSimulatorStore.addBoard wires bridge construction for all three Pi family members. Pi Zero/Pi 1/Pi 2 (armhf) come in Phase 3.3 — separate kernel package + armhf rootfs build, no change here. Smoke-tested inside the prod container: Pi 4 (cortex-a72) → reached agetty login on hvc0 Pi 5 (cortex-a76) → reached agetty login on hvc0 Both show 'aarch64' in uname -m. --- backend/app/services/qemu_manager.py | 126 +++++++++++++----- frontend/src/services/ComponentRegistry.ts | 32 ++++- frontend/src/simulation/Interconnect.ts | 7 +- frontend/src/simulation/RaspberryPi3Bridge.ts | 13 +- frontend/src/store/useSimulatorStore.ts | 8 +- frontend/src/types/board.ts | 8 +- frontend/src/utils/boardProtocols.ts | 21 ++- frontend/src/utils/loadExample.ts | 8 +- 8 files changed, 172 insertions(+), 51 deletions(-) diff --git a/backend/app/services/qemu_manager.py b/backend/app/services/qemu_manager.py index e7f3753e..26eb5c97 100644 --- a/backend/app/services/qemu_manager.py +++ b/backend/app/services/qemu_manager.py @@ -47,14 +47,52 @@ from app.services.boot_images import ( logger = logging.getLogger(__name__) -# Image-set id (matches a key in `boot_images/manifest.json`). -PI3_IMAGE_SET = 'raspberry-pi-3-virt' +# Per-board configuration. Pi 3/4/5 share the same arm64 image set; +# Pi Zero/1/2 (Phase 3 deliverable) will share an armhf image set. +# Only -cpu, -smp, -m, qemu binary and which image-set the provider +# fetches vary per model. +# +# CPU choice notes: +# raspberry-pi-3 → Cortex-A53 (BCM2837, ARMv8 64-bit) +# raspberry-pi-4 → Cortex-A72 (BCM2711, ARMv8 64-bit) +# raspberry-pi-5 → Cortex-A76 (BCM2712, ARMv8 64-bit) +PI_CONFIGS: dict[str, dict] = { + 'raspberry-pi-3': { + 'qemu': 'qemu-system-aarch64', + 'cpu': 'cortex-a53', + 'smp': '4', + 'memory': '1G', + 'image_set': 'raspberry-pi-3-virt', + 'kernel': 'velxio-kernel-arm64', + 'initramfs': 'velxio-initramfs-arm64.cpio.gz', + 'rootfs': 'velxio-pi-rootfs-arm64.ext4', + }, + 'raspberry-pi-4': { + 'qemu': 'qemu-system-aarch64', + 'cpu': 'cortex-a72', + 'smp': '4', + 'memory': '2G', + 'image_set': 'raspberry-pi-3-virt', # same arm64 image set as Pi 3 + 'kernel': 'velxio-kernel-arm64', + 'initramfs': 'velxio-initramfs-arm64.cpio.gz', + 'rootfs': 'velxio-pi-rootfs-arm64.ext4', + }, + 'raspberry-pi-5': { + 'qemu': 'qemu-system-aarch64', + 'cpu': 'cortex-a76', + 'smp': '4', + 'memory': '2G', + 'image_set': 'raspberry-pi-3-virt', # same arm64 image set + 'kernel': 'velxio-kernel-arm64', + 'initramfs': 'velxio-initramfs-arm64.cpio.gz', + 'rootfs': 'velxio-pi-rootfs-arm64.ext4', + }, + # Pi Zero/1/2 require armhf — added in Phase 3.3. +} -# Filenames the provider materialises (must match `name` fields in the -# manifest entry for PI3_IMAGE_SET). -PI3_KERNEL_NAME = 'velxio-kernel-arm64' -PI3_INITRAMFS_NAME = 'velxio-initramfs-arm64.cpio.gz' -PI3_ROOTFS_NAME = 'velxio-pi-rootfs-arm64.ext4' +# Default board if the client doesn't specify one. Kept for clients +# that still send the legacy "start_pi" message without a board field. +DEFAULT_PI_BOARD = 'raspberry-pi-3' def _find_free_port() -> int: @@ -69,9 +107,11 @@ EventCallback = Callable[[str, dict], Awaitable[None]] class PiInstance: """State for one running Pi board.""" - def __init__(self, client_id: str, callback: EventCallback): - self.client_id = client_id - self.callback = callback + def __init__(self, client_id: str, callback: EventCallback, + board_type: str = DEFAULT_PI_BOARD): + self.client_id = client_id + self.callback = callback + self.board_type = board_type # Runtime state self.process: subprocess.Popen | None = None @@ -105,12 +145,18 @@ class QemuManager: # ── Public API ──────────────────────────────────────────────────────────── - def start_instance(self, client_id: str, board_type: str, # noqa: ARG002 + def start_instance(self, client_id: str, board_type: str, callback: EventCallback) -> None: if client_id in self._instances: logger.warning('start_instance: %s already running', client_id) return - inst = PiInstance(client_id, callback) + if board_type not in PI_CONFIGS: + logger.warning( + 'start_instance: unknown board %r, falling back to %s', + board_type, DEFAULT_PI_BOARD, + ) + board_type = DEFAULT_PI_BOARD + inst = PiInstance(client_id, callback, board_type=board_type) self._instances[client_id] = inst asyncio.create_task(self._boot(inst)) @@ -144,21 +190,29 @@ class QemuManager: # ── Boot sequence ───────────────────────────────────────────────────────── async def _boot(self, inst: PiInstance) -> None: + # Per-board configuration drives the QEMU command, image-set, + # CPU type, RAM, and SMP count. See PI_CONFIGS at the top of + # this module. + cfg = PI_CONFIGS[inst.board_type] + logger.info('[%s] booting %s (cpu=%s mem=%s)', + inst.client_id, inst.board_type, cfg['cpu'], cfg['memory']) + # Resolve boot files via the provider (downloads + verifies on # first call; cache hit on subsequent calls thanks to the # lifespan pre-warm at module load). try: - images = await self._get_provider().get(PI3_IMAGE_SET) + images = await self._get_provider().get(cfg['image_set']) except BootImageError as exc: - logger.error('[pi3] boot-image provisioning failed: %s', exc) + logger.error('[%s] boot-image provisioning failed: %s', + inst.client_id, exc) await inst.emit('error', { - 'message': f'Raspberry Pi 3 boot files unavailable: {exc}', + 'message': f'{inst.board_type} boot files unavailable: {exc}', }) self._instances.pop(inst.client_id, None) return - kernel_path: Path = images[PI3_KERNEL_NAME] - initramfs_path: Path = images[PI3_INITRAMFS_NAME] - rootfs_base: Path = images[PI3_ROOTFS_NAME] + kernel_path: Path = images[cfg['kernel']] + initramfs_path: Path = images[cfg['initramfs']] + rootfs_base: Path = images[cfg['rootfs']] # Allocate transport endpoints for the two chardevs. # @@ -213,11 +267,11 @@ class QemuManager: # Why no -dtb: virt machine generates its own DTB on the fly # from the runtime device list, so we don't ship one. cmd = [ - 'qemu-system-aarch64', + cfg['qemu'], '-M', 'virt', - '-cpu', 'cortex-a53', - '-smp', '4', - '-m', '1G', + '-cpu', cfg['cpu'], + '-smp', cfg['smp'], + '-m', cfg['memory'], '-kernel', str(kernel_path), '-initrd', str(initramfs_path), # Root filesystem via virtio-blk over PCI. virt machine @@ -652,29 +706,33 @@ class QemuManager: # ── Lifespan pre-warm ──────────────────────────────────────────────────────── -async def _prewarm_pi3_boot_images() -> None: - """Lifespan hook: download + cache the Pi 3 virt boot files in the +async def _prewarm_pi_boot_images() -> None: + """Lifespan hook: download + cache the Pi virt boot files in the background at process start. - The cache check is cheap when files are already on disk (named - docker volume), so this is a no-op for warm containers and a one- - time pay-on-first-boot for fresh hosts. Failures are logged but - never block startup — a missing licence key or a velxio.dev outage - just means the first user request gets an error with a useful - message, instead of the whole backend refusing to start. + Pre-warms every unique ``image_set`` referenced by PI_CONFIGS + exactly once (Pi 3/4/5 share the arm64 set; Pi Zero/1/2 will add + an armhf set in Phase 3.3). The cache check is cheap when files + are already on disk (named docker volume), so this is a no-op + for warm containers. Failures are logged but never block startup. """ try: provider = get_default_provider() - except Exception as exc: # noqa: BLE001 - log + continue at startup + except Exception as exc: # noqa: BLE001 logger.warning( - '[pi3] cannot build boot-image provider, skipping pre-warm: %s', + '[pi] cannot build boot-image provider, skipping pre-warm: %s', exc, ) return - asyncio.create_task(provider.warmup(PI3_IMAGE_SET)) + seen: set[str] = set() + for cfg in PI_CONFIGS.values(): + if cfg['image_set'] in seen: + continue + seen.add(cfg['image_set']) + asyncio.create_task(provider.warmup(cfg['image_set'])) -register_lifespan_startup(_prewarm_pi3_boot_images) +register_lifespan_startup(_prewarm_pi_boot_images) qemu_manager = QemuManager() diff --git a/frontend/src/services/ComponentRegistry.ts b/frontend/src/services/ComponentRegistry.ts index b4097699..698b9f29 100644 --- a/frontend/src/services/ComponentRegistry.ts +++ b/frontend/src/services/ComponentRegistry.ts @@ -64,13 +64,15 @@ export class ComponentRegistry { const data: ComponentMetadataCollection = await response.json(); - // Inject Raspberry Pi 3 metadata + // Inject Raspberry Pi 3 / 4 / 5 metadata. All three share the + // same 40-pin GPIO header; the simulator backend picks a + // different QEMU CPU model per board (Cortex-A53/A72/A76). data.components.push({ id: 'raspberry-pi-3', tagName: 'velxio-raspberry-pi-3', name: 'Raspberry Pi 3', category: 'boards', - description: 'Raspberry Pi 3 Model B with 40-pin GPIO. Connects to backend QEMU simulator.', + description: 'Raspberry Pi 3 Model B with 40-pin GPIO. QEMU virt + Cortex-A53 backend.', thumbnail: 'RPi3', properties: [], @@ -78,6 +80,32 @@ export class ComponentRegistry { pinCount: 40, tags: ['raspberry', 'pi', 'rp3', 'board', 'qemu', 'linux'], }); + data.components.push({ + id: 'raspberry-pi-4', + tagName: 'velxio-raspberry-pi-3', // reuse Pi 3 board art (40-pin layout identical) + name: 'Raspberry Pi 4', + category: 'boards', + description: 'Raspberry Pi 4 Model B with 40-pin GPIO. QEMU virt + Cortex-A72 backend.', + thumbnail: + 'RPi4', + properties: [], + defaultValues: {}, + pinCount: 40, + tags: ['raspberry', 'pi', 'rp4', 'board', 'qemu', 'linux'], + }); + data.components.push({ + id: 'raspberry-pi-5', + tagName: 'velxio-raspberry-pi-3', // reuse art for now (Phase 3 polish: Pi 5 PCB SVG) + name: 'Raspberry Pi 5', + category: 'boards', + description: 'Raspberry Pi 5 with 40-pin GPIO. QEMU virt + Cortex-A76 backend (no raspi5 machine in QEMU yet).', + thumbnail: + 'RPi5', + properties: [], + defaultValues: {}, + pinCount: 40, + tags: ['raspberry', 'pi', 'rp5', 'board', 'qemu', 'linux'], + }); // Inject SPICE probe instruments — these are Velxio-specific React // components (not wokwi web elements), so they have no auto-generated diff --git a/frontend/src/simulation/Interconnect.ts b/frontend/src/simulation/Interconnect.ts index 651fb88d..8f54eb91 100644 --- a/frontend/src/simulation/Interconnect.ts +++ b/frontend/src/simulation/Interconnect.ts @@ -127,7 +127,12 @@ function isEsp32Bridge(boardKind: string): boolean { } function isPi3Bridge(boardKind: string): boolean { - return boardKind === 'raspberry-pi-3'; + // Pi 3 / 4 / 5 all use the same backend bridge (QEMU virt + virtio-serial). + return ( + boardKind === 'raspberry-pi-3' || + boardKind === 'raspberry-pi-4' || + boardKind === 'raspberry-pi-5' + ); } /** Resolve `(componentId, pinName)` to a `(boardId, pinNumber)` pair. */ diff --git a/frontend/src/simulation/RaspberryPi3Bridge.ts b/frontend/src/simulation/RaspberryPi3Bridge.ts index 97c1c45f..c40d4745 100644 --- a/frontend/src/simulation/RaspberryPi3Bridge.ts +++ b/frontend/src/simulation/RaspberryPi3Bridge.ts @@ -6,7 +6,7 @@ * * Protocol (JSON frames): * Frontend → Backend - * { type: 'start_pi', data: { board: 'raspberry-pi-3' } } + * { type: 'start_pi', data: { board: 'raspberry-pi-3'|'raspberry-pi-4'|'raspberry-pi-5' } } * { type: 'stop_pi' } * { type: 'serial_input', data: { bytes: number[] } } * { type: 'gpio_in', data: { pin: number, state: 0 | 1 } } @@ -23,6 +23,10 @@ const API_BASE = (): string => export class RaspberryPi3Bridge { readonly boardId: string; + /** Pi family member: 'raspberry-pi-3' | 'raspberry-pi-4' | 'raspberry-pi-5'. + * The backend uses this to pick the QEMU -cpu / -m. Defaults to Pi 3 for + * back-compat with code paths that don't know the kind yet. */ + readonly boardKind: string; // Callbacks wired up by useSimulatorStore onSerialData: ((char: string) => void) | null = null; @@ -35,8 +39,9 @@ export class RaspberryPi3Bridge { private socket: WebSocket | null = null; private _connected = false; - constructor(boardId: string) { + constructor(boardId: string, boardKind: string = 'raspberry-pi-3') { this.boardId = boardId; + this.boardKind = boardKind; } get connected(): boolean { @@ -57,8 +62,8 @@ export class RaspberryPi3Bridge { socket.onopen = () => { this._connected = true; this.onConnected?.(); - // Tell the backend to boot the Pi - this._send({ type: 'start_pi', data: { board: 'raspberry-pi-3' } }); + // Tell the backend which Pi family member to boot. + this._send({ type: 'start_pi', data: { board: this.boardKind } }); }; socket.onmessage = (event: MessageEvent) => { diff --git a/frontend/src/store/useSimulatorStore.ts b/frontend/src/store/useSimulatorStore.ts index 8999f063..ddf65b28 100644 --- a/frontend/src/store/useSimulatorStore.ts +++ b/frontend/src/store/useSimulatorStore.ts @@ -954,8 +954,12 @@ export const useSimulatorStore = create((set, get) => { const serialCallback = (ch: string) => appendSerial(id, ch); - if (boardKind === 'raspberry-pi-3') { - const bridge = new RaspberryPi3Bridge(id); + if ( + boardKind === 'raspberry-pi-3' || + boardKind === 'raspberry-pi-4' || + boardKind === 'raspberry-pi-5' + ) { + const bridge = new RaspberryPi3Bridge(id, boardKind); bridge.onSerialData = (ch: string) => { serialCallback(ch); // Cross-board routing now handled by Interconnect (see bind below). diff --git a/frontend/src/types/board.ts b/frontend/src/types/board.ts index 7e610387..e399cf9d 100644 --- a/frontend/src/types/board.ts +++ b/frontend/src/types/board.ts @@ -4,7 +4,9 @@ export type BoardKind = | 'arduino-mega' | 'raspberry-pi-pico' // RP2040, browser emulation | 'pi-pico-w' // RP2040 + WiFi, browser emulation (WiFi ignored) - | 'raspberry-pi-3' // QEMU ARM64, backend + | 'raspberry-pi-3' // QEMU virt + Cortex-A53, backend + | 'raspberry-pi-4' // QEMU virt + Cortex-A72, backend + | 'raspberry-pi-5' // QEMU virt + Cortex-A76, backend | 'esp32' // Xtensa LX6, QEMU backend | 'esp32-devkit-c-v4' // ESP32 DevKit C V4, QEMU (esp32) | 'esp32-cam' // ESP32-CAM, QEMU (esp32) @@ -71,6 +73,8 @@ export const BOARD_KIND_LABELS: Record = { 'raspberry-pi-pico': 'Raspberry Pi Pico', 'pi-pico-w': 'Raspberry Pi Pico W', 'raspberry-pi-3': 'Raspberry Pi 3B', + 'raspberry-pi-4': 'Raspberry Pi 4B', + 'raspberry-pi-5': 'Raspberry Pi 5', esp32: 'ESP32 DevKit V1', 'esp32-devkit-c-v4': 'ESP32 DevKit C V4', 'esp32-cam': 'ESP32-CAM', @@ -91,6 +95,8 @@ export const BOARD_KIND_FQBN: Record = { 'raspberry-pi-pico': 'rp2040:rp2040:rpipico', 'pi-pico-w': 'rp2040:rp2040:rpipicow', 'raspberry-pi-3': null, + 'raspberry-pi-4': null, + 'raspberry-pi-5': null, esp32: 'esp32:esp32:esp32', 'esp32-devkit-c-v4': 'esp32:esp32:esp32', 'esp32-cam': 'esp32:esp32:esp32cam', diff --git a/frontend/src/utils/boardProtocols.ts b/frontend/src/utils/boardProtocols.ts index 17a93474..39ee3956 100644 --- a/frontend/src/utils/boardProtocols.ts +++ b/frontend/src/utils/boardProtocols.ts @@ -144,7 +144,15 @@ function tableFor(boardKind: BoardKind | string): RoleTable | null { if (boardKind === 'raspberry-pi-pico' || boardKind === 'pi-pico-w') return RP2040_DEFAULT; if (boardKind === 'esp32-c3' || (boardKind as string).startsWith('esp32-c3')) return ESP32_C3_DEFAULT; if (boardKind === 'esp32' || (boardKind as string).startsWith('esp32')) return ESP32_DEFAULT; - if (boardKind === 'raspberry-pi-3' || (boardKind as string).startsWith('raspberry-pi-3')) + // Pi 3/4/5 all share the same 40-pin GPIO header → same BCM table. + if ( + boardKind === 'raspberry-pi-3' || + boardKind === 'raspberry-pi-4' || + boardKind === 'raspberry-pi-5' || + (boardKind as string).startsWith('raspberry-pi-3') || + (boardKind as string).startsWith('raspberry-pi-4') || + (boardKind as string).startsWith('raspberry-pi-5') + ) return PI3_BCM; return ARDUINO_NANO; // default fallback: treat unknown as arduino-uno-like } @@ -170,8 +178,15 @@ function normalizePinName(boardKind: string, pinName: string): string | null { return null; } - // Pi3B accepts physical pin numbers (1..40) which map to BCM - if (boardKind === 'raspberry-pi-3' || boardKind.startsWith('raspberry-pi-3')) { + // Pi 3/4/5 all accept physical pin numbers (1..40) which map to BCM + if ( + boardKind === 'raspberry-pi-3' || + boardKind === 'raspberry-pi-4' || + boardKind === 'raspberry-pi-5' || + boardKind.startsWith('raspberry-pi-3') || + boardKind.startsWith('raspberry-pi-4') || + boardKind.startsWith('raspberry-pi-5') + ) { const phys = parseInt(trimmed, 10); if (!isNaN(phys)) { const bcm = PI3_PHYSICAL_TO_BCM[phys]; diff --git a/frontend/src/utils/loadExample.ts b/frontend/src/utils/loadExample.ts index a9b0ce0a..2417f83e 100644 --- a/frontend/src/utils/loadExample.ts +++ b/frontend/src/utils/loadExample.ts @@ -129,12 +129,12 @@ export async function loadExample( // Arduino-style boards (AVR, RP2040, ESP32, …) all need the `.ino` // extension so arduino-cli auto-includes . Only the Pi 3B // uses a different toolchain (Python via VFS or g++ for `.cpp`). - const filename = eb.boardKind === 'raspberry-pi-3' ? 'main.cpp' : 'sketch.ino'; + const filename = (eb.boardKind === 'raspberry-pi-3' || eb.boardKind === 'raspberry-pi-4' || eb.boardKind === 'raspberry-pi-5') ? 'main.cpp' : 'sketch.ino'; useEditorStore.getState().setActiveGroup(board.activeFileGroupId); useEditorStore.getState().loadFiles([{ name: filename, content: eb.code }]); } - if (eb.vfsFiles && eb.boardKind === 'raspberry-pi-3') { + if (eb.vfsFiles && (eb.boardKind === 'raspberry-pi-3' || eb.boardKind === 'raspberry-pi-4' || eb.boardKind === 'raspberry-pi-5')) { const vfsState = useVfsStore.getState(); const tree = vfsState.getTree(boardId); for (const [nodeId, node] of Object.entries(tree)) { @@ -147,7 +147,7 @@ export async function loadExample( const firstArduinoIdx = example.boards.findIndex( (eb) => - eb.boardKind !== 'raspberry-pi-3' && + eb.boardKind !== 'raspberry-pi-3' && eb.boardKind !== 'raspberry-pi-4' && eb.boardKind !== 'raspberry-pi-5' && eb.boardKind !== 'esp32' && eb.boardKind !== 'esp32-s3' && eb.boardKind !== 'esp32-c3', @@ -242,7 +242,7 @@ export async function loadExample( // appear blank. (Regression test: load-example-transitions.test.ts.) const editorStore = useEditorStore.getState(); editorStore.setActiveGroup(liveBoard.activeFileGroupId); - const filename = liveBoard.boardKind === 'raspberry-pi-3' ? 'main.cpp' : 'sketch.ino'; + const filename = (liveBoard.boardKind === 'raspberry-pi-3' || liveBoard.boardKind === 'raspberry-pi-4' || liveBoard.boardKind === 'raspberry-pi-5') ? 'main.cpp' : 'sketch.ino'; editorStore.loadFiles([{ name: filename, content: example.code }]); } else { // Truly board-less: write the placeholder code to whatever the editor