Merge pull request #190 from davidmonterocrespo24/pi-phase-3.3-armhf

feat(pi): Phase 3.3 — Pi Zero / Pi 1 / Pi 2 armhf simulators
This commit is contained in:
David Montero Crespo 2026-05-18 19:42:09 -03:00 committed by GitHub
commit 6a1e506db6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 186 additions and 42 deletions

View File

@ -33,6 +33,37 @@
}
]
},
"raspberry-pi-armhf": {
"description": "Raspberry Pi Zero / 1 / 2 simulator on QEMU -M virt + cortex-a7. Velxio-built armhf kernel (Debian armmp) + initramfs (bundled ext4 + jbd2 + mbcache + crc16 + crc32c_generic + virtio_blk + virtio_mmio) + Alpine-based armhf rootfs. arm-32 virt PCI is broken (missing reg DT prop) so the QEMU command uses the mmio virtio transport; Pi Zero / Pi 1 share this image set with Pi 2 because the real ARMv6 silicon they have isn't supported by any QEMU-virt-capable upstream kernel.",
"images": [
{
"name": "velxio-kernel-armhf",
"asset_id": "velxio-kernel-armhf",
"sha256": "9d7377c33b9a5631e99ab109c8d3d9d89f99331b53eb8cc755cd76e775c9d0d5",
"size_bytes": 6033920,
"version": "2026.05+v6.12.88-armmp+ext4mods"
},
{
"name": "velxio-initramfs-armhf.cpio.gz",
"asset_id": "velxio-initramfs-armhf",
"sha256": "611eb3d219cb5d71a534d417dea564ed6eadbd2d1b40e33d28bb4a5a5f5829f5",
"size_bytes": 1390924,
"version": "2026.05+ext4mods"
},
{
"name": "velxio-pi-rootfs-armhf.ext4",
"asset_id": "velxio-pi-rootfs-armhf-zst",
"sha256": "5f2eafc0e76acc9ecc79da42313c1ffaacd577f42df5a58934d97ea167fab4ee",
"size_bytes": 629145600,
"version": "2026.05+phase2-shims-armhf",
"compressed": {
"encoding": "zstd",
"sha256": "ce9037b371aa1e847f2ce16bf6b0067c4ef51d48b66fe4c2d79941cbdff37b30",
"size_bytes": 23322299
}
}
]
},
"raspberry-pi-3": {
"description": "[DEPRECATED \u2014 kept for one release to ease rollback] Original raspi3b setup. Replaced by 'raspberry-pi-3-virt' (see above). qemu_manager.py no longer references this entry; do not add new clients here.",
"deprecated": true,

View File

@ -66,6 +66,7 @@ PI_CONFIGS: dict[str, dict] = {
'kernel': 'velxio-kernel-arm64',
'initramfs': 'velxio-initramfs-arm64.cpio.gz',
'rootfs': 'velxio-pi-rootfs-arm64.ext4',
'bus': 'pci',
},
'raspberry-pi-4': {
'qemu': 'qemu-system-aarch64',
@ -76,6 +77,7 @@ PI_CONFIGS: dict[str, dict] = {
'kernel': 'velxio-kernel-arm64',
'initramfs': 'velxio-initramfs-arm64.cpio.gz',
'rootfs': 'velxio-pi-rootfs-arm64.ext4',
'bus': 'pci',
},
'raspberry-pi-5': {
'qemu': 'qemu-system-aarch64',
@ -86,8 +88,60 @@ PI_CONFIGS: dict[str, dict] = {
'kernel': 'velxio-kernel-arm64',
'initramfs': 'velxio-initramfs-arm64.cpio.gz',
'rootfs': 'velxio-pi-rootfs-arm64.ext4',
'bus': 'pci',
},
# ── armhf (32-bit ARM, Pi Zero / 1 / 2) ─────────────────────────────
# QEMU virt for arm-32 does not probe PCI cleanly (the pci-host-generic
# node is missing the "reg" DT property and probe fails -75), so we
# use the MMIO virtio transport instead: -device virtio-blk-device /
# virtio-serial-device. The kernel here is linux-image-armmp (Debian
# generic ARMv7) which ships ext4 + virtio_mmio as MODULES — the
# armhf initramfs bundles ext4.ko + jbd2.ko + mbcache.ko + crc16.ko +
# crc32c_generic.ko and insmods them in dep order before /dev/vda
# mount. Pi Zero / Pi 1 are ARMv6 which the Debian armmp kernel does
# not target; Phase 3.3b tracks sourcing an ARMv6 kernel for those.
'raspberry-pi-2': {
'qemu': 'qemu-system-arm',
'cpu': 'cortex-a7',
'smp': '4',
'memory': '1G',
'image_set': 'raspberry-pi-armhf',
'kernel': 'velxio-kernel-armhf',
'initramfs': 'velxio-initramfs-armhf.cpio.gz',
'rootfs': 'velxio-pi-rootfs-armhf.ext4',
'bus': 'mmio',
},
# Pi 1 / Pi Zero are ARMv6 on real silicon (arm1176, BCM2835). Debian
# dropped the ARMv6 kernel and Alpine's linux-rpi kernel is wired for
# the actual BCM2835 hardware (no virtio_blk, no ext4 module) so it
# cannot boot on QEMU virt. We follow the project's "looks-like-a-Pi
# but isn't-exactly-a-Pi" architecture rule: serve them off the same
# ARMv7 armmp kernel as Pi 2, with the smaller RAM / SMP profile of
# the real boards. User code that uses RPi.GPIO / smbus2 / spidev
# behaves identically. We do not advertise ARMv6 anywhere in the
# rootfs (no /proc/cpuinfo lying).
'raspberry-pi-1': {
'qemu': 'qemu-system-arm',
'cpu': 'cortex-a7',
'smp': '1',
'memory': '512M',
'image_set': 'raspberry-pi-armhf',
'kernel': 'velxio-kernel-armhf',
'initramfs': 'velxio-initramfs-armhf.cpio.gz',
'rootfs': 'velxio-pi-rootfs-armhf.ext4',
'bus': 'mmio',
},
'raspberry-pi-zero': {
'qemu': 'qemu-system-arm',
'cpu': 'cortex-a7',
'smp': '1',
'memory': '512M',
'image_set': 'raspberry-pi-armhf',
'kernel': 'velxio-kernel-armhf',
'initramfs': 'velxio-initramfs-armhf.cpio.gz',
'rootfs': 'velxio-pi-rootfs-armhf.ext4',
'bus': 'mmio',
},
# Pi Zero/1/2 require armhf — added in Phase 3.3.
}
# Default board if the client doesn't specify one. Kept for clients
@ -315,6 +369,20 @@ 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.
# Virtio device suffix depends on the bus. arm64 virt has
# working PCI so we use the -pci variants there. arm-32 virt's
# pci-host-generic node has a broken DT (missing "reg" property)
# so the bus never enumerates — we have to fall back to the
# mmio variants. The same device names work for both blk and
# serial; only the suffix changes.
bus = cfg.get('bus', 'pci')
if bus == 'mmio':
blk_dev = 'virtio-blk-device,drive=rootfs'
serial_dev = 'virtio-serial-device,id=virtio-serial0'
else:
blk_dev = 'virtio-blk-pci,drive=rootfs'
serial_dev = 'virtio-serial-pci,id=virtio-serial0'
cmd = [
cfg['qemu'],
'-M', 'virt',
@ -323,12 +391,12 @@ class QemuManager:
'-m', cfg['memory'],
'-kernel', str(kernel_path),
'-initrd', str(initramfs_path),
# Root filesystem via virtio-blk over PCI. virt machine
# uses PCI as the primary virtio transport, so we use
# `virtio-blk-pci` (not `virtio-blk-device`, which is for
# mmio and silently leaves /dev/vda unregistered).
# Root filesystem via virtio-blk. arm64 uses the pci
# transport (works out of the box on virt-aarch64); armhf
# uses the mmio transport because PCI is broken on
# virt-arm32.
'-drive', f'if=none,file={inst.overlay_path},format=qcow2,id=rootfs',
'-device', 'virtio-blk-pci,drive=rootfs',
'-device', blk_dev,
# No default network / display / monitor / serial — we add
# exactly the two chardev-backed virtio-serial ports we
# need. -nographic auto-binds -serial mon:stdio which
@ -342,7 +410,7 @@ class QemuManager:
# port (replaces the old ttyAMA0 path).
'-chardev', f'socket,id=cons,host=127.0.0.1,port={inst.serial_port},'
f'server=on,wait=off',
'-device', 'virtio-serial-pci,id=virtio-serial0',
'-device', serial_dev,
'-device', 'virtconsole,chardev=cons',
# Protocol channel: pipe (FIFO pair) instead of a socket
# chardev. virtserialport on socket chardev has a known

View File

@ -41,8 +41,11 @@ const ALL_EXAMPLES: ExampleProject[] = [
* coverage gap visible at code-review time.
*/
const ACCEPTED_UNCOVERED: ReadonlySet<BoardKind> = new Set([
// Pi 3/4/5 run on the backend (QEMU ARM64) — no in-browser canvas
// example because they boot a full Linux image.
// Pi Zero / 1 / 2 / 3 / 4 / 5 run on the backend (QEMU ARM/ARM64) — no
// in-browser canvas example because they boot a full Linux image.
'raspberry-pi-zero',
'raspberry-pi-1',
'raspberry-pi-2',
'raspberry-pi-3',
'raspberry-pi-4',
'raspberry-pi-5',

View File

@ -67,6 +67,45 @@ export class ComponentRegistry {
// 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-zero',
tagName: 'velxio-raspberry-pi-3', // reuse 40-pin board art
name: 'Raspberry Pi Zero',
category: 'boards',
description: 'Raspberry Pi Zero with 40-pin GPIO. QEMU virt + Cortex-A7 (armhf) backend; presents the Pi Zero memory/SMP profile (1 core, 512 MB).',
thumbnail:
'<svg width="64" height="64" xmlns="http://www.w3.org/2000/svg"><rect width="64" height="64" fill="#7E2553" rx="4"/><text x="50%" y="50%" text-anchor="middle" dy=".3em" font-size="9" fill="#FFF">RPi0</text></svg>',
properties: [],
defaultValues: {},
pinCount: 40,
tags: ['raspberry', 'pi', 'pi-zero', 'board', 'qemu', 'linux'],
});
data.components.push({
id: 'raspberry-pi-1',
tagName: 'velxio-raspberry-pi-3', // reuse 40-pin board art
name: 'Raspberry Pi 1',
category: 'boards',
description: 'Raspberry Pi 1 Model B+ with 40-pin GPIO. QEMU virt + Cortex-A7 (armhf) backend; 1 core / 512 MB profile.',
thumbnail:
'<svg width="64" height="64" xmlns="http://www.w3.org/2000/svg"><rect width="64" height="64" fill="#A8324B" rx="4"/><text x="50%" y="50%" text-anchor="middle" dy=".3em" font-size="9" fill="#FFF">RPi1</text></svg>',
properties: [],
defaultValues: {},
pinCount: 40,
tags: ['raspberry', 'pi', 'rp1', 'board', 'qemu', 'linux'],
});
data.components.push({
id: 'raspberry-pi-2',
tagName: 'velxio-raspberry-pi-3',
name: 'Raspberry Pi 2',
category: 'boards',
description: 'Raspberry Pi 2 Model B with 40-pin GPIO. QEMU virt + Cortex-A7 (armhf) backend; 4 cores / 1 GB.',
thumbnail:
'<svg width="64" height="64" xmlns="http://www.w3.org/2000/svg"><rect width="64" height="64" fill="#C73E5A" rx="4"/><text x="50%" y="50%" text-anchor="middle" dy=".3em" font-size="9" fill="#FFF">RPi2</text></svg>',
properties: [],
defaultValues: {},
pinCount: 40,
tags: ['raspberry', 'pi', 'rp2', 'board', 'qemu', 'linux'],
});
data.components.push({
id: 'raspberry-pi-3',
tagName: 'velxio-raspberry-pi-3',

View File

@ -127,12 +127,9 @@ function isEsp32Bridge(boardKind: string): boolean {
}
function isPi3Bridge(boardKind: string): boolean {
// 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'
);
// Pi Zero / 1 / 2 / 3 / 4 / 5 all use the same backend bridge
// (QEMU virt + virtio-serial). Exclude raspberry-pi-pico (RP2040).
return boardKind.startsWith('raspberry-pi-') && boardKind !== 'raspberry-pi-pico';
}
/** Resolve `(componentId, pinName)` to a `(boardId, pinNumber)` pair. */

View File

@ -18,7 +18,7 @@ import type { I2CDevice } from '../simulation/I2CBusManager';
import type { RP2040I2CDevice } from '../simulation/RP2040Simulator';
import type { Wire, WireInProgress, WireEndpoint } from '../types/wire';
import type { BoardKind, BoardInstance, LanguageMode } from '../types/board';
import { BOARD_SUPPORTS_MICROPYTHON } from '../types/board';
import { BOARD_SUPPORTS_MICROPYTHON, isPiBoardKind } from '../types/board';
import { calculatePinPosition } from '../utils/pinPositionCalculator';
import { useOscilloscopeStore } from './useOscilloscopeStore';
import { RaspberryPi3Bridge } from '../simulation/RaspberryPi3Bridge';
@ -954,11 +954,7 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
const serialCallback = (ch: string) => appendSerial(id, ch);
if (
boardKind === 'raspberry-pi-3' ||
boardKind === 'raspberry-pi-4' ||
boardKind === 'raspberry-pi-5'
) {
if (isPiBoardKind(boardKind)) {
const bridge = new RaspberryPi3Bridge(id, boardKind);
bridge.onSerialData = (ch: string) => {
serialCallback(ch);
@ -1084,7 +1080,7 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
// Create the editor file group for this board
useEditorStore.getState().createFileGroup(`group-${id}`);
// Init VFS for Raspberry Pi 3 boards
if (boardKind === 'raspberry-pi-3') {
if (isPiBoardKind(boardKind)) {
useVfsStore.getState().initBoardVfs(id);
}
// ── Interconnect: register the board and rebuild routes ──────────
@ -1200,7 +1196,7 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
set({
activeBoardId: boardId,
// Sync legacy flat fields to this board's values
boardType: (board.boardKind === 'raspberry-pi-3'
boardType: (isPiBoardKind(board.boardKind)
? 'arduino-uno'
: board.boardKind) as BoardType,
boardPosition: { x: board.x, y: board.y },
@ -1247,7 +1243,7 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
}
} else {
const sim = getBoardSimulator(boardId);
if (sim && board.boardKind !== 'raspberry-pi-3') {
if (sim && !isPiBoardKind(board.boardKind)) {
try {
if (sim instanceof AVRSimulator) {
sim.loadHex(program);
@ -1369,7 +1365,7 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
const board = get().boards.find((b) => b.id === boardId);
if (!board) return;
if (board.boardKind === 'raspberry-pi-3') {
if (isPiBoardKind(board.boardKind)) {
getBoardBridge(boardId)?.connect();
} else if (isEsp32Kind(board.boardKind)) {
// Pre-register sensors connected to this board so the QEMU worker
@ -1532,7 +1528,7 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
const board = get().boards.find((b) => b.id === boardId);
if (!board) return;
if (board.boardKind === 'raspberry-pi-3') {
if (isPiBoardKind(board.boardKind)) {
getBoardBridge(boardId)?.disconnect();
} else if (isEsp32Kind(board.boardKind)) {
getEsp32Bridge(boardId)?.disconnect();
@ -1558,7 +1554,7 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
esp32Bridge.disconnect();
setTimeout(() => esp32Bridge.connect(), 500);
}
} else if (board.boardKind !== 'raspberry-pi-3') {
} else if (!isPiBoardKind(board.boardKind)) {
const sim = getBoardSimulator(boardId);
if (sim) {
sim.reset();
@ -2395,7 +2391,7 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
const board = get().boards.find((b) => b.id === boardId);
if (!board) return;
if (board.boardKind === 'raspberry-pi-3') {
if (isPiBoardKind(board.boardKind)) {
const bridge = getBoardBridge(boardId);
if (bridge) {
for (let i = 0; i < text.length; i++) {
@ -2424,7 +2420,7 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
serialWriteToBoard: (boardId: string, text: string) => {
const board = get().boards.find((b) => b.id === boardId);
if (!board) return;
if (board.boardKind === 'raspberry-pi-3') {
if (isPiBoardKind(board.boardKind)) {
const bridge = getBoardBridge(boardId);
if (bridge) {
for (let i = 0; i < text.length; i++) {

View File

@ -4,6 +4,9 @@ export type BoardKind =
| 'arduino-mega'
| 'raspberry-pi-pico' // RP2040, browser emulation
| 'pi-pico-w' // RP2040 + WiFi, browser emulation (WiFi ignored)
| 'raspberry-pi-zero' // QEMU virt + Cortex-A7 (armhf), backend — looks-like Pi Zero
| 'raspberry-pi-1' // QEMU virt + Cortex-A7 (armhf), backend — looks-like Pi 1
| 'raspberry-pi-2' // QEMU virt + Cortex-A7 (armhf), 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
@ -21,6 +24,13 @@ export type BoardKind =
export type LanguageMode = 'arduino' | 'micropython';
/** True for every Raspberry Pi backed by the QEMU bridge (Zero, 1, 2, 3, 4, 5).
* Excludes the Pico boards (RP2040, browser emulation). */
export function isPiBoardKind(kind: BoardKind | string): boolean {
return typeof kind === 'string' && kind.startsWith('raspberry-pi-')
&& kind !== 'raspberry-pi-pico';
}
export const BOARD_SUPPORTS_MICROPYTHON = new Set<BoardKind>([
'raspberry-pi-pico',
'pi-pico-w',
@ -72,6 +82,9 @@ export const BOARD_KIND_LABELS: Record<BoardKind, string> = {
'arduino-mega': 'Arduino Mega 2560',
'raspberry-pi-pico': 'Raspberry Pi Pico',
'pi-pico-w': 'Raspberry Pi Pico W',
'raspberry-pi-zero': 'Raspberry Pi Zero',
'raspberry-pi-1': 'Raspberry Pi 1B+',
'raspberry-pi-2': 'Raspberry Pi 2B',
'raspberry-pi-3': 'Raspberry Pi 3B',
'raspberry-pi-4': 'Raspberry Pi 4B',
'raspberry-pi-5': 'Raspberry Pi 5',
@ -94,6 +107,9 @@ export const BOARD_KIND_FQBN: Record<BoardKind, string | null> = {
'arduino-mega': 'arduino:avr:mega',
'raspberry-pi-pico': 'rp2040:rp2040:rpipico',
'pi-pico-w': 'rp2040:rp2040:rpipicow',
'raspberry-pi-zero': null,
'raspberry-pi-1': null,
'raspberry-pi-2': null,
'raspberry-pi-3': null,
'raspberry-pi-4': null,
'raspberry-pi-5': null,

View File

@ -144,15 +144,8 @@ 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;
// 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')
)
// Pi Zero/1/2/3/4/5 all share the same 40-pin GPIO header → same BCM table.
if ((boardKind as string).startsWith('raspberry-pi-'))
return PI3_BCM;
return ARDUINO_NANO; // default fallback: treat unknown as arduino-uno-like
}

View File

@ -5,6 +5,7 @@
import type { ExampleProject } from '../data/examples';
import type { BoardKind } from '../types/board';
import { isPiBoardKind } from '../types/board';
import { useEditorStore } from '../store/useEditorStore';
import { useSimulatorStore, DEFAULT_BOARD_POSITION } from '../store/useSimulatorStore';
import { useElectricalStore } from '../store/useElectricalStore';
@ -129,12 +130,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' || eb.boardKind === 'raspberry-pi-4' || eb.boardKind === 'raspberry-pi-5') ? 'main.cpp' : 'sketch.ino';
const filename = isPiBoardKind(eb.boardKind) ? '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' || eb.boardKind === 'raspberry-pi-4' || eb.boardKind === 'raspberry-pi-5')) {
if (eb.vfsFiles && isPiBoardKind(eb.boardKind)) {
const vfsState = useVfsStore.getState();
const tree = vfsState.getTree(boardId);
for (const [nodeId, node] of Object.entries(tree)) {
@ -147,7 +148,7 @@ export async function loadExample(
const firstArduinoIdx = example.boards.findIndex(
(eb) =>
eb.boardKind !== 'raspberry-pi-3' && eb.boardKind !== 'raspberry-pi-4' && eb.boardKind !== 'raspberry-pi-5' &&
!isPiBoardKind(eb.boardKind) &&
eb.boardKind !== 'esp32' &&
eb.boardKind !== 'esp32-s3' &&
eb.boardKind !== 'esp32-c3',
@ -242,7 +243,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' || liveBoard.boardKind === 'raspberry-pi-4' || liveBoard.boardKind === 'raspberry-pi-5') ? 'main.cpp' : 'sketch.ino';
const filename = isPiBoardKind(liveBoard.boardKind) ? 'main.cpp' : 'sketch.ino';
editorStore.loadFiles([{ name: filename, content: example.code }]);
} else {
// Truly board-less: write the placeholder code to whatever the editor