v3.0.2: server-v2 모듈 분리 — Arduino·PPTX 라우트 추출 (-2923줄)
- routes-arduino.ts: Arduino 컴파일/라이브러리/QEMU/ESP8266 에뮬 전체 - routes-pptx.ts: PPTX 생성/편집/PDF/이미지 API 전체 - mocks-esp32: WiFi.STA(_STAProxy), scanNetworks, DELETE() 수정 - mocks-esp8266: DELETE() 수정 - server-v2.ts: 16106 → 13183줄 (–18%) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -562,6 +562,7 @@
|
||||
<div class="wifi-row"><span class="wifi-label">SSID</span><span class="wifi-val" id="wifi-ssid">-</span></div>
|
||||
<div class="wifi-row"><span class="wifi-label">IP</span><span class="wifi-val" id="wifi-ip">-</span></div>
|
||||
<div class="wifi-row"><span class="wifi-label">RSSI</span><span class="wifi-val" id="wifi-rssi">-</span></div>
|
||||
<div class="wifi-row" id="wifi-http-row" style="display:none"><span class="wifi-label">HTTP</span><a id="wifi-http-link" class="wifi-val" href="#" target="_blank" style="color:#60a5fa;text-decoration:underline">-</a></div>
|
||||
<div class="wifi-row" style="margin-top:8px">
|
||||
<input class="wifi-input" id="wifi-ssid-input" placeholder="SSID" />
|
||||
<input class="wifi-input" id="wifi-pass-input" type="password" placeholder="비밀번호" />
|
||||
@@ -1602,6 +1603,18 @@ function _wifiHandleEvent(msg) {
|
||||
_wifiUpdateUI();
|
||||
}
|
||||
|
||||
function _showHttpPortLink(port) {
|
||||
_wifiShow();
|
||||
const row = document.getElementById('wifi-http-row');
|
||||
const link = document.getElementById('wifi-http-link');
|
||||
if (!row || !link) return;
|
||||
const url = 'http://' + location.hostname + ':' + port;
|
||||
link.href = url;
|
||||
link.textContent = url;
|
||||
row.style.display = '';
|
||||
appendSerial('\n[웹서버 실행 중: ' + url + ']\n', 'sys');
|
||||
}
|
||||
|
||||
function _wifiConnectClicked() {
|
||||
const ssid = document.getElementById('wifi-ssid-input').value.trim();
|
||||
const pass = document.getElementById('wifi-pass-input').value;
|
||||
@@ -2573,6 +2586,7 @@ function connectEsp8266Output() {
|
||||
if (msg.motor) { _motorAttach(msg.motor); return; }
|
||||
if (msg.motor_state) { _motorUpdateState(msg.motor_state); return; }
|
||||
if (msg.wifi) { _wifiHandleEvent(msg.wifi); return; }
|
||||
if (msg.http_port) { _showHttpPortLink(msg.http_port); return; }
|
||||
if (msg.tft) { _esp32TftDraw(msg.tft); return; }
|
||||
const t = msg.t;
|
||||
if (!t) return;
|
||||
@@ -2912,10 +2926,8 @@ function connectEsp32Output() {
|
||||
if (msg.motor) { _motorAttach(msg.motor); return; }
|
||||
if (msg.motor_state) { _motorUpdateState(msg.motor_state); return; }
|
||||
if (msg.wifi) { _wifiHandleEvent(msg.wifi); return; }
|
||||
if (msg.tft) {
|
||||
_esp32TftDraw(msg.tft);
|
||||
return;
|
||||
}
|
||||
if (msg.http_port) { _showHttpPortLink(msg.http_port); return; }
|
||||
if (msg.tft) { _esp32TftDraw(msg.tft); return; }
|
||||
const t = msg.t;
|
||||
if (!t) return;
|
||||
_esp32BlinkRx();
|
||||
|
||||
@@ -0,0 +1,911 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Arduino Uploader</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 { display:flex; align-items:center; gap:12px; padding:10px 16px; background:var(--panel); border-bottom:1px solid #2d2d4a; flex-shrink:0; flex-wrap:wrap; }
|
||||
.header h1 { font-size:14px; font-weight:600; }
|
||||
.header .file { font-size:12px; color:var(--accent); }
|
||||
.status { font-size:11px; padding:2px 8px; border-radius:4px; font-weight:600; }
|
||||
.status.idle { background:#252540; color:var(--muted); }
|
||||
.status.ready { background:#1a3a2a; color:var(--green); }
|
||||
.status.compiling{ background:#3a2a1a; color:var(--orange); animation:pulse 1.5s infinite; }
|
||||
.status.uploading{ background:#3a3a1a; color:var(--yellow); animation:pulse 1.5s infinite; }
|
||||
.status.done { background:#1a3a2a; color:var(--green); }
|
||||
.status.error { background:#3a1a1a; color:var(--red); }
|
||||
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.6} }
|
||||
.toolbar { display:flex; gap:8px; margin-left:auto; align-items:center; flex-wrap:wrap; }
|
||||
.btn { 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; white-space:nowrap; }
|
||||
.btn:hover:not(:disabled) { background:#353555; border-color:#4a4a7a; }
|
||||
.btn:disabled { opacity:.4; cursor:not-allowed; }
|
||||
.btn.primary { background:#1a3a2a; border-color:#2d5a3a; color:var(--green); }
|
||||
.btn.primary:hover:not(:disabled) { background:#224a3a; }
|
||||
.btn.accent { background:#1a2a3a; border-color:#2d4a6a; color:var(--accent); }
|
||||
.btn.accent:hover:not(:disabled) { background:#223a4a; }
|
||||
|
||||
.main { flex:1; display:flex; overflow:hidden; }
|
||||
.left-panel { width:280px; flex-shrink:0; border-right:1px solid #2d2d4a; display:flex; flex-direction:column; background:#111122; overflow-y:auto; }
|
||||
.section { padding:14px 16px; border-bottom:1px solid #1e1e36; }
|
||||
.section-title { font-size:10px; color:var(--muted); font-weight:600; letter-spacing:.08em; margin-bottom:10px; }
|
||||
.info-row { display:flex; justify-content:space-between; font-size:11px; padding:2px 0; gap:6px; }
|
||||
.info-label { color:var(--muted); white-space:nowrap; }
|
||||
.info-val { color:#e5e7eb; font-weight:500; word-break:break-all; text-align:right; }
|
||||
.port-name { font-size:11px; color:var(--green); margin-top:8px; min-height:16px; word-break:break-all; }
|
||||
.hint { font-size:10px; color:var(--muted); margin-top:6px; line-height:1.6; }
|
||||
.method-badge { display:inline-block; padding:2px 7px; border-radius:4px; font-size:10px; font-weight:600; }
|
||||
|
||||
.serial-panel { flex:1; display:flex; flex-direction:column; overflow:hidden; }
|
||||
.log-section { flex:2; display:flex; flex-direction:column; overflow:hidden; border-bottom:2px solid #2d2d4a; min-height:80px; }
|
||||
.monitor-section { flex:3; display:flex; flex-direction:column; overflow:hidden; min-height:120px; }
|
||||
.serial-header { padding:8px 16px; font-size:11px; color:var(--muted); font-weight:600; letter-spacing:.08em; border-bottom:1px solid #1e1e36; display:flex; align-items:center; gap:8px; flex-shrink:0; }
|
||||
.serial-clear { background:none; border:none; color:var(--muted); cursor:pointer; font-size:11px; font-family:inherit; }
|
||||
.serial-clear:hover { color:#e5e7eb; }
|
||||
.log-output { flex:1; overflow-y:auto; padding:10px 16px; font-size:12px; line-height:1.7; white-space:pre-wrap; word-break:break-all; }
|
||||
.log-output .info { color:var(--muted); font-style:italic; }
|
||||
.log-output .ok { color:var(--green); }
|
||||
.log-output .err { color:var(--red); }
|
||||
.log-output .prog { color:#60a5fa; }
|
||||
.log-output .warn { color:var(--yellow); }
|
||||
.footer { padding:7px 16px; border-top:1px solid #2d2d4a; font-size:11px; color:var(--muted); background:var(--panel); flex-shrink:0; }
|
||||
.chip-badge { display:inline-block; padding:2px 8px; border-radius:4px; font-size:10px; font-weight:600; background:#1a3a1a; color:var(--green); border:1px solid #2d5a2d; }
|
||||
progress { width:100%; height:4px; border:none; border-radius:2px; margin-top:8px; }
|
||||
progress::-webkit-progress-bar { background:#1e1e36; border-radius:2px; }
|
||||
progress::-webkit-progress-value { background:var(--accent); border-radius:2px; transition:width .2s; }
|
||||
#prog-bar { display:none; }
|
||||
/* Serial monitor */
|
||||
.mon-select { background:#1a1a30; border:1px solid #3a3a5a; color:#e5e7eb; border-radius:4px; padding:2px 6px; font-size:11px; font-family:inherit; }
|
||||
.mon-btn { background:#1a2a1a; border:1px solid #2d4a2d; color:var(--green); border-radius:4px; padding:2px 10px; font-size:11px; cursor:pointer; font-family:inherit; }
|
||||
.mon-btn:hover { background:#223a22; }
|
||||
.mon-btn.active { background:#3a1a1a; border-color:#5a2d2d; color:var(--red); }
|
||||
.mon-cb-label { display:flex; align-items:center; gap:4px; font-size:11px; color:var(--muted); cursor:pointer; user-select:none; }
|
||||
.monitor-output { flex:1; overflow-y:auto; padding:8px 16px; font-size:12px; line-height:1.6; font-family:'IBM Plex Mono',monospace; }
|
||||
.monitor-output div { white-space:pre-wrap; word-break:break-all; }
|
||||
.monitor-output .mon-rx { color:#d1fae5; }
|
||||
.monitor-output .mon-tx { color:#93c5fd; }
|
||||
.monitor-output .mon-sys { color:var(--muted); font-style:italic; }
|
||||
.mon-ts { color:#4b5563; font-size:10px; margin-right:6px; user-select:none; }
|
||||
.monitor-input-row { display:flex; gap:6px; padding:8px 12px; border-top:1px solid #1e1e36; flex-shrink:0; background:#0d0d1a; }
|
||||
.monitor-input-row input { flex:1; background:#1a1a2e; border:1px solid #3a3a5a; color:#e5e7eb; border-radius:4px; padding:5px 10px; font-size:12px; font-family:inherit; outline:none; }
|
||||
.monitor-input-row input:focus { border-color:var(--accent); }
|
||||
.monitor-input-row input:disabled { opacity:.4; }
|
||||
.mon-send-btn { background:#1a2a3a; border:1px solid #2d4a6a; color:var(--accent); border-radius:4px; padding:5px 14px; font-size:12px; cursor:pointer; font-family:inherit; }
|
||||
.mon-send-btn:hover { background:#223a4a; }
|
||||
.opt-row { display:flex; align-items:center; gap:6px; padding:3px 0; }
|
||||
.opt-label { font-size:10px; color:var(--muted); flex:1; min-width:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
.opt-select { flex:1.4; background:#1a1a2e; border:1px solid #3a3a5a; color:#e5e7eb; border-radius:3px; padding:2px 4px; font-size:10px; font-family:inherit; outline:none; }
|
||||
.opt-select:focus { border-color:var(--accent); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="header">
|
||||
<h1>⚡ Arduino Uploader</h1>
|
||||
<span class="file" id="filename">sketch.ino</span>
|
||||
<span class="chip-badge" id="board-badge">-</span>
|
||||
<span class="status idle" id="status">대기 중</span>
|
||||
<div class="toolbar">
|
||||
<button class="btn accent" id="btn-port" onclick="selectPort()">🔌 포트 선택</button>
|
||||
<button class="btn" id="btn-download" onclick="doDownload()" disabled>⬇ 다운로드</button>
|
||||
<button class="btn primary" id="btn-upload" onclick="doUpload()" disabled>⚡ 업로드</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="main">
|
||||
<div class="left-panel">
|
||||
<div class="section">
|
||||
<div class="section-title">포트 (클라이언트)</div>
|
||||
<button class="btn" style="width:100%" onclick="selectPort()">🔌 포트 선택 (WebSerial)</button>
|
||||
<div class="port-name" id="port-name">포트가 선택되지 않음</div>
|
||||
<div class="hint">Chrome/Edge에서 USB 케이블로 보드 연결 후 포트를 선택하세요.</div>
|
||||
</div>
|
||||
<div class="section">
|
||||
<div class="section-title">업로드 방식</div>
|
||||
<div class="info-row"><span class="info-label">방식</span><span class="info-val" id="info-method">-</span></div>
|
||||
<div class="info-row"><span class="info-label">보드</span><span class="info-val" id="info-board">-</span></div>
|
||||
<div class="info-row"><span class="info-label">FQBN</span><span class="info-val" id="info-fqbn" style="font-size:10px">-</span></div>
|
||||
<div class="info-row"><span class="info-label">파일</span><span class="info-val" id="info-file">-</span></div>
|
||||
</div>
|
||||
<div class="section" id="board-options-section" style="display:none">
|
||||
<div class="section-title" style="cursor:pointer;user-select:none" onclick="toggleBoardOptions()">
|
||||
보드 옵션 <span id="board-options-toggle" style="float:right;font-size:10px;color:var(--muted)">▼ 펼치기</span>
|
||||
</div>
|
||||
<div id="board-options-body" style="display:none">
|
||||
<div id="board-options-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section" id="stm32-hint" style="display:none">
|
||||
<div class="section-title">STM32 부트로더 진입</div>
|
||||
<div class="hint">UART 부트로더 업로드 시:<br>① BOOT0 핀을 3.3V에 연결<br>② RESET 버튼 누른 후 놓기<br>③ ⚡ 업로드 버튼 클릭<br><br>업로드 후 BOOT0를 GND로 복원하고 RESET 누르면 프로그램이 실행됩니다.</div>
|
||||
</div>
|
||||
<div class="section" id="uf2-hint" style="display:none">
|
||||
<div class="section-title">UF2 업로드 방법</div>
|
||||
<div class="hint">① BOOTSEL 버튼을 누른 채 USB 연결<br>② BOOT 드라이브가 나타남<br>③ ⬇ 다운로드로 .uf2 파일 받기<br>④ 파일을 BOOT 드라이브에 복사</div>
|
||||
</div>
|
||||
<div class="section">
|
||||
<progress id="prog-bar" value="0" max="100"></progress>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="serial-panel">
|
||||
<!-- Upload log -->
|
||||
<div class="log-section">
|
||||
<div class="serial-header">
|
||||
OUTPUT
|
||||
<button class="serial-clear" onclick="clearLog()">지우기</button>
|
||||
</div>
|
||||
<div class="log-output" id="log-output">
|
||||
<span class="info">🔌 포트 선택 버튼으로 보드를 선택하세요.\n클라이언트(PC)의 USB 포트가 표시됩니다.\n</span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Serial monitor -->
|
||||
<div class="monitor-section">
|
||||
<div class="serial-header">
|
||||
SERIAL MONITOR
|
||||
<select class="mon-select" id="mon-baud" title="보드레이트">
|
||||
<option value="9600">9600</option>
|
||||
<option value="19200">19200</option>
|
||||
<option value="38400">38400</option>
|
||||
<option value="57600">57600</option>
|
||||
<option value="115200" selected>115200</option>
|
||||
<option value="230400">230400</option>
|
||||
<option value="921600">921600</option>
|
||||
</select>
|
||||
<select class="mon-select" id="mon-line-end" title="줄 끝 문자">
|
||||
<option value="none">줄끝 없음</option>
|
||||
<option value="nl">NL (LF)</option>
|
||||
<option value="cr">CR</option>
|
||||
<option value="crnl" selected>CR+NL</option>
|
||||
</select>
|
||||
<button class="mon-btn" id="btn-monitor" onclick="toggleMonitor()">▶ 연결</button>
|
||||
<label class="mon-cb-label"><input type="checkbox" id="mon-timestamp"> 시각</label>
|
||||
<label class="mon-cb-label"><input type="checkbox" id="mon-autoscroll" checked> 자동스크롤</label>
|
||||
<button class="serial-clear" onclick="clearMonitor()">지우기</button>
|
||||
</div>
|
||||
<div class="monitor-output" id="monitor-output">
|
||||
<div class="mon-sys">포트 선택 후 ▶ 연결을 눌러 시리얼 모니터를 시작하세요.</div>
|
||||
</div>
|
||||
<div class="monitor-input-row">
|
||||
<input type="text" id="monitor-input" placeholder="전송할 데이터..." disabled
|
||||
onkeydown="if(event.key==='Enter')monitorSend()">
|
||||
<button class="mon-send-btn" onclick="monitorSend()">전송</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer" id="footer-status">포트를 선택해주세요</div>
|
||||
|
||||
<script type="module">
|
||||
let _code = '', _fqbn = '', _boardName = '', _fname = '';
|
||||
let _port = null;
|
||||
let _compiledData = null; // { hex?, bin?, boardType }
|
||||
let _busy = false;
|
||||
let _boardOptions = []; // [{ id, label, values:[{value,label,selected}] }]
|
||||
let _selOptions = {}; // { optionId: value }
|
||||
|
||||
function getEffectiveFqbn() {
|
||||
const keys = Object.keys(_selOptions);
|
||||
if (!keys.length) return _fqbn;
|
||||
return _fqbn + ':' + keys.map(k => k + '=' + _selOptions[k]).join(',');
|
||||
}
|
||||
|
||||
function toggleBoardOptions() {
|
||||
const body = document.getElementById('board-options-body');
|
||||
const tog = document.getElementById('board-options-toggle');
|
||||
const open = body.style.display === 'none';
|
||||
body.style.display = open ? '' : 'none';
|
||||
tog.textContent = open ? '▲ 접기' : '▼ 펼치기';
|
||||
}
|
||||
|
||||
function renderBoardOptions() {
|
||||
const sec = document.getElementById('board-options-section');
|
||||
const list = document.getElementById('board-options-list');
|
||||
if (!_boardOptions.length) { sec.style.display = 'none'; return; }
|
||||
sec.style.display = '';
|
||||
list.innerHTML = _boardOptions.map(o => {
|
||||
const sel = _selOptions[o.id] ?? (o.values.find(v => v.selected)?.value ?? o.values[0]?.value ?? '');
|
||||
const opts = o.values.map(v =>
|
||||
`<option value="${v.value}"${v.value === sel ? ' selected' : ''}>${v.label}</option>`
|
||||
).join('');
|
||||
return `<div class="opt-row">
|
||||
<span class="opt-label" title="${o.label}">${o.label}</span>
|
||||
<select class="opt-select" data-opt="${o.id}" onchange="if(this.value!==this.dataset.def)_selOptions[this.dataset.opt]=this.value;else delete _selOptions[this.dataset.opt];document.getElementById('info-fqbn').textContent=getEffectiveFqbn()" data-def="${o.values.find(v=>v.selected)?.value??o.values[0]?.value??''}">${opts}</select>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function loadBoardOptions() {
|
||||
const baseFqbn = _fqbn.split(':').slice(0, 3).join(':');
|
||||
try {
|
||||
const r = await fetch(`/api/arduino/board/options?fqbn=${encodeURIComponent(baseFqbn)}`, { credentials: 'include' });
|
||||
const d = await r.json();
|
||||
_boardOptions = d.options || [];
|
||||
// _selOptions는 비워둠 — 사용자가 명시적으로 바꾼 것만 FQBN에 추가
|
||||
_selOptions = {};
|
||||
renderBoardOptions();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// ── logging ──────────────────────────────────────────────────────────────────
|
||||
function log(msg, cls = 'info') {
|
||||
const out = document.getElementById('log-output');
|
||||
const span = document.createElement('span');
|
||||
span.className = cls;
|
||||
span.textContent = msg + '\n';
|
||||
out.appendChild(span);
|
||||
out.scrollTop = out.scrollHeight;
|
||||
}
|
||||
function clearLog() { document.getElementById('log-output').innerHTML = ''; }
|
||||
|
||||
function setStatus(cls, text) {
|
||||
const el = document.getElementById('status');
|
||||
el.className = 'status ' + cls;
|
||||
el.textContent = text;
|
||||
}
|
||||
function setProgress(pct) {
|
||||
const bar = document.getElementById('prog-bar');
|
||||
if (pct == null) { bar.style.display = 'none'; return; }
|
||||
bar.style.display = 'block';
|
||||
bar.value = pct;
|
||||
}
|
||||
function footer(t) { document.getElementById('footer-status').textContent = t; }
|
||||
|
||||
// ── board type helpers ────────────────────────────────────────────────────────
|
||||
function getUploadMethod(fqbn) {
|
||||
const arch = fqbn.split(':')[1] || '';
|
||||
const vendor = fqbn.split(':')[0] || '';
|
||||
if (arch === 'avr') return 'avr-stk500';
|
||||
if (vendor === 'STMicroelectronics') return 'stm32-uart';
|
||||
if (arch === 'samd' || arch === 'sam') return 'download-bin';
|
||||
if (arch === 'mbed_rp2040' || arch === 'rp2040') return 'download-uf2';
|
||||
if (arch === 'esp32' || arch === 'esp8266') return 'esp32-flash';
|
||||
return 'download-bin';
|
||||
}
|
||||
|
||||
function methodLabel(m) {
|
||||
return { 'avr-stk500': 'STK500 (WebSerial)', 'stm32-uart': 'STM32 UART 부트로더', 'download-bin': '.bin 다운로드', 'download-uf2': 'UF2 드래그&드롭', 'esp32-flash': 'esptool-js (브라우저)' }[m] || m;
|
||||
}
|
||||
|
||||
// ── port selection ────────────────────────────────────────────────────────────
|
||||
async function selectPort() {
|
||||
if (!navigator.serial) {
|
||||
log('❌ WebSerial 미지원 브라우저입니다. Chrome 또는 Edge를 사용하세요.', 'err');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
_port = await navigator.serial.requestPort();
|
||||
const info = _port.getInfo();
|
||||
const vid = info.usbVendorId ? `VID:0x${info.usbVendorId.toString(16).padStart(4,'0')}` : '';
|
||||
const pid = info.usbProductId ? `PID:0x${info.usbProductId.toString(16).padStart(4,'0')}` : '';
|
||||
const label = [vid, pid].filter(Boolean).join(' ') || '(선택됨)';
|
||||
document.getElementById('port-name').textContent = '✓ ' + label;
|
||||
document.getElementById('port-name').style.color = 'var(--green)';
|
||||
document.getElementById('btn-upload').disabled = false;
|
||||
document.getElementById('btn-download').disabled = false;
|
||||
log(`포트 선택됨: ${label}`, 'ok');
|
||||
setStatus('ready', '준비됨');
|
||||
footer(`포트 선택됨: ${label}`);
|
||||
} catch(e) {
|
||||
if (e.name !== 'NotFoundError') log('포트 선택 오류: ' + e.message, 'err');
|
||||
}
|
||||
}
|
||||
|
||||
// ── compile on server ─────────────────────────────────────────────────────────
|
||||
async function compile() {
|
||||
// Auto-correct fqbn from code content to avoid cross-platform header errors
|
||||
const _baseFqbn = _fqbn.split(':').slice(0, 3).join(':');
|
||||
const _codeArch = _baseFqbn.split(':')[1] || '';
|
||||
if (/ESP8266WiFi|ESP8266WebServer|ESP8266HTTPClient/i.test(_code) && _codeArch !== 'esp8266') {
|
||||
_fqbn = 'esp8266:esp8266:nodemcuv2';
|
||||
_selOptions = {};
|
||||
log(`⚠ ESP8266 스케치 감지 → FQBN을 ${_fqbn}으로 자동 변경`, 'warn');
|
||||
document.getElementById('info-fqbn').textContent = _fqbn;
|
||||
loadBoardOptions();
|
||||
} else if (/^#include\s+<WiFi\.h>/m.test(_code) && _codeArch !== 'esp32' && _codeArch !== 'esp8266') {
|
||||
_fqbn = 'esp32:esp32:esp32';
|
||||
_selOptions = {};
|
||||
log(`⚠ ESP32 WiFi 스케치 감지 → FQBN을 ${_fqbn}으로 자동 변경`, 'warn');
|
||||
document.getElementById('info-fqbn').textContent = _fqbn;
|
||||
loadBoardOptions();
|
||||
}
|
||||
log('서버에서 컴파일 중...', 'prog');
|
||||
setStatus('compiling', '컴파일 중...');
|
||||
footer('컴파일 중...');
|
||||
const r = await fetch('/api/arduino/compile', {
|
||||
method: 'POST', credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code: _code, fqbn: getEffectiveFqbn() }),
|
||||
});
|
||||
const data = await r.json();
|
||||
if (data.error) {
|
||||
log('컴파일 오류:\n' + data.error, 'err');
|
||||
setStatus('error', '컴파일 실패');
|
||||
footer('컴파일 실패');
|
||||
return null;
|
||||
}
|
||||
const kb = data.size ? ` (${(data.size / 1024).toFixed(1)} KB)` : '';
|
||||
log(`✅ 컴파일 완료${kb} — boardType: ${data.boardType}`, 'ok');
|
||||
return data;
|
||||
}
|
||||
|
||||
// ── ESP32 browser-side flash via esptool-js (WebSerial) ──────────────────────
|
||||
async function uploadESP32() {
|
||||
if (_busy) return;
|
||||
_busy = true;
|
||||
document.getElementById('btn-upload').disabled = true;
|
||||
document.getElementById('btn-download').disabled = true;
|
||||
clearLog();
|
||||
setProgress(0);
|
||||
let transport = null;
|
||||
let success = false;
|
||||
try {
|
||||
// 1. Compile on server
|
||||
const data = await compile();
|
||||
if (!data) return;
|
||||
_compiledData = data;
|
||||
const b64 = data.merged || data.bin;
|
||||
if (!b64) throw new Error('컴파일 결과에 바이너리가 없습니다');
|
||||
|
||||
// 2. Load esptool-js (local bundle)
|
||||
log('esptool-js 로딩 중...', 'prog');
|
||||
const { ESPLoader, Transport, HardReset } = await import('/esptool-bundle.js');
|
||||
|
||||
// 3. Connect & enter bootloader
|
||||
log('보드 연결 중 (자동 리셋)...', 'prog');
|
||||
setStatus('uploading', '연결 중...');
|
||||
transport = new Transport(_port, true);
|
||||
const esploader = new ESPLoader({
|
||||
transport,
|
||||
baudrate: 921600,
|
||||
romBaudrate: 115200,
|
||||
enableTracing: false,
|
||||
terminal: {
|
||||
clean: () => {},
|
||||
writeLine: (msg) => { if (msg.trim()) log(msg, 'info'); },
|
||||
write: (msg) => { if (msg.trim()) log(msg, 'info'); },
|
||||
},
|
||||
});
|
||||
const chip = await esploader.main();
|
||||
log(`칩 감지: ${chip}`, 'ok');
|
||||
setProgress(10);
|
||||
|
||||
// 4. Flash merged.bin at 0x0
|
||||
setStatus('uploading', '업로드 중...');
|
||||
footer('플래싱 중...');
|
||||
const binData = base64ToBytes(b64); // Uint8Array
|
||||
await esploader.writeFlash({
|
||||
fileArray: [{ data: binData, address: 0x0 }],
|
||||
flashSize: 'keep',
|
||||
flashMode: 'keep',
|
||||
flashFreq: 'keep',
|
||||
eraseAll: false,
|
||||
compress: true,
|
||||
reportProgress: (_fi, written, total) => {
|
||||
const pct = Math.round(written / total * 100);
|
||||
setProgress(10 + pct * 0.88);
|
||||
footer(`플래싱 중... ${written.toLocaleString()} / ${total.toLocaleString()} 바이트 (${pct}%)`);
|
||||
},
|
||||
});
|
||||
|
||||
// 5. Reset to app
|
||||
try { await new HardReset(transport).reset(); } catch {}
|
||||
log('✅ 업로드 완료! 보드가 재시작됩니다.', 'ok');
|
||||
setStatus('done', '완료');
|
||||
footer('업로드 완료');
|
||||
setProgress(100);
|
||||
success = true;
|
||||
} catch(e) {
|
||||
log('❌ 오류: ' + e.message, 'err');
|
||||
setStatus('error', '업로드 실패');
|
||||
footer('오류: ' + e.message);
|
||||
setProgress(null);
|
||||
} finally {
|
||||
try { if (transport) await transport.disconnect(); } catch {}
|
||||
_busy = false;
|
||||
document.getElementById('btn-upload').disabled = false;
|
||||
document.getElementById('btn-download').disabled = false;
|
||||
setTimeout(() => setProgress(null), 3000);
|
||||
}
|
||||
// Auto-connect serial monitor after upload (must be after finally so _busy=false and port is free)
|
||||
if (success) {
|
||||
monSys('[업로드 완료 — 시리얼 모니터 자동 연결 중...]');
|
||||
await new Promise(r => setTimeout(r, 1500));
|
||||
await openMonitor();
|
||||
}
|
||||
}
|
||||
|
||||
// ── download binary ───────────────────────────────────────────────────────────
|
||||
async function doDownload() {
|
||||
const data = await compile();
|
||||
if (!data) return;
|
||||
_compiledData = data;
|
||||
if (data.hex) {
|
||||
const blob = new Blob([data.hex], { type: 'text/plain' });
|
||||
triggerDownload(blob, _fname.replace(/\.[^.]+$/, '') + '.hex');
|
||||
} else if (data.bin) {
|
||||
const bytes = base64ToBytes(data.bin || data.merged);
|
||||
const blob = new Blob([bytes], { type: 'application/octet-stream' });
|
||||
const ext = data.boardType === 'uf2' ? '.uf2' : '.bin';
|
||||
triggerDownload(blob, _fname.replace(/\.[^.]+$/, '') + ext);
|
||||
}
|
||||
setStatus('done', '다운로드됨');
|
||||
}
|
||||
|
||||
function triggerDownload(blob, name) {
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = name;
|
||||
a.click();
|
||||
setTimeout(() => URL.revokeObjectURL(a.href), 5000);
|
||||
log(`다운로드: ${name}`, 'ok');
|
||||
}
|
||||
|
||||
// ── main upload ───────────────────────────────────────────────────────────────
|
||||
async function doUpload() {
|
||||
if (_busy) return;
|
||||
if (!_port) { log('포트를 먼저 선택해주세요 (🔌 포트 선택 버튼)', 'err'); return; }
|
||||
|
||||
const method = getUploadMethod(_fqbn);
|
||||
if (method === 'esp32-flash') {
|
||||
await uploadESP32();
|
||||
return;
|
||||
}
|
||||
if (method === 'download-uf2') {
|
||||
await doDownload(); return;
|
||||
}
|
||||
if (method === 'download-bin') {
|
||||
await doDownload(); return;
|
||||
}
|
||||
|
||||
_busy = true;
|
||||
document.getElementById('btn-upload').disabled = true;
|
||||
document.getElementById('btn-download').disabled = true;
|
||||
clearLog();
|
||||
setProgress(0);
|
||||
|
||||
try {
|
||||
const data = await compile();
|
||||
if (!data) return;
|
||||
_compiledData = data;
|
||||
|
||||
log(`\n--- 업로드 시작 [${_boardName}] ---`, 'prog');
|
||||
setStatus('uploading', '업로드 중...');
|
||||
footer('업로드 중...');
|
||||
setProgress(5);
|
||||
|
||||
if (method === 'avr-stk500') {
|
||||
await uploadAVR(data.hex);
|
||||
} else if (method === 'stm32-uart') {
|
||||
await uploadSTM32(base64ToBytes(data.bin));
|
||||
}
|
||||
} catch(e) {
|
||||
log('❌ 오류: ' + e.message, 'err');
|
||||
setStatus('error', '업로드 실패');
|
||||
footer('업로드 실패: ' + e.message);
|
||||
setProgress(null);
|
||||
} finally {
|
||||
_busy = false;
|
||||
document.getElementById('btn-upload').disabled = false;
|
||||
document.getElementById('btn-download').disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── utilities ─────────────────────────────────────────────────────────────────
|
||||
function delay(ms) { return new Promise(r => setTimeout(r, ms)); }
|
||||
|
||||
function base64ToBytes(b64) {
|
||||
const raw = atob(b64);
|
||||
const out = new Uint8Array(raw.length);
|
||||
for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
function parseIntelHex(hexText) {
|
||||
// Returns Map<pageIndex(128B), Uint8Array(128)>
|
||||
const pages = new Map();
|
||||
let extAddr = 0;
|
||||
for (const line of hexText.split('\n')) {
|
||||
const t = line.trim();
|
||||
if (!t.startsWith(':') || t.length < 11) continue;
|
||||
const bytes = [];
|
||||
for (let i = 1; i < t.length - 1; i += 2) bytes.push(parseInt(t.substr(i, 2), 16));
|
||||
const len = bytes[0], addr = (bytes[1] << 8) | bytes[2], type = bytes[3];
|
||||
if (type === 1) break;
|
||||
if (type === 4) { extAddr = ((bytes[4] << 8) | bytes[5]) << 16; continue; }
|
||||
if (type !== 0) continue;
|
||||
const fullAddr = extAddr + addr;
|
||||
for (let i = 0; i < len; i++) {
|
||||
const a = fullAddr + i;
|
||||
const pg = a >> 7;
|
||||
const off = a & 0x7f;
|
||||
if (!pages.has(pg)) pages.set(pg, new Uint8Array(128).fill(0xff));
|
||||
pages.get(pg)[off] = bytes[4 + i];
|
||||
}
|
||||
}
|
||||
return pages;
|
||||
}
|
||||
|
||||
// ── WebSerial helpers ─────────────────────────────────────────────────────────
|
||||
async function _write(writer, data) {
|
||||
await writer.write(data instanceof Uint8Array ? data : new Uint8Array(Array.isArray(data) ? data : [data]));
|
||||
}
|
||||
|
||||
async function _read(reader, n, ms = 5000) {
|
||||
const buf = new Uint8Array(n);
|
||||
let pos = 0;
|
||||
const deadline = Date.now() + ms;
|
||||
while (pos < n) {
|
||||
if (Date.now() > deadline) throw new Error(`시리얼 타임아웃 (${n}바이트 중 ${pos}바이트 수신)`);
|
||||
const { value, done } = await reader.read();
|
||||
if (done) throw new Error('포트가 닫혔습니다');
|
||||
const take = Math.min(value.length, n - pos);
|
||||
buf.set(value.slice(0, take), pos);
|
||||
pos += take;
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
// ── AVR STK500 v1 uploader ────────────────────────────────────────────────────
|
||||
async function uploadAVR(hexText) {
|
||||
const INSYNC = 0x14, OK = 0x10, EOP = 0x20;
|
||||
const CMD_SYNC = 0x30, CMD_SET_DEV = 0x42, CMD_ENTER = 0x50;
|
||||
const CMD_LEAVE = 0x51, CMD_ADDR = 0x55, CMD_PROG = 0x64;
|
||||
|
||||
await _port.open({ baudRate: 115200, dataBits: 8, stopBits: 1, parity: 'none' });
|
||||
const reader = _port.readable.getReader();
|
||||
const writer = _port.writable.getWriter();
|
||||
|
||||
const send = async (...b) => _write(writer, b);
|
||||
const recv = async (n, ms) => _read(reader, n, ms);
|
||||
const cmdOk = async (bytes, extra = 0, ms = 3000) => {
|
||||
await _write(writer, new Uint8Array([...bytes, EOP]));
|
||||
const r = await recv(2 + extra, ms);
|
||||
if (r[0] !== INSYNC) throw new Error(`STK500: INSYNC 없음 (0x${r[0].toString(16)})`);
|
||||
if (r[1 + extra] !== OK) throw new Error(`STK500: OK 없음 (0x${r[1+extra].toString(16)})`);
|
||||
return r.slice(1, 1 + extra);
|
||||
};
|
||||
|
||||
try {
|
||||
// DTR 토글로 리셋
|
||||
if (_port.setSignals) {
|
||||
await _port.setSignals({ dataTerminalReady: false, requestToSend: false });
|
||||
await delay(100);
|
||||
await _port.setSignals({ dataTerminalReady: true, requestToSend: true });
|
||||
await delay(50);
|
||||
await _port.setSignals({ dataTerminalReady: false });
|
||||
}
|
||||
await delay(250);
|
||||
|
||||
// Sync loop
|
||||
log('동기화 중...', 'prog');
|
||||
let synced = false;
|
||||
for (let i = 0; i < 12 && !synced; i++) {
|
||||
try {
|
||||
await send(CMD_SYNC, EOP);
|
||||
const r = await recv(2, 300);
|
||||
if (r[0] === INSYNC && r[1] === OK) synced = true;
|
||||
} catch {}
|
||||
}
|
||||
if (!synced) throw new Error('보드에 연결할 수 없습니다. 케이블/DTR 리셋을 확인하세요.');
|
||||
log('✓ 동기화됨', 'ok');
|
||||
|
||||
// SET_DEVICE — ATmega328P params
|
||||
await cmdOk([CMD_SET_DEV, 0x86,0x00,0x07,0x7f,0x83,0xff,0x00,0x01,0x01,0x01,0x01,0x03,0xff,0x00,0x80,0x04,0x00,0x00,0x00,0x80,0x00]);
|
||||
|
||||
// ENTER_PROGMODE
|
||||
await cmdOk([CMD_ENTER]);
|
||||
log('✓ 프로그래밍 모드 진입', 'ok');
|
||||
|
||||
const pages = parseIntelHex(hexText);
|
||||
const sorted = [...pages.entries()].sort((a, b) => a[0] - b[0]);
|
||||
let done = 0;
|
||||
for (const [pg, data] of sorted) {
|
||||
const waddr = pg << 6; // word address = byte_page_addr / 2
|
||||
await cmdOk([CMD_ADDR, waddr & 0xff, (waddr >> 8) & 0xff]);
|
||||
await cmdOk([CMD_PROG, 0x00, 0x80, 0x46, ...data]); // 0x80=128 bytes, 0x46='F'
|
||||
done++;
|
||||
const pct = 5 + Math.round(done / sorted.length * 90);
|
||||
setProgress(pct);
|
||||
footer(`업로드 중... ${done}/${sorted.length} 페이지`);
|
||||
}
|
||||
|
||||
await cmdOk([CMD_LEAVE]);
|
||||
setProgress(100);
|
||||
log(`✅ 업로드 완료! (${sorted.length} 페이지)`, 'ok');
|
||||
setStatus('done', '완료 ✓');
|
||||
footer('업로드 완료');
|
||||
await delay(500);
|
||||
setProgress(null);
|
||||
} finally {
|
||||
try { reader.releaseLock(); } catch {}
|
||||
try { writer.releaseLock(); } catch {}
|
||||
try { await _port.close(); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
// ── STM32 UART bootloader (AN3155) uploader ───────────────────────────────────
|
||||
async function uploadSTM32(binData) {
|
||||
const ACK = 0x79;
|
||||
|
||||
await _port.open({ baudRate: 115200, dataBits: 8, stopBits: 1, parity: 'even' });
|
||||
const reader = _port.readable.getReader();
|
||||
const writer = _port.writable.getWriter();
|
||||
|
||||
const w = async (d) => _write(writer, d);
|
||||
const readByte = async (ms = 6000) => (await _read(reader, 1, ms))[0];
|
||||
const waitAck = async (ms = 12000) => {
|
||||
const b = await readByte(ms);
|
||||
if (b === ACK) return;
|
||||
if (b === 0x1F) throw new Error('STM32 NACK — 명령 거부. 부트로더 모드 진입 여부 확인.');
|
||||
throw new Error(`예상치 못한 응답: 0x${b.toString(16)}`);
|
||||
};
|
||||
const xor4 = (a) => a[0] ^ a[1] ^ a[2] ^ a[3];
|
||||
const addrPkt = (addr) => {
|
||||
const b = [(addr>>24)&0xff,(addr>>16)&0xff,(addr>>8)&0xff,addr&0xff];
|
||||
return new Uint8Array([...b, xor4(b)]);
|
||||
};
|
||||
|
||||
try {
|
||||
// Sync
|
||||
log('STM32 부트로더 동기화 중...', 'prog');
|
||||
log('(BOOT0=HIGH 상태에서 RESET 눌렀다 뗐는지 확인하세요)', 'warn');
|
||||
let synced = false;
|
||||
for (let i = 0; i < 5 && !synced; i++) {
|
||||
try {
|
||||
await w([0x7F]);
|
||||
const b = await readByte(1200);
|
||||
if (b === ACK || b === 0x1F) synced = true;
|
||||
} catch {}
|
||||
}
|
||||
if (!synced) throw new Error('동기화 실패 — BOOT0 핀을 HIGH로 하고 RESET하세요.');
|
||||
log('✓ 동기화됨', 'ok');
|
||||
|
||||
// GET command to verify bootloader
|
||||
await w([0x00, 0xFF]);
|
||||
await waitAck(3000);
|
||||
const n = await readByte();
|
||||
const cmdBytes = await _read(reader, n + 1, 3000);
|
||||
await waitAck(3000);
|
||||
log(`✓ 부트로더 버전: 0x${cmdBytes[0].toString(16).padStart(2,'0')}`, 'ok');
|
||||
|
||||
// Extended Erase — global mass erase
|
||||
log('플래시 전체 지우는 중... (최대 30초)', 'prog');
|
||||
setProgress(10);
|
||||
await w([0x44, 0xBB]);
|
||||
await waitAck(5000);
|
||||
await w([0xFF, 0xFF, 0x00]); // special global erase
|
||||
await waitAck(30000);
|
||||
log('✓ 플래시 지움 완료', 'ok');
|
||||
setProgress(20);
|
||||
|
||||
// Write Memory
|
||||
const START = 0x08000000;
|
||||
const CHUNK = 256;
|
||||
const total = Math.ceil(binData.length / CHUNK);
|
||||
log(`${binData.length} 바이트 쓰는 중 (${total} 블록)...`, 'prog');
|
||||
|
||||
for (let i = 0; i < binData.length; i += CHUNK) {
|
||||
const raw = binData.slice(i, i + CHUNK);
|
||||
const padded = new Uint8Array(CHUNK).fill(0xff);
|
||||
padded.set(raw);
|
||||
|
||||
await w([0x31, 0xCE]);
|
||||
await waitAck(5000);
|
||||
await w(addrPkt(START + i));
|
||||
await waitAck(5000);
|
||||
|
||||
// [N-1, data[0..N-1], XOR(N-1 ^ data...)]
|
||||
let ck = CHUNK - 1;
|
||||
for (const b of padded) ck ^= b;
|
||||
const pkt = new Uint8Array(CHUNK + 2);
|
||||
pkt[0] = CHUNK - 1;
|
||||
pkt.set(padded, 1);
|
||||
pkt[CHUNK + 1] = ck;
|
||||
await w(pkt);
|
||||
await waitAck(10000);
|
||||
|
||||
const blk = Math.floor(i / CHUNK) + 1;
|
||||
const pct = 20 + Math.round(blk / total * 75);
|
||||
setProgress(pct);
|
||||
footer(`업로드 중... ${blk}/${total} 블록`);
|
||||
}
|
||||
|
||||
// GO — jump to application
|
||||
setProgress(97);
|
||||
log('실행 중...', 'prog');
|
||||
await w([0x21, 0xDE]);
|
||||
await waitAck(5000);
|
||||
await w(addrPkt(START));
|
||||
try { await waitAck(2000); } catch {} // GO 후 응답 없을 수 있음
|
||||
|
||||
setProgress(100);
|
||||
log('✅ 업로드 완료! BOOT0 핀을 GND로 복원하세요.', 'ok');
|
||||
setStatus('done', '완료 ✓');
|
||||
footer('업로드 완료 — BOOT0=GND 후 RESET 하세요');
|
||||
await delay(500);
|
||||
setProgress(null);
|
||||
} finally {
|
||||
try { reader.releaseLock(); } catch {}
|
||||
try { writer.releaseLock(); } catch {}
|
||||
try { await _port.close(); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Serial Monitor ────────────────────────────────────────────────────────────
|
||||
let _monActive = false;
|
||||
let _monReader = null;
|
||||
let _monWriter = null;
|
||||
let _monBuf = '';
|
||||
const _monDecoder = new TextDecoder();
|
||||
|
||||
function monAppend(text, cls = 'mon-rx') {
|
||||
const out = document.getElementById('monitor-output');
|
||||
const showTs = document.getElementById('mon-timestamp').checked;
|
||||
const autoScroll = document.getElementById('mon-autoscroll').checked;
|
||||
|
||||
// Buffer partial lines; flush on \n
|
||||
_monBuf += text;
|
||||
const parts = _monBuf.split('\n');
|
||||
_monBuf = parts.pop(); // keep last incomplete segment
|
||||
|
||||
for (const raw of parts) {
|
||||
const line = raw.replace(/\r/g, '');
|
||||
const div = document.createElement('div');
|
||||
div.className = cls;
|
||||
if (showTs) {
|
||||
const d = new Date();
|
||||
const ts = `${d.getHours().toString().padStart(2,'0')}:${d.getMinutes().toString().padStart(2,'0')}:${d.getSeconds().toString().padStart(2,'0')}.${d.getMilliseconds().toString().padStart(3,'0')}`;
|
||||
const tsSpan = document.createElement('span');
|
||||
tsSpan.className = 'mon-ts';
|
||||
tsSpan.textContent = ts;
|
||||
div.appendChild(tsSpan);
|
||||
div.appendChild(document.createTextNode(line));
|
||||
} else {
|
||||
div.textContent = line;
|
||||
}
|
||||
out.appendChild(div);
|
||||
}
|
||||
if (autoScroll) out.scrollTop = out.scrollHeight;
|
||||
}
|
||||
|
||||
function monSys(msg) {
|
||||
const out = document.getElementById('monitor-output');
|
||||
const div = document.createElement('div');
|
||||
div.className = 'mon-sys';
|
||||
div.textContent = msg;
|
||||
out.appendChild(div);
|
||||
if (document.getElementById('mon-autoscroll').checked) out.scrollTop = out.scrollHeight;
|
||||
}
|
||||
|
||||
async function _monReadLoop() {
|
||||
while (_monActive && _monReader) {
|
||||
try {
|
||||
const { value, done } = await _monReader.read();
|
||||
if (done) break;
|
||||
monAppend(_monDecoder.decode(value, { stream: true }));
|
||||
} catch(e) {
|
||||
if (_monActive) monSys(`[오류: ${e.message}]`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (_monActive) closeMonitor();
|
||||
}
|
||||
|
||||
async function toggleMonitor() {
|
||||
if (_monActive) await closeMonitor();
|
||||
else await openMonitor();
|
||||
}
|
||||
|
||||
async function openMonitor() {
|
||||
if (!_port) { monSys('[포트를 먼저 선택해주세요]'); return; }
|
||||
if (_busy) { monSys('[업로드 중입니다. 완료 후 연결하세요]'); return; }
|
||||
const baud = parseInt(document.getElementById('mon-baud').value) || 115200;
|
||||
try {
|
||||
await _port.open({ baudRate: baud });
|
||||
_monReader = _port.readable.getReader();
|
||||
_monWriter = _port.writable.getWriter();
|
||||
_monActive = true;
|
||||
const btn = document.getElementById('btn-monitor');
|
||||
btn.textContent = '■ 연결 해제';
|
||||
btn.classList.add('active');
|
||||
document.getElementById('monitor-input').disabled = false;
|
||||
monSys(`[연결됨 — ${baud} baud]`);
|
||||
_monReadLoop();
|
||||
} catch(e) {
|
||||
monSys(`[연결 실패: ${e.message}]`);
|
||||
}
|
||||
}
|
||||
|
||||
async function closeMonitor() {
|
||||
_monActive = false;
|
||||
try { await _monReader?.cancel(); } catch {}
|
||||
try { _monReader?.releaseLock(); } catch {}
|
||||
try { _monWriter?.releaseLock(); } catch {}
|
||||
try { await _port?.close(); } catch {}
|
||||
_monReader = null;
|
||||
_monWriter = null;
|
||||
const btn = document.getElementById('btn-monitor');
|
||||
btn.textContent = '▶ 연결';
|
||||
btn.classList.remove('active');
|
||||
document.getElementById('monitor-input').disabled = true;
|
||||
monSys('[연결 해제됨]');
|
||||
}
|
||||
|
||||
async function monitorSend() {
|
||||
if (!_monWriter) return;
|
||||
const input = document.getElementById('monitor-input');
|
||||
const endings = { none: '', nl: '\n', cr: '\r', crnl: '\r\n' };
|
||||
const suffix = endings[document.getElementById('mon-line-end').value] ?? '\r\n';
|
||||
const text = input.value + suffix;
|
||||
input.value = '';
|
||||
try {
|
||||
await _monWriter.write(new TextEncoder().encode(text));
|
||||
monAppend(text, 'mon-tx');
|
||||
} catch(e) { monSys(`[전송 오류: ${e.message}]`); }
|
||||
}
|
||||
|
||||
function clearMonitor() {
|
||||
document.getElementById('monitor-output').innerHTML = '';
|
||||
_monBuf = '';
|
||||
}
|
||||
|
||||
// ── boot ──────────────────────────────────────────────────────────────────────
|
||||
function boot() {
|
||||
_code = sessionStorage.getItem('arduino_upload_code') || '';
|
||||
_fqbn = sessionStorage.getItem('arduino_upload_fqbn') || 'arduino:avr:uno';
|
||||
_boardName = sessionStorage.getItem('arduino_upload_board') || _fqbn;
|
||||
_fname = sessionStorage.getItem('arduino_upload_file') || 'sketch.ino';
|
||||
sessionStorage.removeItem('arduino_upload_code');
|
||||
sessionStorage.removeItem('arduino_upload_fqbn');
|
||||
sessionStorage.removeItem('arduino_upload_board');
|
||||
sessionStorage.removeItem('arduino_upload_file');
|
||||
|
||||
document.getElementById('filename').textContent = _fname;
|
||||
document.getElementById('board-badge').textContent = _boardName;
|
||||
document.getElementById('info-file').textContent = _fname;
|
||||
document.getElementById('info-board').textContent = _boardName;
|
||||
document.getElementById('info-fqbn').textContent = _fqbn;
|
||||
|
||||
const method = getUploadMethod(_fqbn);
|
||||
document.getElementById('info-method').textContent = methodLabel(method);
|
||||
document.getElementById('stm32-hint').style.display = method === 'stm32-uart' ? '' : 'none';
|
||||
document.getElementById('uf2-hint').style.display = method === 'download-uf2' ? '' : 'none';
|
||||
|
||||
if (!_code) {
|
||||
log('코드가 없습니다. 코드 에디터에서 다시 시도해주세요.', 'err');
|
||||
setStatus('error', '코드 없음');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!navigator.serial) {
|
||||
log('⚠ 이 브라우저는 WebSerial을 지원하지 않습니다.\nChrome 또는 Edge를 사용하세요.', 'warn');
|
||||
}
|
||||
|
||||
loadBoardOptions();
|
||||
|
||||
log(`보드: ${_boardName}`, 'info');
|
||||
log(`업로드 방식: ${methodLabel(method)}`, 'info');
|
||||
if (method === 'stm32-uart')
|
||||
log('STM32: BOOT0=HIGH 후 RESET → ⚡ 업로드', 'warn');
|
||||
else if (method === 'avr-stk500')
|
||||
log('AVR: 포트 선택 후 ⚡ 업로드 (자동 DTR 리셋)', 'info');
|
||||
else if (method === 'download-uf2' || method === 'download-bin')
|
||||
log('이 보드는 WebSerial 업로드 대신 ⬇ 다운로드 후 수동 복사 방식을 사용합니다.', 'warn');
|
||||
else if (method === 'esp32-flash')
|
||||
log('ESP32/ESP8266: 🔌 포트 선택(COM포트/ttyUSB) 후 ⚡ 업로드\n 컴파일: 서버 | 플래싱: 브라우저 esptool-js (WebSerial)', 'info');
|
||||
}
|
||||
|
||||
boot();
|
||||
|
||||
// Expose functions for inline onclick handlers (required by type="module" scope)
|
||||
Object.assign(window, { selectPort, doUpload, doDownload, clearLog, toggleMonitor, clearMonitor, monitorSend, toggleBoardOptions });
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -280,6 +280,7 @@
|
||||
<!-- Arduino 에뮬레이터 + 업로드 -->
|
||||
<div class="csb-term-row">
|
||||
<button id="code-emu-btn" class="csb-term-btn" onclick="openArduinoEmulator()" title="Arduino 에뮬레이터 열기">아두이노</button>
|
||||
<button id="code-compile-btn" class="csb-term-btn" onclick="csbCompileOnly()" title="실제 보드용 컴파일 (오류 시 AI 채팅으로 전송)">🔨 컴파일</button>
|
||||
<button id="code-upload-btn" class="csb-term-btn" onclick="csbUploadToBoard()" title="보드에 업로드 (USB 연결 필요)" style="background:var(--brand);color:#fff;border-color:var(--brand)">⚡ 업로드</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -300,6 +301,12 @@
|
||||
<button class="code-sb-tab active" id="csb-libtab-installed" onclick="csbLibTab('installed')" style="flex:1;font-size:10px;padding:5px 0">설치됨</button>
|
||||
<button class="code-sb-tab" id="csb-libtab-search" onclick="csbLibTab('search')" style="flex:1;font-size:10px;padding:5px 0">검색/설치</button>
|
||||
</div>
|
||||
<div id="csb-lib-filter-row" style="display:flex;padding:4px 6px;gap:4px;border-bottom:1px solid var(--line);flex-shrink:0;flex-wrap:wrap;align-items:center">
|
||||
<span style="font-size:9px;color:var(--muted);margin-right:2px">필터:</span>
|
||||
<button id="csb-libf-all" onclick="csbLibFilter('all')" class="csb-libf-btn active" style="font-size:9px;padding:2px 7px;border-radius:3px;border:1px solid var(--line);background:var(--brand);color:#fff;cursor:pointer;font-family:inherit">전체</button>
|
||||
<button id="csb-libf-compat" onclick="csbLibFilter('compat')" class="csb-libf-btn" style="font-size:9px;padding:2px 7px;border-radius:3px;border:1px solid var(--line);background:var(--panel-2);color:var(--muted);cursor:pointer;font-family:inherit">이 보드만</button>
|
||||
<button id="csb-libf-incompat" onclick="csbLibFilter('incompat')" class="csb-libf-btn" style="font-size:9px;padding:2px 7px;border-radius:3px;border:1px solid var(--line);background:var(--panel-2);color:var(--muted);cursor:pointer;font-family:inherit">비호환</button>
|
||||
</div>
|
||||
<div id="csb-lib-search-row" style="display:none;padding:6px 8px;border-bottom:1px solid var(--line);gap:5px;flex-shrink:0;align-items:center">
|
||||
<input id="csb-lib-q" placeholder="키워드 또는 라이브러리명..." onkeydown="if(event.key==='Enter')csbLibSearch()"
|
||||
style="flex:1;font-size:11px;padding:5px 8px;background:var(--panel-2);color:var(--text);border:1px solid var(--line);border-radius:6px;font-family:inherit;min-width:0">
|
||||
|
||||
+184
-17
@@ -762,8 +762,8 @@ function createXtermShell(container, shellId) {
|
||||
fontFamily: "'IBM Plex Mono', 'D2Coding', 'NanumGothicCoding', 'Malgun Gothic', 'AppleGothic', monospace",
|
||||
fontSize: 13, lineHeight: 1.4, cursorBlink: true, scrollback: 5000,
|
||||
});
|
||||
const fitAddon = new AddonFit.FitAddon();
|
||||
const webLinksAddon = new AddonWebLinks.WebLinksAddon();
|
||||
const fitAddon = new FitAddon.FitAddon();
|
||||
const webLinksAddon = new WebLinksAddon.WebLinksAddon();
|
||||
term.loadAddon(fitAddon); term.loadAddon(webLinksAddon);
|
||||
term.open(sub); fitAddon.fit();
|
||||
// Intercept Ctrl+` inside xterm to toggle the terminal panel.
|
||||
@@ -1514,10 +1514,86 @@ function codeRunWith(mode) {
|
||||
}, codeTermOpen ? 0 : 500);
|
||||
}
|
||||
|
||||
function _boardSpecificHints(code = '') {
|
||||
const fqbn = _csbSelBoard.fqbn || '';
|
||||
const arch = fqbn.split(':')[1] || '';
|
||||
const usesWifi = /WiFi|ESP8266WiFi|HTTPClient|WebServer/i.test(code);
|
||||
if (arch === 'esp32') {
|
||||
const lines = [
|
||||
'ESP32 코딩 규칙 (반드시 준수):',
|
||||
'- analogWrite() 없음 → ledcAttach(pin, freq, res) + ledcWrite(pin, duty) 사용',
|
||||
'- loop()에서 오래 blocking하면 watchdog(TG1WDT) 리셋 발생 → delay() 또는 yield() 필수',
|
||||
'- SPIFFS 대신 LittleFS 권장 (esp32 3.x 코어)',
|
||||
];
|
||||
if (usesWifi) lines.splice(1, 0,
|
||||
'- WiFi는 SoC 내장 — #include <SPI.h> 절대 추가하지 말 것',
|
||||
'- #include <WiFi.h> 하나로 충분 (esp32 코어 제공)',
|
||||
'- WiFi.firmwareVersion() 없음 (WiFiNINA 전용)');
|
||||
return '\n' + lines.join('\n');
|
||||
}
|
||||
if (arch === 'esp8266') {
|
||||
const lines = [
|
||||
'ESP8266 코딩 규칙 (반드시 준수):',
|
||||
'- analogWrite() 있음 (PWM, 0-1023)',
|
||||
'- loop()에서 yield() 또는 delay() 호출 필수 (watchdog 방지)',
|
||||
'- SPI.h / ESP32Servo.h / ledcAttach 등 ESP32 전용 API 사용 금지',
|
||||
];
|
||||
if (usesWifi) lines.splice(1, 0,
|
||||
'- WiFi: #include <ESP8266WiFi.h> — #include <WiFi.h> 절대 사용 금지',
|
||||
'- WiFi 암호화 상수: ENC_TYPE_NONE / ENC_TYPE_CCMP / ENC_TYPE_TKIP 사용 (WIFI_AUTH_OPEN 등 ESP32 상수 금지)',
|
||||
'- WiFi 스캔: WiFi.scanNetworks() / WiFi.getNetworkInfo() / WiFi.getScanInfoByIndex() / bss_info 사용');
|
||||
return '\n' + lines.join('\n');
|
||||
}
|
||||
if (arch === 'avr') {
|
||||
const lines = ['AVR(Arduino) 코딩 규칙:'];
|
||||
if (usesWifi) lines.push('- WiFi 실드: #include <SPI.h> + #include <WiFiNINA.h> 또는 #include <WiFi.h>');
|
||||
return '\n' + lines.join('\n');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
async function csbCompileOnly() {
|
||||
if (!_csbSelBoard) { codeAiAppend('system', '⚠ 보드를 먼저 선택하세요.'); return; }
|
||||
const file = codeFiles.find(f => f.id === activeCodeFileId);
|
||||
if (!file || !file.name.endsWith('.ino')) { codeAiAppend('system', '⚠ .ino 파일을 선택하세요.'); return; }
|
||||
const code = file.model?.getValue() || file.content || '';
|
||||
const btn = document.getElementById('code-compile-btn');
|
||||
if (btn) { btn.textContent = '⏳ 컴파일 중…'; btn.disabled = true; }
|
||||
codeAiAppend('system', `🔨 컴파일 중... [${_csbSelBoard.name}]`);
|
||||
try {
|
||||
const r = await fetch('/api/arduino/compile', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
body: JSON.stringify({ code, fqbn: _csbSelBoard.fqbn }),
|
||||
});
|
||||
const data = await r.json();
|
||||
if (data.error) {
|
||||
sendCompileErrorToAI(data.error, file.name, code);
|
||||
} else {
|
||||
const kb = data.size ? (data.size / 1024).toFixed(1) : null;
|
||||
const extra = data.autoInstalled?.length ? `\n자동 설치된 라이브러리: ${data.autoInstalled.join(', ')}` : '';
|
||||
codeAiAppend('system', `✅ 컴파일 성공${kb ? ` (${kb} KB)` : ''} — 오류 없음.${extra}`);
|
||||
}
|
||||
} catch (e) {
|
||||
codeAiAppend('system', `컴파일 오류: ${e.message}`);
|
||||
} finally {
|
||||
if (btn) { btn.textContent = '🔨 컴파일'; btn.disabled = false; }
|
||||
}
|
||||
}
|
||||
|
||||
function sendCompileErrorToAI(error, filename, code) {
|
||||
const inp = document.getElementById('code-ai-input');
|
||||
if (!inp) return;
|
||||
inp.value = `아두이노 컴파일 오류가 발생했습니다. Read로 ${filename}을 읽은 뒤 Edit 또는 Write 도구로 파일을 직접 수정해줘.\n\n오류:\n\`\`\`\n${error}\n\`\`\``;
|
||||
const boardInfo = `대상 보드: ${_csbSelBoard.name} (FQBN: ${_csbSelBoard.fqbn})`;
|
||||
const hints = _boardSpecificHints(code);
|
||||
// Library conflict (redefinition) — not fixable by editing the .ino file
|
||||
if (/redefinition of/.test(error)) {
|
||||
const m = error.match(/\/sketch\/(\S+?\.h)/);
|
||||
const conflictHeader = m ? m[1] : 'unknown.h';
|
||||
inp.value = `아두이노 컴파일 오류: **라이브러리 충돌**입니다. ${boardInfo}${hints}\n\n원인: \`${conflictHeader}\`가 코어 내장 헤더와 중복 정의(redefinition) 충돌을 일으키고 있습니다. ${filename} 코드의 \`#include\` 방향을 조정하거나 외부 라이브러리를 교체해야 합니다.\n\n오류:\n\`\`\`\n${error}\n\`\`\`\n\n해결 방법을 설명해주세요. 파일 검색은 하지 않아도 됩니다.`;
|
||||
} else {
|
||||
inp.value = `아두이노 컴파일 오류가 발생했습니다. ${boardInfo}${hints}\n\nRead로 ${filename}을 읽은 뒤 Edit 또는 Write 도구로 파일을 직접 수정해줘.\n\n오류:\n\`\`\`\n${error}\n\`\`\``;
|
||||
}
|
||||
codeAiSend();
|
||||
}
|
||||
|
||||
@@ -2711,6 +2787,9 @@ async function _codeAiOneRound(messages, onToken) {
|
||||
function _buildCodeSystemPrompt() {
|
||||
const localNote = localDirHandle ? `로컬 폴더 "${localDirHandle.name}"이 연결되어 있습니다.` : '로컬 폴더가 연결되지 않았습니다.';
|
||||
const summaryBlock = _historySummary ? `\n[이전 대화 요약]\n${_historySummary}\n` : '';
|
||||
const _activeCode = (() => { try { const f = codeFiles.find(f => f.id === activeCodeFileId); return f?.model?.getValue() || f?.content || ''; } catch { return ''; } })();
|
||||
const boardHints = _boardSpecificHints(_activeCode);
|
||||
const boardBlock = boardHints ? `\n## 대상 보드: ${_csbSelBoard.name} (${_csbSelBoard.fqbn})${boardHints}\n` : '';
|
||||
const karpathyBlock = _karpathyMode ? `
|
||||
## Karpathy 코딩 가이드라인 (필수)
|
||||
1. Think Before Coding — 가정을 명시하고, 모호하면 질문하세요. 여러 해석 가능 시 제시 후 선택하세요.
|
||||
@@ -2718,7 +2797,7 @@ function _buildCodeSystemPrompt() {
|
||||
3. Surgical Changes — 사용자 요청과 직접 관련 없는 코드는 건드리지 마세요. 기존 스타일을 그대로 따르세요. 내 변경으로 인한 고아 코드만 제거하세요.
|
||||
4. Goal-Driven Execution — 작업을 검증 가능한 목표로 변환하세요. "버그 수정" → "재현 테스트 작성 → 통과".
|
||||
` : '';
|
||||
return `당신은 전문 코드 어시스턴트입니다. ${localNote}${summaryBlock}${karpathyBlock}
|
||||
return `당신은 전문 코드 어시스턴트입니다. ${localNote}${summaryBlock}${boardBlock}${karpathyBlock}
|
||||
|
||||
## 툴 사용 지침
|
||||
- 파일을 수정하기 전에 반드시 Read로 현재 내용을 확인하세요.
|
||||
@@ -2726,6 +2805,7 @@ function _buildCodeSystemPrompt() {
|
||||
- **Write**: 수정이 3곳 이상이거나 전체 리팩토링·일괄 변경 시 사용. Read로 읽은 내용을 수정한 뒤 Write로 교체하세요. Edit 반복보다 훨씬 빠릅니다.
|
||||
- Glob으로 파일 목록 검색, Grep으로 코드 내용 검색이 가능합니다.
|
||||
- Bash는 companion.py 연결 시 터미널 명령을 실행합니다.
|
||||
- **파일을 2번 검색해도 없으면 즉시 멈추세요.** 같은 패턴으로 반복 검색하지 말고, 파일이 없다는 사실을 사용자에게 설명한 뒤 다른 해결책을 제안하세요.
|
||||
|
||||
## 파일 경로 규칙 (중요)
|
||||
- 파일 경로에 절대경로·Windows 경로를 절대 사용하지 마세요: Documents/, Desktop/, C:\\, /home/ 등 금지.
|
||||
@@ -4145,6 +4225,8 @@ function setCodeSbTab(tab) {
|
||||
|
||||
// ── 라이브러리/보드 매니저 (code editor sidebar) ──────────────────────────────
|
||||
let _csbLibCurrentTab = 'installed';
|
||||
let _csbLibFilterMode = 'all'; // 'all' | 'compat' | 'incompat'
|
||||
let _csbLibCache = [];
|
||||
let _csbArdSection = 'lib';
|
||||
|
||||
function csbArdSection(sec) {
|
||||
@@ -4167,11 +4249,26 @@ function csbLibTab(tab) {
|
||||
});
|
||||
const sr = document.getElementById('csb-lib-search-row');
|
||||
if (sr) sr.style.display = tab === 'search' ? 'flex' : 'none';
|
||||
const fr = document.getElementById('csb-lib-filter-row');
|
||||
if (fr) fr.style.display = tab === 'installed' ? 'flex' : 'none';
|
||||
document.getElementById('csb-lib-status').textContent = '';
|
||||
if (tab === 'installed') csbLibLoad();
|
||||
else _csbLibListEl().innerHTML = '<div style="padding:16px;color:var(--muted);font-size:11px;text-align:center">키워드를 입력하세요.</div>';
|
||||
}
|
||||
|
||||
function csbLibFilter(mode) {
|
||||
_csbLibFilterMode = mode;
|
||||
['all','compat','incompat'].forEach(m => {
|
||||
const btn = document.getElementById('csb-libf-' + m);
|
||||
if (!btn) return;
|
||||
const active = m === mode;
|
||||
btn.style.background = active ? 'var(--brand)' : 'var(--panel-2)';
|
||||
btn.style.color = active ? '#fff' : 'var(--muted)';
|
||||
btn.style.borderColor = active ? 'var(--brand)' : 'var(--line)';
|
||||
});
|
||||
_csbLibRenderInstalled(_csbLibCache);
|
||||
}
|
||||
|
||||
function _csbLibListEl() { return document.getElementById('csb-lib-list'); }
|
||||
function _csbLibStatus(msg) { const el = document.getElementById('csb-lib-status'); if (el) el.textContent = msg; }
|
||||
function _csbEsc(s) { return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,'''); }
|
||||
@@ -4179,36 +4276,95 @@ function _csbEsc(s) { return String(s).replace(/&/g,'&').replace(/</g,'<'
|
||||
async function csbLibLoad() {
|
||||
_csbLibListEl().innerHTML = '<div style="padding:16px;color:var(--muted);font-size:11px;text-align:center">불러오는 중...</div>';
|
||||
try {
|
||||
const r = await fetch('/api/arduino/libs', { headers: authHeaders() });
|
||||
const fqbn = _csbSelBoard?.fqbn ? '&fqbn=' + encodeURIComponent(_csbSelBoard.fqbn) : '';
|
||||
const r = await fetch('/api/arduino/libs?all=1' + fqbn, { headers: authHeaders() });
|
||||
const data = await r.json();
|
||||
_csbLibRenderInstalled(data.libs || []);
|
||||
_csbLibCache = data.libs || [];
|
||||
_csbLibRenderInstalled(_csbLibCache);
|
||||
} catch { _csbLibListEl().innerHTML = '<div style="padding:16px;color:var(--muted);font-size:11px;text-align:center">오류</div>'; }
|
||||
}
|
||||
|
||||
function _csbArchBadge(architectures) {
|
||||
const arch = _csbSelBoard.fqbn.split(':')[1] || '';
|
||||
const archs = architectures || [];
|
||||
const isAll = archs.includes('*') || archs.length === 0;
|
||||
const compat = isAll || archs.some(a => a === arch || a.startsWith(arch) || arch.startsWith(a));
|
||||
if (isAll) return `<span style="font-size:9px;padding:1px 5px;border-radius:3px;background:#1a2a1a;color:#6ee7b7;border:1px solid #2d4a2d">모든 보드</span>`;
|
||||
const label = archs.slice(0,3).join(', ') + (archs.length>3?'…':'');
|
||||
const color = compat ? '#6ee7b7' : '#f87171';
|
||||
const bg = compat ? '#1a2a1a' : '#2a1a1a';
|
||||
const bd = compat ? '#2d4a2d' : '#4a2d2d';
|
||||
const icon = compat ? '✓' : '✗';
|
||||
return `<span style="font-size:9px;padding:1px 5px;border-radius:3px;background:${bg};color:${color};border:1px solid ${bd}" title="호환 아키텍처: ${archs.join(', ')}">${icon} ${_csbEsc(label)}</span>`;
|
||||
}
|
||||
|
||||
function _csbIsCompat(l) {
|
||||
const arch = _csbSelBoard.fqbn.split(':')[1] || '';
|
||||
const archs = l.architectures || [];
|
||||
return archs.includes('*') || archs.length === 0 || archs.some(a => a === arch || a.startsWith(arch) || arch.startsWith(a));
|
||||
}
|
||||
|
||||
function _csbLibRenderInstalled(libs) {
|
||||
const el = _csbLibListEl();
|
||||
if (!libs.length) { el.innerHTML = '<div style="padding:16px;color:var(--muted);font-size:11px;text-align:center">설치된 라이브러리 없음</div>'; return; }
|
||||
el.innerHTML = libs.map(l => {
|
||||
|
||||
// Apply filter
|
||||
let filtered = libs;
|
||||
if (_csbLibFilterMode === 'compat') filtered = libs.filter(l => _csbIsCompat(l));
|
||||
if (_csbLibFilterMode === 'incompat') filtered = libs.filter(l => !_csbIsCompat(l));
|
||||
|
||||
// Sort: platform/core first, then compatible, then incompatible
|
||||
if (_csbLibFilterMode === 'all') {
|
||||
filtered = [...libs].sort((a, b) => {
|
||||
const pa = a.location === 'platform', pb = b.location === 'platform';
|
||||
if (pa && !pb) return -1;
|
||||
if (!pa && pb) return 1;
|
||||
const ca = _csbIsCompat(a), cb = _csbIsCompat(b);
|
||||
if (ca && !cb) return -1;
|
||||
if (!ca && cb) return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}
|
||||
|
||||
if (!filtered.length) {
|
||||
const msg = _csbLibFilterMode === 'compat' ? `${_csbSelBoard.name}과 호환되는 라이브러리 없음` : '비호환 라이브러리 없음';
|
||||
el.innerHTML = `<div style="padding:16px;color:var(--muted);font-size:11px;text-align:center">${msg}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
const fqbn = _csbSelBoard?.fqbn || '';
|
||||
el.innerHTML = filtered.map(l => {
|
||||
const sid = 'csbex_' + l.name.replace(/\W/g,'_');
|
||||
const archs = l.architectures || [];
|
||||
const compat = _csbIsCompat(l);
|
||||
const isPlatform = l.location === 'platform';
|
||||
const incompatWarn = !compat ? `<div style="font-size:10px;color:#f87171;margin-top:3px">⚠ 현재 보드(${_csbEsc(_csbSelBoard.name)})와 호환되지 않을 수 있음</div>` : '';
|
||||
const platformBadge = isPlatform ? `<span style="font-size:9px;padding:1px 5px;border-radius:3px;background:#1a2a3a;color:#60a5fa;border:1px solid #2d4a6a">코어</span>` : '';
|
||||
const exRows = (l.examples||[]).map(ex =>
|
||||
`<div style="display:flex;align-items:center;padding:3px 0;gap:6px">
|
||||
<span style="font-size:10px;color:var(--muted);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">📄 ${_csbEsc(ex)}</span>
|
||||
<button onclick="csbLibOpenExample('${_csbEsc(l.name)}','${_csbEsc(ex)}')"
|
||||
style="font-size:10px;padding:2px 7px;background:none;border:1px solid var(--line);color:var(--brand);border-radius:4px;cursor:pointer;font-family:inherit;white-space:nowrap">열기</button>
|
||||
<button onclick="csbLibOpenExample('${_csbEsc(l.name)}','${_csbEsc(ex)}',${compat},'${_csbEsc(fqbn)}')"
|
||||
style="font-size:10px;padding:2px 7px;background:none;border:1px solid var(--line);color:${compat?'var(--brand)':'#f87171'};border-radius:4px;cursor:pointer;font-family:inherit;white-space:nowrap"
|
||||
title="${compat?'':'⚠ 비호환 예제 — 열면 AI가 자동으로 변환해드립니다'}">
|
||||
${compat?'열기':'열기 ⚠'}</button>
|
||||
</div>`).join('');
|
||||
const hasEx = l.examples?.length > 0;
|
||||
return `<div style="padding:8px 10px;border-bottom:1px solid var(--line)">
|
||||
return `<div style="padding:8px 10px;border-bottom:1px solid var(--line)${!compat?';border-left:2px solid #7f1d1d':''}">
|
||||
<div style="display:flex;align-items:flex-start;gap:6px">
|
||||
<div style="flex:1;min-width:0">
|
||||
<div style="font-size:11px;font-weight:700;color:var(--text)">${_csbEsc(l.name)}</div>
|
||||
<div style="display:flex;align-items:center;gap:5px;flex-wrap:wrap">
|
||||
<span style="font-size:11px;font-weight:700;color:var(--text)">${_csbEsc(l.name)}</span>
|
||||
${platformBadge}${_csbArchBadge(archs)}
|
||||
</div>
|
||||
<div style="font-size:10px;color:var(--muted);margin-top:1px">${_csbEsc(l.author||'')} · v${_csbEsc(l.version||'?')}${l.category?' · '+_csbEsc(l.category):''}</div>
|
||||
${l.sentence?`<div style="font-size:10px;color:var(--muted);margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${_csbEsc(l.sentence)}</div>`:''}
|
||||
${incompatWarn}
|
||||
</div>
|
||||
<div style="display:flex;gap:4px;flex-shrink:0">
|
||||
${hasEx?`<button onclick="document.getElementById('${sid}').style.display=document.getElementById('${sid}').style.display==='none'?'block':'none'"
|
||||
style="font-size:10px;padding:2px 7px;background:none;border:1px solid var(--line);color:var(--muted);border-radius:4px;cursor:pointer;font-family:inherit">예제</button>`:''}
|
||||
<button onclick="csbLibUninstall('${_csbEsc(l.name)}')"
|
||||
style="font-size:10px;padding:2px 7px;background:none;border:1px solid var(--line);color:#f87171;border-radius:4px;cursor:pointer;font-family:inherit">삭제</button>
|
||||
${isPlatform?'':`<button onclick="csbLibUninstall('${_csbEsc(l.name)}')"
|
||||
style="font-size:10px;padding:2px 7px;background:none;border:1px solid var(--line);color:#f87171;border-radius:4px;cursor:pointer;font-family:inherit">삭제</button>`}
|
||||
</div>
|
||||
</div>
|
||||
${hasEx?`<div id="${sid}" style="display:none;margin-top:5px;padding:5px 8px;background:var(--panel-2);border-radius:5px">${exRows}</div>`:''}
|
||||
@@ -4216,15 +4372,25 @@ function _csbLibRenderInstalled(libs) {
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function csbLibOpenExample(libName, exName) {
|
||||
async function csbLibOpenExample(libName, exName, isCompat = true, fqbn = '') {
|
||||
_csbLibStatus(`예제 로딩: ${exName}...`);
|
||||
try {
|
||||
const r = await fetch(`/api/arduino/libs/example?lib=${encodeURIComponent(libName)}&example=${encodeURIComponent(exName)}`, { headers: authHeaders() });
|
||||
const fqbnParam = (fqbn || _csbSelBoard?.fqbn || '');
|
||||
const r = await fetch(`/api/arduino/libs/example?lib=${encodeURIComponent(libName)}&example=${encodeURIComponent(exName)}&fqbn=${encodeURIComponent(fqbnParam)}`, { headers: authHeaders() });
|
||||
const data = await r.json();
|
||||
if (data.error) { _csbLibStatus('오류: ' + data.error); return; }
|
||||
_csbLibStatus('');
|
||||
codeNewFile(data.name, data.code);
|
||||
setCodeSbTab('files');
|
||||
if (!isCompat) {
|
||||
// Auto-send to AI for board adaptation
|
||||
const hints = _boardSpecificHints(data.code);
|
||||
const inp = document.getElementById('code-ai-input');
|
||||
if (inp) {
|
||||
inp.value = `이 예제는 ${libName} 라이브러리용이지만 현재 보드(${_csbSelBoard.name}, FQBN: ${_csbSelBoard.fqbn})와 호환되지 않을 수 있어.\n${hints}\n\nRead로 ${data.name}을 읽은 뒤 이 보드에서 동작하도록 코드를 수정해줘. 호환되지 않는 API는 이 보드에 맞는 방식으로 교체해.`;
|
||||
codeAiSend();
|
||||
}
|
||||
}
|
||||
} catch(e) { _csbLibStatus('오류: ' + e.message); }
|
||||
}
|
||||
|
||||
@@ -4246,9 +4412,10 @@ function _csbLibRenderSearch(libs) {
|
||||
el.innerHTML = libs.map(l => `
|
||||
<div style="padding:8px 10px;border-bottom:1px solid var(--line);display:flex;align-items:flex-start;gap:6px">
|
||||
<div style="flex:1;min-width:0">
|
||||
<div style="display:flex;align-items:center;gap:5px">
|
||||
<div style="display:flex;align-items:center;gap:5px;flex-wrap:wrap">
|
||||
<span style="font-size:11px;font-weight:700;color:var(--text)">${_csbEsc(l.name)}</span>
|
||||
${l.category?`<span style="font-size:9px;padding:1px 5px;border:1px solid var(--line);border-radius:3px;color:var(--muted)">${_csbEsc(l.category)}</span>`:''}
|
||||
${_csbArchBadge(l.architectures||[])}
|
||||
</div>
|
||||
<div style="font-size:10px;color:var(--muted);margin-top:1px">${_csbEsc(l.author||'')} · v${_csbEsc(l.version||'?')}</div>
|
||||
${l.sentence?`<div style="font-size:10px;color:var(--muted);margin-top:2px">${_csbEsc(l.sentence)}</div>`:''}
|
||||
@@ -5027,7 +5194,7 @@ function _applyHideBlankLines() {
|
||||
if (l.includes(blockEnd)) inBlock = false;
|
||||
} else if (l === '') {
|
||||
hide = true;
|
||||
} else if (/^(\/\/|#|--|;)/.test(l)) {
|
||||
} else if (/^(\/\/|--|;)/.test(l) || /^#(?!include|define|ifdef|ifndef|endif|else|elif|pragma|undef|error|warning)/.test(l)) {
|
||||
hide = true;
|
||||
} else if (l.startsWith('/*')) {
|
||||
hide = true;
|
||||
|
||||
Reference in New Issue
Block a user