From efd4c11e03be2cf57db4e2693d1a3bf391bb68aa Mon Sep 17 00:00:00 2001 From: David Montero Crespo Date: Thu, 5 Mar 2026 01:52:15 -0300 Subject: [PATCH] feat: add ILI9341 TFT display simulation and enhance component registry loading --- frontend/src/components/DynamicComponent.tsx | 2 +- .../components/simulator/SimulatorCanvas.tsx | 10 +- frontend/src/data/examples.ts | 107 ++++++++++ frontend/src/services/ComponentRegistry.ts | 18 ++ frontend/src/simulation/AVRSimulator.ts | 10 +- frontend/src/simulation/parts/ComplexParts.ts | 182 ++++++++++++++++++ 6 files changed, 326 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/DynamicComponent.tsx b/frontend/src/components/DynamicComponent.tsx index 64948e97..c2b27b29 100644 --- a/frontend/src/components/DynamicComponent.tsx +++ b/frontend/src/components/DynamicComponent.tsx @@ -214,7 +214,7 @@ export const DynamicComponent: React.FC = ({ el.removeEventListener('button-press', onButtonPress); el.removeEventListener('button-release', onButtonRelease); }; - }, [id, handleComponentEvent, metadata.id, simulator]); + }, [id, handleComponentEvent, metadata.id, simulator, running]); return (
{ // Component picker modal const [showComponentPicker, setShowComponentPicker] = useState(false); const [registry] = useState(() => ComponentRegistry.getInstance()); + const [registryLoaded, setRegistryLoaded] = useState(registry.isLoaded); + + // Wait for registry to finish loading before rendering components + useEffect(() => { + if (!registryLoaded) { + registry.loadPromise.then(() => setRegistryLoaded(true)); + } + }, [registry, registryLoaded]); // Component selection const [selectedComponentId, setSelectedComponentId] = useState(null); @@ -454,7 +462,7 @@ export const SimulatorCanvas = () => { /> {/* Components using wokwi-elements */} -
{components.map(renderComponent)}
+
{registryLoaded && components.map(renderComponent)}
diff --git a/frontend/src/data/examples.ts b/frontend/src/data/examples.ts index a57d4e92..291e56a2 100644 --- a/frontend/src/data/examples.ts +++ b/frontend/src/data/examples.ts @@ -593,6 +593,113 @@ void loop() { }, ], }, + { + id: 'tft-display', + title: 'TFT ILI9341 Display', + description: 'Color TFT display demo: fills, text, and a bouncing ball animation using the Arduino TFT library', + category: 'displays', + difficulty: 'intermediate', + code: `// TFT ILI9341 Display Demo +// Library: TFT by Arduino, Adafruit +// Connect: CS=10, DC/RS=9, RST=8, LED=7, MOSI=11(SPI), SCK=13(SPI) + +#include +#include + +#define TFT_CS 10 +#define TFT_DC 9 +#define TFT_RST 8 +#define TFT_LED 7 + +TFT TFTscreen = TFT(TFT_CS, TFT_DC, TFT_RST); + +int ballX = 64, ballY = 90; +int ballDX = 3, ballDY = 2; +const int BALL_R = 8; + +void drawStaticUI() { + // Background + TFTscreen.background(0, 0, 50); + + // Title + TFTscreen.setTextSize(2); + TFTscreen.stroke(255, 220, 0); + TFTscreen.text("WOKWI TFT", 5, 5); + + // Subtitle + TFTscreen.setTextSize(1); + TFTscreen.stroke(180, 180, 255); + TFTscreen.text("ILI9341 Demo", 12, 28); + + // Color palette bars + TFTscreen.noStroke(); + TFTscreen.fill(220, 50, 50); + TFTscreen.rect(5, 45, 36, 14); + TFTscreen.fill(50, 200, 50); + TFTscreen.rect(45, 45, 36, 14); + TFTscreen.fill(50, 100, 240); + TFTscreen.rect(85, 45, 36, 14); + + // Play-field border + TFTscreen.stroke(80, 80, 130); + TFTscreen.noFill(); + TFTscreen.rect(2, 68, 124, 88); +} + +void setup() { + pinMode(TFT_LED, OUTPUT); + digitalWrite(TFT_LED, HIGH); // Backlight on + + TFTscreen.begin(); + drawStaticUI(); +} + +void loop() { + // Erase old ball + TFTscreen.noStroke(); + TFTscreen.fill(0, 0, 50); + TFTscreen.circle(ballX, ballY, BALL_R); + + // Update position + ballX += ballDX; + ballY += ballDY; + + // Bounce off field borders + if (ballX < 3 + BALL_R || ballX > 124 - BALL_R) ballDX = -ballDX; + if (ballY < 69 + BALL_R || ballY > 155 - BALL_R) ballDY = -ballDY; + + // Draw ball + TFTscreen.fill(255, 140, 0); + TFTscreen.circle(ballX, ballY, BALL_R); + + delay(30); +} +`, + components: [ + { + type: 'wokwi-arduino-uno', + id: 'arduino-uno', + x: 80, + y: 220, + properties: {}, + }, + { + type: 'wokwi-ili9341', + id: 'tft1', + x: 480, + y: 60, + properties: {}, + }, + ], + wires: [ + { id: 'w-sck', start: { componentId: 'arduino-uno', pinName: '13' }, end: { componentId: 'tft1', pinName: 'SCK' }, color: '#ff8800' }, + { id: 'w-mosi', start: { componentId: 'arduino-uno', pinName: '11' }, end: { componentId: 'tft1', pinName: 'MOSI' }, color: '#ff8800' }, + { id: 'w-cs', start: { componentId: 'arduino-uno', pinName: '10' }, end: { componentId: 'tft1', pinName: 'CS' }, color: '#00aaff' }, + { id: 'w-dc', start: { componentId: 'arduino-uno', pinName: '9' }, end: { componentId: 'tft1', pinName: 'D/C' }, color: '#00cc00' }, + { id: 'w-rst', start: { componentId: 'arduino-uno', pinName: '8' }, end: { componentId: 'tft1', pinName: 'RST' }, color: '#cc0000' }, + { id: 'w-led', start: { componentId: 'arduino-uno', pinName: '7' }, end: { componentId: 'tft1', pinName: 'LED' }, color: '#ffffff' }, + ], + }, { id: 'lcd-hello', title: 'LCD 20x4 Display', diff --git a/frontend/src/services/ComponentRegistry.ts b/frontend/src/services/ComponentRegistry.ts index 91743c2c..373630a6 100644 --- a/frontend/src/services/ComponentRegistry.ts +++ b/frontend/src/services/ComponentRegistry.ts @@ -17,6 +17,7 @@ export class ComponentRegistry { private categories: Map = new Map(); private allComponents: ComponentMetadata[] = []; private loaded = false; + private _loadPromise: Promise | null = null; private constructor() {} @@ -35,6 +36,23 @@ export class ComponentRegistry { */ async load(): Promise { if (this.loaded) return; + if (this._loadPromise) return this._loadPromise; + this._loadPromise = this._doLoad(); + return this._loadPromise; + } + + /** + * Returns the load promise so consumers can await registry readiness + */ + get loadPromise(): Promise { + return this._loadPromise ?? this.load(); + } + + get isLoaded(): boolean { + return this.loaded; + } + + private async _doLoad(): Promise { try { const response = await fetch('/components-metadata.json'); diff --git a/frontend/src/simulation/AVRSimulator.ts b/frontend/src/simulation/AVRSimulator.ts index 19acf47b..dd144d21 100644 --- a/frontend/src/simulation/AVRSimulator.ts +++ b/frontend/src/simulation/AVRSimulator.ts @@ -1,4 +1,4 @@ -import { CPU, AVRTimer, timer0Config, timer1Config, timer2Config, AVRUSART, usart0Config, AVRIOPort, portBConfig, portCConfig, portDConfig, avrInstruction, AVRADC, adcConfig } from 'avr8js'; +import { CPU, AVRTimer, timer0Config, timer1Config, timer2Config, AVRUSART, usart0Config, AVRIOPort, portBConfig, portCConfig, portDConfig, avrInstruction, AVRADC, adcConfig, AVRSPI, spiConfig } from 'avr8js'; import { PinManager } from './PinManager'; import { hexToUint8Array } from '../utils/hexParser'; @@ -33,6 +33,7 @@ export class AVRSimulator { private portC: AVRIOPort | null = null; private portD: AVRIOPort | null = null; private adc: AVRADC | null = null; + public spi: AVRSPI | null = null; private program: Uint16Array | null = null; private running = false; private animationFrame: number | null = null; @@ -72,11 +73,18 @@ export class AVRSimulator { this.cpu = new CPU(this.program); // 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.peripherals = [ new AVRTimer(this.cpu, timer0Config), new AVRTimer(this.cpu, timer1Config), new AVRTimer(this.cpu, timer2Config), new AVRUSART(this.cpu, usart0Config, 16000000), + this.spi, ]; // Initialize ADC (analogRead support) diff --git a/frontend/src/simulation/parts/ComplexParts.ts b/frontend/src/simulation/parts/ComplexParts.ts index f9a42366..5f18c0c9 100644 --- a/frontend/src/simulation/parts/ComplexParts.ts +++ b/frontend/src/simulation/parts/ComplexParts.ts @@ -554,3 +554,185 @@ function createLcdSimulation(cols: number, rows: number) { PartSimulationRegistry.register('lcd1602', createLcdSimulation(16, 2)); PartSimulationRegistry.register('lcd2004', createLcdSimulation(20, 4)); + +// ─── ILI9341 TFT Display (SPI) ─────────────────────────────────────────────── + +/** + * ILI9341 TFT display simulation via hardware SPI. + * + * Intercepts writes to SPDR (via AVRSPI) and decodes ILI9341 commands: + * - 0x2A CASET – set column address window + * - 0x2B PASET – set page (row) address window + * - 0x2C RAMWR – stream RGB-565 pixel data + * - 0x01 SWRESET – clear display + * - All others are silently accepted (init sequences, DISPON, MADCTL…) + * + * DC/RS pin: LOW = command byte, HIGH = data bytes. + */ +PartSimulationRegistry.register('ili9341', { + attachEvents: (element, avrSimulator, getArduinoPinHelper) => { + const el = element as any; + const pinManager = (avrSimulator as any).pinManager; + const spi = (avrSimulator as any).spi; + + if (!pinManager || !spi) { + console.warn('[ILI9341] pinManager or SPI peripheral not available'); + return () => {}; + } + + // ── Canvas setup ────────────────────────────────────────────────── + const SCREEN_W = 240; + const SCREEN_H = 320; + + const initCanvas = (): CanvasRenderingContext2D | null => { + // el.canvas is the getter defined in ili9341-element.ts: + // get canvas() { return this.shadowRoot?.querySelector('canvas'); } + // The element already sets width=240 height=320 in its LitElement template. + const canvas = el.canvas as HTMLCanvasElement | null; + if (!canvas) return null; + return canvas.getContext('2d'); + }; + + let ctx = initCanvas(); + + const onCanvasReady = () => { ctx = initCanvas(); }; + el.addEventListener('canvas-ready', onCanvasReady); + + // ── Shared ImageData buffer ─────────────────────────────────────── + // Accumulate pixels here; flush to canvas once per animation frame. + let imageData: ImageData | null = null; + + const getOrCreateImageData = (): ImageData => { + if (!ctx) ctx = initCanvas(); + if (!imageData && ctx) imageData = ctx.createImageData(SCREEN_W, SCREEN_H); + return imageData!; + }; + + let pendingFlush = false; + let rafId: number | null = null; + + const scheduleFlush = () => { + if (rafId !== null) return; + rafId = requestAnimationFrame(() => { + rafId = null; + if (pendingFlush && ctx && imageData) { + ctx.putImageData(imageData, 0, 0); + pendingFlush = false; + } + }); + }; + + // ── ILI9341 state ───────────────────────────────────────────────── + let colStart = 0, colEnd = SCREEN_W - 1; + let rowStart = 0, rowEnd = SCREEN_H - 1; + let curX = 0, curY = 0; + + let currentCmd = -1; + let dataBytes: number[] = []; + let inRamWrite = false; + let pixelHiByte = 0; + let pixelByteCount = 0; + + // ── DC pin tracking ─────────────────────────────────────────────── + let dcState = false; // LOW = command, HIGH = data + const pinDC = getArduinoPinHelper('D/C'); + + const unsubscribers: (() => void)[] = []; + + if (pinDC !== null) { + unsubscribers.push( + pinManager.onPinChange(pinDC, (_: number, s: boolean) => { dcState = s; }) + ); + } + + // ── Pixel writer ────────────────────────────────────────────────── + const writePixel = (hi: number, lo: number) => { + if (curX > colEnd || curY > rowEnd || curY >= SCREEN_H || curX >= SCREEN_W) return; + + const id = getOrCreateImageData(); + const color = (hi << 8) | lo; + const r = ((color >> 11) & 0x1F) * 8; + const g = ((color >> 5) & 0x3F) * 4; + const b = ( color & 0x1F) * 8; + + const idx = (curY * SCREEN_W + curX) * 4; + id.data[idx] = r; + id.data[idx + 1] = g; + id.data[idx + 2] = b; + id.data[idx + 3] = 255; + + pendingFlush = true; + curX++; + if (curX > colEnd) { + curX = colStart; + curY++; + } + }; + + // ── Command / data processing ───────────────────────────────────── + const processCommand = (cmd: number) => { + currentCmd = cmd; + dataBytes = []; + inRamWrite = (cmd === 0x2C); + pixelByteCount = 0; + + if (cmd === 0x01) { // SWRESET – clear framebuffer + colStart = 0; colEnd = SCREEN_W - 1; + rowStart = 0; rowEnd = SCREEN_H - 1; + curX = 0; curY = 0; + imageData = null; + if (ctx) ctx.clearRect(0, 0, SCREEN_W, SCREEN_H); + } + }; + + const processData = (value: number) => { + if (inRamWrite) { + // RGB-565: two bytes per pixel + if (pixelByteCount === 0) { + pixelHiByte = value; + pixelByteCount = 1; + } else { + writePixel(pixelHiByte, value); + scheduleFlush(); + pixelByteCount = 0; + } + return; + } + + dataBytes.push(value); + switch (currentCmd) { + case 0x2A: // CASET – column address set + if (dataBytes.length === 2) colStart = (dataBytes[0] << 8) | dataBytes[1]; + if (dataBytes.length === 4) { colEnd = (dataBytes[2] << 8) | dataBytes[3]; curX = colStart; } + break; + case 0x2B: // PASET – page address set + if (dataBytes.length === 2) rowStart = (dataBytes[0] << 8) | dataBytes[1]; + if (dataBytes.length === 4) { rowEnd = (dataBytes[2] << 8) | dataBytes[3]; curY = rowStart; } + break; + // All other commands (DISPON, MADCTL, COLMOD…) just buffer data + } + }; + + // ── Intercept SPI ───────────────────────────────────────────────── + const prevOnByte = spi.onByte.bind(spi); + + spi.onByte = (value: number) => { + if (!dcState) { + processCommand(value); + } else { + processData(value); + } + spi.completeTransfer(0xFF); // Unblock CPU immediately + }; + + console.log(`[ILI9341] SPI simulation ready. DC→pin${pinDC}`); + + // ── Cleanup ─────────────────────────────────────────────────────── + return () => { + spi.onByte = prevOnByte; + if (rafId !== null) cancelAnimationFrame(rafId); + el.removeEventListener('canvas-ready', onCanvasReady); + unsubscribers.forEach(u => u()); + }; + }, +});