feat: add ILI9341 TFT display simulation and enhance component registry loading

pull/10/head
David Montero Crespo 2026-03-05 01:52:15 -03:00
parent da47f69cb2
commit efd4c11e03
6 changed files with 326 additions and 3 deletions

View File

@ -214,7 +214,7 @@ export const DynamicComponent: React.FC<DynamicComponentProps> = ({
el.removeEventListener('button-press', onButtonPress);
el.removeEventListener('button-release', onButtonRelease);
};
}, [id, handleComponentEvent, metadata.id, simulator]);
}, [id, handleComponentEvent, metadata.id, simulator, running]);
return (
<div

View File

@ -39,6 +39,14 @@ export const SimulatorCanvas = () => {
// 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<string | null>(null);
@ -454,7 +462,7 @@ export const SimulatorCanvas = () => {
/>
{/* Components using wokwi-elements */}
<div className="components-area">{components.map(renderComponent)}</div>
<div className="components-area">{registryLoaded && components.map(renderComponent)}</div>
</div>
</div>

View File

@ -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 <TFT.h>
#include <SPI.h>
#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',

View File

@ -17,6 +17,7 @@ export class ComponentRegistry {
private categories: Map<ComponentCategory, ComponentMetadata[]> = new Map();
private allComponents: ComponentMetadata[] = [];
private loaded = false;
private _loadPromise: Promise<void> | null = null;
private constructor() {}
@ -35,6 +36,23 @@ export class ComponentRegistry {
*/
async load(): Promise<void> {
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<void> {
return this._loadPromise ?? this.load();
}
get isLoaded(): boolean {
return this.loaded;
}
private async _doLoad(): Promise<void> {
try {
const response = await fetch('/components-metadata.json');

View File

@ -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)

View File

@ -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());
};
},
});