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:
'',
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:
+ '',
+ 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:
+ '',
+ 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