feat: implement syncElement method for rendering GDDRAM to RGBA ImageData in VirtualSSD1306
test: add tests for SSD1306 rendering path and syncElement functionalitypull/10/head
parent
a07d219a7d
commit
6a10675a5a
|
|
@ -0,0 +1,243 @@
|
||||||
|
/**
|
||||||
|
* ssd1306-render.test.ts
|
||||||
|
*
|
||||||
|
* Tests the SSD1306 OLED simulation's rendering path:
|
||||||
|
* - GDDRAM is filled correctly via I2C writes
|
||||||
|
* - syncElement() converts 1-bit GDDRAM → RGBA ImageData
|
||||||
|
* - element.imageData is updated and element.redraw() is called
|
||||||
|
*
|
||||||
|
* This covers the bug fix where syncElement() was calling el.buffer /
|
||||||
|
* el.renderFrame() (non-existent) instead of el.imageData / el.redraw().
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeAll } from 'vitest';
|
||||||
|
import { PartSimulationRegistry } from '../simulation/parts/PartSimulationRegistry';
|
||||||
|
import '../simulation/parts/ProtocolParts';
|
||||||
|
|
||||||
|
// ─── Polyfill ImageData for Node/Vitest (no browser) ─────────────────────────
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
if (typeof globalThis.ImageData === 'undefined') {
|
||||||
|
class ImageDataPolyfill {
|
||||||
|
readonly width: number;
|
||||||
|
readonly height: number;
|
||||||
|
readonly data: Uint8ClampedArray;
|
||||||
|
|
||||||
|
constructor(widthOrData: number | Uint8ClampedArray, height: number) {
|
||||||
|
if (typeof widthOrData === 'number') {
|
||||||
|
this.width = widthOrData;
|
||||||
|
this.height = height;
|
||||||
|
this.data = new Uint8ClampedArray(widthOrData * height * 4);
|
||||||
|
} else {
|
||||||
|
this.width = widthOrData.length / 4 / height;
|
||||||
|
this.height = height;
|
||||||
|
this.data = new Uint8ClampedArray(widthOrData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(globalThis as any).ImageData = ImageDataPolyfill;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Build a mock wokwi-ssd1306 element with the real ImageData API. */
|
||||||
|
function makeOLEDElement() {
|
||||||
|
const imageData = new ImageData(128, 64);
|
||||||
|
const redraw = vi.fn();
|
||||||
|
return {
|
||||||
|
imageData,
|
||||||
|
redraw,
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
} as unknown as HTMLElement & { imageData: ImageData; redraw: ReturnType<typeof vi.fn> };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a minimal AVR simulator stub that supports addI2CDevice. */
|
||||||
|
function makeSim() {
|
||||||
|
const devices: any[] = [];
|
||||||
|
return {
|
||||||
|
addI2CDevice: vi.fn((d: any) => devices.push(d)),
|
||||||
|
i2cBus: { removeDevice: vi.fn() },
|
||||||
|
_devices: devices,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Simulate the Adafruit SSD1306 library's I2C init + fill sequence.
|
||||||
|
*
|
||||||
|
* The library sends:
|
||||||
|
* START → addr 0x3C write → 0x00 (cmd ctrl) → [commands…] → STOP
|
||||||
|
* START → addr 0x3C write → 0x40 (data ctrl) → [data…] → STOP
|
||||||
|
*
|
||||||
|
* In our model the I2CBusManager calls device.writeByte() for every byte
|
||||||
|
* after the address phase, starting with the control byte.
|
||||||
|
*/
|
||||||
|
function sendCommandStream(device: any, cmds: number[]) {
|
||||||
|
device.writeByte(0x00); // control byte: command stream (Co=0, D/C#=0)
|
||||||
|
for (const b of cmds) device.writeByte(b);
|
||||||
|
device.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendDataStream(device: any, data: number[]) {
|
||||||
|
device.writeByte(0x40); // control byte: GDDRAM data
|
||||||
|
for (const b of data) device.writeByte(b);
|
||||||
|
device.stop();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tests ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe('SSD1306 — ImageData rendering (syncElement fix)', () => {
|
||||||
|
it('registers ssd1306 in PartSimulationRegistry', () => {
|
||||||
|
expect(PartSimulationRegistry.get('ssd1306')).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates a VirtualSSD1306 device at address 0x3C', () => {
|
||||||
|
const el = makeOLEDElement();
|
||||||
|
const sim = makeSim();
|
||||||
|
PartSimulationRegistry.get('ssd1306')!.attachEvents!(el, sim as any, () => null);
|
||||||
|
expect(sim.addI2CDevice).toHaveBeenCalledOnce();
|
||||||
|
expect(sim._devices[0].address).toBe(0x3C);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calls element.redraw() after a STOP', () => {
|
||||||
|
const el = makeOLEDElement();
|
||||||
|
const sim = makeSim();
|
||||||
|
PartSimulationRegistry.get('ssd1306')!.attachEvents!(el, sim as any, () => null);
|
||||||
|
const device = sim._devices[0];
|
||||||
|
|
||||||
|
// Simple data write — fill first byte
|
||||||
|
sendDataStream(device, [0xFF]);
|
||||||
|
|
||||||
|
expect(el.redraw).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders a fully-lit column 0 of page 0 (0xFF → top 8 pixels lit)', () => {
|
||||||
|
const el = makeOLEDElement();
|
||||||
|
const sim = makeSim();
|
||||||
|
PartSimulationRegistry.get('ssd1306')!.attachEvents!(el, sim as any, () => null);
|
||||||
|
const device = sim._devices[0];
|
||||||
|
|
||||||
|
// Set horizontal addressing, col 0–127, page 0–7
|
||||||
|
sendCommandStream(device, [
|
||||||
|
0x20, 0x00, // horizontal addressing mode
|
||||||
|
0x21, 0x00, 0x7F, // col 0–127
|
||||||
|
0x22, 0x00, 0x07, // page 0–7
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Write 0xFF to column 0 of page 0 → all 8 bits set → rows 0–7, col 0 lit
|
||||||
|
sendDataStream(device, [0xFF]);
|
||||||
|
|
||||||
|
const px = el.imageData.data; // RGBA
|
||||||
|
|
||||||
|
// Row 0, col 0 → pixel index 0
|
||||||
|
const idx = (0 * 128 + 0) * 4;
|
||||||
|
expect(px[idx + 3]).toBe(255); // alpha = 255 (opaque)
|
||||||
|
expect(px[idx] + px[idx + 1] + px[idx + 2]).toBeGreaterThan(0); // not black
|
||||||
|
|
||||||
|
// Row 7, col 0 → pixel index (7 * 128 + 0) * 4
|
||||||
|
const idx7 = (7 * 128 + 0) * 4;
|
||||||
|
expect(px[idx7 + 3]).toBe(255);
|
||||||
|
expect(px[idx7] + px[idx7 + 1] + px[idx7 + 2]).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders an unlit pixel as black (RGB = 0)', () => {
|
||||||
|
const el = makeOLEDElement();
|
||||||
|
const sim = makeSim();
|
||||||
|
PartSimulationRegistry.get('ssd1306')!.attachEvents!(el, sim as any, () => null);
|
||||||
|
const device = sim._devices[0];
|
||||||
|
|
||||||
|
sendCommandStream(device, [0x20, 0x00, 0x21, 0x00, 0x7F, 0x22, 0x00, 0x07]);
|
||||||
|
// 0x01 → only bit 0 set → only row 0 of page 0 is lit; row 1 is off
|
||||||
|
sendDataStream(device, [0x01]);
|
||||||
|
|
||||||
|
const px = el.imageData.data;
|
||||||
|
|
||||||
|
// Row 0 col 0 → lit
|
||||||
|
const idxLit = (0 * 128 + 0) * 4;
|
||||||
|
expect(px[idxLit] + px[idxLit + 1] + px[idxLit + 2]).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
// Row 1 col 0 → unlit (bit 1 of 0x01 = 0)
|
||||||
|
const idxOff = (1 * 128 + 0) * 4;
|
||||||
|
expect(px[idxOff]).toBe(0);
|
||||||
|
expect(px[idxOff + 1]).toBe(0);
|
||||||
|
expect(px[idxOff + 2]).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fills all 1024 GDDRAM bytes via horizontal addressing', () => {
|
||||||
|
const el = makeOLEDElement();
|
||||||
|
const sim = makeSim();
|
||||||
|
PartSimulationRegistry.get('ssd1306')!.attachEvents!(el, sim as any, () => null);
|
||||||
|
const device = sim._devices[0];
|
||||||
|
|
||||||
|
sendCommandStream(device, [0x20, 0x00, 0x21, 0x00, 0x7F, 0x22, 0x00, 0x07]);
|
||||||
|
|
||||||
|
// Fill all 1024 GDDRAM bytes with a checkerboard pattern (0xAA / 0x55)
|
||||||
|
const data: number[] = [];
|
||||||
|
for (let i = 0; i < 1024; i++) data.push(i % 2 === 0 ? 0xAA : 0x55);
|
||||||
|
sendDataStream(device, data);
|
||||||
|
|
||||||
|
// Spot-check: page 7, col 127 = index 7*128+127 = 1023
|
||||||
|
expect(device.buffer[1023]).toBe(0x55);
|
||||||
|
|
||||||
|
// All 128*64 pixels must have alpha=255
|
||||||
|
const px = el.imageData.data;
|
||||||
|
let allOpaque = true;
|
||||||
|
for (let i = 3; i < px.length; i += 4) {
|
||||||
|
if (px[i] !== 255) { allOpaque = false; break; }
|
||||||
|
}
|
||||||
|
expect(allOpaque).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not throw when element has no imageData yet (null/undefined)', () => {
|
||||||
|
const el = {
|
||||||
|
imageData: undefined,
|
||||||
|
redraw: vi.fn(),
|
||||||
|
addEventListener: vi.fn(),
|
||||||
|
removeEventListener: vi.fn(),
|
||||||
|
} as unknown as HTMLElement;
|
||||||
|
|
||||||
|
const sim = makeSim();
|
||||||
|
PartSimulationRegistry.get('ssd1306')!.attachEvents!(el, sim as any, () => null);
|
||||||
|
const device = sim._devices[0];
|
||||||
|
|
||||||
|
expect(() => sendDataStream(device, [0xFF])).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Adafruit SSD1306 init sequence: processes multi-byte commands without crashing', () => {
|
||||||
|
const el = makeOLEDElement();
|
||||||
|
const sim = makeSim();
|
||||||
|
PartSimulationRegistry.get('ssd1306')!.attachEvents!(el, sim as any, () => null);
|
||||||
|
const device = sim._devices[0];
|
||||||
|
|
||||||
|
// Minimal Adafruit init (from Adafruit_SSD1306.cpp begin())
|
||||||
|
const initCmds = [
|
||||||
|
0xAE, // Display OFF
|
||||||
|
0xD5, 0x80, // Set display clock divide
|
||||||
|
0xA8, 0x3F, // Set multiplex ratio (64-1)
|
||||||
|
0xD3, 0x00, // Set display offset
|
||||||
|
0x40, // Set start line
|
||||||
|
0x8D, 0x14, // Charge pump ON
|
||||||
|
0x20, 0x00, // Horizontal addressing
|
||||||
|
0xA1, // Segment remap
|
||||||
|
0xC8, // COM output scan direction
|
||||||
|
0xDA, 0x12, // COM pins hardware config
|
||||||
|
0x81, 0xCF, // Contrast
|
||||||
|
0xD9, 0xF1, // Pre-charge period
|
||||||
|
0xDB, 0x40, // VCOMH deselect level
|
||||||
|
0xA4, // Display from RAM
|
||||||
|
0xA6, // Normal display
|
||||||
|
0x2E, // Deactivate scroll
|
||||||
|
0xAF, // Display ON
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(() => {
|
||||||
|
sendCommandStream(device, initCmds);
|
||||||
|
// After init, write one page of data
|
||||||
|
sendCommandStream(device, [0x21, 0x00, 0x7F, 0x22, 0x00, 0x07]);
|
||||||
|
sendDataStream(device, new Array(1024).fill(0x00));
|
||||||
|
}).not.toThrow();
|
||||||
|
|
||||||
|
expect(el.redraw).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -166,15 +166,51 @@ class VirtualSSD1306 implements I2CDevice {
|
||||||
this.syncElement();
|
this.syncElement();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Push the 1-bit GDDRAM buffer to the wokwi-ssd1306 web component.
|
||||||
|
*
|
||||||
|
* wokwi-ssd1306 API:
|
||||||
|
* - `element.imageData` — a 128×64 ImageData (RGBA, 4 bytes/pixel)
|
||||||
|
* - `element.redraw()` — flushes imageData to the internal canvas
|
||||||
|
*
|
||||||
|
* GDDRAM layout: 8 pages × 128 columns.
|
||||||
|
* Each byte holds 8 vertical pixels; bit 0 = topmost pixel in the page.
|
||||||
|
*/
|
||||||
private syncElement(): void {
|
private syncElement(): void {
|
||||||
const el = this.element as any;
|
const el = this.element as any;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
// wokwi-ssd1306 exposes a Uint8Array `buffer` property
|
|
||||||
if (el.buffer !== undefined) {
|
// Obtain the ImageData object (initialised by wokwi-ssd1306 constructor)
|
||||||
el.buffer = new Uint8Array(this.buffer);
|
let imgData: ImageData | undefined = el.imageData;
|
||||||
|
if (!imgData || imgData.width !== 128 || imgData.height !== 64) {
|
||||||
|
try {
|
||||||
|
imgData = new ImageData(128, 64);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (typeof el.renderFrame === 'function') {
|
|
||||||
el.renderFrame(this.buffer);
|
const px = imgData.data; // Uint8ClampedArray, RGBA
|
||||||
|
|
||||||
|
for (let page = 0; page < 8; page++) {
|
||||||
|
for (let col = 0; col < 128; col++) {
|
||||||
|
const byte = this.buffer[page * 128 + col];
|
||||||
|
for (let bit = 0; bit < 8; bit++) {
|
||||||
|
const row = page * 8 + bit;
|
||||||
|
const lit = (byte >> bit) & 1;
|
||||||
|
const idx = (row * 128 + col) * 4;
|
||||||
|
// Lit pixel: bright cyan-white typical of OLED; off pixel: full black
|
||||||
|
px[idx] = lit ? 200 : 0; // R
|
||||||
|
px[idx + 1] = lit ? 230 : 0; // G
|
||||||
|
px[idx + 2] = lit ? 255 : 0; // B
|
||||||
|
px[idx + 3] = 255; // A
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
el.imageData = imgData;
|
||||||
|
if (typeof el.redraw === 'function') {
|
||||||
|
el.redraw();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue