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:
parent
b63a068307
commit
13997ff491
|
|
@ -78,3 +78,6 @@ wokwi-libs/*/.cache/
|
|||
.history/*
|
||||
.daveagent/*
|
||||
data/*
|
||||
.publicar/*
|
||||
.publicar_discord/*
|
||||
img/*
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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())
|
||||
|
|
@ -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)
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 />} />
|
||||
|
|
|
|||
|
|
@ -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 {};
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
@ -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()
|
||||
Loading…
Reference in New Issue