diff --git a/frontend/src/__tests__/ili9341-emulation.test.ts b/frontend/src/__tests__/ili9341-emulation.test.ts new file mode 100644 index 0000000..b805711 --- /dev/null +++ b/frontend/src/__tests__/ili9341-emulation.test.ts @@ -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); + }); +}); diff --git a/frontend/src/components/components-wokwi/ArduinoMega.tsx b/frontend/src/components/components-wokwi/ArduinoMega.tsx new file mode 100644 index 0000000..a45398a --- /dev/null +++ b/frontend/src/components/components-wokwi/ArduinoMega.tsx @@ -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(null); + + useEffect(() => { + if (megaRef.current) { + (megaRef.current as any).led13 = led13; + } + }, [led13]); + + return ( + + ); +}; diff --git a/frontend/src/components/simulator/SimulatorCanvas.tsx b/frontend/src/components/simulator/SimulatorCanvas.tsx index 95c6a9b..693f61e 100644 --- a/frontend/src/components/simulator/SimulatorCanvas.tsx +++ b/frontend/src/components/simulator/SimulatorCanvas.tsx @@ -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' ? ( + c.id === 'led-builtin')?.properties.state)} + /> ) : ( { 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 */} = { + // 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 = {}; + 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, E–L); keyed by port name */ + private megaPorts: Map = new Map(); + private megaPortValues: Map = 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) { diff --git a/frontend/src/simulation/PinManager.ts b/frontend/src/simulation/PinManager.ts index 5868c85..6cc6151 100644 --- a/frontend/src/simulation/PinManager.ts +++ b/frontend/src/simulation/PinManager.ts @@ -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: + * PORTB→8, PORTC→14, PORTD→0. */ - 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 = { '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); diff --git a/frontend/src/store/useSimulatorStore.ts b/frontend/src/store/useSimulatorStore.ts index b249987..481b2ad 100644 --- a/frontend/src/store/useSimulatorStore.ts +++ b/frontend/src/store/useSimulatorStore.ts @@ -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 = { '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 = { '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((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((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) => { diff --git a/frontend/src/utils/boardPinMapping.ts b/frontend/src/utils/boardPinMapping.ts index d1dd9e1..aade0d2 100644 --- a/frontend/src/utils/boardPinMapping.ts +++ b/frontend/src/utils/boardPinMapping.ts @@ -50,8 +50,19 @@ const ARDUINO_UNO_ANALOG_MAP: Record = { 'A7': 21, }; +/** + * Arduino Mega analog pin names → AVR pin numbers. + * A0–A15 map to physical pins 54–69 on the ATmega2560. + */ +const ARDUINO_MEGA_ANALOG_MAP: Record = { + '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 D0–D53 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; } diff --git a/frontend/src/utils/wokwiZip.ts b/frontend/src/utils/wokwiZip.ts index 2b744bf..307c15b 100644 --- a/frontend/src/utils/wokwiZip.ts +++ b/frontend/src/utils/wokwiZip.ts @@ -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 = { +const WOKWI_TYPE_TO_BOARD: Record = { '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 = { '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 = { const BOARD_TO_WOKWI_ID: Record = { 'arduino-uno': 'uno', 'arduino-nano': 'nano', + 'arduino-mega': 'mega', 'raspberry-pi-pico': 'pico', }; @@ -217,6 +219,7 @@ export async function importFromWokwiZip(file: File): Promise { const VELXIO_BOARD_ID: Record = { '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';