Arduino 에뮬레이터: SSD1306 OLED(128×64) I2C 에뮬레이션 추가

- AVRTWI connectToSlave에서 LCD(0x27/0x3F)와 OLED(0x3C/0x3D) 동시 지원
- SSD1306 프로토콜 파서:
  - control byte (0x00=cmd stream, 0x40=data stream)
  - 주요 명령: addressing mode, set col/page addr, display on/off,
    inverse, page/col low/high nibble, 2바이트 명령 처리
  - horizontal/vertical/page 어드레싱 모드 모두 지원
- GDDRAM(128×8 pages) 유지 → canvas 128×64 px 렌더링
  (픽셀 2× 스케일, 청백색 톤)
- displayOn 명령 수신 시 OLED 패널 자동 표시
- runFrame마다 dirty 플래그로 효율적 canvas flush

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
kim
2026-06-11 16:58:08 +09:00
co-authored by Claude Fable 5
parent cd999dcd12
commit 3d62dc796e
+172 -2
View File
@@ -52,6 +52,12 @@
.lcd-off .lcd-cell { color:#0d1a05; background:#0d1a05; }
.lcd-off .lcd-wrap { filter:brightness(.4); }
/* SSD1306 OLED */
.oled-section { padding:10px 14px; flex-shrink:0; border-bottom:1px solid #1e1e36; }
.oled-section-title { font-size:10px; color:var(--muted); margin-bottom:8px; font-weight:600; letter-spacing:.08em; }
.oled-wrap { background:#000; border:1px solid #333; border-radius:4px; padding:4px; display:inline-block; }
#oled-canvas { display:block; image-rendering:pixelated; width:256px; height:128px; }
/* Pin grid */
.pins-section { padding:10px 14px; flex:1; overflow-y:auto; }
.pins-header { font-size:10px; color:var(--muted); margin-bottom:8px; font-weight:600; letter-spacing:.08em; }
@@ -161,6 +167,12 @@
</svg>
</div>
<!-- SSD1306 OLED -->
<div class="oled-section" id="oled-section" style="display:none">
<div class="oled-section-title">SSD1306 OLED 128×64</div>
<div class="oled-wrap"><canvas id="oled-canvas" width="128" height="64"></canvas></div>
</div>
<!-- LCD display -->
<div class="lcd-section" id="lcd-section" style="display:none">
<div class="lcd-section-title">LCD <span id="lcd-size-label" style="color:#60a5fa"></span> <span id="lcd-backlight-dot" style="font-size:8px">●</span></div>
@@ -389,16 +401,171 @@ function lcdOnI2CByte(byte) {
}
}
// ── SSD1306 OLED emulator ─────────────────────────────────────────────────────
// I2C addresses: 0x3C (SA0=0) or 0x3D (SA0=1)
const OLED_ADDRS = [0x3C, 0x3D];
const oled = {
gddram: new Uint8Array(128 * 8), // 128 cols × 8 pages (each page = 8 rows)
displayOn: false, inverse: false,
addrMode: 0x02, // 0=horizontal, 1=vertical, 2=page (default)
col: 0, page: 0,
colStart: 0, colEnd: 127,
pageStart: 0, pageEnd: 7,
// I2C parse state
_ctrlByte: null, _active: false,
};
let oledCtx = null;
let oledDirty = false;
function oledReset() {
oled.gddram.fill(0);
oled.displayOn = false; oled.inverse = false; oled.addrMode = 0x02;
oled.col = 0; oled.page = 0;
oled.colStart = 0; oled.colEnd = 127;
oled.pageStart = 0; oled.pageEnd = 7;
oled._ctrlByte = null; oled._active = false;
document.getElementById('oled-section').style.display = 'none';
oledDirty = false;
}
function oledFlushCanvas() {
if (!oledCtx || !oledDirty) return;
oledDirty = false;
const img = oledCtx.createImageData(128, 64);
const fg = oled.inverse ? 0 : 255;
const bg = oled.inverse ? 255 : 0;
for (let page = 0; page < 8; page++) {
for (let col = 0; col < 128; col++) {
const byte = oled.gddram[page * 128 + col];
for (let bit = 0; bit < 8; bit++) {
const row = page * 8 + bit;
const idx = (row * 128 + col) * 4;
const on = (byte >> bit) & 1;
img.data[idx] = on ? fg : bg;
img.data[idx + 1] = on ? (oled.inverse ? 0 : 0x62) : bg; // slight blue tint when on
img.data[idx + 2] = on ? (oled.inverse ? 0 : 0xff) : bg;
img.data[idx + 3] = 255;
}
}
}
oledCtx.putImageData(img, 0, 0);
}
function oledWriteData(byte) {
oled.gddram[oled.page * 128 + oled.col] = byte;
oledDirty = true;
if (oled.addrMode === 0x00) { // horizontal
oled.col++;
if (oled.col > oled.colEnd) {
oled.col = oled.colStart;
oled.page++;
if (oled.page > oled.pageEnd) oled.page = oled.pageStart;
}
} else if (oled.addrMode === 0x01) { // vertical
oled.page++;
if (oled.page > oled.pageEnd) {
oled.page = oled.pageStart;
oled.col++;
if (oled.col > oled.colEnd) oled.col = oled.colStart;
}
} else { // page
oled.col = (oled.col + 1) & 0x7F;
}
}
function oledCommand(cmd, stream) {
// single-byte commands
if (cmd === 0xAE) { oled.displayOn = false; return 0; }
if (cmd === 0xAF) {
oled.displayOn = true;
document.getElementById('oled-section').style.display = 'block';
oled._active = true;
return 0;
}
if (cmd === 0xA6) { oled.inverse = false; oledDirty = true; return 0; }
if (cmd === 0xA7) { oled.inverse = true; oledDirty = true; return 0; }
if (cmd === 0x2E || cmd === 0x2F) return 0; // scroll stop/start — ignore
if ((cmd & 0xF0) === 0xB0) { oled.page = cmd & 0x07; return 0; } // set page (page mode)
if ((cmd & 0xF0) === 0x00) { oled.col = (oled.col & 0xF0) | (cmd & 0x0F); return 0; } // col low
if ((cmd & 0xF0) === 0x10) { oled.col = (oled.col & 0x0F) | ((cmd & 0x0F) << 4); return 0; } // col high
// two-byte commands (consume next byte from stream)
if (cmd === 0x81) return 1; // contrast — skip 1
if (cmd === 0xD5 || cmd === 0xD9 || cmd === 0xDA || cmd === 0xDB || cmd === 0xD3 || cmd === 0xA8) return 1;
if (cmd === 0x8D) return 1; // charge pump
// three-byte commands
if (cmd === 0x20) { // addressing mode
if (stream.length >= 1) { oled.addrMode = stream[0] & 0x03; return 1; }
return 1;
}
if (cmd === 0x21) { // set column address
if (stream.length >= 2) { oled.colStart = stream[0]; oled.colEnd = stream[1]; oled.col = oled.colStart; return 2; }
return 2;
}
if (cmd === 0x22) { // set page address
if (stream.length >= 2) { oled.pageStart = stream[0]; oled.pageEnd = stream[1]; oled.page = oled.pageStart; return 2; }
return 2;
}
if (cmd === 0x40) { oled.col = 0; oled.page = 0; return 0; } // display start line
return 0;
}
// I2C receive state for SSD1306
let _oledI2cBuf = [];
let _oledI2cMode = null; // null=await control byte, 'cmd'=command stream, 'data'=data stream
let _oledCmdPending = null; // [cmd, bytesLeft]
function oledOnI2CByte(byte) {
if (_oledI2cMode === null) {
// Control byte: Co=bit7, D/C#=bit6
// 0x00 = stream of commands, 0x40 = stream of data, 0x80 = single cmd, 0xC0 = single data
const co = (byte >> 7) & 1;
const dc = (byte >> 6) & 1;
_oledI2cMode = dc ? 'data' : 'cmd';
_oledCmdPending = null;
return;
}
if (_oledI2cMode === 'data') {
oledWriteData(byte);
return;
}
// command mode
if (_oledCmdPending) {
_oledCmdPending.args.push(byte);
_oledCmdPending.left--;
if (_oledCmdPending.left === 0) {
oledCommand(_oledCmdPending.cmd, _oledCmdPending.args);
_oledCmdPending = null;
}
return;
}
const skip = oledCommand(byte, []);
if (skip > 0) _oledCmdPending = { cmd: byte, left: skip, args: [] };
}
function oledOnI2CStart() {
_oledI2cMode = null;
_oledCmdPending = null;
}
function setupTWI(cpu) {
if (!AVRTWI || !twiConfig) return;
try {
const twi = new AVRTWI(cpu, twiConfig, 16e6);
let currentSlave = null;
twi.eventHandler = {
start(restart) { return true; },
connectToSlave(address, write) {
return LCD_ADDRS.includes(address >> 1);
const addr7 = address >> 1;
if (LCD_ADDRS.includes(addr7)) { currentSlave = 'lcd'; return true; }
if (OLED_ADDRS.includes(addr7)) { currentSlave = 'oled'; oledOnI2CStart(); return true; }
currentSlave = null; return false;
},
writeByte(byte) {
if (currentSlave === 'lcd') lcdOnI2CByte(byte);
if (currentSlave === 'oled') oledOnI2CByte(byte);
return true;
},
writeByte(byte) { lcdOnI2CByte(byte); return true; },
readByte() { return 0xFF; },
};
} catch(e) {
@@ -535,6 +702,8 @@ function initEmulator(hex) {
totalCycles = 0;
lcdReset();
oledReset();
oledCtx = document.getElementById('oled-canvas').getContext('2d');
buildPinUI();
let lastD13 = -1;
@@ -564,6 +733,7 @@ function runFrame() {
}
totalCycles = cpu.cycles;
updatePinUI();
oledFlushCanvas();
const simSec = (totalCycles / 16e6).toFixed(2);
document.getElementById('footer-cycles').textContent = `${Math.round(totalCycles).toLocaleString()} cycles (${simSec}s)`;
rafId = requestAnimationFrame(runFrame);