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:
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+8
-4830
File diff suppressed because it is too large
Load Diff
@@ -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