Files
homeclaw/web-ui/arduino-emulator.html
T
kimandClaude Fable 5 cd269f017e Arduino 에뮬레이터: OLED 캔버스 크기 수정 + 플레이스홀더
- 캔버스 width:256px → width:100%; aspect-ratio:2/1
  (보드 패널 280px 기준 오버플로우·클리핑 해소)
- OLED 섹션 항상 표시, 연결 전 플레이스홀더:
  "OLED 연결 대기 / 0x3C / 0x3D" + 점선 격자
- 파란 점(dot) 인디케이터: 연결 전=꺼짐, displayOn=켜짐
- display.display() 수신 시 플레이스홀더 숨김·캔버스 표시
- display off(0xAE) 시 플레이스홀더 복원

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 17:25:23 +09:00

923 lines
40 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Arduino Emulator</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&display=swap');
:root { --bg: #0c0c0c; --panel: #1a1a2e; --accent: #3b82f6; --green: #22c55e; --red: #ef4444; --yellow: #fbbf24; --muted: #6b7280; --orange: #f97316; }
* { margin:0; padding:0; box-sizing:border-box; }
body { background:var(--bg); color:#e5e7eb; font-family:'IBM Plex Mono',monospace; height:100vh; display:flex; flex-direction:column; overflow:hidden; }
/* Header */
.header { display:flex; align-items:center; gap:12px; padding:10px 16px; background:var(--panel); border-bottom:1px solid #2d2d4a; flex-shrink:0; }
.header h1 { font-size:14px; font-weight:600; color:#e5e7eb; }
.header .file { font-size:12px; color:var(--accent); }
.status { font-size:11px; padding:2px 8px; border-radius:4px; font-weight:600; }
.status.loading { background:#1e3a5f; color:#60a5fa; }
.status.ready { background:#1a3a2a; color:var(--green); }
.status.running { background:#3a3a1a; color:var(--yellow); animation: pulse 1.5s infinite; }
.status.error { background:#3a1a1a; color:var(--red); }
.status.stopped { background:#2a2a2a; color:var(--muted); }
@keyframes pulse { 0%,100% { opacity:1 } 50% { opacity:.6 } }
.toolbar { display:flex; gap:8px; margin-left:auto; align-items:center; }
.toolbar button { background:#252540; border:1px solid #3a3a5a; color:#e5e7eb; border-radius:6px; padding:6px 14px; font-size:12px; cursor:pointer; font-family:inherit; transition:all .15s; }
.toolbar button:hover { background:#353555; border-color:#4a4a7a; }
.toolbar button:disabled { opacity:.4; cursor:not-allowed; }
.toolbar button.run { background:#1a3a2a; border-color:#2d5a3a; color:var(--green); }
.toolbar button.run:hover { background:#224a3a; }
.toolbar button.stop { background:#3a1a1a; border-color:#5a2d2d; color:var(--red); }
.toolbar button.stop:hover { background:#4a2020; }
.speed-label { font-size:11px; color:var(--muted); }
select { background:#252540; border:1px solid #3a3a5a; color:#e5e7eb; border-radius:6px; padding:5px 10px; font-size:12px; font-family:inherit; cursor:pointer; }
/* Main layout */
.main { flex:1; display:flex; gap:0; overflow:hidden; }
/* Board panel */
.board-panel { width:280px; flex-shrink:0; background:#111122; border-right:1px solid #2d2d4a; display:flex; flex-direction:column; overflow:hidden; }
.board-title { padding:10px 14px; font-size:11px; color:var(--muted); font-weight:600; letter-spacing:.08em; border-bottom:1px solid #1e1e36; flex-shrink:0; }
.board-svg { padding:16px; flex-shrink:0; }
.board-svg svg { width:100%; }
/* LCD display */
.lcd-section { padding:10px 14px; flex-shrink:0; border-bottom:1px solid #1e1e36; }
.lcd-section-title { font-size:10px; color:var(--muted); margin-bottom:8px; font-weight:600; letter-spacing:.08em; display:flex; align-items:center; gap:6px; }
.lcd-wrap { background:#1a2a0a; border:1px solid #2a4a1a; border-radius:5px; padding:8px 10px; display:inline-block; }
.lcd-row { display:flex; gap:2px; margin-bottom:2px; }
.lcd-row:last-child { margin-bottom:0; }
.lcd-cell { width:9px; height:14px; background:#0d1a05; border-radius:1px; display:flex; align-items:center; justify-content:center; font-size:9px; color:#6aff20; line-height:1; font-family:'IBM Plex Mono',monospace; }
.lcd-cell.lit { background:#1a3a08; }
.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:6px; font-weight:600; letter-spacing:.08em; display:flex; align-items:center; gap:6px; }
.oled-dot { width:6px; height:6px; border-radius:50%; background:#333; flex-shrink:0; }
.oled-dot.on { background:#60a5fa; box-shadow:0 0 4px #60a5fa; }
.oled-wrap { background:#000; border:2px solid #222; border-radius:4px; padding:4px; position:relative; }
#oled-canvas { display:block; image-rendering:pixelated; width:100%; aspect-ratio:2/1; }
.oled-placeholder { position:absolute; inset:4px; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:4px; pointer-events:none; }
.oled-placeholder span { font-size:10px; color:#333; letter-spacing:.05em; }
.oled-placeholder .oled-ph-grid { width:100%; height:2px; background:repeating-linear-gradient(90deg,#1a1a1a 0,#1a1a1a 4px,transparent 4px,transparent 8px); }
/* 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; }
.pin-row { display:flex; align-items:center; gap:8px; padding:3px 0; font-size:11px; }
.pin-label { width:36px; color:var(--muted); flex-shrink:0; }
.pin-led { width:12px; height:12px; border-radius:50%; background:#2a2a3a; border:1px solid #3a3a5a; flex-shrink:0; transition:background .08s, box-shadow .08s; }
.pin-led.on { background:var(--green); box-shadow:0 0 6px var(--green); border-color:var(--green); }
.pin-led.pwm { background:var(--yellow); box-shadow:0 0 6px var(--yellow); border-color:var(--yellow); }
.pin-led.in { background:var(--accent); box-shadow:0 0 6px var(--accent); border-color:var(--accent); }
.pin-val { color:#9ca3af; font-size:10px; }
.pin-sep { height:1px; background:#1e1e36; margin:4px 0; }
/* Serial monitor */
.serial-panel { flex:1; display:flex; flex-direction:column; }
.serial-header { padding:10px 16px; font-size:11px; color:var(--muted); font-weight:600; letter-spacing:.08em; border-bottom:1px solid #1e1e36; display:flex; align-items:center; gap:12px; flex-shrink:0; }
.serial-header .baud { font-size:11px; color:#60a5fa; }
.serial-clear { margin-left:auto; background:none; border:none; color:var(--muted); cursor:pointer; font-size:11px; font-family:inherit; }
.serial-clear:hover { color:#e5e7eb; }
.serial-output { flex:1; overflow-y:auto; padding:12px 16px; font-size:13px; line-height:1.7; white-space:pre-wrap; word-break:break-all; }
.serial-output .line { color:#e5e7eb; }
.serial-output .sys { color:var(--muted); font-style:italic; }
.serial-output .error { color:var(--red); }
.serial-input-row { display:flex; gap:8px; padding:10px 16px; border-top:1px solid #1e1e36; flex-shrink:0; }
.serial-input { flex:1; background:#1a1a2e; border:1px solid #3a3a5a; color:#e5e7eb; border-radius:6px; padding:7px 12px; font-family:inherit; font-size:13px; }
.serial-input:focus { outline:none; border-color:var(--accent); }
.serial-send { background:#1a3a2a; border:1px solid #2d5a3a; color:var(--green); border-radius:6px; padding:7px 14px; font-family:inherit; font-size:12px; cursor:pointer; }
.serial-send:hover { background:#224a3a; }
/* Footer */
.footer { padding:7px 16px; border-top:1px solid #2d2d4a; font-size:11px; color:var(--muted); background:var(--panel); flex-shrink:0; display:flex; justify-content:space-between; }
.footer .cycles { color:#60a5fa; }
/* Compile overlay */
.overlay { position:fixed; inset:0; background:rgba(0,0,0,.7); display:flex; align-items:center; justify-content:center; z-index:100; }
.overlay-box { background:var(--panel); border:1px solid #3a3a5a; border-radius:10px; padding:28px 36px; text-align:center; min-width:300px; }
.overlay-box h2 { font-size:15px; margin-bottom:12px; }
.overlay-box p { font-size:12px; color:var(--muted); margin-bottom:18px; white-space:pre-wrap; text-align:left; max-height:200px; overflow-y:auto; }
.overlay-box button { background:#1a3a2a; border:1px solid #2d5a3a; color:var(--green); border-radius:6px; padding:8px 20px; font-family:inherit; font-size:13px; cursor:pointer; }
.spinner { width:28px; height:28px; border:3px solid #3a3a5a; border-top-color:var(--accent); border-radius:50%; animation:spin .8s linear infinite; margin:0 auto 16px; }
@keyframes spin { to { transform:rotate(360deg) } }
/* Library manager modal */
.lib-modal { position:fixed; inset:0; background:rgba(0,0,0,.75); display:none; align-items:center; justify-content:center; z-index:200; }
.lib-modal.open { display:flex; }
.lib-box { background:#141428; border:1px solid #3a3a5a; border-radius:10px; width:520px; max-height:80vh; display:flex; flex-direction:column; }
.lib-box h2 { font-size:14px; font-weight:600; padding:16px 20px; border-bottom:1px solid #2d2d4a; display:flex; align-items:center; gap:8px; }
.lib-box h2 button { margin-left:auto; background:none; border:none; color:var(--muted); cursor:pointer; font-size:16px; }
.lib-tabs { display:flex; border-bottom:1px solid #2d2d4a; flex-shrink:0; }
.lib-tab { padding:9px 18px; font-size:12px; cursor:pointer; color:var(--muted); background:none; border:none; font-family:inherit; border-bottom:2px solid transparent; }
.lib-tab.active { color:#e5e7eb; border-bottom-color:var(--accent); }
.lib-search-row { display:flex; gap:8px; padding:12px 16px; border-bottom:1px solid #1e1e36; flex-shrink:0; }
.lib-search-input { flex:1; background:#1a1a2e; border:1px solid #3a3a5a; color:#e5e7eb; border-radius:6px; padding:7px 12px; font-family:inherit; font-size:12px; }
.lib-search-input:focus { outline:none; border-color:var(--accent); }
.lib-search-btn { background:#252540; border:1px solid #3a3a5a; color:#e5e7eb; border-radius:6px; padding:7px 14px; font-size:12px; cursor:pointer; font-family:inherit; }
.lib-list { flex:1; overflow-y:auto; padding:8px 0; }
.lib-item { display:flex; align-items:flex-start; gap:10px; padding:10px 16px; border-bottom:1px solid #1a1a2e; }
.lib-item:hover { background:#1a1a30; }
.lib-info { flex:1; min-width:0; }
.lib-name { font-size:12px; font-weight:600; color:#e5e7eb; }
.lib-meta { font-size:10px; color:var(--muted); margin-top:2px; }
.lib-sentence { font-size:11px; color:#9ca3af; margin-top:3px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.lib-action { flex-shrink:0; }
.lib-install-btn { background:#1a3a2a; border:1px solid #2d5a3a; color:var(--green); border-radius:5px; padding:5px 12px; font-size:11px; cursor:pointer; font-family:inherit; }
.lib-install-btn:hover { background:#224a3a; }
.lib-install-btn:disabled { opacity:.4; cursor:not-allowed; }
.lib-remove-btn { background:#3a1a1a; border:1px solid #5a2d2d; color:var(--red); border-radius:5px; padding:5px 12px; font-size:11px; cursor:pointer; font-family:inherit; }
.lib-remove-btn:hover { background:#4a2020; }
.lib-empty { padding:24px 16px; text-align:center; color:var(--muted); font-size:12px; }
.lib-status { padding:8px 16px; font-size:11px; color:var(--yellow); border-top:1px solid #2d2d4a; min-height:32px; flex-shrink:0; }
</style>
</head>
<body>
<div class="header">
<h1>🔌 Arduino Emulator</h1>
<span class="file" id="filename">sketch.ino</span>
<span class="status loading" id="status">컴파일 중...</span>
<div class="toolbar">
<button onclick="openLibModal()" title="라이브러리 관리">📦 라이브러리</button>
<span class="speed-label">속도</span>
<select id="speed-sel" onchange="setSpeed(this.value)">
<option value="1" selected>1x</option>
<option value="10">10x</option>
<option value="100">100x</option>
<option value="1000">최대</option>
</select>
<button class="run" id="btn-run" onclick="startEmulator()" disabled>▶ 실행</button>
<button class="stop" id="btn-stop" onclick="stopEmulator()" disabled>■ 정지</button>
<button onclick="resetEmulator()" id="btn-reset" disabled>↺ 리셋</button>
</div>
</div>
<div class="main">
<!-- Board / Pin panel -->
<div class="board-panel">
<div class="board-title">ARDUINO UNO · ATmega328P</div>
<div class="board-svg">
<svg viewBox="0 0 200 80" xmlns="http://www.w3.org/2000/svg">
<rect x="2" y="2" width="196" height="76" rx="6" fill="#1a3a1a" stroke="#2d5a2d" stroke-width="1.5"/>
<rect x="2" y="22" width="18" height="36" rx="3" fill="#1e3a5f" stroke="#3a5a8f" stroke-width="1"/>
<rect x="70" y="22" width="60" height="36" rx="2" fill="#0a0a0a" stroke="#444" stroke-width="1"/>
<text x="100" y="41" text-anchor="middle" fill="#666" font-size="6" font-family="monospace">ATmega328P</text>
<text x="100" y="12" text-anchor="middle" fill="#4a7a4a" font-size="5" font-family="monospace">DIGITAL</text>
<text x="100" y="74" text-anchor="middle" fill="#4a4a7a" font-size="5" font-family="monospace">ANALOG / POWER</text>
<circle id="board-led-13" cx="160" cy="16" r="5" fill="#1a1a1a" stroke="#333"/>
<text x="160" y="10" text-anchor="middle" fill="#555" font-size="4" font-family="monospace">L</text>
</svg>
</div>
<!-- SSD1306 OLED -->
<div class="oled-section" id="oled-section">
<div class="oled-section-title">
<span class="oled-dot" id="oled-dot"></span>
SSD1306 OLED 128×64
</div>
<div class="oled-wrap">
<canvas id="oled-canvas" width="128" height="64"></canvas>
<div class="oled-placeholder" id="oled-placeholder">
<div class="oled-ph-grid"></div>
<span>OLED 연결 대기</span>
<span style="font-size:9px;color:#222">0x3C / 0x3D</span>
</div>
</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>
<div class="lcd-wrap" id="lcd-wrap">
<div id="lcd-display"></div>
</div>
</div>
<div class="pins-section">
<div class="pins-header">DIGITAL PINS</div>
<div id="digital-pins"></div>
<div class="pin-sep"></div>
<div class="pins-header" style="margin-top:8px">ANALOG PINS</div>
<div id="analog-pins"></div>
</div>
</div>
<!-- Serial monitor -->
<div class="serial-panel">
<div class="serial-header">
SERIAL MONITOR
<span class="baud" id="baud-label">9600 baud</span>
<button class="serial-clear" onclick="clearSerial()">지우기</button>
</div>
<div class="serial-output" id="serial-output">
<span class="sys">에뮬레이터 준비 중...</span>
</div>
<div class="serial-input-row">
<input class="serial-input" id="serial-input" placeholder="직렬 입력 (Enter 전송)" onkeydown="if(event.key==='Enter')sendSerial()"/>
<button class="serial-send" onclick="sendSerial()">전송</button>
</div>
</div>
</div>
<div class="footer">
<span id="footer-info">avr8js · ATmega328P @ 16MHz</span>
<span class="cycles" id="footer-cycles">0 cycles</span>
</div>
<!-- Compile overlay -->
<div class="overlay" id="overlay">
<div class="overlay-box">
<div class="spinner" id="overlay-spinner"></div>
<h2 id="overlay-title">컴파일 중...</h2>
<p id="overlay-msg" style="display:none"></p>
<button id="overlay-btn" onclick="closeOverlay()" style="display:none">닫기</button>
</div>
</div>
<!-- Library manager modal -->
<div class="lib-modal" id="lib-modal">
<div class="lib-box">
<h2>📦 라이브러리 관리
<button onclick="closeLibModal()">✕</button>
</h2>
<div class="lib-tabs">
<button class="lib-tab active" id="tab-installed" onclick="switchLibTab('installed')">설치됨</button>
<button class="lib-tab" id="tab-search" onclick="switchLibTab('search')">검색/설치</button>
</div>
<div id="lib-search-row" class="lib-search-row" style="display:none">
<input class="lib-search-input" id="lib-search-input" placeholder="라이브러리 이름 검색..." onkeydown="if(event.key==='Enter')doLibSearch()"/>
<button class="lib-search-btn" onclick="doLibSearch()">검색</button>
</div>
<div class="lib-list" id="lib-list"><div class="lib-empty">불러오는 중...</div></div>
<div class="lib-status" id="lib-status"></div>
</div>
</div>
<!-- avr8js from CDN -->
<script type="module">
// ── avr8js import ────────────────────────────────────────────────────────────
let avr8jsReady = false;
let CPU, AVRIOPort, AVRUSART, AVRTimer, AVRTWI,
portAConfig, portBConfig, portCConfig, portDConfig,
timer0Config, timer1Config, timer2Config, twiConfig;
try {
const mod = await import('https://esm.sh/avr8js@0.21.0');
let avrInstruction;
({ CPU, AVRIOPort, AVRUSART, AVRTimer, AVRTWI,
portAConfig, portBConfig, portCConfig, portDConfig,
timer0Config, timer1Config, timer2Config, twiConfig,
avrInstruction } = mod);
if (typeof avrInstruction !== 'function') throw new Error('avrInstruction not exported');
window._avrInstruction = avrInstruction;
avr8jsReady = true;
} catch(e) {
showOverlayError('avr8js 로드 실패\n' + e.message);
}
// ── Intel HEX parser ─────────────────────────────────────────────────────────
function parseIntelHex(hex) {
const flash = new Uint8Array(0x8000);
for (const line of hex.split('\n')) {
if (!line.startsWith(':')) continue;
const bytes = line.slice(1).match(/.{2}/g).map(h => parseInt(h, 16));
const count = bytes[0], addr = (bytes[1] << 8) | bytes[2], type = bytes[3];
if (type === 0) for (let i = 0; i < count; i++) flash[addr + i] = bytes[4 + i];
}
return flash;
}
// ── LCD emulator (HD44780 via PCF8574 I2C expander) ─────────────────────────
// PCF8574 bit mapping used by LiquidCrystal_I2C:
// bit0=RS, bit1=RW, bit2=EN, bit3=BL, bit4-7=D4-D7
const LCD_RS = 0, LCD_RW = 1, LCD_EN = 2, LCD_BL = 3;
const LCD_ADDRS = [0x27, 0x3F]; // common I2C addresses
const lcd = {
cols: 16, rows: 2,
ddram: new Uint8Array(128), // HD44780 DDRAM
cgram: new Uint8Array(64), // CGRAM (custom chars)
cursor: 0, displayOn: false,
backlight: false, entryInc: true,
rowOffsets: [0x00, 0x40, 0x10, 0x50],
active: false,
// I2C receive state
_nibbleHigh: null, _lastRS: 0, _inCGRAM: false, _cgramAddr: 0,
};
function lcdReset() {
lcd.ddram.fill(0x20); // space
lcd.cgram.fill(0);
lcd.cursor = 0; lcd.displayOn = false; lcd.backlight = false;
lcd.entryInc = true; lcd._nibbleHigh = null; lcd._inCGRAM = false; lcd._cgramAddr = 0;
lcd.active = false;
document.getElementById('lcd-section').style.display = 'none';
}
function lcdRenderDOM() {
const section = document.getElementById('lcd-section');
const wrap = document.getElementById('lcd-wrap');
const disp = document.getElementById('lcd-display');
section.style.display = 'block';
document.getElementById('lcd-size-label').textContent = `${lcd.cols}×${lcd.rows}`;
document.getElementById('lcd-backlight-dot').style.color = lcd.backlight ? '#fbbf24' : '#333';
wrap.className = 'lcd-wrap' + (lcd.backlight ? '' : ' lcd-off');
let html = '';
for (let r = 0; r < lcd.rows; r++) {
html += '<div class="lcd-row">';
const base = lcd.rowOffsets[r];
for (let c = 0; c < lcd.cols; c++) {
const ch = lcd.ddram[base + c];
const glyph = ch >= 0x20 ? String.fromCharCode(ch) : ' ';
html += `<div class="lcd-cell lit">${glyph === ' ' ? '&nbsp;' : glyph}</div>`;
}
html += '</div>';
}
disp.innerHTML = html;
}
function lcdExecuteCommand(rs, data) {
if (rs === 0) {
// Instruction
if (data & 0x80) { // Set DDRAM address
lcd.cursor = data & 0x7F;
lcd._inCGRAM = false;
return;
}
if (data & 0x40) { // Set CGRAM address
lcd._cgramAddr = data & 0x3F;
lcd._inCGRAM = true;
return;
}
if (data & 0x20) { // Function set
const lines = (data >> 3) & 1;
if (lines) lcd.rows = 2; else lcd.rows = 1;
return;
}
if (data & 0x10) return; // Cursor/display shift — ignore
if (data & 0x08) { // Display on/off
lcd.displayOn = !!(data & 0x04);
lcdRenderDOM();
return;
}
if (data & 0x04) { // Entry mode
lcd.entryInc = !!(data & 0x02);
return;
}
if (data === 0x01) { // Clear display
lcd.ddram.fill(0x20);
lcd.cursor = 0;
lcdRenderDOM();
return;
}
if (data & 0x02) { // Return home
lcd.cursor = 0;
lcdRenderDOM();
return;
}
} else {
// Data write
if (lcd._inCGRAM) {
lcd.cgram[lcd._cgramAddr & 0x3F] = data;
lcd._cgramAddr = (lcd._cgramAddr + 1) & 0x3F;
} else {
lcd.ddram[lcd.cursor & 0x7F] = data;
lcd.cursor = lcd.entryInc ? (lcd.cursor + 1) & 0x7F : (lcd.cursor - 1) & 0x7F;
lcdRenderDOM();
}
}
}
function lcdOnI2CByte(byte) {
// PCF8574 sends nibbles with EN strobe:
// first write: high nibble (EN=1), second write: high nibble (EN=0)
// then: low nibble (EN=1), fourth write: low nibble (EN=0)
const en = (byte >> LCD_EN) & 1;
const rs = (byte >> LCD_RS) & 1;
const bl = (byte >> LCD_BL) & 1;
const nibble = (byte >> 4) & 0x0F;
lcd.backlight = !!bl;
lcd.active = true;
if (en) {
if (lcd._nibbleHigh === null) {
lcd._nibbleHigh = nibble;
lcd._lastRS = rs;
} else {
const full = (lcd._nibbleHigh << 4) | nibble;
lcd._nibbleHigh = null;
lcdExecuteCommand(lcd._lastRS, full);
}
}
}
// ── 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 oledSetActive(on) {
document.getElementById('oled-placeholder').style.display = on ? 'none' : 'flex';
document.getElementById('oled-dot').className = 'oled-dot' + (on ? ' on' : '');
if (oledCtx && !on) {
oledCtx.clearRect(0, 0, 128, 64); // blank canvas when inactive
}
}
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;
oledDirty = false;
oledSetActive(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; oledSetActive(false); return 0; }
if (cmd === 0xAF) {
oled.displayOn = true;
oled._active = true;
oledSetActive(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) { twi.completeStart(); },
stop() { twi.completeStop(); currentSlave = null; },
connectToSlave(addr, write) {
if (LCD_ADDRS.includes(addr)) { currentSlave = 'lcd'; twi.completeConnect(true); return; }
if (OLED_ADDRS.includes(addr)) { currentSlave = 'oled'; oledOnI2CStart(); twi.completeConnect(true); return; }
currentSlave = null; twi.completeConnect(false);
},
writeByte(byte) {
if (currentSlave === 'lcd') lcdOnI2CByte(byte);
if (currentSlave === 'oled') oledOnI2CByte(byte);
twi.completeWrite(true);
},
readByte(ack) { twi.completeRead(0xFF); },
};
} catch(e) {
console.warn('TWI init failed:', e.message);
}
}
// ── State ────────────────────────────────────────────────────────────────────
let cpu = null, uart = null, portB = null, portC = null, portD = null;
let rafId = null, ticksPerFrame = 160000, totalCycles = 0, running = false;
let hexData = null;
const PIN_COUNT_D = 14, PIN_COUNT_A = 6;
let serialLineSpan = null;
// ── UI helpers ───────────────────────────────────────────────────────────────
function setStatus(cls, text) {
const el = document.getElementById('status');
el.className = 'status ' + cls;
el.textContent = text;
}
function appendSerial(text, cls = 'line') {
const out = document.getElementById('serial-output');
const span = document.createElement('span');
span.className = cls;
span.textContent = text;
out.appendChild(span);
out.scrollTop = out.scrollHeight;
}
window.clearSerial = () => { document.getElementById('serial-output').innerHTML = ''; serialLineSpan = null; };
window.sendSerial = () => {
const inp = document.getElementById('serial-input');
if (!inp.value || !uart) return;
for (const ch of inp.value + '\n') uart.writeByte(ch.charCodeAt(0));
inp.value = '';
};
window.showOverlayError = (msg) => {
document.getElementById('overlay-spinner').style.display = 'none';
document.getElementById('overlay-title').textContent = '⚠️ 오류';
const p = document.getElementById('overlay-msg');
p.textContent = msg;
p.style.display = 'block';
document.getElementById('overlay-btn').style.display = 'inline-block';
};
window.closeOverlay = () => { document.getElementById('overlay').style.display = 'none'; };
// ── Pin UI ────────────────────────────────────────────────────────────────────
function buildPinUI() {
const dEl = document.getElementById('digital-pins');
dEl.innerHTML = '';
for (let i = 0; i < PIN_COUNT_D; i++) {
const row = document.createElement('div'); row.className = 'pin-row';
row.innerHTML = `<span class="pin-label">D${i}</span><div class="pin-led" id="dpin-${i}"></div><span class="pin-val" id="dpinv-${i}">0</span>`;
dEl.appendChild(row);
}
const aEl = document.getElementById('analog-pins');
aEl.innerHTML = '';
for (let i = 0; i < PIN_COUNT_A; i++) {
const row = document.createElement('div'); row.className = 'pin-row';
row.innerHTML = `<span class="pin-label">A${i}</span><div class="pin-led" id="apin-${i}"></div><span class="pin-val" id="apinv-${i}">0</span>`;
aEl.appendChild(row);
}
}
function updatePinUI() {
if (!portD || !portB || !portC) return;
for (let i = 0; i < 8; i++) {
const val = portD.pinState(i);
const led = document.getElementById('dpin-' + i);
const lbl = document.getElementById('dpinv-' + i);
if (led) led.className = 'pin-led' + (val ? ' on' : '');
if (lbl) lbl.textContent = val ? '1' : '0';
}
for (let i = 0; i < 6; i++) {
const val = portB.pinState(i);
const pinNum = 8 + i;
const led = document.getElementById('dpin-' + pinNum);
const lbl = document.getElementById('dpinv-' + pinNum);
if (led) led.className = 'pin-led' + (val ? ' on' : '');
if (lbl) lbl.textContent = val ? '1' : '0';
if (i === 5) {
const bl = document.getElementById('board-led-13');
if (bl) bl.setAttribute('fill', val ? '#22c55e' : '#1a1a1a');
}
}
for (let i = 0; i < 6; i++) {
const val = portC.pinState(i);
const led = document.getElementById('apin-' + i);
const lbl = document.getElementById('apinv-' + i);
if (led) led.className = 'pin-led' + (val ? ' in' : '');
if (lbl) lbl.textContent = val ? '1' : '0';
}
}
// ── Emulator init ─────────────────────────────────────────────────────────────
function initEmulator(hex) {
hexData = hex;
if (!avr8jsReady) { showOverlayError('avr8js 미로드'); return; }
const flash = parseIntelHex(hex);
cpu = new CPU(new Uint16Array(flash.buffer));
portB = new AVRIOPort(cpu, portBConfig);
portC = new AVRIOPort(cpu, portCConfig);
portD = new AVRIOPort(cpu, portDConfig);
if (timer0Config) new AVRTimer(cpu, timer0Config);
if (timer1Config) new AVRTimer(cpu, timer1Config);
if (timer2Config) new AVRTimer(cpu, timer2Config);
setupTWI(cpu);
uart = new AVRUSART(cpu, {
rxCompleteInterrupt: 0x24, dataRegisterEmptyInterrupt: 0x26, txCompleteInterrupt: 0x28,
UCSRA: 0xc0, UCSRB: 0xc1, UCSRC: 0xc2, UBRRL: 0xc4, UBRRH: 0xc5, UDR: 0xc6,
}, 16e6);
serialLineSpan = null;
uart.onByteTransmit = (value) => {
const ch = String.fromCharCode(value);
if (ch === '\r') return;
const out = document.getElementById('serial-output');
if (!serialLineSpan) {
serialLineSpan = document.createElement('span');
serialLineSpan.className = 'line';
out.appendChild(serialLineSpan);
}
serialLineSpan.textContent += ch;
if (ch === '\n') serialLineSpan = null;
out.scrollTop = out.scrollHeight;
};
totalCycles = 0;
lcdReset();
oledReset();
oledCtx = document.getElementById('oled-canvas').getContext('2d');
buildPinUI();
let lastD13 = -1;
portB.addListener((value) => {
const d13 = (value >> 5) & 1;
if (d13 !== lastD13) {
lastD13 = d13;
appendSerial(`[D13] ${d13 ? 'HIGH ^' : 'LOW _'}\n`, 'sys');
}
});
document.getElementById('btn-run').disabled = false;
document.getElementById('btn-reset').disabled = false;
setStatus('ready', '준비');
document.getElementById('overlay').style.display = 'none';
appendSerial('에뮬레이터 준비 완료. ▶ 실행을 누르세요.\n', 'sys');
}
// ── Run loop ──────────────────────────────────────────────────────────────────
function runFrame() {
if (!running || !cpu) return;
try {
const target = cpu.cycles + ticksPerFrame;
while (cpu.cycles < target) {
window._avrInstruction(cpu);
cpu.tick();
}
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);
} catch(e) {
running = false;
setStatus('error', '오류');
appendSerial('실행 오류: ' + e.message + '\n', 'error');
document.getElementById('btn-run').disabled = false;
document.getElementById('btn-stop').disabled = true;
}
}
window.startEmulator = () => {
if (!cpu || running) return;
running = true;
appendSerial('▶ 실행 시작\n', 'sys');
setStatus('running', '실행 중');
document.getElementById('btn-run').disabled = true;
document.getElementById('btn-stop').disabled = false;
runFrame();
};
window.stopEmulator = () => {
running = false;
if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
setStatus('stopped', '정지');
document.getElementById('btn-run').disabled = false;
document.getElementById('btn-stop').disabled = true;
};
window.resetEmulator = () => {
stopEmulator();
if (hexData) { appendSerial('\n--- 리셋 ---\n', 'sys'); initEmulator(hexData); }
};
window.setSpeed = (val) => { ticksPerFrame = Math.round(160000 * parseInt(val)); };
// ── Library manager ───────────────────────────────────────────────────────────
let libTab = 'installed';
window.openLibModal = async () => {
document.getElementById('lib-modal').classList.add('open');
if (libTab === 'installed') await loadInstalledLibs();
};
window.closeLibModal = () => document.getElementById('lib-modal').classList.remove('open');
window.switchLibTab = async (tab) => {
libTab = tab;
document.getElementById('tab-installed').classList.toggle('active', tab === 'installed');
document.getElementById('tab-search').classList.toggle('active', tab === 'search');
document.getElementById('lib-search-row').style.display = tab === 'search' ? 'flex' : 'none';
document.getElementById('lib-status').textContent = '';
if (tab === 'installed') await loadInstalledLibs();
else document.getElementById('lib-list').innerHTML = '<div class="lib-empty">라이브러리 이름을 검색하세요.</div>';
};
async function loadInstalledLibs() {
document.getElementById('lib-list').innerHTML = '<div class="lib-empty">불러오는 중...</div>';
try {
const r = await fetch('/api/arduino/libs');
const data = await r.json();
renderLibList(data.libs || [], 'installed');
} catch { document.getElementById('lib-list').innerHTML = '<div class="lib-empty">오류</div>'; }
}
window.doLibSearch = async () => {
const q = document.getElementById('lib-search-input').value.trim();
if (!q) return;
document.getElementById('lib-list').innerHTML = '<div class="lib-empty">검색 중...</div>';
try {
const r = await fetch('/api/arduino/libs/search?q=' + encodeURIComponent(q));
const data = await r.json();
renderLibList(data.libs || [], 'search');
} catch { document.getElementById('lib-list').innerHTML = '<div class="lib-empty">오류</div>'; }
};
function renderLibList(libs, mode) {
const el = document.getElementById('lib-list');
if (!libs.length) { el.innerHTML = '<div class="lib-empty">없음</div>'; return; }
el.innerHTML = libs.map(l => `
<div class="lib-item">
<div class="lib-info">
<div class="lib-name">${l.name}</div>
<div class="lib-meta">${l.author || ''} · v${l.version || '?'}</div>
${l.sentence ? `<div class="lib-sentence">${l.sentence}</div>` : ''}
</div>
<div class="lib-action">
${mode === 'installed'
? `<button class="lib-remove-btn" onclick="libUninstall('${l.name.replace(/'/g,"\\'")}')">삭제</button>`
: `<button class="lib-install-btn" onclick="libInstall('${l.name.replace(/'/g,"\\'")}')">설치</button>`
}
</div>
</div>`).join('');
}
window.libInstall = async (name) => {
const st = document.getElementById('lib-status');
st.textContent = `설치 중: ${name}...`;
try {
const r = await fetch('/api/arduino/libs/install', {
method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ name })
});
const data = await r.json();
if (data.error) { st.textContent = '오류: ' + data.error.slice(0, 120); return; }
st.textContent = `✅ ${name} 설치 완료`;
} catch(e) { st.textContent = '오류: ' + e.message; }
};
window.libUninstall = async (name) => {
if (!confirm(`"${name}" 을 삭제할까요?`)) return;
const st = document.getElementById('lib-status');
st.textContent = `삭제 중: ${name}...`;
try {
const r = await fetch('/api/arduino/libs/uninstall', {
method: 'DELETE', headers: {'Content-Type':'application/json'}, body: JSON.stringify({ name })
});
const data = await r.json();
if (data.error) { st.textContent = '오류: ' + data.error.slice(0, 120); return; }
st.textContent = `✅ ${name} 삭제 완료`;
await loadInstalledLibs();
} catch(e) { st.textContent = '오류: ' + e.message; }
};
// ── Boot ──────────────────────────────────────────────────────────────────────
async function boot() {
const sketch = sessionStorage.getItem('arduino_sketch');
const board = sessionStorage.getItem('arduino_board') || 'uno';
const fname = sessionStorage.getItem('arduino_filename') || 'sketch.ino';
const hexDirect = sessionStorage.getItem('arduino_hex');
sessionStorage.removeItem('arduino_sketch');
sessionStorage.removeItem('arduino_board');
sessionStorage.removeItem('arduino_filename');
sessionStorage.removeItem('arduino_hex');
document.getElementById('filename').textContent = fname;
if (hexDirect) { initEmulator(hexDirect); return; }
if (!sketch) {
showOverlayError('스케치가 없습니다.\n코드 에디터에서 .ino 파일을 열고 실행하세요.');
return;
}
try {
const r = await fetch('/api/arduino/compile', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ code: sketch, board }),
});
const data = await r.json();
if (data.error) {
let msg = data.error;
if (data.install) msg += '\n\n설치 필요:\n' + data.install + '\n' + (data.installCore || '');
showOverlayError(msg);
return;
}
initEmulator(data.hex);
} catch(e) { showOverlayError('서버 오류: ' + e.message); }
}
if (avr8jsReady) boot();
</script>
</body>
</html>