feat: add Arduino Mega support to simulator

- Introduced ArduinoMega component for rendering in the simulator.
- Updated SimulatorCanvas to handle Arduino Mega board type.
- Enhanced AVRSimulator to support ATmega2560 architecture, including PWM pin mapping and port management.
- Modified PinManager to accommodate Mega's non-linear pin mapping.
- Updated boardPinMapping utility to include Mega analog pins.
- Adjusted Wokwi import/export functionality to recognize and handle Arduino Mega.
- Updated useSimulatorStore to initialize AVRSimulator with the correct board variant.
pull/10/head
David Montero Crespo 2026-03-09 10:08:14 -03:00
parent faa6f6b7b3
commit 1018609ed4
8 changed files with 684 additions and 94 deletions

View File

@ -0,0 +1,397 @@
/**
* ili9341-emulation.test.ts
*
* End-to-end test for ILI9341 TFT display emulation:
*
* 1. Compile ili9341-test-sketch.ino with arduino-cli (arduino:avr:nano)
* 2. Load the resulting .hex into AVRSimulator
* 3. Intercept the SPI bus with a VirtualILI9341
* 4. Run ~5 million simulated cycles
* 5. Assert the display received pixel data (RAMWR command)
* 6. Verify colored regions (red fill, white rect, blue circle)
*
* Requirements:
* - arduino-cli in PATH
* - "arduino:avr" core installed
* - "Adafruit ILI9341" and "Adafruit GFX Library" installed
*/
import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
import { spawnSync } from 'child_process';
import {
mkdtempSync, writeFileSync, readFileSync, existsSync,
rmSync, mkdirSync, readdirSync,
} from 'fs';
import { tmpdir } from 'os';
import { join, resolve } from 'path';
import { AVRSimulator } from '../simulation/AVRSimulator';
import { PinManager } from '../simulation/PinManager';
// ─── ImageData polyfill ───────────────────────────────────────────────────────
if (typeof globalThis.ImageData === 'undefined') {
class ImageDataPoly {
readonly width: number;
readonly height: number;
readonly data: Uint8ClampedArray;
constructor(w: number, h: number) {
this.width = w;
this.height = h;
this.data = new Uint8ClampedArray(w * h * 4);
}
}
(globalThis as any).ImageData = ImageDataPoly;
}
// ─── RAF stub ────────────────────────────────────────────────────────────────
vi.stubGlobal('requestAnimationFrame', (_cb: FrameRequestCallback) => 1);
vi.stubGlobal('cancelAnimationFrame', vi.fn());
// ─── Paths ───────────────────────────────────────────────────────────────────
const SKETCH_DIR = resolve(
__dirname,
'../../../example_zip/extracted/ili9341-test-sketch',
);
const SKETCH_INO = join(SKETCH_DIR, 'ili9341-test-sketch.ino');
// ─── Hex cache ───────────────────────────────────────────────────────────────
const HEX_CACHE = join(tmpdir(), 'velxio-ili9341-nano.hex');
function compileSketch(): string {
if (existsSync(HEX_CACHE)) {
console.log('[compile] Using cached hex:', HEX_CACHE);
return readFileSync(HEX_CACHE, 'utf-8');
}
console.log('[compile] Compiling ili9341-test-sketch.ino for Arduino Nano…');
const workDir = mkdtempSync(join(tmpdir(), 'velxio-ili9341-'));
const sketchDir = join(workDir, 'ili9341-test-sketch');
mkdirSync(sketchDir);
writeFileSync(join(sketchDir, 'ili9341-test-sketch.ino'), readFileSync(SKETCH_INO, 'utf-8'));
const buildDir = join(workDir, 'build');
mkdirSync(buildDir);
const result = spawnSync(
'arduino-cli',
[
'compile',
'--fqbn', 'arduino:avr:nano:cpu=atmega328old',
'--build-path', buildDir,
sketchDir,
],
{ encoding: 'utf-8', timeout: 120_000 },
);
if (result.status !== 0) {
console.error('[compile] stdout:', result.stdout);
console.error('[compile] stderr:', result.stderr);
throw new Error(`arduino-cli failed (exit ${result.status}): ${result.stderr}`);
}
// Find .hex (prefer non-bootloader)
let hexPath: string | null = null;
for (const candidate of ['ili9341-test-sketch.ino.hex', 'sketch.ino.hex']) {
const p = join(buildDir, candidate);
if (existsSync(p)) { hexPath = p; break; }
}
if (!hexPath) {
const files = readdirSync(buildDir, { recursive: true }) as string[];
const found = files.find(
(f) => typeof f === 'string' && f.endsWith('.hex') && !f.includes('bootloader'),
);
if (!found) throw new Error('No .hex found in build output');
hexPath = join(buildDir, found);
}
const hex = readFileSync(hexPath, 'utf-8');
writeFileSync(HEX_CACHE, hex);
rmSync(workDir, { recursive: true });
console.log('[compile] Done. Hex size:', hex.length, 'chars');
return hex;
}
// ─── VirtualILI9341 SPI monitor ──────────────────────────────────────────────
/**
* Intercepts the AVRSimulator SPI bus and decodes ILI9341 commands/data.
*
* ILI9341 protocol (hardware SPI):
* - DC pin LOW command byte
* - DC pin HIGH data byte
* - Commands: 0x2A CASET, 0x2B PASET, 0x2C RAMWR, 0x01 SWRESET, etc.
* - Pixel data: RGB-565 (2 bytes per pixel) streamed after RAMWR
*/
class VirtualILI9341 {
static readonly WIDTH = 240;
static readonly HEIGHT = 320;
// Raw framebuffer: RGB-565 per pixel (0 = black / unwritten)
readonly framebuffer = new Uint16Array(VirtualILI9341.WIDTH * VirtualILI9341.HEIGHT);
// Statistics
commandCount = 0;
ramwrCount = 0; // number of RAMWR (0x2C) commands received
pixelCount = 0; // total pixels written
// DC pin state (must be injected from AVRSimulator PinManager)
dcHigh = false;
// ILI9341 address window
private colStart = 0; private colEnd = VirtualILI9341.WIDTH - 1;
private rowStart = 0; private rowEnd = VirtualILI9341.HEIGHT - 1;
private curX = 0; private curY = 0;
// Command state machine
private currentCmd = -1;
private dataBytes: number[] = [];
private inRamWrite = false;
private pixelHiByte = 0;
private pixelByteIdx = 0;
/**
* Process one SPI byte. Call with the byte value BEFORE calling
* spi.completeTransfer() so we see it first.
*/
processByte(value: number): void {
if (!this.dcHigh) {
// Command byte
this.commandCount++;
this.currentCmd = value;
this.dataBytes = [];
this.inRamWrite = (value === 0x2C);
this.pixelByteIdx = 0;
if (value === 0x2C) this.ramwrCount++;
if (value === 0x01) { // SWRESET
this.framebuffer.fill(0);
this.pixelCount = 0;
}
} else {
// Data byte
if (this.inRamWrite) {
if (this.pixelByteIdx === 0) {
this.pixelHiByte = value;
this.pixelByteIdx = 1;
} else {
this.writePixel(this.pixelHiByte, value);
this.pixelByteIdx = 0;
}
} else {
this.dataBytes.push(value);
this.applyCmd();
}
}
}
private applyCmd(): void {
const d = this.dataBytes;
switch (this.currentCmd) {
case 0x2A: // CASET
if (d.length === 2) this.colStart = (d[0] << 8) | d[1];
if (d.length === 4) { this.colEnd = (d[2] << 8) | d[3]; this.curX = this.colStart; }
break;
case 0x2B: // PASET
if (d.length === 2) this.rowStart = (d[0] << 8) | d[1];
if (d.length === 4) { this.rowEnd = (d[2] << 8) | d[3]; this.curY = this.rowStart; }
break;
}
}
private writePixel(hi: number, lo: number): void {
if (this.curX > this.colEnd || this.curY > this.rowEnd ||
this.curX >= VirtualILI9341.WIDTH || this.curY >= VirtualILI9341.HEIGHT) return;
const rgb565 = (hi << 8) | lo;
this.framebuffer[this.curY * VirtualILI9341.WIDTH + this.curX] = rgb565;
this.pixelCount++;
this.curX++;
if (this.curX > this.colEnd) {
this.curX = this.colStart;
this.curY++;
}
}
/** Convert RGB-565 pixel to R,G,B channels */
static rgb565ToRGB(p: number): [number, number, number] {
return [
((p >> 11) & 0x1F) * 8,
((p >> 5) & 0x3F) * 4,
( p & 0x1F) * 8,
];
}
/** True if any pixel in a region matches a specific colour (within tolerance) */
regionHasColor(
x0: number, y0: number, x1: number, y1: number,
targetRGB565: number, tolerance = 20,
): boolean {
const [tr, tg, tb] = VirtualILI9341.rgb565ToRGB(targetRGB565);
for (let y = y0; y <= y1; y++) {
for (let x = x0; x <= x1; x++) {
const p = this.framebuffer[y * VirtualILI9341.WIDTH + x];
if (p === 0) continue;
const [r, g, b] = VirtualILI9341.rgb565ToRGB(p);
if (
Math.abs(r - tr) <= tolerance &&
Math.abs(g - tg) <= tolerance &&
Math.abs(b - tb) <= tolerance
) return true;
}
}
return false;
}
/**
* Count pixels whose colour is within `tolerance` of targetRGB565.
*/
countColor(targetRGB565: number, tolerance = 20): number {
const [tr, tg, tb] = VirtualILI9341.rgb565ToRGB(targetRGB565);
let n = 0;
for (const p of this.framebuffer) {
if (p === 0) continue;
const [r, g, b] = VirtualILI9341.rgb565ToRGB(p);
if (
Math.abs(r - tr) <= tolerance &&
Math.abs(g - tg) <= tolerance &&
Math.abs(b - tb) <= tolerance
) n++;
}
return n;
}
/** ILI9341_RED (RGB-565: 0xF800) */
static readonly COLOR_RED = 0xF800;
/** ILI9341_WHITE (RGB-565: 0xFFFF) */
static readonly COLOR_WHITE = 0xFFFF;
/** ILI9341_BLUE (RGB-565: 0x001F) */
static readonly COLOR_BLUE = 0x001F;
/** ILI9341_BLACK (RGB-565: 0x0000) */
static readonly COLOR_BLACK = 0x0000;
/** ILI9341_YELLOW (RGB-565: 0xFFE0) */
static readonly COLOR_YELLOW = 0xFFE0;
/** Summary string for console output */
summary(): string {
return (
`ILI9341 stats: commands=${this.commandCount}, ` +
`RAMWR=${this.ramwrCount}, pixelsWritten=${this.pixelCount}`
);
}
}
// ─── Run N cycles ─────────────────────────────────────────────────────────────
function runCycles(sim: AVRSimulator, cycles: number): void {
for (let i = 0; i < cycles; i++) sim.step();
}
// ─── Tests ───────────────────────────────────────────────────────────────────
describe('ILI9341 emulation — full end-to-end', () => {
let hexContent: string;
let sim: AVRSimulator;
let display: VirtualILI9341;
beforeAll(() => {
hexContent = compileSketch();
});
afterAll(() => {
try { sim?.stop(); } catch { /* ignore */ }
vi.unstubAllGlobals();
});
it('🔧 compiles ili9341-test-sketch.ino successfully', () => {
expect(hexContent).toBeTruthy();
expect(hexContent).toContain(':');
console.log('[hex] First line:', hexContent.split('\n')[0]);
});
it('🖥️ boots, initialises ILI9341, and fills screen within 15M cycles', () => {
const pm = new PinManager();
sim = new AVRSimulator(pm);
sim.loadHex(hexContent);
// ── Attach VirtualILI9341 to the SPI bus ──────────────────────────────
display = new VirtualILI9341();
// DC pin: Arduino pin 9 → PORTB bit 1 (D9 = PB1)
// We track DC state by watching pin 9 in the pinManager.
// PinManager.onPinChange gives us the logical state (true = HIGH).
const DC_ARDUINO_PIN = 9;
pm.onPinChange(DC_ARDUINO_PIN, (_pin: number, state: boolean) => {
display.dcHigh = state;
});
// Intercept SPI: wrap the existing onByte
sim.spi!.onByte = (value: number) => {
display.processByte(value);
sim.spi!.completeTransfer(0xFF);
};
// 15M cycles = ~937ms simulated @16MHz.
// Breakdown:
// ~3M Arduino startup + Serial.begin + tft.begin() init commands (~20+ cmd)
// ~2M fillScreen(RED) = 76800px × 2bytes × ~4CPU/byte + overhead
// ~6M fillRect + fillCircle + fillTriangle
// ~4M margin
runCycles(sim, 15_000_000);
console.log(`[init] ${display.summary()}`);
console.log(`[init] Pixels written: ${display.pixelCount}`);
// The Adafruit_ILI9341::begin() sends ~20+ init commands
expect(display.commandCount).toBeGreaterThan(10);
});
it('🎨 fillScreen(RED) was issued (pixels present after 15M cycles)', () => {
// By the time we reach this test, 15M cycles have already run.
// fillScreen alone writes 76800 pixels; we accept ≥10000 to be lenient.
runCycles(sim, 0); // no-op; just reads current state
console.log(`[fill] ${display.summary()}`);
const redCount = display.countColor(VirtualILI9341.COLOR_RED, 30);
console.log(`[fill] Red pixels: ${redCount} / ${VirtualILI9341.WIDTH * VirtualILI9341.HEIGHT}`);
// At least the majority of the screen should be red
expect(redCount).toBeGreaterThan(10_000);
});
it('⬜ draws white rectangle at (20,20,200,80)', () => {
// White rect was drawn before circle — should already be in framebuffer
const hasWhiteRect = display.regionHasColor(20, 20, 220, 100, VirtualILI9341.COLOR_WHITE, 10);
console.log(`[rect] White rect region has white pixels: ${hasWhiteRect}`);
expect(hasWhiteRect).toBe(true);
});
it('🔵 draws blue circle center (120,200) r=50', () => {
// Check a small region within the circle bounds
const hasBluePx = display.regionHasColor(90, 170, 150, 230, VirtualILI9341.COLOR_BLUE, 30);
console.log(`[circle] Blue circle region has blue pixels: ${hasBluePx}`);
expect(hasBluePx).toBe(true);
});
it('🖼️ RAMWR received multiple times (multi-shape drawing)', () => {
console.log(`[ramwr] RAMWR count: ${display.ramwrCount}`);
// fillScreen + fillRect + fillCircle + fillTriangle → at least 4 RAMWR
expect(display.ramwrCount).toBeGreaterThan(3);
});
it('📊 total pixel writes cover most of the 240×320 screen', () => {
const total = VirtualILI9341.WIDTH * VirtualILI9341.HEIGHT;
const coverage = display.pixelCount / total;
console.log(
`[coverage] pixelCount=${display.pixelCount} / ${total} = ${(coverage * 100).toFixed(1)}%`
);
// fillScreen alone should cover 100%; we expect at least 50% to account
// for cases where address windows overlap (pixels counted once per write)
expect(display.pixelCount).toBeGreaterThan(total * 0.5);
});
});

View File

@ -0,0 +1,36 @@
import '@wokwi/elements';
import { useRef, useEffect } from 'react';
interface ArduinoMegaProps {
id?: string;
x?: number;
y?: number;
led13?: boolean;
}
export const ArduinoMega = ({
id = 'arduino-mega',
x = 0,
y = 0,
led13 = false,
}: ArduinoMegaProps) => {
const megaRef = useRef<HTMLElement>(null);
useEffect(() => {
if (megaRef.current) {
(megaRef.current as any).led13 = led13;
}
}, [led13]);
return (
<wokwi-arduino-mega
id={id}
ref={megaRef}
style={{
position: 'absolute',
left: `${x}px`,
top: `${y}px`,
}}
/>
);
};

View File

@ -3,6 +3,7 @@ import type { BoardType } from '../../store/useSimulatorStore';
import React, { useEffect, useState, useRef, useCallback } from 'react';
import { ArduinoUno } from '../components-wokwi/ArduinoUno';
import { ArduinoNano } from '../components-wokwi/ArduinoNano';
import { ArduinoMega } from '../components-wokwi/ArduinoMega';
import { NanoRP2040 } from '../components-wokwi/NanoRP2040';
import { ComponentPickerModal } from '../ComponentPickerModal';
import { ComponentPropertyDialog } from './ComponentPropertyDialog';
@ -625,6 +626,12 @@ export const SimulatorCanvas = () => {
y={boardPosition.y}
led13={Boolean(components.find((c) => c.id === 'led-builtin')?.properties.state)}
/>
) : boardType === 'arduino-mega' ? (
<ArduinoMega
x={boardPosition.x}
y={boardPosition.y}
led13={Boolean(components.find((c) => c.id === 'led-builtin')?.properties.state)}
/>
) : (
<NanoRP2040
x={boardPosition.x}
@ -640,8 +647,8 @@ export const SimulatorCanvas = () => {
position: 'absolute',
left: boardPosition.x,
top: boardPosition.y,
width: boardType === 'arduino-uno' ? 360 : boardType === 'arduino-nano' ? 175 : 280,
height: boardType === 'arduino-uno' ? 250 : boardType === 'arduino-nano' ? 70 : 180,
width: boardType === 'arduino-uno' ? 360 : boardType === 'arduino-nano' ? 175 : boardType === 'arduino-mega' ? 530 : 280,
height: boardType === 'arduino-uno' ? 250 : boardType === 'arduino-nano' ? 70 : boardType === 'arduino-mega' ? 195 : 180,
cursor: 'move',
zIndex: 1,
}}
@ -659,7 +666,7 @@ export const SimulatorCanvas = () => {
{/* Board pin overlay */}
<PinOverlay
componentId={boardType === 'arduino-uno' ? 'arduino-uno' : boardType === 'arduino-nano' ? 'arduino-nano' : 'nano-rp2040'}
componentId={boardType === 'arduino-uno' ? 'arduino-uno' : boardType === 'arduino-nano' ? 'arduino-nano' : boardType === 'arduino-mega' ? 'arduino-mega' : 'nano-rp2040'}
componentX={boardPosition.x}
componentY={boardPosition.y}
onPinClick={handlePinClick}

View File

@ -1,4 +1,4 @@
import { CPU, AVRTimer, timer0Config, timer1Config, timer2Config, AVRUSART, usart0Config, AVRIOPort, portBConfig, portCConfig, portDConfig, avrInstruction, AVRADC, adcConfig, AVRSPI, spiConfig, AVRTWI, twiConfig } from 'avr8js';
import { CPU, AVRTimer, timer0Config, timer1Config, timer2Config, AVRUSART, usart0Config, AVRIOPort, portAConfig, portBConfig, portCConfig, portDConfig, portEConfig, portFConfig, portGConfig, portHConfig, portJConfig, portKConfig, portLConfig, avrInstruction, AVRADC, adcConfig, AVRSPI, spiConfig, AVRTWI, twiConfig } from 'avr8js';
import { PinManager } from './PinManager';
import { hexToUint8Array } from '../utils/hexParser';
import { I2CBusManager } from './I2CBusManager';
@ -17,8 +17,8 @@ import type { I2CDevice } from './I2CBusManager';
* - Pin state tracking via PinManager
*/
// OCR register addresses → Arduino pin mapping for PWM
const PWM_PINS = [
// OCR register addresses → Arduino pin mapping for PWM (ATmega328P / Uno / Nano)
const PWM_PINS_UNO = [
{ ocrAddr: 0x47, pin: 6, label: 'OCR0A' }, // Timer0A → D6
{ ocrAddr: 0x48, pin: 5, label: 'OCR0B' }, // Timer0B → D5
{ ocrAddr: 0x88, pin: 9, label: 'OCR1AL' }, // Timer1A low byte → D9
@ -27,6 +27,87 @@ const PWM_PINS = [
{ ocrAddr: 0xB4, pin: 3, label: 'OCR2B' }, // Timer2B → D3
];
// OCR register addresses → Arduino Mega pin mapping for PWM (ATmega2560)
// Timers 0/1/2 same addresses; Timers 3/4/5 at higher addresses.
const PWM_PINS_MEGA = [
{ ocrAddr: 0x47, pin: 13, label: 'OCR0A' }, // Timer0A → D13
{ ocrAddr: 0x48, pin: 4, label: 'OCR0B' }, // Timer0B → D4
{ ocrAddr: 0x88, pin: 11, label: 'OCR1AL' }, // Timer1A → D11
{ ocrAddr: 0x8A, pin: 12, label: 'OCR1BL' }, // Timer1B → D12
{ ocrAddr: 0xB3, pin: 10, label: 'OCR2A' }, // Timer2A → D10
{ ocrAddr: 0xB4, pin: 9, label: 'OCR2B' }, // Timer2B → D9
// Timer3 (0x800x8D, but OCR3A/B/C at 0x98/0x9A/0x9C)
{ ocrAddr: 0x98, pin: 5, label: 'OCR3AL' }, // Timer3A → D5
{ ocrAddr: 0x9A, pin: 2, label: 'OCR3BL' }, // Timer3B → D2
{ ocrAddr: 0x9C, pin: 3, label: 'OCR3CL' }, // Timer3C → D3
// Timer4 (OCR4A/B/C at 0xA8/0xAA/0xAC)
{ ocrAddr: 0xA8, pin: 6, label: 'OCR4AL' }, // Timer4A → D6
{ ocrAddr: 0xAA, pin: 7, label: 'OCR4BL' }, // Timer4B → D7
{ ocrAddr: 0xAC, pin: 8, label: 'OCR4CL' }, // Timer4C → D8
// Timer5 (OCR5A/B/C at 0x128/0x12A/0x12C — extended I/O)
{ ocrAddr: 0x128, pin: 46, label: 'OCR5AL' }, // Timer5A → D46
{ ocrAddr: 0x12A, pin: 45, label: 'OCR5BL' }, // Timer5B → D45
{ ocrAddr: 0x12C, pin: 44, label: 'OCR5CL' }, // Timer5C → D44
];
/**
* ATmega2560 port-bit Arduino Mega pin mapping.
* Index = bit position (07). -1 = not exposed on the Arduino Mega header.
*/
const MEGA_PORT_BIT_MAP: Record<string, number[]> = {
// PA0-PA7 → D22-D29
'PORTA': [22, 23, 24, 25, 26, 27, 28, 29],
// PB0=D53(SS), PB1=D52(SCK), PB2=D51(MOSI), PB3=D50(MISO), PB4-PB7=D10-D13
'PORTB': [53, 52, 51, 50, 10, 11, 12, 13],
// PC0-PC7 → D37, D36, D35, D34, D33, D32, D31, D30 (reversed)
'PORTC': [37, 36, 35, 34, 33, 32, 31, 30],
// PD0=D21(SCL), PD1=D20(SDA), PD2=D19(RX1), PD3=D18(TX1), PD7=D38
'PORTD': [21, 20, 19, 18, -1, -1, -1, 38],
// PE0=D0(RX0), PE1=D1(TX0), PE3=D5, PE4=D2, PE5=D3
'PORTE': [0, 1, -1, 5, 2, 3, -1, -1],
// PF0-PF7 → A0-A7 (pin numbers 54-61)
'PORTF': [54, 55, 56, 57, 58, 59, 60, 61],
// PG0=D41, PG1=D40, PG2=D39, PG5=D4
'PORTG': [41, 40, 39, -1, -1, 4, -1, -1],
// PH0=D17(RX2), PH1=D16(TX2), PH3=D6, PH4=D7, PH5=D8, PH6=D9
'PORTH': [17, 16, -1, 6, 7, 8, 9, -1],
// PJ0=D15(RX3), PJ1=D14(TX3)
'PORTJ': [15, 14, -1, -1, -1, -1, -1, -1],
// PK0-PK7 → A8-A15 (pin numbers 62-69)
'PORTK': [62, 63, 64, 65, 66, 67, 68, 69],
// PL0=D49, PL1=D48, PL2=D47, PL3=D46, PL4=D45, PL5=D44, PL6=D43, PL7=D42
'PORTL': [49, 48, 47, 46, 45, 44, 43, 42],
};
/**
* Reverse of MEGA_PORT_BIT_MAP: Arduino Mega pin { portName, bit }.
* Pre-built for fast setPinState() lookups.
*/
const MEGA_PIN_TO_PORT = (() => {
const map: Record<number, { portName: string; bit: number; port?: AVRIOPort }> = {};
for (const [portName, pins] of Object.entries(MEGA_PORT_BIT_MAP)) {
pins.forEach((pin, bit) => {
if (pin >= 0) map[pin] = { portName, bit };
});
}
return map;
})();
/** Ordered list of Mega ports with their avr8js configs */
const MEGA_PORT_CONFIGS = [
{ name: 'PORTA', config: portAConfig },
{ name: 'PORTB', config: portBConfig },
{ name: 'PORTC', config: portCConfig },
{ name: 'PORTD', config: portDConfig },
{ name: 'PORTE', config: portEConfig },
{ name: 'PORTF', config: portFConfig },
{ name: 'PORTG', config: portGConfig },
{ name: 'PORTH', config: portHConfig },
{ name: 'PORTJ', config: portJConfig },
{ name: 'PORTK', config: portKConfig },
{ name: 'PORTL', config: portLConfig },
];
export class AVRSimulator {
private cpu: CPU | null = null;
/** Peripherals kept alive by reference so GC doesn't collect their CPU hooks */
@ -34,6 +115,9 @@ export class AVRSimulator {
private portB: AVRIOPort | null = null;
private portC: AVRIOPort | null = null;
private portD: AVRIOPort | null = null;
/** Extra ports used by the Mega (A, EL); keyed by port name */
private megaPorts: Map<string, AVRIOPort> = new Map();
private megaPortValues: Map<string, number> = new Map();
private adc: AVRADC | null = null;
public spi: AVRSPI | null = null;
public usart: AVRUSART | null = null;
@ -43,7 +127,9 @@ export class AVRSimulator {
private running = false;
private animationFrame: number | null = null;
public pinManager: PinManager;
private speed = 1.0; // Simulation speed multiplier
private speed = 1.0;
/** 'uno' for ATmega328P boards (Uno, Nano); 'mega' for ATmega2560 */
private boardVariant: 'uno' | 'mega';
/** Serial output buffer — subscribers receive each byte or line */
public onSerialData: ((char: string) => void) | null = null;
@ -52,10 +138,15 @@ export class AVRSimulator {
private lastPortBValue = 0;
private lastPortCValue = 0;
private lastPortDValue = 0;
private lastOcrValues: number[] = new Array(PWM_PINS.length).fill(-1);
private lastOcrValues: number[] = [];
constructor(pinManager: PinManager) {
constructor(pinManager: PinManager, boardVariant: 'uno' | 'mega' = 'uno') {
this.pinManager = pinManager;
this.boardVariant = boardVariant;
}
private get pwmPins() {
return this.boardVariant === 'mega' ? PWM_PINS_MEGA : PWM_PINS_UNO;
}
/**
@ -64,45 +155,33 @@ export class AVRSimulator {
loadHex(hexContent: string): void {
console.log('Loading HEX file...');
// Parse Intel HEX format to Uint8Array
const bytes = hexToUint8Array(hexContent);
// Create program memory (ATmega328p has 32KB = 16K words)
this.program = new Uint16Array(16384);
// ATmega328P: 32 KB = 16 384 words. ATmega2560: 256 KB = 131 072 words.
const progWords = this.boardVariant === 'mega' ? 131072 : 16384;
// ATmega2560 has 8 KB SRAM; 328P has 2 KB but avr8js defaults 8 KB (safe over-alloc)
const sramBytes = this.boardVariant === 'mega' ? 8192 : 8192;
// Load bytes into program memory (little-endian, 16-bit words)
this.program = new Uint16Array(progWords);
for (let i = 0; i < bytes.length; i += 2) {
const low = bytes[i] || 0;
const high = bytes[i + 1] || 0;
this.program[i >> 1] = low | (high << 8);
this.program[i >> 1] = (bytes[i] || 0) | ((bytes[i + 1] || 0) << 8);
}
console.log(`Loaded ${bytes.length} bytes into program memory`);
// Initialize CPU (ATmega328p @ 16MHz)
this.cpu = new CPU(this.program);
this.cpu = new CPU(this.program, sramBytes);
// Initialize peripherals (kept alive so their CPU hooks are not GC'd)
this.spi = new AVRSPI(this.cpu, spiConfig, 16000000);
// Default onByte: complete transfer immediately (no external device)
this.spi.onByte = (value) => {
this.spi!.completeTransfer(value);
};
this.spi.onByte = (value) => { this.spi!.completeTransfer(value); };
// USART (Serial) — hook onByteTransmit to forward output
this.usart = new AVRUSART(this.cpu, usart0Config, 16000000);
this.usart.onByteTransmit = (value: number) => {
if (this.onSerialData) {
this.onSerialData(String.fromCharCode(value));
}
if (this.onSerialData) this.onSerialData(String.fromCharCode(value));
};
this.usart.onConfigurationChange = () => {
if (this.onBaudRateChange && this.usart) {
this.onBaudRateChange(this.usart.baudRate);
}
if (this.onBaudRateChange && this.usart) this.onBaudRateChange(this.usart.baudRate);
};
// TWI (I2C)
this.twi = new AVRTWI(this.cpu, twiConfig, 16000000);
this.i2cBus = new I2CBusManager(this.twi);
@ -115,21 +194,31 @@ export class AVRSimulator {
this.twi,
];
// Initialize ADC (analogRead support)
this.adc = new AVRADC(this.cpu, adcConfig);
// Initialize IO ports
// ── GPIO ports ────────────────────────────────────────────────────────
this.portB = new AVRIOPort(this.cpu, portBConfig);
this.portC = new AVRIOPort(this.cpu, portCConfig);
this.portD = new AVRIOPort(this.cpu, portDConfig);
// Reset OCR tracking
this.lastOcrValues = new Array(PWM_PINS.length).fill(-1);
if (this.boardVariant === 'mega') {
this.megaPorts.clear();
this.megaPortValues.clear();
for (const { name, config } of MEGA_PORT_CONFIGS) {
this.megaPorts.set(name, new AVRIOPort(this.cpu, config));
this.megaPortValues.set(name, 0);
}
}
this.lastPortBValue = 0;
this.lastPortCValue = 0;
this.lastPortDValue = 0;
this.lastOcrValues = new Array(this.pwmPins.length).fill(-1);
// Set up pin change hooks
this.setupPinHooks();
console.log(`AVR CPU initialized (${this.peripherals.length} peripherals, ADC + Timer1/Timer2 enabled)`);
const board = this.boardVariant === 'mega' ? 'ATmega2560' : 'ATmega328P';
console.log(`AVR CPU initialized (${board}, ${this.peripherals.length} peripherals)`);
}
/**
@ -144,32 +233,42 @@ export class AVRSimulator {
*/
private setupPinHooks(): void {
if (!this.cpu) return;
console.log('Setting up pin hooks...');
// PORTB (Digital pins 8-13)
this.portB!.addListener((value, _oldValue) => {
if (value !== this.lastPortBValue) {
this.pinManager.updatePort('PORTB', value, this.lastPortBValue);
this.lastPortBValue = value;
if (this.boardVariant === 'mega') {
// Mega: use explicit per-bit pin maps for all 11 ports
for (const [portName, port] of this.megaPorts) {
const pinMap = MEGA_PORT_BIT_MAP[portName];
this.megaPortValues.set(portName, 0);
port.addListener((value) => {
const old = this.megaPortValues.get(portName) ?? 0;
if (value !== old) {
this.pinManager.updatePort(portName, value, old, pinMap);
this.megaPortValues.set(portName, value);
}
});
}
});
// PORTC (Analog pins A0-A5)
this.portC!.addListener((value, _oldValue) => {
if (value !== this.lastPortCValue) {
this.pinManager.updatePort('PORTC', value, this.lastPortCValue);
this.lastPortCValue = value;
}
});
// PORTD (Digital pins 0-7)
this.portD!.addListener((value, _oldValue) => {
if (value !== this.lastPortDValue) {
this.pinManager.updatePort('PORTD', value, this.lastPortDValue);
this.lastPortDValue = value;
}
});
} else {
// Uno / Nano: simple 3-port setup
this.portB!.addListener((value) => {
if (value !== this.lastPortBValue) {
this.pinManager.updatePort('PORTB', value, this.lastPortBValue);
this.lastPortBValue = value;
}
});
this.portC!.addListener((value) => {
if (value !== this.lastPortCValue) {
this.pinManager.updatePort('PORTC', value, this.lastPortCValue);
this.lastPortCValue = value;
}
});
this.portD!.addListener((value) => {
if (value !== this.lastPortDValue) {
this.pinManager.updatePort('PORTD', value, this.lastPortDValue);
this.lastPortDValue = value;
}
});
}
console.log('Pin hooks configured successfully');
}
@ -179,14 +278,13 @@ export class AVRSimulator {
*/
private pollPwmRegisters(): void {
if (!this.cpu) return;
for (let i = 0; i < PWM_PINS.length; i++) {
const { ocrAddr, pin } = PWM_PINS[i];
const pins = this.pwmPins;
for (let i = 0; i < pins.length; i++) {
const { ocrAddr, pin } = pins[i];
const ocrValue = this.cpu.data[ocrAddr];
if (ocrValue !== this.lastOcrValues[i]) {
this.lastOcrValues[i] = ocrValue;
const dutyCycle = ocrValue / 255;
this.pinManager.updatePwm(pin, dutyCycle);
this.pinManager.updatePwm(pin, ocrValue / 255);
}
}
}
@ -273,15 +371,16 @@ export class AVRSimulator {
}
/**
* Reset simulator
* Reset simulator (re-run program from scratch without recompiling)
*/
reset(): void {
this.stop();
if (this.cpu && this.program) {
if (this.program) {
// Re-use the stored hex content path: just reload
const sramBytes = this.boardVariant === 'mega' ? 8192 : 8192;
console.log('Resetting AVR CPU...');
this.cpu = new CPU(this.program);
this.cpu = new CPU(this.program, sramBytes);
this.spi = new AVRSPI(this.cpu, spiConfig, 16000000);
this.spi.onByte = (value) => { this.spi!.completeTransfer(value); };
@ -291,9 +390,7 @@ export class AVRSimulator {
if (this.onSerialData) this.onSerialData(String.fromCharCode(value));
};
this.usart.onConfigurationChange = () => {
if (this.onBaudRateChange && this.usart) {
this.onBaudRateChange(this.usart.baudRate);
}
if (this.onBaudRateChange && this.usart) this.onBaudRateChange(this.usart.baudRate);
};
this.twi = new AVRTWI(this.cpu, twiConfig, 16000000);
@ -303,9 +400,7 @@ export class AVRSimulator {
new AVRTimer(this.cpu, timer0Config),
new AVRTimer(this.cpu, timer1Config),
new AVRTimer(this.cpu, timer2Config),
this.usart,
this.spi,
this.twi,
this.usart, this.spi, this.twi,
];
this.adc = new AVRADC(this.cpu, adcConfig);
@ -313,11 +408,19 @@ export class AVRSimulator {
this.portC = new AVRIOPort(this.cpu, portCConfig);
this.portD = new AVRIOPort(this.cpu, portDConfig);
if (this.boardVariant === 'mega') {
this.megaPorts.clear();
this.megaPortValues.clear();
for (const { name, config } of MEGA_PORT_CONFIGS) {
this.megaPorts.set(name, new AVRIOPort(this.cpu, config));
this.megaPortValues.set(name, 0);
}
}
this.lastPortBValue = 0;
this.lastPortCValue = 0;
this.lastPortDValue = 0;
this.lastOcrValues = new Array(PWM_PINS.length).fill(-1);
this.lastOcrValues = new Array(this.pwmPins.length).fill(-1);
this.setupPinHooks();
console.log('AVR CPU reset complete');
@ -347,6 +450,15 @@ export class AVRSimulator {
* Set the state of an Arduino pin externally (e.g. from a UI button)
*/
setPinState(arduinoPin: number, state: boolean): void {
if (this.boardVariant === 'mega') {
const entry = MEGA_PIN_TO_PORT[arduinoPin];
if (entry) {
const port = this.megaPorts.get(entry.portName);
port?.setPin(entry.bit, state);
}
return;
}
// Uno / Nano
if (arduinoPin >= 0 && arduinoPin <= 7 && this.portD) {
this.portD.setPin(arduinoPin, state);
} else if (arduinoPin >= 8 && arduinoPin <= 13 && this.portB) {

View File

@ -1,11 +1,16 @@
/**
* PinManager - Manages Arduino pin states and notifies listeners
*
* Maps AVR PORT registers to Arduino pin numbers:
* Maps AVR PORT registers to Arduino pin numbers.
*
* Arduino Uno / Nano (ATmega328P):
* - PORTB (0x25) Digital pins 8-13
* - PORTC (0x28) Analog pins A0-A5 (14-19)
* - PORTD (0x2B) Digital pins 0-7
*
* Arduino Mega 2560 (ATmega2560): uses explicit per-bit pin maps
* for non-linear port Arduino-pin relationships.
*
* Also supports:
* - Analog voltage injection (for potentiometers, sensors)
* - PWM duty cycle tracking (for servos, RGB LEDs, buzzers)
@ -41,13 +46,17 @@ export class PinManager {
/**
* Update port register and notify digital pin listeners.
*
* @param portName Human-readable port name for log output (e.g. 'PORTB').
* @param newValue New 8-bit port value.
* @param oldValue Previous 8-bit port value (default 0).
* @param pinMap Optional per-bit Arduino pin numbers (length 8).
* Use -1 for bits that are not exposed as Arduino pins.
* When omitted the legacy Uno/Nano fixed offsets are used:
* PORTB8, PORTC14, PORTD0.
*/
updatePort(portName: 'PORTB' | 'PORTC' | 'PORTD', newValue: number, oldValue: number = 0) {
const pinOffset = {
'PORTB': 8,
'PORTC': 14,
'PORTD': 0,
}[portName];
updatePort(portName: string, newValue: number, oldValue: number = 0, pinMap?: number[]) {
const legacyOffsets: Record<string, number> = { 'PORTB': 8, 'PORTC': 14, 'PORTD': 0 };
for (let bit = 0; bit < 8; bit++) {
const mask = 1 << bit;
@ -55,7 +64,9 @@ export class PinManager {
const newState = (newValue & mask) !== 0;
if (oldState !== newState) {
const arduinoPin = pinOffset + bit;
const arduinoPin = pinMap ? pinMap[bit] : (legacyOffsets[portName] ?? 0) + bit;
if (arduinoPin < 0) continue; // unmapped bit
this.pinStates.set(arduinoPin, newState);
const callbacks = this.listeners.get(arduinoPin);

View File

@ -7,17 +7,19 @@ import type { RP2040I2CDevice } from '../simulation/RP2040Simulator';
import type { Wire, WireInProgress, WireEndpoint } from '../types/wire';
import { calculatePinPosition } from '../utils/pinPositionCalculator';
export type BoardType = 'arduino-uno' | 'arduino-nano' | 'raspberry-pi-pico';
export type BoardType = 'arduino-uno' | 'arduino-nano' | 'arduino-mega' | 'raspberry-pi-pico';
export const BOARD_FQBN: Record<BoardType, string> = {
'arduino-uno': 'arduino:avr:uno',
'arduino-nano': 'arduino:avr:nano:cpu=atmega328',
'arduino-mega': 'arduino:avr:mega',
'raspberry-pi-pico': 'rp2040:rp2040:rpipico',
};
export const BOARD_LABELS: Record<BoardType, string> = {
'arduino-uno': 'Arduino Uno',
'arduino-nano': 'Arduino Nano',
'arduino-mega': 'Arduino Mega 2560',
'raspberry-pi-pico': 'Raspberry Pi Pico',
};
@ -191,8 +193,8 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
if (running) {
get().stopSimulation();
}
const simulator = (type === 'arduino-uno' || type === 'arduino-nano')
? new AVRSimulator(pinManager)
const simulator = (type === 'arduino-uno' || type === 'arduino-nano' || type === 'arduino-mega')
? new AVRSimulator(pinManager, type === 'arduino-mega' ? 'mega' : 'uno')
: new RP2040Simulator(pinManager);
// Wire serial output callback for both simulator types
simulator.onSerialData = (char: string) => {
@ -207,8 +209,8 @@ export const useSimulatorStore = create<SimulatorState>((set, get) => {
initSimulator: () => {
const { boardType } = get();
const simulator = (boardType === 'arduino-uno' || boardType === 'arduino-nano')
? new AVRSimulator(pinManager)
const simulator = (boardType === 'arduino-uno' || boardType === 'arduino-nano' || boardType === 'arduino-mega')
? new AVRSimulator(pinManager, boardType === 'arduino-mega' ? 'mega' : 'uno')
: new RP2040Simulator(pinManager);
// Wire serial output callback for both simulator types
simulator.onSerialData = (char: string) => {

View File

@ -50,8 +50,19 @@ const ARDUINO_UNO_ANALOG_MAP: Record<string, number> = {
'A7': 21,
};
/**
* Arduino Mega analog pin names AVR pin numbers.
* A0A15 map to physical pins 5469 on the ATmega2560.
*/
const ARDUINO_MEGA_ANALOG_MAP: Record<string, number> = {
'A0': 54, 'A1': 55, 'A2': 56, 'A3': 57,
'A4': 58, 'A5': 59, 'A6': 60, 'A7': 61,
'A8': 62, 'A9': 63, 'A10': 64, 'A11': 65,
'A12': 66, 'A13': 67, 'A14': 68, 'A15': 69,
};
/** All known board component IDs in the simulator */
export const BOARD_COMPONENT_IDS = ['arduino-uno', 'arduino-nano', 'nano-rp2040'];
export const BOARD_COMPONENT_IDS = ['arduino-uno', 'arduino-nano', 'arduino-mega', 'nano-rp2040'];
/**
* Check whether a componentId represents a board (not an external component).
@ -82,6 +93,17 @@ export function boardPinToNumber(boardId: string, pinName: string): number | nul
return ARDUINO_UNO_ANALOG_MAP[pinName] ?? null;
}
if (boardId === 'arduino-mega') {
// Digital pins D0D53 parsed numerically
const num = parseInt(pinName, 10);
if (!isNaN(num) && num >= 0 && num <= 53) return num;
if (pinName.startsWith('D')) {
const d = parseInt(pinName.substring(1), 10);
if (!isNaN(d) && d <= 53) return d;
}
return ARDUINO_MEGA_ANALOG_MAP[pinName] ?? null;
}
if (boardId === 'nano-rp2040') {
return NANO_RP2040_PIN_MAP[pinName] ?? null;
}

View File

@ -43,7 +43,7 @@ export interface VelxioComponent {
}
export interface ImportResult {
boardType: 'arduino-uno' | 'arduino-nano' | 'raspberry-pi-pico';
boardType: 'arduino-uno' | 'arduino-nano' | 'arduino-mega' | 'raspberry-pi-pico';
boardPosition: { x: number; y: number };
components: VelxioComponent[];
wires: Wire[];
@ -53,10 +53,10 @@ export interface ImportResult {
// ── Board mappings ────────────────────────────────────────────────────────────
// Wokwi board type → Velxio boardType
const WOKWI_TYPE_TO_BOARD: Record<string, 'arduino-uno' | 'arduino-nano' | 'raspberry-pi-pico'> = {
const WOKWI_TYPE_TO_BOARD: Record<string, 'arduino-uno' | 'arduino-nano' | 'arduino-mega' | 'raspberry-pi-pico'> = {
'wokwi-arduino-uno': 'arduino-uno',
'wokwi-arduino-nano': 'arduino-nano',
'wokwi-arduino-mega': 'arduino-uno',
'wokwi-arduino-mega': 'arduino-mega',
'wokwi-raspberry-pi-pico': 'raspberry-pi-pico',
};
@ -64,6 +64,7 @@ const WOKWI_TYPE_TO_BOARD: Record<string, 'arduino-uno' | 'arduino-nano' | 'rasp
const BOARD_TO_WOKWI_TYPE: Record<string, string> = {
'arduino-uno': 'wokwi-arduino-uno',
'arduino-nano': 'wokwi-arduino-nano',
'arduino-mega': 'wokwi-arduino-mega',
'raspberry-pi-pico': 'wokwi-raspberry-pi-pico',
};
@ -71,6 +72,7 @@ const BOARD_TO_WOKWI_TYPE: Record<string, string> = {
const BOARD_TO_WOKWI_ID: Record<string, string> = {
'arduino-uno': 'uno',
'arduino-nano': 'nano',
'arduino-mega': 'mega',
'raspberry-pi-pico': 'pico',
};
@ -217,6 +219,7 @@ export async function importFromWokwiZip(file: File): Promise<ImportResult> {
const VELXIO_BOARD_ID: Record<string, string> = {
'arduino-uno': 'arduino-uno',
'arduino-nano': 'arduino-nano',
'arduino-mega': 'arduino-mega',
'raspberry-pi-pico': 'nano-rp2040',
};
const velxioBoardId = VELXIO_BOARD_ID[boardType] ?? 'arduino-uno';