feat: add documentation page and Arduino serial integration test

- Created a new DocsPage component for project documentation with links to GitHub and Discord.
- Added Arduino sketch for serial communication test between Raspberry Pi and Arduino.
- Implemented avr_runner.js to emulate ATmega328P and bridge serial communication over TCP.
- Developed a Python test script to validate the serial integration between the emulated Raspberry Pi and Arduino.
This commit is contained in:
David Montero Crespo 2026-03-12 08:17:29 -03:00
parent b63a068307
commit 13997ff491
22 changed files with 1807 additions and 172 deletions

3
.gitignore vendored
View File

@ -78,3 +78,6 @@ wokwi-libs/*/.cache/
.history/*
.daveagent/*
data/*
.publicar/*
.publicar_discord/*
img/*

View File

@ -0,0 +1,63 @@
import json
import logging
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from app.services.qemu_manager import qemu_manager
router = APIRouter()
logger = logging.getLogger(__name__)
class ConnectionManager:
def __init__(self):
self.active_connections: dict[str, WebSocket] = {}
async def connect(self, websocket: WebSocket, client_id: str):
await websocket.accept()
self.active_connections[client_id] = websocket
def disconnect(self, client_id: str):
if client_id in self.active_connections:
self.active_connections.pop(client_id, None)
async def send_personal_message(self, message: str, client_id: str):
if client_id in self.active_connections:
await self.active_connections[client_id].send_text(message)
manager = ConnectionManager()
@router.websocket("/ws/{client_id}")
async def simulation_websocket(websocket: WebSocket, client_id: str):
await manager.connect(websocket, client_id)
# Callback for QEMU manager to send data to frontend
async def qemu_callback(event_type: str, data: dict):
payload = json.dumps({"type": event_type, "data": data})
await manager.send_personal_message(payload, client_id)
try:
while True:
data = await websocket.receive_text()
message = json.loads(data)
msg_type = message.get("type")
msg_data = message.get("data", {})
if msg_type == "start_pi":
board = msg_data.get("board", "raspberry-pi-3")
qemu_manager.start_instance(client_id, board, qemu_callback)
elif msg_type == "stop_pi":
qemu_manager.stop_instance(client_id)
elif msg_type == "pin_change":
# Received from frontend (Arduino changed a pin connected to Pi)
pin = msg_data.get("pin")
state = msg_data.get("state")
qemu_manager.set_pin_state(client_id, pin, state)
except WebSocketDisconnect:
manager.disconnect(client_id)
qemu_manager.stop_instance(client_id)
except Exception as e:
logger.error(f"WebSocket error for {client_id}: {e}")
manager.disconnect(client_id)
qemu_manager.stop_instance(client_id)

View File

@ -56,6 +56,9 @@ app.include_router(auth_router, prefix="/api/auth", tags=["auth"])
app.include_router(projects_router, prefix="/api", tags=["projects"])
app.include_router(admin_router, prefix="/api/admin", tags=["admin"])
# WebSockets
from app.api.routes import simulation
app.include_router(simulation.router, prefix="/api/simulation", tags=["simulation"])
@app.get("/")
def root():

View File

@ -0,0 +1,125 @@
import asyncio
import logging
import os
import subprocess
from typing import Dict, Callable, Awaitable, Any
logger = logging.getLogger(__name__)
class QemuManager:
"""
Manages the QEMU subprocess for Raspberry Pi emulation.
"""
def __init__(self):
self.running_instances: Dict[str, dict] = {}
self.callbacks: Dict[str, Callable[[str, dict], Awaitable[None]]] = {}
# Paths for QEMU execution
self.img_dir = r"e:\Hardware\wokwi_clon\img"
self.kernel_path = os.path.join(self.img_dir, "kernel_extracted.img")
self.dtb_path = os.path.join(self.img_dir, "bcm271~1.dtb")
self.sd_path = os.path.join(self.img_dir, "2025-12-04-raspios-trixie-armhf.img")
def start_instance(self, client_id: str, board_type: str, callback: Callable[[str, dict], Awaitable[None]]):
"""Starts a new QEMU emulator instance for the given client."""
logger.info(f"Starting REAL QEMU instance for client {client_id}, board: {board_type}")
self.running_instances[client_id] = {
"board": board_type,
"status": "booting",
"pins": {},
"process": None
}
self.callbacks[client_id] = callback
# Check if files exist
if not os.path.exists(self.kernel_path) or not os.path.exists(self.sd_path):
logger.error("Missing QEMU image or kernel files!")
asyncio.create_task(self.send_event_to_frontend(client_id, "error", {"message": "Missing QEMU boot files"}))
return
# Start QEMU in a background task
asyncio.create_task(self._launch_qemu(client_id))
async def _launch_qemu(self, client_id: str):
try:
# QEMU Command for Raspberry Pi 3 (32-bit armhf)
cmd = [
"qemu-system-arm",
"-M", "raspi3b",
"-kernel", self.kernel_path,
"-dtb", self.dtb_path,
"-drive", f"file={self.sd_path},if=sd,format=raw",
"-append", "console=ttyAMA0 root=/dev/mmcblk0p2 rootwait dwc_otg.lpm_enable=0",
"-m", "1G",
"-smp", "4",
"-nographic",
"-serial", "mon:stdio"
]
logger.info(f"Executing QEMU: {' '.join(cmd)}")
# Use asyncio subprocess for non-blocking execution
process = await asyncio.create_subprocess_exec(
*cmd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
stdin=subprocess.PIPE
)
self.running_instances[client_id]["process"] = process
self.running_instances[client_id]["status"] = "running"
await self.send_event_to_frontend(client_id, "system", {"event": "booted"})
# Start background tasks to monitor QEMU output
asyncio.create_task(self._monitor_qemu(client_id, process.stdout, "serial_output"))
asyncio.create_task(self._monitor_qemu(client_id, process.stderr, "system_error"))
except Exception as e:
logger.error(f"Failed to launch QEMU: {e}")
await self.send_event_to_frontend(client_id, "error", {"message": str(e)})
async def _monitor_qemu(self, client_id: str, stream, event_type: str):
"""Monitors a QEMU stream and forwards it to the frontend."""
try:
while True:
line = await stream.readline()
if not line:
break
decoded_line = line.decode("ascii", "ignore")
await self.send_event_to_frontend(client_id, event_type, {"data": decoded_line})
except Exception as e:
logger.error(f"Error monitoring QEMU {event_type}: {e}")
def stop_instance(self, client_id: str):
"""Stops the QEMU instance."""
if client_id in self.running_instances:
instance = self.running_instances[client_id]
process = instance.get("process")
if process:
try:
process.terminate()
except:
pass
self.running_instances.pop(client_id, None)
if client_id in self.callbacks:
self.callbacks.pop(client_id, None)
def set_pin_state(self, client_id: str, pin: str, state: int):
"""Called when the Arduino/Frontend changes a pin connected to the Pi."""
if client_id in self.running_instances:
self.running_instances[client_id]["pins"][pin] = state
# For a real implementation, we would send this to the QEMU guest
# via a virtual character device or a custom protocol.
# Currently we maintain state internally for verification.
logger.debug(f"Pi {client_id} Pin {pin} set to {state}")
async def send_event_to_frontend(self, client_id: str, event_type: str, data: dict):
if client_id in self.callbacks:
try:
await self.callbacks[client_id](event_type, data)
except Exception as e:
logger.error(f"Failed to send event to frontend: {e}")
qemu_manager = QemuManager()

30
backend/debug_qemu.py Normal file
View File

@ -0,0 +1,30 @@
import asyncio
import sys
import os
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
from app.services.qemu_manager import qemu_manager
async def test_real_qemu():
print("Testing real QEMU launch...")
async def callback(event_type, data):
if event_type == "serial_output":
print(f"[SERIAL] {data.get('data')}", end="")
if "Booting Linux" in data.get("data", ""):
print("\n✓ Linux boot detected in serial!")
else:
print(f"[{event_type.upper()}] {data}")
qemu_manager.start_instance("debug-client", "raspberry-pi-3", callback)
print("Waiting 30 seconds for boot output...")
# Wait some time to see if QEMU starts and prints something
await asyncio.sleep(30)
qemu_manager.stop_instance("debug-client")
print("Test finished.")
if __name__ == "__main__":
asyncio.run(test_real_qemu())

136
backend/test_simulation.py Normal file
View File

@ -0,0 +1,136 @@
import asyncio
import sys
import os
from pathlib import Path
# Set UTF-8 encoding for Windows console
if os.name == 'nt':
if hasattr(sys.stdout, 'reconfigure'):
sys.stdout.reconfigure(encoding='utf-8')
if hasattr(sys.stderr, 'reconfigure'):
sys.stderr.reconfigure(encoding='utf-8')
sys.path.insert(0, str(Path(__file__).parent))
from app.services.qemu_manager import qemu_manager
def test_qemu_manager_starts_stops():
"""Test that the QemuManager can register and unregister instances."""
print("\n=== Test 1: QemuManager Instance tracking ===")
async def dummy_callback(event, data):
pass
# Directly set state to avoid asyncio.create_task outside a running event loop
qemu_manager.running_instances["test-client"] = {
"board": "raspberry-pi-3",
"status": "running",
"pins": {},
"process": None,
}
qemu_manager.callbacks["test-client"] = dummy_callback
assert "test-client" in qemu_manager.running_instances
assert qemu_manager.running_instances["test-client"]["status"] == "running"
print("✓ QEMU instance tracked correctly upon start")
qemu_manager.stop_instance("test-client")
assert "test-client" not in qemu_manager.running_instances
print("✓ QEMU instance removed correctly upon stop")
async def _test_websocket_flow_async():
"""Async core of the WebSocket simulation flow test."""
print("\n=== Test 2: WebSocket Communication Flow ===")
received_events: list[dict] = []
async def ws_callback(event_type: str, data: dict):
received_events.append({"type": event_type, "data": data})
print(f" [WS Event] type={event_type}, data={data}")
# --- start_pi simulation ---
qemu_manager.running_instances["test-ws-client"] = {
"board": "raspberry-pi-3",
"status": "running",
"pins": {},
"process": None,
}
qemu_manager.callbacks["test-ws-client"] = ws_callback
assert "test-ws-client" in qemu_manager.running_instances
print("✓ start_pi: QemuManager instance registered")
# --- send a system 'booted' event via the callback ---
await qemu_manager.send_event_to_frontend("test-ws-client", "system", {"event": "booted"})
assert any(e["type"] == "system" for e in received_events)
print("✓ send_event_to_frontend: booted event delivered to callback")
# --- pin_change ---
qemu_manager.set_pin_state("test-ws-client", "18", 1)
assert qemu_manager.running_instances["test-ws-client"]["pins"].get("18") == 1
print("✓ pin_change: Pin=18 set to HIGH (1)")
qemu_manager.set_pin_state("test-ws-client", "18", 0)
assert qemu_manager.running_instances["test-ws-client"]["pins"].get("18") == 0
print("✓ pin_change: Pin=18 set to LOW (0)")
# --- stop_pi ---
qemu_manager.stop_instance("test-ws-client")
assert "test-ws-client" not in qemu_manager.running_instances
print("✓ stop_pi: instance removed from manager")
def test_websocket_simulation_flow():
asyncio.run(_test_websocket_flow_async())
def test_qemu_files_exist():
"""Verify that the required QEMU boot files are present."""
print("\n=== Test 3: QEMU Boot Files ===")
files = {
"kernel": qemu_manager.kernel_path,
"dtb": qemu_manager.dtb_path,
"sd img": qemu_manager.sd_path,
}
all_ok = True
for label, path in files.items():
exists = os.path.exists(path)
status = "" if exists else "✗ MISSING"
print(f" {status} {label}: {path}")
if not exists:
all_ok = False
if all_ok:
print("✓ All QEMU boot files found")
else:
print("⚠ Some files are missing — QEMU will not launch until they are present")
# Not a hard failure: missing files are expected during early development
return all_ok
def run_all_tests():
print("=" * 60)
print("SIMULATION BACKEND TEST SUITE")
print("=" * 60)
try:
test_qemu_manager_starts_stops()
test_websocket_simulation_flow()
test_qemu_files_exist()
print("\n✅ All WebSocket & Simulation tests passed!")
return True
except AssertionError as e:
print(f"\n❌ Test assertion failed: {e}")
return False
except Exception as e:
import traceback
print(f"\n❌ Unexpected error: {e}")
traceback.print_exc()
return False
if __name__ == "__main__":
success = run_all_tests()
sys.exit(0 if success else 1)

View File

@ -28,6 +28,8 @@ body {
flex-shrink: 0;
display: flex;
align-items: center;
position: relative;
z-index: 100;
}
.header-content {
@ -50,6 +52,7 @@ body {
letter-spacing: 0.2px;
}
/* Kept for backward compatibility */
.examples-link {
display: flex;
align-items: center;
@ -71,6 +74,77 @@ body {
border-color: #555;
}
/* ── Unified nav links ──────────────────────────── */
.header-nav-links {
display: flex;
align-items: center;
gap: 2px;
}
.header-nav-link {
display: flex;
align-items: center;
gap: 5px;
padding: 5px 11px;
color: #9d9d9d;
text-decoration: none;
border-radius: 4px;
font-size: 13px;
font-weight: 500;
transition: color 0.15s, background 0.15s;
white-space: nowrap;
}
.header-nav-link:hover {
color: #e0e0e0;
background: #2a2d2e;
}
.header-nav-link-active {
color: #e0e0e0;
background: #2a2d2e;
}
.header-nav-discord {
color: #7289da;
}
.header-nav-discord:hover {
color: #8fa8f5;
}
.header-right {
display: flex;
align-items: center;
gap: 8px;
}
/* Mobile hamburger */
.header-hamburger {
display: none;
flex-direction: column;
justify-content: center;
gap: 4px;
width: 30px;
height: 30px;
background: transparent;
border: none;
cursor: pointer;
padding: 4px;
}
.header-hamburger span {
display: block;
height: 2px;
background: #9d9d9d;
border-radius: 2px;
transition: background 0.15s;
}
.header-hamburger:hover span {
background: #e0e0e0;
}
/* ── Main panels container ──────────────────────── */
.app-container {
flex: 1;
@ -247,4 +321,34 @@ body {
.examples-link {
padding: 6px 8px;
}
/* Mobile nav: hide links, show hamburger, dropdown on open */
.header-nav-links {
display: none;
position: absolute;
top: 44px;
left: 0;
right: 0;
background: #252526;
border-bottom: 1px solid #007acc;
flex-direction: column;
align-items: stretch;
gap: 0;
padding: 6px 0;
z-index: 200;
}
.header-nav-links.header-nav-open {
display: flex;
}
.header-nav-link {
padding: 10px 16px;
border-radius: 0;
font-size: 14px;
}
.header-hamburger {
display: flex;
}
}

View File

@ -3,6 +3,7 @@ import { BrowserRouter as Router, Routes, Route } from 'react-router-dom';
import { LandingPage } from './pages/LandingPage';
import { EditorPage } from './pages/EditorPage';
import { ExamplesPage } from './pages/ExamplesPage';
import { DocsPage } from './pages/DocsPage';
import { LoginPage } from './pages/LoginPage';
import { RegisterPage } from './pages/RegisterPage';
import { UserProfilePage } from './pages/UserProfilePage';
@ -25,6 +26,7 @@ function App() {
<Route path="/" element={<LandingPage />} />
<Route path="/editor" element={<EditorPage />} />
<Route path="/examples" element={<ExamplesPage />} />
<Route path="/docs" element={<DocsPage />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route path="/admin" element={<AdminPage />} />

View File

@ -0,0 +1,119 @@
const PI_WIDTH = 250;
const PI_HEIGHT = 160;
class RaspberryPi3Element extends HTMLElement {
constructor() {
super();
this.attachShadow({ mode: 'open' });
}
connectedCallback() {
this.render();
}
get pinInfo() {
const pins = [];
const startX = 20;
const startY = 10;
const pinSpacing = 10;
// 40-pin header (2 rows of 20)
for (let i = 0; i < 20; i++) {
// Row 1 (odd pins 1, 3, 5...)
pins.push({
name: `${i * 2 + 1}`,
x: startX + i * pinSpacing,
y: startY,
signals: []
});
// Row 2 (even pins 2, 4, 6...)
pins.push({
name: `${i * 2 + 2}`,
x: startX + i * pinSpacing,
y: startY + pinSpacing,
signals: []
});
}
return pins;
}
render() {
let pinsSvg = '';
const pins = this.pinInfo;
pins.forEach(pin => {
// Pin gold plating
pinsSvg += `<rect x="${pin.x - 3}" y="${pin.y - 3}" width="6" height="6" fill="#D4AF37" />`;
// Pin hole
pinsSvg += `<circle cx="${pin.x}" cy="${pin.y}" r="2" fill="#000" />`;
});
this.shadowRoot!.innerHTML = `
<style>
:host {
display: block;
width: ${PI_WIDTH}px;
height: ${PI_HEIGHT}px;
position: relative;
}
svg {
width: 100%;
height: 100%;
}
.board {
fill: #E60049; /* Raspberry Pi Red/Pink */
stroke: #B30039;
stroke-width: 2;
rx: 8;
}
.cpu {
fill: #333;
stroke: #111;
rx: 2;
}
.usb {
fill: #ccc;
stroke: #999;
rx: 2;
}
.eth {
fill: #bbb;
stroke: #888;
rx: 2;
}
.gpio-header {
fill: #222;
}
</style>
<svg viewBox="0 0 ${PI_WIDTH} ${PI_HEIGHT}">
<!-- PCB -->
<rect class="board" x="2" y="2" width="${PI_WIDTH-4}" height="${PI_HEIGHT-4}" />
<!-- CPU / Broadcom SoC -->
<rect class="cpu" x="100" y="60" width="40" height="40" />
<text x="120" y="80" fill="#777" font-size="8" text-anchor="middle" dy=".3em">BCM2837</text>
<!-- USB Ports -->
<rect class="usb" x="${PI_WIDTH - 40}" y="20" width="38" height="30" />
<rect class="usb" x="${PI_WIDTH - 40}" y="60" width="38" height="30" />
<!-- Ethernet -->
<rect class="eth" x="${PI_WIDTH - 40}" y="100" width="38" height="40" />
<!-- GPIO Header Base -->
<rect class="gpio-header" x="15" y="5" width="200" height="20" rx="1" />
<!-- Pins -->
${pinsSvg}
<!-- Logo Text -->
<text x="50" y="110" fill="#FFF" font-family="sans-serif" font-size="14" font-weight="bold">Raspberry Pi 3</text>
</svg>
`;
}
}
if (!customElements.get('wokwi-raspberry-pi-3')) {
customElements.define('wokwi-raspberry-pi-3', RaspberryPi3Element);
}
export {};

View File

@ -5,7 +5,6 @@
*/
import React, { useState } from 'react';
import { Link } from 'react-router-dom';
import { exampleProjects, getCategories, type ExampleProject } from '../../data/examples';
import './ExamplesGallery.css';
@ -110,11 +109,6 @@ export const ExamplesGallery: React.FC<ExamplesGalleryProps> = ({ onLoadExample
return (
<div className="examples-gallery">
<div className="examples-nav">
<Link to="/editor" className="back-link">
Back to Editor
</Link>
</div>
<div className="examples-header">
<h1>Featured Projects</h1>
<p>Explore and run example Arduino projects</p>

View File

@ -1,17 +1,21 @@
import { useState, useRef, useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { Link, useNavigate, useLocation } from 'react-router-dom';
import { useAuthStore } from '../../store/useAuthStore';
const GITHUB_URL = 'https://github.com/davidmonterocrespo24/velxio';
const DISCORD_URL = 'https://discord.gg/rCScB9cG';
interface AppHeaderProps {}
export const AppHeader: React.FC<AppHeaderProps> = () => {
const user = useAuthStore((s) => s.user);
const logout = useAuthStore((s) => s.logout);
const navigate = useNavigate();
const location = useLocation();
const [dropdownOpen, setDropdownOpen] = useState(false);
const [menuOpen, setMenuOpen] = useState(false);
const dropdownRef = useRef<HTMLDivElement>(null);
// Close dropdown when clicking outside
useEffect(() => {
const handler = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node)) {
@ -22,15 +26,25 @@ export const AppHeader: React.FC<AppHeaderProps> = () => {
return () => document.removeEventListener('mousedown', handler);
}, []);
// Close mobile menu on route change
useEffect(() => {
setMenuOpen(false);
}, [location.pathname]);
const handleLogout = async () => {
setDropdownOpen(false);
await logout();
navigate('/');
};
const isActive = (path: string) =>
location.pathname === path ? ' header-nav-link-active' : '';
return (
<header className="app-header">
<div className="header-content">
{/* Brand */}
<div className="header-brand">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#007acc" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="5" y="5" width="14" height="14" rx="2" />
@ -42,73 +56,83 @@ export const AppHeader: React.FC<AppHeaderProps> = () => {
</Link>
</div>
<Link to="/examples" className="examples-link" title="Browse Examples">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" />
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" />
</svg>
<span>Examples</span>
</Link>
{/* Main nav links (desktop) */}
<nav className={'header-nav-links' + (menuOpen ? ' header-nav-open' : '')}>
<Link to="/" className={'header-nav-link' + isActive('/')}>Home</Link>
<Link to="/docs" className={'header-nav-link' + isActive('/docs')}>Documentation</Link>
<Link to="/examples" className={'header-nav-link' + isActive('/examples')}>Examples</Link>
<Link to="/editor" className={'header-nav-link' + isActive('/editor')}>Editor</Link>
<a href={GITHUB_URL} target="_blank" rel="noopener noreferrer" className="header-nav-link">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" style={{ flexShrink: 0 }}>
<path d="M12 2C6.477 2 2 6.484 2 12.021c0 4.428 2.865 8.185 6.839 9.504.5.092.682-.217.682-.482 0-.237-.009-.868-.013-1.703-2.782.605-3.369-1.342-3.369-1.342-.454-1.154-1.11-1.462-1.11-1.462-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0 1 12 6.844a9.59 9.59 0 0 1 2.504.337c1.909-1.296 2.747-1.026 2.747-1.026.546 1.378.202 2.397.1 2.65.64.7 1.028 1.595 1.028 2.688 0 3.848-2.338 4.695-4.566 4.944.359.309.678.919.678 1.852 0 1.336-.012 2.415-.012 2.743 0 .267.18.578.688.48C19.138 20.203 22 16.447 22 12.021 22 6.484 17.523 2 12 2z" />
</svg>
GitHub
</a>
<a href={DISCORD_URL} target="_blank" rel="noopener noreferrer" className="header-nav-link header-nav-discord">
<svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" style={{ flexShrink: 0 }}>
<path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057c.002.022.015.043.032.053a19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/>
</svg>
Discord
</a>
</nav>
<a
href="https://github.com/davidmonterocrespo24/velxio"
target="_blank"
rel="noopener noreferrer"
title="GitHub — Velxio"
style={{ display: 'flex', alignItems: 'center', gap: 5, color: '#ccc', textDecoration: 'none', fontSize: 13 }}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2C6.477 2 2 6.484 2 12.021c0 4.428 2.865 8.185 6.839 9.504.5.092.682-.217.682-.482 0-.237-.009-.868-.013-1.703-2.782.605-3.369-1.342-3.369-1.342-.454-1.154-1.11-1.462-1.11-1.462-.908-.62.069-.608.069-.608 1.003.07 1.531 1.032 1.531 1.032.892 1.53 2.341 1.088 2.91.832.092-.647.35-1.088.636-1.338-2.22-.253-4.555-1.113-4.555-4.951 0-1.093.39-1.988 1.029-2.688-.103-.253-.446-1.272.098-2.65 0 0 .84-.27 2.75 1.026A9.564 9.564 0 0 1 12 6.844a9.59 9.59 0 0 1 2.504.337c1.909-1.296 2.747-1.026 2.747-1.026.546 1.378.202 2.397.1 2.65.64.7 1.028 1.595 1.028 2.688 0 3.848-2.338 4.695-4.566 4.944.359.309.678.919.678 1.852 0 1.336-.012 2.415-.012 2.743 0 .267.18.578.688.48C19.138 20.203 22 16.447 22 12.021 22 6.484 17.523 2 12 2z" />
</svg>
<span className="header-github-text">GitHub</span>
</a>
{/* Right: auth + mobile hamburger */}
<div className="header-right">
{/* Auth UI */}
{user ? (
<div style={{ position: 'relative' }} ref={dropdownRef}>
<button
onClick={() => setDropdownOpen((v) => !v)}
style={{ background: 'transparent', border: '1px solid #555', borderRadius: 20, padding: '3px 10px 3px 6px', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 6, color: '#ccc', fontSize: 13 }}
>
{user.avatar_url ? (
<img src={user.avatar_url} alt="" style={{ width: 22, height: 22, borderRadius: '50%' }} />
) : (
<div style={{ width: 22, height: 22, borderRadius: '50%', background: '#0e639c', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 12, color: '#fff', fontWeight: 600 }}>
{user.username[0].toUpperCase()}
</div>
)}
<span className="header-username-text">{user.username}</span>
</button>
{/* Auth UI */}
{user ? (
<div style={{ position: 'relative' }} ref={dropdownRef}>
<button
onClick={() => setDropdownOpen((v) => !v)}
style={{ background: 'transparent', border: '1px solid #555', borderRadius: 20, padding: '3px 10px 3px 6px', cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 6, color: '#ccc', fontSize: 13 }}
>
{user.avatar_url ? (
<img src={user.avatar_url} alt="" style={{ width: 22, height: 22, borderRadius: '50%' }} />
) : (
<div style={{ width: 22, height: 22, borderRadius: '50%', background: '#0e639c', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 12, color: '#fff', fontWeight: 600 }}>
{user.username[0].toUpperCase()}
{dropdownOpen && (
<div style={{ position: 'absolute', right: 0, top: '110%', background: '#252526', border: '1px solid #3c3c3c', borderRadius: 6, minWidth: 150, zIndex: 100, boxShadow: '0 4px 12px rgba(0,0,0,.4)' }}>
<Link
to={`/${user.username}`}
onClick={() => setDropdownOpen(false)}
style={{ display: 'block', padding: '9px 14px', color: '#ccc', textDecoration: 'none', fontSize: 13 }}
>
My projects
</Link>
<div style={{ borderTop: '1px solid #3c3c3c' }} />
<button
onClick={handleLogout}
style={{ width: '100%', background: 'none', border: 'none', padding: '9px 14px', color: '#ccc', textAlign: 'left', cursor: 'pointer', fontSize: 13 }}
>
Sign out
</button>
</div>
)}
<span className="header-username-text">{user.username}</span>
</button>
</div>
) : (
<div style={{ display: 'flex', gap: 8 }}>
<Link to="/login" style={{ color: '#ccc', padding: '4px 10px', fontSize: 13, textDecoration: 'none', border: '1px solid #555', borderRadius: 4 }}>
Sign in
</Link>
<Link to="/register" style={{ color: '#fff', padding: '4px 10px', fontSize: 13, textDecoration: 'none', background: '#0e639c', borderRadius: 4 }}>
Sign up
</Link>
</div>
)}
{/* Mobile hamburger */}
<button className="header-hamburger" onClick={() => setMenuOpen((v) => !v)} aria-label="Toggle menu">
<span />
<span />
<span />
</button>
</div>
{dropdownOpen && (
<div style={{ position: 'absolute', right: 0, top: '110%', background: '#252526', border: '1px solid #3c3c3c', borderRadius: 6, minWidth: 150, zIndex: 100, boxShadow: '0 4px 12px rgba(0,0,0,.4)' }}>
<Link
to={`/${user.username}`}
onClick={() => setDropdownOpen(false)}
style={{ display: 'block', padding: '9px 14px', color: '#ccc', textDecoration: 'none', fontSize: 13 }}
>
My projects
</Link>
<div style={{ borderTop: '1px solid #3c3c3c' }} />
<button
onClick={handleLogout}
style={{ width: '100%', background: 'none', border: 'none', padding: '9px 14px', color: '#ccc', textAlign: 'left', cursor: 'pointer', fontSize: 13 }}
>
Sign out
</button>
</div>
)}
</div>
) : (
<div style={{ display: 'flex', gap: 8 }}>
<Link to="/login" style={{ color: '#ccc', padding: '4px 10px', fontSize: 13, textDecoration: 'none', border: '1px solid #555', borderRadius: 4 }}>
Sign in
</Link>
<Link to="/register" style={{ color: '#fff', padding: '4px 10px', fontSize: 13, textDecoration: 'none', background: '#0e639c', borderRadius: 4 }}>
Sign up
</Link>
</div>
)}
</div>
</header>
);

View File

@ -33,6 +33,8 @@ export const SimulatorCanvas = () => {
updateComponent,
serialMonitorOpen,
toggleSerialMonitor,
connectRemoteSimulator,
disconnectRemoteSimulator,
} = useSimulatorStore();
// Wire management from store
@ -119,6 +121,21 @@ export const SimulatorCanvas = () => {
initSimulator();
}, [initSimulator]);
// Connect to Remote Simulator (QEMU) if a Raspberry Pi exists and simulation is running
useEffect(() => {
const hasPi = components.some(c => c.metadataId === 'raspberry-pi-3');
if (running && hasPi) {
// Using a random client ID or static one for local development
connectRemoteSimulator('local-velxio-client');
} else {
disconnectRemoteSimulator();
}
return () => {
if (!running) disconnectRemoteSimulator();
};
}, [running, components, connectRemoteSimulator, disconnectRemoteSimulator]);
// Attach wheel listener as non-passive so preventDefault() works
useEffect(() => {
const el = canvasRef.current;

View File

@ -2,6 +2,7 @@ import { createRoot } from 'react-dom/client'
import './index.css'
import './components/components-wokwi/IC74HC595'
import './components/components-wokwi/LogicGateElements'
import './components/components-wokwi/RaspberryPi3Element'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(

View File

@ -0,0 +1,121 @@
import React from 'react';
import { Link } from 'react-router-dom';
import { AppHeader } from '../components/layout/AppHeader';
const GITHUB_URL = 'https://github.com/davidmonterocrespo24/velxio';
const DISCORD_URL = 'https://discord.gg/rCScB9cG';
interface DocSection {
title: string;
items: { label: string; href: string; desc: string }[];
}
const sections: DocSection[] = [
{
title: 'Getting Started',
items: [
{ label: 'README', href: `${GITHUB_URL}#readme`, desc: 'Project overview, features, and setup instructions.' },
{ label: 'Self-Hosting with Docker', href: `${GITHUB_URL}#self-hosting`, desc: 'Run Velxio locally with a single Docker command.' },
{ label: 'Manual Setup', href: `${GITHUB_URL}#option-c-manual-setup`, desc: 'Set up the frontend and backend manually for development.' },
],
},
{
title: 'Architecture',
items: [
{ label: 'Architecture Overview', href: `${GITHUB_URL}/blob/master/doc/ARCHITECTURE.md`, desc: 'High-level data flow, component system, and simulation loop.' },
{ label: 'AVR8 Simulation', href: `${GITHUB_URL}#avr8-simulation-arduino-uno--nano--mega`, desc: 'How the ATmega328p emulation works at 16 MHz.' },
{ label: 'RP2040 Simulation', href: `${GITHUB_URL}#rp2040-simulation-raspberry-pi-pico`, desc: 'Raspberry Pi Pico emulation via rp2040js.' },
],
},
{
title: 'Using the Editor',
items: [
{ label: 'Writing Sketches', href: `${GITHUB_URL}#code-editing`, desc: 'Monaco editor features — autocomplete, multi-file, minimap.' },
{ label: 'Supported Boards', href: `${GITHUB_URL}#multi-board-support`, desc: 'Arduino Uno, Nano, Mega, and Raspberry Pi Pico.' },
{ label: 'Serial Monitor', href: `${GITHUB_URL}#serial-monitor`, desc: 'Live TX/RX output with auto baud-rate detection.' },
{ label: 'Library Manager', href: `${GITHUB_URL}#library-manager`, desc: 'Browse and install the full Arduino library index.' },
],
},
{
title: 'Components & Wiring',
items: [
{ label: 'Component System', href: `${GITHUB_URL}#component-system-48-components`, desc: '48+ electronic components — LEDs, displays, sensors, and more.' },
{ label: 'Wire System', href: `${GITHUB_URL}#wire-system`, desc: 'Orthogonal routing, segment editing, and signal-type colors.' },
],
},
{
title: 'Contributing',
items: [
{ label: 'Contributing Guide', href: `${GITHUB_URL}#contributing`, desc: 'Bug reports, pull requests, and CLA information.' },
{ label: 'Open Issues', href: `${GITHUB_URL}/issues`, desc: 'Browse open issues and feature requests on GitHub.' },
{ label: 'Discord Community', href: DISCORD_URL, desc: 'Ask questions and share projects with the community.' },
],
},
];
const IcoExternal = () => (
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ opacity: 0.5, flexShrink: 0 }}>
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
<polyline points="15 3 21 3 21 9" />
<line x1="10" y1="14" x2="21" y2="3" />
</svg>
);
export const DocsPage: React.FC = () => {
return (
<div style={{ display: 'flex', flexDirection: 'column', minHeight: '100vh', background: '#1e1e1e', color: '#d4d4d4' }}>
<AppHeader />
<main style={{ maxWidth: 860, margin: '0 auto', padding: '48px 24px 80px', width: '100%' }}>
<h1 style={{ fontSize: 28, fontWeight: 700, color: '#e0e0e0', marginBottom: 6 }}>Documentation</h1>
<p style={{ color: '#888', fontSize: 14, marginBottom: 48 }}>
Resources and guides for using and extending Velxio. Full documentation lives on{' '}
<a href={GITHUB_URL} target="_blank" rel="noopener noreferrer" style={{ color: '#007acc' }}>GitHub</a>.
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: 40 }}>
{sections.map((section) => (
<section key={section.title}>
<h2 style={{ fontSize: 13, fontWeight: 600, color: '#007acc', textTransform: 'uppercase', letterSpacing: '0.8px', marginBottom: 12 }}>
{section.title}
</h2>
<div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{section.items.map((item) => (
<a
key={item.label}
href={item.href}
target="_blank"
rel="noopener noreferrer"
style={{ display: 'flex', alignItems: 'baseline', gap: 10, padding: '10px 14px', borderRadius: 6, background: '#252526', textDecoration: 'none', transition: 'background 0.15s', border: '1px solid #333' }}
onMouseEnter={(e) => (e.currentTarget.style.background = '#2a2d2e')}
onMouseLeave={(e) => (e.currentTarget.style.background = '#252526')}
>
<span style={{ color: '#e0e0e0', fontSize: 13, fontWeight: 500, whiteSpace: 'nowrap', display: 'flex', alignItems: 'center', gap: 6 }}>
{item.label} <IcoExternal />
</span>
<span style={{ color: '#888', fontSize: 12, flexShrink: 1 }}>{item.desc}</span>
</a>
))}
</div>
</section>
))}
</div>
<div style={{ marginTop: 56, padding: '20px 24px', background: '#252526', borderRadius: 8, border: '1px solid #333', display: 'flex', alignItems: 'center', gap: 16 }}>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#007acc" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" /><line x1="12" y1="8" x2="12" y2="12" /><line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
<p style={{ margin: 0, fontSize: 13, color: '#888' }}>
Something missing? Open an <a href={`${GITHUB_URL}/issues`} target="_blank" rel="noopener noreferrer" style={{ color: '#007acc' }}>issue on GitHub</a> or ask in the{' '}
<a href={DISCORD_URL} target="_blank" rel="noopener noreferrer" style={{ color: '#7289da' }}>Discord</a>.
</p>
</div>
<div style={{ marginTop: 24, textAlign: 'center' }}>
<Link to="/editor" style={{ display: 'inline-flex', alignItems: 'center', gap: 8, padding: '10px 22px', background: '#007acc', color: '#fff', borderRadius: 6, textDecoration: 'none', fontSize: 14, fontWeight: 600 }}>
Open Editor
</Link>
</div>
</main>
</div>
);
};

View File

@ -7,6 +7,7 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { ExamplesGallery } from '../components/examples/ExamplesGallery';
import { AppHeader } from '../components/layout/AppHeader';
import { useEditorStore } from '../store/useEditorStore';
import { useSimulatorStore } from '../store/useSimulatorStore';
import type { ExampleProject } from '../data/examples';
@ -71,5 +72,10 @@ export const ExamplesPage: React.FC = () => {
navigate('/editor');
};
return <ExamplesGallery onLoadExample={handleLoadExample} />;
return (
<div style={{ display: 'flex', flexDirection: 'column', minHeight: '100vh', background: '#1e1e1e' }}>
<AppHeader />
<ExamplesGallery onLoadExample={handleLoadExample} />
</div>
);
};

View File

@ -1,6 +1,5 @@
import { useState, useRef, useEffect } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { useAuthStore } from '../store/useAuthStore';
import { Link } from 'react-router-dom';
import { AppHeader } from '../components/layout/AppHeader';
import '@wokwi/elements';
import './LandingPage.css';
@ -282,108 +281,11 @@ const IcoSponsor = () => (
</svg>
);
/* ── User nav dropdown ────────────────────────────────── */
const UserMenu: React.FC = () => {
const user = useAuthStore((s) => s.user);
const logout = useAuthStore((s) => s.logout);
const navigate = useNavigate();
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, []);
if (!user) return null;
const initials = user.username[0].toUpperCase();
return (
<div className="user-menu" ref={ref}>
<button className="user-menu-trigger" onClick={() => setOpen((v) => !v)}>
{user.avatar_url ? (
<img src={user.avatar_url} alt="" className="user-avatar" />
) : (
<span className="user-avatar user-avatar-initials">{initials}</span>
)}
<span className="user-menu-name">{user.username}</span>
<svg viewBox="0 0 16 16" fill="currentColor" width="10" height="10" style={{ opacity: 0.5 }}>
<path d="M4 6l4 4 4-4" stroke="currentColor" strokeWidth="1.5" fill="none" strokeLinecap="round"/>
</svg>
</button>
{open && (
<div className="user-menu-dropdown">
<div className="user-menu-header">
{user.avatar_url ? (
<img src={user.avatar_url} alt="" className="user-avatar user-avatar-lg" />
) : (
<span className="user-avatar user-avatar-initials user-avatar-lg">{initials}</span>
)}
<div>
<div className="user-menu-uname">{user.username}</div>
<div className="user-menu-email">{user.email}</div>
</div>
</div>
<div className="user-menu-divider" />
<Link to="/editor" className="user-menu-item" onClick={() => setOpen(false)}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" width="15" height="15">
<polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/>
</svg>
Open Editor
</Link>
<Link to={`/${user.username}`} className="user-menu-item" onClick={() => setOpen(false)}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" width="15" height="15">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><polyline points="9 22 9 12 15 12 15 22"/>
</svg>
My Projects
</Link>
<div className="user-menu-divider" />
<button className="user-menu-item user-menu-signout" onClick={async () => { setOpen(false); await logout(); navigate('/'); }}>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" width="15" height="15">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/><polyline points="16 17 21 12 16 7"/><line x1="21" y1="12" x2="9" y2="12"/>
</svg>
Sign out
</button>
</div>
)}
</div>
);
};
/* ── Component ────────────────────────────────────────── */
export const LandingPage: React.FC = () => {
const user = useAuthStore((s) => s.user);
const isLoading = useAuthStore((s) => s.isLoading);
return (
<div className="landing">
{/* Nav */}
<nav className="landing-nav">
<div className="landing-nav-brand">
<IcoChip />
<span>Velxio</span>
</div>
<div className="landing-nav-links">
<a href={GITHUB_URL} target="_blank" rel="noopener noreferrer" className="nav-link">
<IcoGitHub /> GitHub
</a>
<Link to="/examples" className="nav-link">Examples</Link>
{isLoading ? (
<div className="nav-auth-skeleton" />
) : user ? (
<UserMenu />
) : (
<>
<Link to="/login" className="nav-link">Sign in</Link>
<Link to="/editor" className="nav-btn-primary">Launch Editor</Link>
</>
)}
</div>
</nav>
<AppHeader />
{/* Hero */}
<section className="landing-hero">

View File

@ -61,6 +61,21 @@ export class ComponentRegistry {
}
const data: ComponentMetadataCollection = await response.json();
// Inject Raspberry Pi 3 metadata
data.components.push({
id: 'raspberry-pi-3',
tagName: 'wokwi-raspberry-pi-3',
name: 'Raspberry Pi 3',
category: 'boards',
description: 'Raspberry Pi 3 Model B with 40-pin GPIO. Connects to backend QEMU simulator.',
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: [],
defaultValues: {},
pinCount: 40,
tags: ['raspberry', 'pi', 'rp3', 'board', 'qemu', 'linux']
});
this.processMetadata(data.components);
this.loaded = true;

View File

@ -46,3 +46,14 @@ class PartRegistry {
}
export const PartSimulationRegistry = new PartRegistry();
// Import store explicitly inside a function to avoid circular dependencies if any,
// but since we just need it at runtime, we can import it at the top or dynamically.
import { useSimulatorStore } from '../../store/useSimulatorStore';
PartSimulationRegistry.register('raspberry-pi-3', {
onPinStateChange: (pinName: string, state: boolean, _element: HTMLElement) => {
// When Arduino changes a pin connected to Raspberry Pi, forward to backend
useSimulatorStore.getState().sendRemotePinEvent(pinName, state ? 1 : 0);
}
});

View File

@ -68,6 +68,13 @@ interface SimulatorState {
serialBaudRate: number;
serialMonitorOpen: boolean;
// Remote Simulator (Raspberry Pi/QEMU)
remoteConnected: boolean;
remoteSocket: WebSocket | null;
connectRemoteSimulator: (clientId: string) => void;
disconnectRemoteSimulator: () => void;
sendRemotePinEvent: (pin: string, state: number) => void;
// Actions
initSimulator: () => void;
loadHex: (hex: string) => void;
@ -114,6 +121,9 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
// Create PinManager instance
const pinManager = new PinManager();
// Create remote socket reference (cannot be strictly in state without triggering too many renders)
// We'll put it in state for simple disconnect, but typically useRef/module level is better.
return {
boardType: 'arduino-uno' as BoardType,
boardPosition: { ...DEFAULT_BOARD_POSITION },
@ -184,6 +194,87 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
serialBaudRate: 0,
serialMonitorOpen: false,
remoteConnected: false,
remoteSocket: null,
connectRemoteSimulator: (clientId: string) => {
const { remoteSocket } = get();
if (remoteSocket) remoteSocket.close();
const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const API_BASE = import.meta.env.VITE_API_BASE || 'http://localhost:8001/api';
const wsUrl = API_BASE.replace(/^https?:/, wsProtocol) + `/simulation/ws/${clientId}`;
const socket = new WebSocket(wsUrl);
socket.onopen = () => {
console.log('Connected to remote simulator');
set({ remoteConnected: true, remoteSocket: socket });
};
socket.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log("Remote Event:", data);
if (data.type === 'pin_change') {
const { pin, state } = data.data;
const { wires, simulator } = get();
if (!simulator) return;
// Find if this Pi pin is connected to the Arduino
// We need to look for a wire where one end is the Pi and the other is the Arduino
// In a real scenario we might have multiple Pi components, so we should match the clientId
// Here we assume any Pi component connected to current simulator
const wire = wires.find(w =>
(w.start.componentId.includes('raspberry-pi') && w.start.pinName === String(pin)) ||
(w.end.componentId.includes('raspberry-pi') && w.end.pinName === String(pin))
);
if (wire) {
const IsArduinoStart = !wire.start.componentId.includes('raspberry-pi');
const targetEndpoint = IsArduinoStart ? wire.start : wire.end;
// If target is an arduino board pin, set its state
if (targetEndpoint.componentId.startsWith('arduino-') || targetEndpoint.componentId === 'raspberry-pi-pico') {
// We need boardPinToNumber utility to get the numeric pin
// For now, if the pinName is a number, we can use it directly
const pinNum = parseInt(targetEndpoint.pinName, 10);
if (!isNaN(pinNum)) {
simulator.setPinState(pinNum, state);
}
}
}
}
};
socket.onclose = () => {
console.log('Disconnected from remote simulator');
set({ remoteConnected: false, remoteSocket: null });
};
socket.onerror = (error) => {
console.error('Remote simulator WS error:', error);
};
},
disconnectRemoteSimulator: () => {
const { remoteSocket } = get();
if (remoteSocket) {
remoteSocket.close();
}
},
sendRemotePinEvent: (pin: string, state: number) => {
const { remoteSocket, remoteConnected } = get();
if (remoteConnected && remoteSocket && remoteSocket.readyState === WebSocket.OPEN) {
remoteSocket.send(JSON.stringify({
type: 'pin_change',
data: { pin, state }
}));
}
},
setBoardPosition: (pos) => {
set({ boardPosition: pos });
},

View File

@ -0,0 +1,48 @@
/**
* arduino_sketch.ino
* ------------------
* Part of the Pi <-> Arduino Serial Integration Test.
*
* Protocol (9600 baud, newline-terminated messages):
* Pi --> Arduino : "HELLO_FROM_PI"
* Arduino --> Pi : "ACK_FROM_ARDUINO"
*
* The sketch accumulates incoming characters into a line buffer.
* When a full line arrives and it contains "HELLO_FROM_PI" it
* replies "ACK_FROM_ARDUINO\n". All other input is silently
* ignored so kernel boot chatter from the Pi console does not
* produce spurious replies.
*/
static String lineBuffer = "";
void setup() {
Serial.begin(9600);
// Signal that the Arduino is ready (useful for debugging via a real UART)
Serial.println("ARDUINO_READY");
}
void loop() {
while (Serial.available() > 0) {
char c = (char)Serial.read();
if (c == '\r') {
// ignore carriage-return (Windows / Pi console may send \r\n)
continue;
}
if (c == '\n') {
// Process complete line
if (lineBuffer.indexOf("HELLO_FROM_PI") >= 0) {
Serial.println("ACK_FROM_ARDUINO");
}
lineBuffer = "";
} else {
lineBuffer += c;
// Safety: prevent unbounded growth from high-volume console noise
if (lineBuffer.length() > 256) {
lineBuffer = "";
}
}
}
}

View File

@ -0,0 +1,188 @@
/**
* avr_runner.js
* -------------
* Node.js ATmega328P (Arduino Uno) emulator using avr8js.
*
* Loads a compiled Intel HEX firmware file and emulates the CPU at
* 16 MHz. The USART (Serial) peripheral is bridged to a TCP socket
* so the Python broker can connect and exchange bytes.
*
* Usage:
* node avr_runner.js <hex_file> [broker_host] [broker_port]
*
* The script acts as a TCP CLIENT. It connects (with retries) to the
* Python broker which acts as the server for the Arduino side.
*
* Data flow:
* Pi --> broker:5556 --> avr_runner --> usart.writeByte() --> Arduino RX
* Arduino TX --> usart.onByteTransmit --> broker:5556 --> Pi
*/
'use strict';
const fs = require('fs');
const net = require('net');
const path = require('path');
// ── Load avr8js from local wokwi-libs ────────────────────────────────────────
const AVR8JS_CJS = path.resolve(
__dirname, '..', '..', 'wokwi-libs', 'avr8js', 'dist', 'cjs', 'index.js'
);
let avr8js;
try {
avr8js = require(AVR8JS_CJS);
} catch (e) {
process.stderr.write(`[avr_runner] FATAL: cannot load avr8js from:\n ${AVR8JS_CJS}\n ${e.message}\n`);
process.exit(1);
}
const {
CPU,
avrInstruction,
AVRUSART, usart0Config,
AVRTimer, timer0Config, timer1Config, timer2Config,
} = avr8js;
// ── CLI arguments ─────────────────────────────────────────────────────────────
const [,, hexFile, brokerHost = '127.0.0.1', brokerPort = '5556'] = process.argv;
if (!hexFile) {
process.stderr.write('Usage: node avr_runner.js <hex_file> [broker_host] [broker_port]\n');
process.exit(1);
}
if (!fs.existsSync(hexFile)) {
process.stderr.write(`[avr_runner] ERROR: hex file not found: ${hexFile}\n`);
process.exit(1);
}
// ── Intel HEX parser ──────────────────────────────────────────────────────────
function parseIntelHex(content) {
// ATmega328P has 32 KB flash → 0x8000 bytes
const flash = new Uint8Array(0x8000);
for (const rawLine of content.split('\n')) {
const line = rawLine.trim();
if (!line.startsWith(':') || line.length < 11) continue;
const bytes = Buffer.from(line.slice(1), 'hex');
const byteCount = bytes[0];
const addr = (bytes[1] << 8) | bytes[2];
const recordType = bytes[3];
if (recordType === 0x00) { // Data record
for (let i = 0; i < byteCount; i++) {
if (addr + i < flash.length) {
flash[addr + i] = bytes[4 + i];
}
}
}
// recordType 0x01 = EOF — nothing to do
}
// AVR instructions are 16-bit little-endian words
return new Uint16Array(flash.buffer);
}
// ── Build the CPU ─────────────────────────────────────────────────────────────
const CLOCK_HZ = 16_000_000;
const hexContent = fs.readFileSync(hexFile, 'utf8');
const program = parseIntelHex(hexContent);
const cpu = new CPU(program);
// Timers are needed for delay() / millis() inside the Arduino sketch
const timers = [
new AVRTimer(cpu, timer0Config),
new AVRTimer(cpu, timer1Config),
new AVRTimer(cpu, timer2Config),
];
const usart = new AVRUSART(cpu, usart0Config, CLOCK_HZ);
// ── TCP bridge state ───────────────────────────────────────────────────────────
let socket = null;
let txBacklog = []; // bytes queued before TCP connects
// Arduino → Pi: forward transmitted bytes
usart.onByteTransmit = (byte) => {
const ch = String.fromCharCode(byte);
process.stdout.write(`[AVR->Pi] ${ch === '\n' ? '\\n\n' : ch}`);
if (socket && !socket.destroyed) {
socket.write(Buffer.from([byte]));
} else {
txBacklog.push(byte); // buffer until connected
}
};
// ── Simulation loop ───────────────────────────────────────────────────────────
// Run ~160 000 instructions per Node.js event-loop tick ≈ 10 ms simulated time.
// setImmediate() yields after each batch so I/O callbacks can fire.
const BATCH = 160_000;
function runBatch() {
for (let i = 0; i < BATCH; i++) {
avrInstruction(cpu);
cpu.tick();
}
setImmediate(runBatch);
}
// ── TCP connection to broker ──────────────────────────────────────────────────
let retryCount = 0;
const MAX_RETRIES = 40; // 40 × 500 ms = 20 s
function connectToBroker() {
if (retryCount >= MAX_RETRIES) {
process.stderr.write('[avr_runner] ERROR: could not connect to broker after max retries\n');
process.exit(1);
}
const s = new net.Socket();
s.connect(parseInt(brokerPort, 10), brokerHost, () => {
retryCount = 0;
socket = s;
process.stdout.write(`[avr_runner] Connected to broker ${brokerHost}:${brokerPort}\n`);
// Flush bytes queued before connection
if (txBacklog.length > 0) {
s.write(Buffer.from(txBacklog));
txBacklog = [];
}
});
// Pi → Arduino: feed received bytes into USART RX
s.on('data', (chunk) => {
for (const byte of chunk) {
const ch = String.fromCharCode(byte);
process.stdout.write(`[Pi->AVR] ${ch === '\n' ? '\\n\n' : ch}`);
// writeByte(value, immediate=true) bypasses baud-rate timing
usart.writeByte(byte, true);
}
});
s.on('close', () => {
process.stdout.write('[avr_runner] Broker connection closed\n');
socket = null;
});
s.on('error', (err) => {
if (err.code === 'ECONNREFUSED') {
retryCount++;
process.stdout.write(`[avr_runner] Broker not ready, retry ${retryCount}/${MAX_RETRIES} ...\n`);
setTimeout(connectToBroker, 500);
} else {
process.stderr.write(`[avr_runner] Socket error: ${err.message}\n`);
}
});
}
// ── Start ─────────────────────────────────────────────────────────────────────
process.stdout.write(`[avr_runner] Loaded: ${path.basename(hexFile)}\n`);
process.stdout.write(`[avr_runner] ATmega328P @ ${CLOCK_HZ / 1e6} MHz — simulation starting\n`);
connectToBroker();
runBatch();

View File

@ -0,0 +1,632 @@
#!/usr/bin/env python3
"""
Pi <-> Arduino Serial Integration Test
=======================================
What this test proves
---------------------
Python code running on an **emulated Raspberry Pi 3B** (QEMU) sends the
string "HELLO_FROM_PI" over its UART serial port (ttyAMA0).
An **emulated Arduino Uno** (avr8js, via Node.js) receives it and replies
"ACK_FROM_ARDUINO".
The Pi receives that reply and prints "TEST_PASSED".
Architecture
------------
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
| Python Test Process (this file) |
| |
| ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||| |
| | SerialBroker (asyncio) | |
| | | |
| | TCP server :5555 <||||||||||||||> QEMU Pi (ttyAMA0) | |
| | TCP server :5556 <||> avr_runner.js (Arduino UART) | |
| | | |
| | - Bridges bytes Pi <-> Arduino | |
| | - State machine automates Pi serial console: | |
| | - waits for shell prompt | |
| | - disables TTY echo | |
| | - injects Pi Python test script via base64 | |
| | - Asserts "TEST_PASSED" in Pi output | |
| ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||| |
| |
| Subprocesses: |
| - qemu-system-arm (Raspberry Pi 3B, init=/bin/sh) |
| - node avr_runner.js (ATmega328P emulation) |
||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
Prerequisites
-------------
- qemu-system-arm in PATH
- node (Node.js >= 18) in PATH
- arduino-cli in PATH, with arduino:avr core installed
- QEMU images in <repo>/img/:
kernel_extracted.img
bcm271~1.dtb
2025-12-04-raspios-trixie-armhf.img
Run
---
cd <repo>
python test/pi_arduino_serial/test_pi_arduino_serial.py
The test may take several minutes while the Pi boots inside QEMU.
"""
import asyncio
import base64
import shutil
import subprocess
import sys
import tempfile
import time
from pathlib import Path
from typing import Optional
# || Paths |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
IMG_DIR = REPO_ROOT / "img"
TEST_DIR = Path(__file__).resolve().parent
SKETCH_FILE = TEST_DIR / "arduino_sketch.ino"
AVR_RUNNER = TEST_DIR / "avr_runner.js"
KERNEL_IMG = IMG_DIR / "kernel_extracted.img"
DTB_FILE = IMG_DIR / "bcm271~1.dtb" # Windows 8.3 short name
SD_IMAGE = IMG_DIR / "2025-12-04-raspios-trixie-armhf.img"
# || Network ports (must be free) |||||||||||||||||||||||||||||||||||||||||||||||
BROKER_PI_PORT = 15555 # Broker listens; QEMU Pi connects here
BROKER_AVR_PORT = 15556 # Broker listens; avr_runner.js connects here
# || Timeouts ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
BOOT_TIMEOUT_S = 360 # 6 min -- Pi boot + shell prompt
SCRIPT_TIMEOUT_S = 45 # Script execution after prompt seen
COMPILE_TIMEOUT_S = 120 # arduino-cli
# || Pi console state-machine ||||||||||||||||||||||||||||||||||||||||||||||||||
# States
ST_BOOT = "BOOT" # waiting for shell prompt
ST_SETUP = "SETUP" # sent stty -echo, waiting for next prompt
ST_INJECT = "INJECT" # script injected, waiting for TEST_PASSED / TEST_FAILED
ST_DONE = "DONE"
# Patterns that indicate a ready shell prompt
PROMPT_BYTES = [b"# ", b"$ "]
# || Pi Python test script ||||||||||||||||||||||||||||||||||||||||||||||||||||||
# This script is base64-encoded and written to /tmp/pi_test.py on the Pi,
# then executed with "python3 /tmp/pi_test.py".
#
# When python3 runs from the serial console, its stdin/stdout ARE ttyAMA0,
# i.e. the same wire the Arduino is connected to. select() lets us poll
# for incoming bytes without busy-waiting.
_PI_SCRIPT_SRC = b"""\
import sys, os, time, select
# --- send trigger message to Arduino ---
sys.stdout.write("HELLO_FROM_PI\\n")
sys.stdout.flush()
# --- wait for Arduino reply ---
resp = b""
deadline = time.time() + 15 # 15-second window
while time.time() < deadline:
readable, _, _ = select.select([sys.stdin], [], [], 1.0)
if readable:
chunk = os.read(sys.stdin.fileno(), 256)
resp += chunk
if b"ACK_FROM_ARDUINO" in resp:
sys.stdout.write("TEST_PASSED\\n")
sys.stdout.flush()
sys.exit(0)
sys.stdout.write("TEST_FAILED_TIMEOUT\\n")
sys.stdout.flush()
sys.exit(1)
"""
# One-liner shell command injected into the Pi console:
# 1. PATH is set explicitly (init=/bin/sh may not load a profile)
# 2. TTY echo is disabled so our typed command doesn't loop back
# 3. The base64-encoded script is decoded and written to /tmp/pi_test.py
# 4. python3 runs it (stdin = ttyAMA0 = Arduino wire)
_PI_B64 = base64.b64encode(_PI_SCRIPT_SRC).decode()
_PI_CMD = (
"export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
" && stty -echo"
f" && printf '%s' '{_PI_B64}' | /usr/bin/base64 -d > /tmp/pi_test.py"
" && /usr/bin/python3 /tmp/pi_test.py"
"\n"
)
# |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
class SerialBroker:
"""
Bridges the emulated Pi (QEMU) and the emulated Arduino (avr8js).
Both sides connect to this broker via separate TCP ports.
All bytes are forwarded transparently in both directions while the
broker also automates the Pi serial console to inject and run the
test script.
"""
def __init__(self) -> None:
self._pi_reader: Optional[asyncio.StreamReader] = None
self._pi_writer: Optional[asyncio.StreamWriter] = None
self._avr_reader: Optional[asyncio.StreamReader] = None
self._avr_writer: Optional[asyncio.StreamWriter] = None
# Accumulate Pi output for pattern matching
self._pi_buf: bytearray = bytearray()
# Full traffic log: list of (direction, bytes)
self.traffic: list[tuple[str, bytes]] = []
self._state = ST_BOOT
self._prompt_count = 0
self._script_deadline: float = 0.0
# Signals
self.pi_connected = asyncio.Event()
self.avr_connected = asyncio.Event()
self.result_event = asyncio.Event()
self.test_passed = False
# || Server callbacks |||||||||||||||||||||||||||||||||||||||||||||||||||||||
async def _on_pi_connect(self,
reader: asyncio.StreamReader,
writer: asyncio.StreamWriter) -> None:
addr = writer.get_extra_info("peername")
print(f"[broker] Pi (QEMU) connected from {addr}")
self._pi_reader = reader
self._pi_writer = writer
self.pi_connected.set()
async def _on_avr_connect(self,
reader: asyncio.StreamReader,
writer: asyncio.StreamWriter) -> None:
addr = writer.get_extra_info("peername")
print(f"[broker] Arduino (avr_runner.js) connected from {addr}")
self._avr_reader = reader
self._avr_writer = writer
self.avr_connected.set()
# || Start TCP servers ||||||||||||||||||||||||||||||||||||||||||||||||||||||
async def start(self) -> tuple:
pi_srv = await asyncio.start_server(
self._on_pi_connect, "127.0.0.1", BROKER_PI_PORT
)
avr_srv = await asyncio.start_server(
self._on_avr_connect, "127.0.0.1", BROKER_AVR_PORT
)
print(f"[broker] Listening -- Pi port:{BROKER_PI_PORT} "
f"Arduino port:{BROKER_AVR_PORT}")
return pi_srv, avr_srv
# || Main relay (call after both sides connected) |||||||||||||||||||||||||||
async def run(self) -> None:
await asyncio.gather(
self._relay_pi_to_avr(),
self._relay_avr_to_pi(),
self._console_automator(),
)
# || Pi -> Arduino |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
async def _relay_pi_to_avr(self) -> None:
reader = self._pi_reader
if reader is None:
return
while True:
try:
data = await reader.read(512)
except Exception:
break
if not data:
break
self._log("Pi->AVR", data)
self._pi_buf.extend(data)
if self._avr_writer and not self._avr_writer.is_closing():
self._avr_writer.write(data)
await self._avr_writer.drain()
# || Arduino -> Pi |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
async def _relay_avr_to_pi(self) -> None:
reader = self._avr_reader
if reader is None:
return
while True:
try:
data = await reader.read(512)
except Exception:
break
if not data:
break
self._log("AVR->Pi", data)
if self._pi_writer and not self._pi_writer.is_closing():
self._pi_writer.write(data)
await self._pi_writer.drain()
# || Console state machine ||||||||||||||||||||||||||||||||||||||||||||||||||
async def _console_automator(self) -> None:
boot_deadline = time.monotonic() + BOOT_TIMEOUT_S
last_poke_time = time.monotonic()
# Give QEMU a moment to start emitting serial output, then poke
await asyncio.sleep(3.0)
self._send_to_pi(b"\n")
while self._state != ST_DONE:
await asyncio.sleep(0.15)
now = time.monotonic()
# || Global boot timeout ||||||||||||||||||||||||||||||||||||||||||||
if now > boot_deadline:
print("\n[broker] [timeout] BOOT TIMEOUT -- shell prompt never appeared")
self.test_passed = False
self._state = ST_DONE
self.result_event.set()
return
# || Script-execution timeout (after script was injected) ||||||||||
if self._state == ST_INJECT and self._script_deadline and now > self._script_deadline:
print("\n[broker] [timeout] SCRIPT TIMEOUT -- no result from Pi script")
self.test_passed = False
self._state = ST_DONE
self.result_event.set()
return
buf = bytes(self._pi_buf)
# || ST_BOOT: wait for shell prompt |||||||||||||||||||||||||||||||||
if self._state == ST_BOOT:
# Periodically poke the shell to get a fresh prompt
# (in case we missed the initial one)
if now - last_poke_time > 8.0:
self._send_to_pi(b"\n")
last_poke_time = now
if self._prompt_seen(buf):
print("\n[broker] [OK] Shell prompt detected")
self._pi_buf.clear()
# Disable echo and set PATH, then wait for next prompt
self._send_to_pi(
b"export PATH=/usr/local/sbin:/usr/local/bin"
b":/usr/sbin:/usr/bin:/sbin:/bin && stty -echo\n"
)
self._state = ST_SETUP
self._prompt_count = 0
# || ST_SETUP: wait for prompt after stty -echo |||||||||||||||||||||
elif self._state == ST_SETUP:
if self._prompt_seen(buf) or len(buf) > 10:
# Either got a prompt or the shell already answered
await asyncio.sleep(0.3)
self._pi_buf.clear()
print("[broker] [OK] Environment set -- injecting Pi test script")
self._send_to_pi(_PI_CMD.encode())
self._state = ST_INJECT
self._script_deadline = time.monotonic() + SCRIPT_TIMEOUT_S
# || ST_INJECT: wait for TEST_PASSED / TEST_FAILED |||||||||||||||||
elif self._state == ST_INJECT:
if b"TEST_PASSED" in buf:
print("\n[broker] [OK] TEST_PASSED received from Pi!")
self.test_passed = True
self._state = ST_DONE
self.result_event.set()
elif b"TEST_FAILED" in buf:
snippet = buf.decode("utf-8", errors="replace")[-120:]
print(f"\n[broker] [FAIL] TEST_FAILED received from Pi\n last output: {snippet!r}")
self.test_passed = False
self._state = ST_DONE
self.result_event.set()
# || Helpers ||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
def _prompt_seen(self, buf: bytes) -> bool:
tail = buf[-32:] # only check the last 32 bytes
return any(p in tail for p in PROMPT_BYTES)
def _send_to_pi(self, data: bytes) -> None:
if self._pi_writer and not self._pi_writer.is_closing():
self._pi_writer.write(data)
asyncio.get_event_loop().create_task(self._pi_writer.drain())
def _log(self, direction: str, data: bytes) -> None:
self.traffic.append((direction, data))
text = data.decode("utf-8", errors="replace")
for line in text.splitlines():
stripped = line.strip()
if stripped:
print(f" [{direction}] {stripped}")
# |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
# Compilation
# |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
def compile_arduino_sketch() -> Path:
"""Compile arduino_sketch.ino via arduino-cli and return the .hex path."""
print("\n[compile] Compiling Arduino sketch with arduino-cli ...")
# arduino-cli requires the sketch to live inside a folder whose name
# matches the .ino file (without extension).
tmp_root = Path(tempfile.mkdtemp())
sk_dir = tmp_root / "arduino_sketch"
build_dir = tmp_root / "build"
sk_dir.mkdir()
build_dir.mkdir()
shutil.copy(SKETCH_FILE, sk_dir / "arduino_sketch.ino")
cli = shutil.which("arduino-cli") or "arduino-cli"
cmd = [
cli, "compile",
"--fqbn", "arduino:avr:uno",
"--output-dir", str(build_dir),
str(sk_dir),
]
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=COMPILE_TIMEOUT_S,
)
except FileNotFoundError:
raise RuntimeError(
"arduino-cli not found in PATH.\n"
"Install: https://arduino.github.io/arduino-cli/\n"
"Then: arduino-cli core install arduino:avr"
)
except subprocess.TimeoutExpired:
raise RuntimeError("arduino-cli compile timed out")
if result.returncode != 0:
raise RuntimeError(
f"Compilation failed (exit {result.returncode}):\n"
f" STDOUT: {result.stdout.strip()}\n"
f" STDERR: {result.stderr.strip()}"
)
hex_files = sorted(build_dir.glob("*.hex"))
if not hex_files:
raise RuntimeError(f"No .hex file produced in {build_dir}")
hex_path = hex_files[0]
print(f"[compile] [OK] {hex_path.name} ({hex_path.stat().st_size:,} bytes)")
return hex_path
# |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
# QEMU SD overlay (qcow2 thin-copy aligned to 8 GiB power-of-2)
# |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
def _ensure_sd_overlay() -> Path:
"""
QEMU raspi3b requires the SD card size to be a power of 2.
The Raspbian image (5.29 GiB) does not satisfy this, so we create a
qcow2 overlay that presents the image as 8 GiB without modifying the
original file. The overlay is re-created on every run.
"""
overlay = IMG_DIR / "sd_overlay.qcow2"
qemu_img = shutil.which("qemu-img") or "C:/Program Files/qemu/qemu-img.exe"
# Always rebuild so stale overlays don't cause issues
if overlay.exists():
overlay.unlink()
cmd = [
qemu_img, "create",
"-f", "qcow2",
"-b", str(SD_IMAGE),
"-F", "raw",
str(overlay),
"8G",
]
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
if result.returncode != 0:
raise RuntimeError(
f"qemu-img overlay creation failed:\n{result.stderr}"
)
print(f"[qemu-img] SD overlay created: {overlay.name} (8 GiB virtual, "
f"backed by {SD_IMAGE.name})")
return overlay
# |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
# QEMU command builder
# |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
def build_qemu_cmd() -> list[str]:
missing = [
f" {label}: {p}"
for label, p in [("kernel", KERNEL_IMG), ("dtb", DTB_FILE), ("sd", SD_IMAGE)]
if not p.exists()
]
if missing:
raise RuntimeError("Missing QEMU image files:\n" + "\n".join(missing))
sd_path = _ensure_sd_overlay()
# raspi3b is only available in qemu-system-aarch64 on this platform
# (qemu-system-arm only ships raspi0/1ap/2b in this Windows build)
qemu_bin = shutil.which("qemu-system-aarch64") or "qemu-system-aarch64"
return [
qemu_bin,
"-M", "raspi3b",
"-kernel", str(KERNEL_IMG),
"-dtb", str(DTB_FILE),
"-drive", f"file={sd_path},if=sd,format=qcow2",
# init=/bin/sh -> skip systemd, get a root shell immediately
# rw -> mount root filesystem read-write
"-append", (
"console=ttyAMA0 "
"root=/dev/mmcblk0p2 rootwait rw "
"init=/bin/sh "
"dwc_otg.lpm_enable=0"
),
"-m", "1G",
"-smp", "4",
"-display", "none",
# Connect Pi ttyAMA0 directly to our broker (broker is the TCP server)
"-serial", f"tcp:127.0.0.1:{BROKER_PI_PORT}",
]
# |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
# Subprocess log drainer
# |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
async def drain_log(stream: Optional[asyncio.StreamReader], prefix: str) -> None:
if stream is None:
return
async for raw in stream:
line = raw.decode("utf-8", errors="replace").rstrip()
if line:
# encode to the console charset, dropping unrepresentable chars
safe = line.encode(sys.stdout.encoding or "ascii", errors="replace").decode(sys.stdout.encoding or "ascii")
print(f"{prefix} {safe}")
# |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
# Main test coroutine
# |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
async def run_test() -> bool:
_banner("Pi <-> Arduino Serial Integration Test")
# 1. Compile ---------------------------------------------------------------
hex_path = compile_arduino_sketch()
# 2. Start broker ----------------------------------------------------------
broker = SerialBroker()
pi_srv, avr_srv = await broker.start()
procs: list[asyncio.subprocess.Process] = []
try:
# 3. Start avr_runner.js (Arduino emulation) ---------------------------
node_exe = shutil.which("node") or "node"
avr_cmd = [
node_exe,
str(AVR_RUNNER),
str(hex_path),
"127.0.0.1",
str(BROKER_AVR_PORT),
]
print(f"\n[avr] Starting Arduino emulator ...\n {' '.join(avr_cmd)}")
avr_proc = await asyncio.create_subprocess_exec(
*avr_cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
procs.append(avr_proc)
asyncio.create_task(drain_log(avr_proc.stdout, "[avr]"))
# 4. Start QEMU (Raspberry Pi emulation) --------------------------------
qemu_cmd = build_qemu_cmd()
print(f"\n[qemu] Starting Raspberry Pi 3B emulation ...")
print(f" {' '.join(qemu_cmd[:6])} ...")
qemu_proc = await asyncio.create_subprocess_exec(
*qemu_cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.STDOUT,
)
procs.append(qemu_proc)
asyncio.create_task(drain_log(qemu_proc.stdout, "[qemu]"))
# 5. Wait for both TCP connections -------------------------------------
print(f"\n[broker] Waiting for Pi + Arduino TCP connections (30 s) ...")
try:
await asyncio.wait_for(
asyncio.gather(
broker.pi_connected.wait(),
broker.avr_connected.wait(),
),
timeout=30.0,
)
except asyncio.TimeoutError:
print("[broker] [FAIL] Timeout waiting for TCP connections")
return False
# 6. Start relay + state machine ----------------------------------------
print(f"\n[broker] Both sides connected. Boot timeout: {BOOT_TIMEOUT_S} s\n")
asyncio.create_task(broker.run())
# 7. Await test result --------------------------------------------------
try:
await asyncio.wait_for(
broker.result_event.wait(),
timeout=BOOT_TIMEOUT_S + SCRIPT_TIMEOUT_S + 10,
)
except asyncio.TimeoutError:
print("[test] [FAIL] Global timeout -- no result received")
return False
return broker.test_passed
finally:
pi_srv.close()
avr_srv.close()
await pi_srv.wait_closed()
await avr_srv.wait_closed()
for p in procs:
try:
p.terminate()
await asyncio.wait_for(p.wait(), timeout=5.0)
except Exception:
try:
p.kill()
except Exception:
pass
# Clean up temp compilation dir
try:
shutil.rmtree(hex_path.parent.parent, ignore_errors=True)
except Exception:
pass
# |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
# Helpers
# |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
def _banner(title: str) -> None:
bar = "=" * 65
print(f"\n{bar}\n {title}\n{bar}")
def _print_result(passed: bool) -> None:
_banner("Result")
if passed:
print(" [PASS] INTEGRATION TEST PASSED\n")
print(" Pi -> Arduino : HELLO_FROM_PI")
print(" Arduino -> Pi : ACK_FROM_ARDUINO")
print(" Pi confirmed : TEST_PASSED")
else:
print(" [FAIL] INTEGRATION TEST FAILED")
print("\n Troubleshooting hints:")
print(" - Confirm qemu-system-arm, node, and arduino-cli are in PATH.")
print(" - Check that init=/bin/sh produces a '#' prompt on the Pi OS.")
print(" - The SD image may require 'pi'/'raspberry' user for python3.")
print("=" * 65 + "\n")
# |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
def main() -> None:
# Windows requires ProactorEventLoop for subprocess + asyncio
if sys.platform == "win32":
asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy())
passed = asyncio.run(run_test())
_print_result(passed)
sys.exit(0 if passed else 1)
if __name__ == "__main__":
main()