라이브러리 관리: - GET/POST/DELETE /api/arduino/libs(/search/install/uninstall) 엔드포인트 - 에뮬레이터 헤더 '라이브러리' 버튼 → 설치됨/검색 탭 모달 (설치·삭제, 검색 결과 20개, 상태 피드백) LCD 에뮬레이션 (AVRTWI 기반): - avr8js AVRTWI로 I2C 버스 인터셉트 - PCF8574 바이트 → HD44780 nibble 디코딩 - DDRAM/CGRAM 상태 유지, Clear/Home/SetAddr/EntryMode 명령 처리 - 보드 패널에 가상 16×2 LCD 렌더링 (백라이트 온/오프 표시) - I2C 주소 0x27, 0x3F 모두 지원 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
730 lines
33 KiB
HTML
730 lines
33 KiB
HTML
<!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); }
|
||
|
||
/* 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>
|
||
|
||
<!-- 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 === ' ' ? ' ' : 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);
|
||
}
|
||
}
|
||
}
|
||
|
||
function setupTWI(cpu) {
|
||
if (!AVRTWI || !twiConfig) return;
|
||
try {
|
||
const twi = new AVRTWI(cpu, twiConfig, 16e6);
|
||
twi.eventHandler = {
|
||
start(restart) { return true; },
|
||
connectToSlave(address, write) {
|
||
return LCD_ADDRS.includes(address >> 1);
|
||
},
|
||
writeByte(byte) { lcdOnI2CByte(byte); return true; },
|
||
readByte() { return 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();
|
||
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();
|
||
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>
|