feat(pi3 phase 3.1+3.2): Pi 3/4/5 family via PI_CONFIGS

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.
This commit is contained in:
davidmonterocrespo24 2026-05-18 15:41:18 +02:00
parent 32f5b407af
commit db5e3a8623
8 changed files with 172 additions and 51 deletions

View File

@ -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()

View File

@ -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:
'<svg width="64" height="64" xmlns="http://www.w3.org/2000/svg"><rect width="64" height="64" fill="#E60049" rx="4"/><text x="50%" y="50%" text-anchor="middle" dy=".3em" font-size="10" fill="#FFF">RPi3</text></svg>',
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:
'<svg width="64" height="64" xmlns="http://www.w3.org/2000/svg"><rect width="64" height="64" fill="#83B81A" rx="4"/><text x="50%" y="50%" text-anchor="middle" dy=".3em" font-size="10" fill="#FFF">RPi4</text></svg>',
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:
'<svg width="64" height="64" xmlns="http://www.w3.org/2000/svg"><rect width="64" height="64" fill="#76323F" rx="4"/><text x="50%" y="50%" text-anchor="middle" dy=".3em" font-size="10" fill="#FFF">RPi5</text></svg>',
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

View File

@ -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. */

View File

@ -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) => {

View File

@ -954,8 +954,12 @@ export const useSimulatorStore = create<SimulatorState>((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).

View File

@ -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<BoardKind, string> = {
'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<BoardKind, string | null> = {
'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',

View File

@ -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];

View File

@ -129,12 +129,12 @@ export async function loadExample(
// Arduino-style boards (AVR, RP2040, ESP32, …) all need the `.ino`
// extension so arduino-cli auto-includes <Arduino.h>. 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