v3.0.3: 아두이노 업로더 통합 — 팝업 제거, 코드 에디터 하단 패널로 이전
- arduino-upload.html 삭제, 모든 로직을 code.js로 이식 - ⚡ 업로드 버튼 → 인라인 패널 오픈 (팝업·sessionStorage 릴레이 제거) - OUTPUT / 옵션 / 시리얼 탭, 드래그 리사이즈, ESP32·AVR·STM32 업로드 - server-v2.ts 중복 /api/settings/paths 라우트 제거 (설정 저장 오류 수정) - 업로드 패널 색상·폰트 개선 (배경 밝게, 12~13px) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,911 +0,0 @@
|
||||
<!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>
|
||||
+61
-5
@@ -282,6 +282,7 @@
|
||||
<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>
|
||||
</div>
|
||||
@@ -301,11 +302,17 @@
|
||||
<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 id="csb-lib-filter-row" style="display:flex;flex-direction:column;padding:4px 6px;gap:3px;border-bottom:1px solid var(--line);flex-shrink:0">
|
||||
<div style="display:flex;align-items:center;gap:4px;flex-wrap:wrap">
|
||||
<span style="font-size:9px;color:var(--muted)">보기:</span>
|
||||
<button id="csb-libv-flat" onclick="csbLibView('flat')" style="font-size:9px;padding:2px 7px;border-radius:3px;border:1px solid var(--brand);background:var(--brand);color:#fff;cursor:pointer;font-family:inherit">목록</button>
|
||||
<button id="csb-libv-category" onclick="csbLibView('category')" 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>
|
||||
<span style="font-size:9px;color:var(--muted);margin-left:4px">보드:</span>
|
||||
<button id="csb-libf-all" onclick="csbLibFilter('all')" style="font-size:9px;padding:2px 7px;border-radius:3px;border:1px solid var(--brand);background:var(--brand);color:#fff;cursor:pointer;font-family:inherit">전체</button>
|
||||
<button id="csb-libf-compat" onclick="csbLibFilter('compat')" 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')" 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-arch-row" style="display:none;flex-wrap:wrap;gap:3px"></div>
|
||||
</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()"
|
||||
@@ -503,6 +510,55 @@
|
||||
</div>
|
||||
<iframe id="code-preview-iframe" src="" style="flex:1;border:none;background:#fff;min-height:0" sandbox="allow-scripts allow-same-origin allow-forms allow-modals allow-popups"></iframe>
|
||||
</div>
|
||||
<!-- ⚡ Upload panel -->
|
||||
<div id="code-upload-panel" style="display:none;flex-direction:column;flex-shrink:0;height:300px;min-height:100px;border-top:2px solid #7a4e20;background:#1e1810;position:relative">
|
||||
<div id="code-upload-drag" style="position:absolute;top:0;left:0;right:0;height:5px;cursor:ns-resize;z-index:2;transition:background .15s"></div>
|
||||
<div style="display:flex;align-items:center;height:32px;background:#2a1e10;border-bottom:1px solid #5a3820;flex-shrink:0;padding:0 8px;gap:6px;overflow-x:auto">
|
||||
<span id="cup-status" style="padding:2px 8px;border-radius:4px;font-size:11px;font-weight:700;background:#3a2810;color:#e09040;flex-shrink:0;white-space:nowrap">대기 중</span>
|
||||
<span id="cup-port-name" style="font-size:12px;color:#c8a060;font-family:'IBM Plex Mono',monospace;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1">포트 없음</span>
|
||||
<button id="cup-tbtn-log" onclick="cupTab('log')" style="background:none;border:none;color:#e89040;cursor:pointer;font-size:12px;font-family:'IBM Plex Mono',monospace;padding:0 8px;height:100%;border-bottom:2px solid #e89040;white-space:nowrap;flex-shrink:0">OUTPUT</button>
|
||||
<button id="cup-tbtn-opts" onclick="cupTab('opts')" style="background:none;border:none;color:#8a7050;cursor:pointer;font-size:12px;font-family:'IBM Plex Mono',monospace;padding:0 8px;height:100%;border-bottom:2px solid transparent;white-space:nowrap;flex-shrink:0">옵션</button>
|
||||
<button id="cup-tbtn-mon" onclick="cupTab('mon')" style="background:none;border:none;color:#8a7050;cursor:pointer;font-size:12px;font-family:'IBM Plex Mono',monospace;padding:0 8px;height:100%;border-bottom:2px solid transparent;white-space:nowrap;flex-shrink:0">시리얼</button>
|
||||
<button onclick="cupSelectPort()" style="background:#3a2818;border:1px solid #6a4820;color:#e89040;border-radius:5px;padding:3px 10px;font-size:12px;cursor:pointer;font-family:'IBM Plex Mono',monospace;flex-shrink:0;white-space:nowrap">🔌 포트</button>
|
||||
<button id="cup-btn-download" onclick="cupDoDownload()" disabled style="background:#3a2818;border:1px solid #4a3820;color:#907050;border-radius:5px;padding:3px 9px;font-size:12px;cursor:pointer;font-family:'IBM Plex Mono',monospace;flex-shrink:0">⬇</button>
|
||||
<button id="cup-btn-upload" onclick="cupDoUpload()" disabled style="background:color-mix(in srgb,#e89040 20%,#2a1e10);border:1px solid #e89040;color:#e89040;border-radius:5px;padding:3px 10px;font-size:12px;cursor:pointer;font-family:'IBM Plex Mono',monospace;font-weight:700;flex-shrink:0;white-space:nowrap">⚡ 업로드</button>
|
||||
<button onclick="toggleUploadPanel()" style="background:none;border:none;color:#8a6840;cursor:pointer;font-size:16px;padding:0 6px;line-height:1;flex-shrink:0">×</button>
|
||||
</div>
|
||||
<progress id="cup-progress" value="0" max="100" style="display:none;width:100%;height:4px;border:none;border-radius:0;flex-shrink:0;accent-color:#e89040"></progress>
|
||||
<!-- OUTPUT tab -->
|
||||
<div id="cup-tab-log" style="flex:1;overflow-y:auto;padding:10px 14px;font-size:13px;line-height:1.8;white-space:pre-wrap;word-break:break-all;font-family:'IBM Plex Mono',monospace"></div>
|
||||
<!-- 옵션 tab -->
|
||||
<div id="cup-tab-opts" style="display:none;flex:1;overflow-y:auto;padding:12px 14px;font-size:13px;font-family:'IBM Plex Mono',monospace">
|
||||
<div id="cup-info-rows" style="display:flex;flex-direction:column;gap:5px;margin-bottom:12px"></div>
|
||||
<div id="cup-board-opts-list"></div>
|
||||
<div id="cup-stm32-hint" style="display:none;margin-top:10px;padding:10px 12px;background:#2a1e10;border:1px solid #5a3820;border-radius:6px;color:#c8a060;font-size:13px;line-height:1.7">STM32 부트로더:<br>① BOOT0=3.3V ② RESET → ⚡ 업로드<br>업로드 후 BOOT0=GND → RESET</div>
|
||||
<div id="cup-uf2-hint" style="display:none;margin-top:10px;padding:10px 12px;background:#2a1e10;border:1px solid #5a3820;border-radius:6px;color:#c8a060;font-size:13px;line-height:1.7">UF2 업로드:<br>① BOOTSEL 누른채 USB 연결<br>② BOOT 드라이브 나타남<br>③ ⬇ 다운로드 후 드라이브에 복사</div>
|
||||
</div>
|
||||
<!-- 시리얼 tab -->
|
||||
<div id="cup-tab-mon" style="display:none;flex-direction:column;flex:1;min-height:0">
|
||||
<div style="display:flex;align-items:center;padding:5px 10px;border-bottom:1px solid #5a3820;flex-shrink:0;gap:7px;flex-wrap:wrap;background:#241a0e">
|
||||
<select id="cup-mon-baud" style="background:#3a2818;border:1px solid #5a3820;color:#c8a060;border-radius:4px;padding:3px 6px;font-size:12px;font-family:'IBM Plex Mono',monospace">
|
||||
<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 id="cup-mon-line-end" style="background:#3a2818;border:1px solid #5a3820;color:#c8a060;border-radius:4px;padding:3px 6px;font-size:12px;font-family:'IBM Plex Mono',monospace">
|
||||
<option value="none">줄끝없음</option><option value="nl">LF</option><option value="cr">CR</option><option value="crnl" selected>CR+LF</option>
|
||||
</select>
|
||||
<button id="cup-mon-btn" onclick="cupMonToggle()" style="background:color-mix(in srgb,#22c55e 15%,#241a0e);border:1px solid color-mix(in srgb,#22c55e 35%,#241a0e);color:#4ade80;border-radius:4px;padding:3px 10px;font-size:12px;cursor:pointer;font-family:'IBM Plex Mono',monospace">▶ 연결</button>
|
||||
<label style="display:flex;align-items:center;gap:4px;font-size:12px;color:#907050;cursor:pointer"><input type="checkbox" id="cup-mon-ts"> 시각</label>
|
||||
<label style="display:flex;align-items:center;gap:4px;font-size:12px;color:#907050;cursor:pointer"><input type="checkbox" id="cup-mon-auto" checked> 자동스크롤</label>
|
||||
<button onclick="cupMonClear()" style="background:none;border:none;color:#907050;cursor:pointer;font-size:12px;font-family:'IBM Plex Mono',monospace">지우기</button>
|
||||
</div>
|
||||
<div id="cup-mon-output" style="flex:1;overflow-y:auto;padding:8px 14px;font-size:13px;line-height:1.7;font-family:'IBM Plex Mono',monospace"></div>
|
||||
<div style="display:flex;gap:7px;padding:7px 10px;border-top:1px solid #3a2818;background:#1e1408;flex-shrink:0">
|
||||
<input type="text" id="cup-mon-input" placeholder="전송할 데이터..." disabled
|
||||
onkeydown="if(event.key==='Enter')cupMonSend()"
|
||||
style="flex:1;background:#2a1e10;border:1px solid #5a3820;color:#e8d0a0;border-radius:5px;padding:6px 10px;font-size:13px;font-family:'IBM Plex Mono',monospace;outline:none">
|
||||
<button onclick="cupMonSend()" style="background:color-mix(in srgb,#c8a060 20%,#1e1408);border:1px solid #8a6840;color:#c8a060;border-radius:5px;padding:5px 14px;font-size:13px;cursor:pointer;font-family:'IBM Plex Mono',monospace">전송</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
+750
-52
@@ -1004,13 +1004,7 @@ function csbUploadToBoard() {
|
||||
if (!file || !file.name.endsWith('.ino')) {
|
||||
codeAiAppend('system', '⚠️ .ino 파일을 선택해주세요'); return;
|
||||
}
|
||||
const content = file.model ? file.model.getValue() : (file.content || '');
|
||||
sessionStorage.setItem('arduino_upload_code', content);
|
||||
sessionStorage.setItem('arduino_upload_fqbn', _csbSelBoard.fqbn);
|
||||
sessionStorage.setItem('arduino_upload_board', _csbSelBoard.name);
|
||||
sessionStorage.setItem('arduino_upload_file', file.name);
|
||||
const w = window.open('/arduino-upload.html', '_blank');
|
||||
if (!w) codeAiAppend('system', '⚠️ 팝업 차단됨');
|
||||
cupOpenWith(file);
|
||||
}
|
||||
|
||||
function codePreviewRefresh() {
|
||||
@@ -4226,9 +4220,26 @@ function setCodeSbTab(tab) {
|
||||
// ── 라이브러리/보드 매니저 (code editor sidebar) ──────────────────────────────
|
||||
let _csbLibCurrentTab = 'installed';
|
||||
let _csbLibFilterMode = 'all'; // 'all' | 'compat' | 'incompat'
|
||||
let _csbLibViewMode = 'flat'; // 'flat' | 'category'
|
||||
let _csbLibArchFilter = ''; // '' = 전체, 'esp32' | 'esp8266' | 'avr' ...
|
||||
let _csbLibCache = [];
|
||||
let _csbArdSection = 'lib';
|
||||
|
||||
const _CAT_KO = {
|
||||
'Communication': '통신',
|
||||
'Data Processing': '데이터 처리',
|
||||
'Data Storage': '데이터 저장',
|
||||
'Device Control': '장치 제어',
|
||||
'Display': '디스플레이',
|
||||
'Encryption': '암호화',
|
||||
'Imaging': '이미징',
|
||||
'Other': '기타',
|
||||
'Sensors': '센서',
|
||||
'Signal Input/Output':'신호 입출력',
|
||||
'Timing': '타이밍',
|
||||
'Uncategorized': '미분류',
|
||||
};
|
||||
|
||||
function csbArdSection(sec) {
|
||||
_csbArdSection = sec;
|
||||
['lib','board'].forEach(s => {
|
||||
@@ -4269,6 +4280,48 @@ function csbLibFilter(mode) {
|
||||
_csbLibRenderInstalled(_csbLibCache);
|
||||
}
|
||||
|
||||
function csbLibView(mode) {
|
||||
_csbLibViewMode = mode;
|
||||
['flat','category'].forEach(m => {
|
||||
const btn = document.getElementById('csb-libv-' + 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 _csbLibSetArch(arch) {
|
||||
_csbLibArchFilter = arch;
|
||||
_csbLibUpdateArchChips(_csbLibCache);
|
||||
_csbLibRenderInstalled(_csbLibCache);
|
||||
}
|
||||
|
||||
function _csbLibUpdateArchChips(libs) {
|
||||
const archRow = document.getElementById('csb-lib-arch-row');
|
||||
if (!archRow) return;
|
||||
// Count non-wildcard archs
|
||||
const archCount = {};
|
||||
for (const l of libs) {
|
||||
for (const a of (l.architectures || [])) {
|
||||
if (a !== '*') archCount[a] = (archCount[a] || 0) + 1;
|
||||
}
|
||||
}
|
||||
// Show archs with ≥2 libs, sorted by count desc
|
||||
const major = Object.entries(archCount).filter(([,c]) => c >= 2).sort((a,b) => b[1]-a[1]).map(([a]) => a);
|
||||
if (!major.length) { archRow.style.display = 'none'; return; }
|
||||
const all = ['', ...major];
|
||||
archRow.innerHTML = all.map(a => {
|
||||
const label = a ? a.toUpperCase() : '전체';
|
||||
const cnt = a ? (archCount[a] || 0) : libs.length;
|
||||
const active = _csbLibArchFilter === a;
|
||||
return `<button onclick="_csbLibSetArch('${a}')" style="font-size:9px;padding:1px 6px;border-radius:3px;border:1px solid ${active?'var(--brand)':'var(--line)'};background:${active?'var(--brand)':'var(--panel-2)'};color:${active?'#fff':'var(--muted)'};cursor:pointer;font-family:inherit" title="${cnt}개">${label}</button>`;
|
||||
}).join('');
|
||||
archRow.style.display = 'flex';
|
||||
}
|
||||
|
||||
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,'''); }
|
||||
@@ -4280,6 +4333,8 @@ async function csbLibLoad() {
|
||||
const r = await fetch('/api/arduino/libs?all=1' + fqbn, { headers: authHeaders() });
|
||||
const data = await r.json();
|
||||
_csbLibCache = data.libs || [];
|
||||
_csbLibArchFilter = '';
|
||||
_csbLibUpdateArchChips(_csbLibCache);
|
||||
_csbLibRenderInstalled(_csbLibCache);
|
||||
} catch { _csbLibListEl().innerHTML = '<div style="padding:16px;color:var(--muted);font-size:11px;text-align:center">오류</div>'; }
|
||||
}
|
||||
@@ -4308,70 +4363,238 @@ 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; }
|
||||
|
||||
// Apply filter
|
||||
// Apply board compat 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);
|
||||
// Apply architecture filter
|
||||
if (_csbLibArchFilter) {
|
||||
filtered = filtered.filter(l => {
|
||||
const archs = l.architectures || [];
|
||||
return archs.includes('*') || archs.length === 0 || archs.includes(_csbLibArchFilter) ||
|
||||
archs.some(a => a.startsWith(_csbLibArchFilter) || _csbLibArchFilter.startsWith(a));
|
||||
});
|
||||
}
|
||||
|
||||
// Dedup by name when a specific arch or compat filter is active
|
||||
// Priority: user-installed > platform, specific arch > wildcard *, newer version
|
||||
if (_csbLibArchFilter || _csbLibFilterMode === 'compat') {
|
||||
const byName = new Map();
|
||||
for (const l of filtered) {
|
||||
if (!byName.has(l.name)) { byName.set(l.name, []); }
|
||||
byName.get(l.name).push(l);
|
||||
}
|
||||
filtered = [...byName.values()].map(group => group.reduce((best, l) => {
|
||||
if (l.location === 'user' && best.location !== 'user') return l;
|
||||
if (best.location === 'user' && l.location !== 'user') return best;
|
||||
const lWild = !(l.architectures||[]).length || (l.architectures||[]).includes('*');
|
||||
const bWild = !(best.architectures||[]).length || (best.architectures||[]).includes('*');
|
||||
if (!lWild && bWild) return l;
|
||||
if (lWild && !bWild) return best;
|
||||
return (l.version||'') >= (best.version||'') ? l : best;
|
||||
}));
|
||||
}
|
||||
|
||||
// Sort: platform/core first, then compatible, then name
|
||||
filtered = [...filtered].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}과 호환되는 라이브러리 없음` : '비호환 라이브러리 없음';
|
||||
const msg = _csbLibFilterMode === 'compat' ? `${_csbSelBoard.name}과 호환되는 라이브러리 없음`
|
||||
: _csbLibArchFilter ? `${_csbLibArchFilter.toUpperCase()} 라이브러리 없음`
|
||||
: '비호환 라이브러리 없음';
|
||||
el.innerHTML = `<div style="padding:16px;color:var(--muted);font-size:11px;text-align:center">${msg}</div>`;
|
||||
return;
|
||||
}
|
||||
|
||||
if (_csbLibViewMode === 'category') { _csbLibRenderGrouped(filtered); 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)}',${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)${!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="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>`:''}
|
||||
${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>`}
|
||||
el.innerHTML = _csbBuiltinCard() + filtered.map(l => _csbLibCard(l, fqbn)).join('');
|
||||
}
|
||||
|
||||
function _csbLibCard(l, fqbn) {
|
||||
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)}',${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)${!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="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>
|
||||
${hasEx?`<div id="${sid}" style="display:none;margin-top:5px;padding:5px 8px;background:var(--panel-2);border-radius:5px">${exRows}</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>`:''}
|
||||
${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>`:''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _csbLibRenderGrouped(libs) {
|
||||
const el = _csbLibListEl();
|
||||
const fqbn = _csbSelBoard?.fqbn || '';
|
||||
// Group by category
|
||||
const groups = {};
|
||||
for (const l of libs) {
|
||||
const cat = l.category || 'Uncategorized';
|
||||
if (!groups[cat]) groups[cat] = [];
|
||||
groups[cat].push(l);
|
||||
}
|
||||
// Sort categories: put Uncategorized/Other last
|
||||
const last = new Set(['Uncategorized', 'Other', '']);
|
||||
const sorted = Object.keys(groups).sort((a,b) => {
|
||||
if (last.has(a) && !last.has(b)) return 1;
|
||||
if (!last.has(a) && last.has(b)) return -1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
el.innerHTML = _csbBuiltinCard() + sorted.map(cat => {
|
||||
const ko = _CAT_KO[cat] || cat;
|
||||
const gid = 'csbg_' + cat.replace(/\W/g,'_');
|
||||
const cards = groups[cat].map(l => _csbLibCard(l, fqbn)).join('');
|
||||
const cnt = groups[cat].length;
|
||||
return `<div>
|
||||
<div onclick="const b=document.getElementById('${gid}');b.style.display=b.style.display==='none'?'block':'none'"
|
||||
style="display:flex;align-items:center;gap:6px;padding:6px 10px;cursor:pointer;background:var(--panel-2);border-bottom:1px solid var(--line);position:sticky;top:0;z-index:1">
|
||||
<span style="font-size:11px;font-weight:700;color:var(--text)">${_csbEsc(ko)}</span>
|
||||
${cat !== ko ? `<span style="font-size:9px;color:var(--muted)">${_csbEsc(cat)}</span>` : ''}
|
||||
<span style="font-size:10px;color:var(--muted);margin-left:auto">${cnt}개</span>
|
||||
</div>
|
||||
<div id="${gid}">${cards}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
const _BUILTIN_EX = [
|
||||
{ name:'Blink.ino', desc:'LED 깜빡이기', code:
|
||||
`void setup() {
|
||||
pinMode(LED_BUILTIN, OUTPUT);
|
||||
}
|
||||
|
||||
void loop() {
|
||||
digitalWrite(LED_BUILTIN, HIGH);
|
||||
delay(1000);
|
||||
digitalWrite(LED_BUILTIN, LOW);
|
||||
delay(1000);
|
||||
}` },
|
||||
{ name:'Button.ino', desc:'버튼으로 LED 제어', code:
|
||||
`const int BTN = 0;
|
||||
const int LED = LED_BUILTIN;
|
||||
|
||||
void setup() {
|
||||
pinMode(BTN, INPUT_PULLUP);
|
||||
pinMode(LED, OUTPUT);
|
||||
}
|
||||
|
||||
void loop() {
|
||||
digitalWrite(LED, digitalRead(BTN) == LOW ? HIGH : LOW);
|
||||
}` },
|
||||
{ name:'AnalogRead.ino', desc:'아날로그 읽기', code:
|
||||
`void setup() {
|
||||
Serial.begin(115200);
|
||||
}
|
||||
|
||||
void loop() {
|
||||
int val = analogRead(A0);
|
||||
Serial.println(val);
|
||||
delay(200);
|
||||
}` },
|
||||
{ name:'Fade.ino', desc:'LED 밝기 조절 (PWM)', code:
|
||||
`const int LED = LED_BUILTIN;
|
||||
int brightness = 0, step = 5;
|
||||
|
||||
void setup() {
|
||||
pinMode(LED, OUTPUT);
|
||||
}
|
||||
|
||||
void loop() {
|
||||
analogWrite(LED, brightness);
|
||||
brightness += step;
|
||||
if (brightness <= 0 || brightness >= 255) step = -step;
|
||||
delay(15);
|
||||
}` },
|
||||
{ name:'BlinkWithoutDelay.ino', desc:'딜레이 없는 깜빡이기', code:
|
||||
`const int LED = LED_BUILTIN;
|
||||
bool state = false;
|
||||
unsigned long prev = 0;
|
||||
|
||||
void setup() {
|
||||
pinMode(LED, OUTPUT);
|
||||
}
|
||||
|
||||
void loop() {
|
||||
unsigned long now = millis();
|
||||
if (now - prev >= 1000) {
|
||||
prev = now;
|
||||
state = !state;
|
||||
digitalWrite(LED, state);
|
||||
}
|
||||
}` },
|
||||
{ name:'SerialHello.ino', desc:'시리얼 출력', code:
|
||||
`void setup() {
|
||||
Serial.begin(115200);
|
||||
Serial.println("Hello, World!");
|
||||
}
|
||||
|
||||
void loop() {
|
||||
if (Serial.available()) {
|
||||
String s = Serial.readStringUntil('\\n');
|
||||
Serial.print("받음: ");
|
||||
Serial.println(s);
|
||||
}
|
||||
}` },
|
||||
];
|
||||
|
||||
function csbLibOpenBuiltin(idx) {
|
||||
const ex = _BUILTIN_EX[idx];
|
||||
if (!ex) return;
|
||||
codeNewFile(ex.name, ex.code);
|
||||
setCodeSbTab('files');
|
||||
}
|
||||
|
||||
function _csbBuiltinCard() {
|
||||
const rows = _BUILTIN_EX.map((ex, i) =>
|
||||
`<div style="display:flex;align-items:center;padding:3px 0;gap:6px">
|
||||
<span style="font-size:10px;color:var(--muted);flex:1">📄 ${_csbEsc(ex.desc)}</span>
|
||||
<button onclick="csbLibOpenBuiltin(${i})"
|
||||
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>
|
||||
</div>`).join('');
|
||||
return `<div style="padding:8px 10px;border-bottom:2px solid var(--line);border-left:3px solid #facc15">
|
||||
<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px">
|
||||
<span style="font-size:11px;font-weight:700;color:var(--text)">Arduino 기본 예제</span>
|
||||
<span style="font-size:9px;padding:1px 5px;border-radius:3px;background:#2a2010;color:#facc15;border:1px solid #4a3a10">기본</span>
|
||||
</div>
|
||||
<div style="margin-top:4px;padding:5px 8px;background:var(--panel-2);border-radius:5px">${rows}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function csbLibOpenExample(libName, exName, isCompat = true, fqbn = '') {
|
||||
_csbLibStatus(`예제 로딩: ${exName}...`);
|
||||
try {
|
||||
@@ -5596,6 +5819,481 @@ _esp32CodeChannel.onmessage = (e) => {
|
||||
_esp32SyncBusy = false;
|
||||
};
|
||||
|
||||
// ── Upload Panel (⚡) ─────────────────────────────────────────────────────────
|
||||
let _cupOpen = false;
|
||||
let _cupPort = null;
|
||||
let _cupBusy = false;
|
||||
let _cupCode = '', _cupFqbn = '', _cupBoardName = '', _cupFname = '';
|
||||
let _cupCompiledData = null;
|
||||
let _cupBoardOptions = [];
|
||||
let _cupSelOptions = {};
|
||||
let _cupMonActive = false;
|
||||
let _cupMonReader = null;
|
||||
let _cupMonWriter = null;
|
||||
let _cupMonBuf = '';
|
||||
const _cupDecoder = new TextDecoder();
|
||||
|
||||
function toggleUploadPanel() {
|
||||
const panel = document.getElementById('code-upload-panel');
|
||||
if (!panel) return;
|
||||
_cupOpen = !_cupOpen;
|
||||
panel.style.display = _cupOpen ? 'flex' : 'none';
|
||||
const btn = document.getElementById('code-upload-btn');
|
||||
if (btn) btn.classList.toggle('active', _cupOpen);
|
||||
if (monacoEditor) setTimeout(() => monacoEditor.layout(), 60);
|
||||
}
|
||||
|
||||
function cupOpenWith(file) {
|
||||
_cupCode = file.model ? file.model.getValue() : (file.content || '');
|
||||
_cupFqbn = _csbSelBoard?.fqbn || 'arduino:avr:uno';
|
||||
_cupBoardName = _csbSelBoard?.name || _cupFqbn;
|
||||
_cupFname = file.name;
|
||||
_cupSelOptions = {};
|
||||
const panel = document.getElementById('code-upload-panel');
|
||||
if (!_cupOpen) {
|
||||
_cupOpen = true;
|
||||
panel.style.display = 'flex';
|
||||
const btn = document.getElementById('code-upload-btn');
|
||||
if (btn) btn.classList.add('active');
|
||||
if (monacoEditor) setTimeout(() => monacoEditor.layout(), 60);
|
||||
}
|
||||
cupTab('log');
|
||||
document.getElementById('cup-tab-log').innerHTML = '';
|
||||
const method = _cupGetUploadMethod(_cupFqbn);
|
||||
_cupUpdateInfoRows();
|
||||
document.getElementById('cup-stm32-hint').style.display = method === 'stm32-uart' ? '' : 'none';
|
||||
document.getElementById('cup-uf2-hint').style.display = method === 'download-uf2' ? '' : 'none';
|
||||
document.getElementById('cup-port-name').textContent = _cupPort ? '✓ 포트 선택됨' : '포트 없음';
|
||||
document.getElementById('cup-btn-upload').disabled = !_cupPort;
|
||||
document.getElementById('cup-btn-download').disabled = !_cupPort;
|
||||
cupLog(`보드: ${_cupBoardName}`, 'info');
|
||||
cupLog(`방식: ${_cupMethodLabel(method)}`, 'info');
|
||||
if (method === 'stm32-uart') cupLog('STM32: BOOT0=HIGH 후 RESET → ⚡ 업로드', 'warn');
|
||||
else if (method === 'avr-stk500') cupLog('AVR: 포트 선택 후 ⚡ 업로드 (자동 DTR 리셋)', 'info');
|
||||
else if (method === 'download-uf2' || method === 'download-bin') cupLog('이 보드는 ⬇ 다운로드 후 수동 복사 방식입니다.', 'warn');
|
||||
else if (method === 'esp32-flash') cupLog('ESP32/ESP8266: 🔌 포트 선택 후 ⚡ 업로드\n 컴파일: 서버 | 플래싱: 브라우저 esptool-js', 'info');
|
||||
if (!navigator.serial) cupLog('⚠ WebSerial 미지원 브라우저입니다. Chrome/Edge를 사용하세요.', 'warn');
|
||||
_cupLoadBoardOptions();
|
||||
}
|
||||
|
||||
function cupTab(name) {
|
||||
const tabs = { log: 'block', opts: 'block', mon: 'flex' };
|
||||
Object.keys(tabs).forEach(t => {
|
||||
const p = document.getElementById('cup-tab-' + t);
|
||||
const b = document.getElementById('cup-tbtn-' + t);
|
||||
if (p) p.style.display = t === name ? tabs[t] : 'none';
|
||||
if (b) { b.style.color = t === name ? '#e89040' : '#8a7050'; b.style.borderBottom = t === name ? '2px solid #e89040' : '2px solid transparent'; }
|
||||
});
|
||||
}
|
||||
|
||||
function cupLog(msg, cls = 'info') {
|
||||
const out = document.getElementById('cup-tab-log');
|
||||
if (!out) return;
|
||||
const colors = { info: '#9aacb8', ok: '#5dc48a', err: '#e07070', prog: '#80a8e0', warn: '#e8c860' };
|
||||
const span = document.createElement('span');
|
||||
span.style.color = colors[cls] || '#6a7880';
|
||||
span.style.display = 'block';
|
||||
span.textContent = msg;
|
||||
out.appendChild(span);
|
||||
out.scrollTop = out.scrollHeight;
|
||||
}
|
||||
|
||||
function _cupSetStatus(cls, text) {
|
||||
const el = document.getElementById('cup-status');
|
||||
if (!el) return;
|
||||
const styles = { idle:{ bg:'#2a1806', color:'#6a5030' }, ready:{ bg:'#1a3020', color:'#4d9e74' }, compiling:{ bg:'#2a1e08', color:'#ccaa50' }, uploading:{ bg:'#2a1208', color:'#c87d30' }, done:{ bg:'#1a3020', color:'#4d9e74' }, error:{ bg:'#2a1010', color:'#c46060' } };
|
||||
const s = styles[cls] || styles.idle;
|
||||
el.style.background = s.bg; el.style.color = s.color; el.textContent = text;
|
||||
}
|
||||
|
||||
function _cupSetProgress(pct) {
|
||||
const bar = document.getElementById('cup-progress');
|
||||
if (!bar) return;
|
||||
if (pct == null) { bar.style.display = 'none'; return; }
|
||||
bar.style.display = 'block'; bar.value = pct;
|
||||
}
|
||||
|
||||
function _cupGetUploadMethod(fqbn) {
|
||||
const arch = fqbn.split(':')[1] || '', 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 _cupMethodLabel(m) {
|
||||
return { 'avr-stk500':'STK500 (WebSerial)', 'stm32-uart':'STM32 UART', 'download-bin':'.bin 다운로드', 'download-uf2':'UF2 드래그&드롭', 'esp32-flash':'esptool-js (브라우저)' }[m] || m;
|
||||
}
|
||||
|
||||
function _cupGetEffectiveFqbn() {
|
||||
const keys = Object.keys(_cupSelOptions);
|
||||
if (!keys.length) return _cupFqbn;
|
||||
return _cupFqbn + ':' + keys.map(k => k + '=' + _cupSelOptions[k]).join(',');
|
||||
}
|
||||
|
||||
function _cupUpdateInfoRows() {
|
||||
const el = document.getElementById('cup-info-rows');
|
||||
if (!el) return;
|
||||
const method = _cupGetUploadMethod(_cupFqbn);
|
||||
el.innerHTML = [['방식', _cupMethodLabel(method)], ['보드', _cupBoardName], ['FQBN', _cupGetEffectiveFqbn()], ['파일', _cupFname]]
|
||||
.map(([k, v]) => `<div style="display:flex;gap:10px;font-size:13px"><span style="color:#907050;min-width:40px">${k}</span><span style="color:#c8a060;word-break:break-all">${v}</span></div>`).join('');
|
||||
}
|
||||
|
||||
async function _cupLoadBoardOptions() {
|
||||
const baseFqbn = _cupFqbn.split(':').slice(0, 3).join(':');
|
||||
try {
|
||||
const r = await fetch(`/api/arduino/board/options?fqbn=${encodeURIComponent(baseFqbn)}`, { credentials: 'include', headers: authHeaders() });
|
||||
const d = await r.json();
|
||||
_cupBoardOptions = d.options || []; _cupSelOptions = {};
|
||||
_cupRenderBoardOptions();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function _cupRenderBoardOptions() {
|
||||
const list = document.getElementById('cup-board-opts-list');
|
||||
if (!list) return;
|
||||
if (!_cupBoardOptions.length) { list.innerHTML = ''; return; }
|
||||
list.innerHTML = '<div style="font-size:12px;font-weight:700;color:#907050;margin-bottom:8px;text-transform:uppercase;letter-spacing:.05em">보드 옵션</div>' +
|
||||
_cupBoardOptions.map(o => {
|
||||
const sel = _cupSelOptions[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('');
|
||||
const optId = JSON.stringify(o.id), defVal = o.values.find(v => v.selected)?.value ?? o.values[0]?.value ?? '';
|
||||
return `<div style="display:flex;align-items:center;gap:8px;padding:4px 0"><span style="color:#907050;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px" title="${o.label}">${o.label}</span><select style="background:#3a2818;border:1px solid #5a3820;color:#c8a060;border-radius:4px;padding:3px 6px;font-size:12px;font-family:'IBM Plex Mono',monospace;flex:1.4" onchange="if(this.value!==this.dataset.def)_cupSelOptions[${optId}]=this.value;else delete _cupSelOptions[${optId}];_cupUpdateInfoRows()" data-def="${defVal}">${opts}</select></div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function cupSelectPort() {
|
||||
if (!navigator.serial) { cupLog('❌ WebSerial 미지원. Chrome/Edge를 사용하세요.', 'err'); return; }
|
||||
try {
|
||||
_cupPort = await navigator.serial.requestPort();
|
||||
const info = _cupPort.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('cup-port-name').textContent = '✓ ' + label;
|
||||
document.getElementById('cup-btn-upload').disabled = false;
|
||||
document.getElementById('cup-btn-download').disabled = false;
|
||||
cupLog(`포트 선택됨: ${label}`, 'ok');
|
||||
_cupSetStatus('ready', '준비됨');
|
||||
} catch(e) { if (e.name !== 'NotFoundError') cupLog('포트 선택 오류: ' + e.message, 'err'); }
|
||||
}
|
||||
|
||||
async function _cupCompile() {
|
||||
const arch = _cupFqbn.split(':')[1] || '';
|
||||
if (/ESP8266WiFi|ESP8266WebServer|ESP8266HTTPClient/i.test(_cupCode) && arch !== 'esp8266') {
|
||||
_cupFqbn = 'esp8266:esp8266:nodemcuv2'; _cupSelOptions = {};
|
||||
cupLog(`⚠ ESP8266 감지 → FQBN 자동 변경: ${_cupFqbn}`, 'warn'); _cupUpdateInfoRows(); _cupLoadBoardOptions();
|
||||
} else if (/^#include\s+<WiFi\.h>/m.test(_cupCode) && arch !== 'esp32' && arch !== 'esp8266') {
|
||||
_cupFqbn = 'esp32:esp32:esp32'; _cupSelOptions = {};
|
||||
cupLog(`⚠ ESP32 WiFi 감지 → FQBN 자동 변경: ${_cupFqbn}`, 'warn'); _cupUpdateInfoRows(); _cupLoadBoardOptions();
|
||||
}
|
||||
cupLog('서버에서 컴파일 중...', 'prog'); _cupSetStatus('compiling', '컴파일 중...');
|
||||
const r = await fetch('/api/arduino/compile', {
|
||||
method: 'POST', credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json', ...authHeaders() },
|
||||
body: JSON.stringify({ code: _cupCode, fqbn: _cupGetEffectiveFqbn() }),
|
||||
});
|
||||
const data = await r.json();
|
||||
if (data.error) { cupLog('컴파일 오류:\n' + data.error, 'err'); _cupSetStatus('error', '컴파일 실패'); return null; }
|
||||
const kb = data.size ? ` (${(data.size/1024).toFixed(1)} KB)` : '';
|
||||
cupLog(`✅ 컴파일 완료${kb}`, 'ok');
|
||||
return data;
|
||||
}
|
||||
|
||||
async function cupDoDownload() {
|
||||
cupTab('log');
|
||||
const data = await _cupCompile(); if (!data) return;
|
||||
_cupCompiledData = data;
|
||||
const isHex = !!data.hex;
|
||||
const content = isHex ? data.hex : _cupBase64ToBytes(data.bin || data.merged);
|
||||
const ext = data.boardType === 'uf2' ? '.uf2' : (isHex ? '.hex' : '.bin');
|
||||
const blob = new Blob([content], { type: isHex ? 'text/plain' : 'application/octet-stream' });
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = _cupFname.replace(/\.[^.]+$/, '') + ext;
|
||||
a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 5000);
|
||||
cupLog(`다운로드: ${a.download}`, 'ok'); _cupSetStatus('done', '다운로드됨');
|
||||
}
|
||||
|
||||
async function cupDoUpload() {
|
||||
if (_cupBusy) return;
|
||||
if (!_cupPort) { cupLog('포트를 먼저 선택하세요 (🔌 포트 버튼)', 'err'); return; }
|
||||
cupTab('log');
|
||||
const method = _cupGetUploadMethod(_cupGetEffectiveFqbn());
|
||||
if (method === 'esp32-flash') { await _cupUploadESP32(); return; }
|
||||
if (method === 'download-uf2' || method === 'download-bin') { await cupDoDownload(); return; }
|
||||
_cupBusy = true;
|
||||
document.getElementById('cup-btn-upload').disabled = true;
|
||||
document.getElementById('cup-btn-download').disabled = true;
|
||||
document.getElementById('cup-tab-log').innerHTML = '';
|
||||
_cupSetProgress(0);
|
||||
try {
|
||||
const data = await _cupCompile(); if (!data) return;
|
||||
_cupCompiledData = data;
|
||||
cupLog(`\n--- 업로드 시작 [${_cupBoardName}] ---`, 'prog'); _cupSetStatus('uploading', '업로드 중...'); _cupSetProgress(5);
|
||||
if (method === 'avr-stk500') await _cupUploadAVR(data.hex);
|
||||
else if (method === 'stm32-uart') await _cupUploadSTM32(_cupBase64ToBytes(data.bin));
|
||||
} catch(e) { cupLog('❌ 오류: ' + e.message, 'err'); _cupSetStatus('error', '업로드 실패'); _cupSetProgress(null); }
|
||||
finally { _cupBusy = false; document.getElementById('cup-btn-upload').disabled = false; document.getElementById('cup-btn-download').disabled = false; }
|
||||
}
|
||||
|
||||
async function _cupUploadESP32() {
|
||||
_cupBusy = true;
|
||||
document.getElementById('cup-btn-upload').disabled = true;
|
||||
document.getElementById('cup-btn-download').disabled = true;
|
||||
document.getElementById('cup-tab-log').innerHTML = '';
|
||||
_cupSetProgress(0);
|
||||
let transport = null, success = false;
|
||||
try {
|
||||
const data = await _cupCompile(); if (!data) return;
|
||||
_cupCompiledData = data;
|
||||
const b64 = data.merged || data.bin;
|
||||
if (!b64) throw new Error('컴파일 결과에 바이너리가 없습니다');
|
||||
cupLog('esptool-js 로딩 중...', 'prog');
|
||||
const { ESPLoader, Transport, HardReset } = await import('/esptool-bundle.js');
|
||||
cupLog('보드 연결 중 (자동 리셋)...', 'prog'); _cupSetStatus('uploading', '연결 중...');
|
||||
transport = new Transport(_cupPort, true);
|
||||
const terminal = { clean:()=>{}, writeLine:m=>{ if(m.trim()) cupLog(m,'info'); }, write:m=>{ if(m.trim()) cupLog(m,'info'); } };
|
||||
const esploader = new ESPLoader({ transport, baudrate:115200, romBaudrate:115200, enableTracing:false, terminal });
|
||||
let chip;
|
||||
try { chip = await esploader.main(); }
|
||||
catch(e) { cupLog('⚠ 자동 리셋 실패 — BOOT 누른채 RST → BOOT 놓기 후 재시도.', 'warn'); throw e; }
|
||||
cupLog(`칩 감지: ${chip}`, 'ok'); _cupSetProgress(10); _cupSetStatus('uploading', '업로드 중...');
|
||||
const binData = _cupBase64ToBytes(b64);
|
||||
let _t0 = Date.now(), _w0 = 0;
|
||||
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); _cupSetProgress(10 + pct*0.88);
|
||||
const now = Date.now();
|
||||
if (_w0===0 && written>0) { _t0=now; _w0=written; }
|
||||
const kbps = (now-_t0)>200 ? ((written-_w0)/1024/((now-_t0)/1000)).toFixed(1) : '...';
|
||||
document.getElementById('cup-port-name').textContent = `플래싱 ${(written/1024).toFixed(1)}/${(total/1024).toFixed(1)} KB (${pct}%) · ${kbps} KB/s`;
|
||||
},
|
||||
});
|
||||
try { await new HardReset(transport).reset(); } catch {}
|
||||
cupLog('✅ 업로드 완료! 보드가 재시작됩니다.', 'ok'); _cupSetStatus('done', '완료'); _cupSetProgress(100); success = true;
|
||||
} catch(e) { cupLog('❌ 오류: ' + e.message, 'err'); _cupSetStatus('error', '업로드 실패'); _cupSetProgress(null); }
|
||||
finally {
|
||||
try { if (transport) await transport.disconnect(); } catch {}
|
||||
_cupBusy = false;
|
||||
document.getElementById('cup-btn-upload').disabled = false;
|
||||
document.getElementById('cup-btn-download').disabled = false;
|
||||
document.getElementById('cup-port-name').textContent = _cupPort ? '✓ 포트 선택됨' : '포트 없음';
|
||||
setTimeout(() => _cupSetProgress(null), 3000);
|
||||
}
|
||||
if (success) {
|
||||
cupTab('mon'); _cupMonAppendSys('[업로드 완료 — 시리얼 모니터 자동 연결 중...]');
|
||||
await new Promise(r => setTimeout(r, 1500));
|
||||
await cupMonOpen();
|
||||
}
|
||||
}
|
||||
|
||||
function _cupBase64ToBytes(b64) {
|
||||
const raw = atob(b64), out = new Uint8Array(raw.length);
|
||||
for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
function _cupParseIntelHex(hexText) {
|
||||
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, pg=a>>7, off=a&0x7f; if (!pages.has(pg)) pages.set(pg,new Uint8Array(128).fill(0xff)); pages.get(pg)[off]=bytes[4+i]; }
|
||||
}
|
||||
return pages;
|
||||
}
|
||||
|
||||
async function _cupWrite(writer, data) { await writer.write(data instanceof Uint8Array ? data : new Uint8Array(Array.isArray(data) ? data : [data])); }
|
||||
|
||||
async function _cupRead(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}B 중 ${pos}B)`);
|
||||
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;
|
||||
}
|
||||
|
||||
async function _cupUploadAVR(hexText) {
|
||||
const INSYNC=0x14,OK=0x10,EOP=0x20,CMD_SYNC=0x30,CMD_SET_DEV=0x42,CMD_ENTER=0x50,CMD_LEAVE=0x51,CMD_ADDR=0x55,CMD_PROG=0x64;
|
||||
await _cupPort.open({ baudRate:115200, dataBits:8, stopBits:1, parity:'none' });
|
||||
const reader=_cupPort.readable.getReader(), writer=_cupPort.writable.getWriter();
|
||||
const cmdOk = async (bytes,extra=0,ms=3000) => {
|
||||
await _cupWrite(writer, new Uint8Array([...bytes,EOP]));
|
||||
const r=await _cupRead(reader,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 {
|
||||
if (_cupPort.setSignals) {
|
||||
await _cupPort.setSignals({ dataTerminalReady:false, requestToSend:false });
|
||||
await new Promise(r=>setTimeout(r,100));
|
||||
await _cupPort.setSignals({ dataTerminalReady:true, requestToSend:true });
|
||||
await new Promise(r=>setTimeout(r,50));
|
||||
await _cupPort.setSignals({ dataTerminalReady:false });
|
||||
}
|
||||
await new Promise(r=>setTimeout(r,250));
|
||||
cupLog('동기화 중...', 'prog');
|
||||
let synced=false;
|
||||
for (let i=0; i<12&&!synced; i++) { try { await _cupWrite(writer,[CMD_SYNC,EOP]); const r=await _cupRead(reader,2,300); if(r[0]===INSYNC&&r[1]===OK) synced=true; } catch {} }
|
||||
if (!synced) throw new Error('보드에 연결할 수 없습니다. 케이블/DTR 리셋을 확인하세요.');
|
||||
cupLog('✓ 동기화됨', 'ok');
|
||||
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]);
|
||||
await cmdOk([CMD_ENTER]); cupLog('✓ 프로그래밍 모드 진입', 'ok');
|
||||
const pages=_cupParseIntelHex(hexText), sorted=[...pages.entries()].sort((a,b)=>a[0]-b[0]);
|
||||
let done=0;
|
||||
for (const [pg, pgData] of sorted) {
|
||||
const waddr=pg<<6; await cmdOk([CMD_ADDR,waddr&0xff,(waddr>>8)&0xff]); await cmdOk([CMD_PROG,0x00,0x80,0x46,...pgData]);
|
||||
done++; _cupSetProgress(5+Math.round(done/sorted.length*90));
|
||||
document.getElementById('cup-port-name').textContent = `업로드 중... ${done}/${sorted.length} 페이지`;
|
||||
}
|
||||
await cmdOk([CMD_LEAVE]); _cupSetProgress(100); cupLog(`✅ 업로드 완료! (${sorted.length} 페이지)`, 'ok');
|
||||
_cupSetStatus('done', '완료 ✓'); document.getElementById('cup-port-name').textContent = '✓ 포트 선택됨';
|
||||
await new Promise(r=>setTimeout(r,500)); _cupSetProgress(null);
|
||||
} finally { try{reader.releaseLock();}catch{} try{writer.releaseLock();}catch{} try{await _cupPort.close();}catch{} }
|
||||
}
|
||||
|
||||
async function _cupUploadSTM32(binData) {
|
||||
const ACK=0x79;
|
||||
await _cupPort.open({ baudRate:115200, dataBits:8, stopBits:1, parity:'even' });
|
||||
const reader=_cupPort.readable.getReader(), writer=_cupPort.writable.getWriter();
|
||||
const w = async d=>_cupWrite(writer,d), readByte=async(ms=6000)=>(await _cupRead(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], addrPkt=addr=>{ const b=[(addr>>24)&0xff,(addr>>16)&0xff,(addr>>8)&0xff,addr&0xff]; return new Uint8Array([...b,xor4(b)]); };
|
||||
try {
|
||||
cupLog('STM32 동기화 중...', 'prog'); cupLog('(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 하세요.');
|
||||
cupLog('✓ 동기화됨', 'ok');
|
||||
await w([0x00,0xFF]); await waitAck(3000); const n=await readByte(); await _cupRead(reader,n+1,3000); await waitAck(3000);
|
||||
cupLog('플래시 지우는 중... (최대 30초)', 'prog'); _cupSetProgress(10);
|
||||
await w([0x44,0xBB]); await waitAck(5000); await w([0xFF,0xFF,0x00]); await waitAck(30000);
|
||||
cupLog('✓ 플래시 지움', 'ok'); _cupSetProgress(20);
|
||||
const START=0x08000000, CHUNK=256, total=Math.ceil(binData.length/CHUNK);
|
||||
cupLog(`${binData.length} 바이트 쓰는 중 (${total} 블록)...`, 'prog');
|
||||
for (let i=0; i<binData.length; i+=CHUNK) {
|
||||
const raw=binData.slice(i,i+CHUNK), padded=new Uint8Array(CHUNK).fill(0xff); padded.set(raw);
|
||||
await w([0x31,0xCE]); await waitAck(5000); await w(addrPkt(START+i)); await waitAck(5000);
|
||||
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; _cupSetProgress(20+Math.round(blk/total*75));
|
||||
document.getElementById('cup-port-name').textContent = `업로드 중... ${blk}/${total} 블록`;
|
||||
}
|
||||
_cupSetProgress(97); cupLog('실행 중...', 'prog');
|
||||
await w([0x21,0xDE]); await waitAck(5000); await w(addrPkt(START)); try{await waitAck(2000);}catch{}
|
||||
_cupSetProgress(100); cupLog('✅ 업로드 완료! BOOT0=GND 후 RESET 하세요.', 'ok');
|
||||
_cupSetStatus('done', '완료 ✓'); document.getElementById('cup-port-name').textContent = '✓ 포트 선택됨';
|
||||
await new Promise(r=>setTimeout(r,500)); _cupSetProgress(null);
|
||||
} finally { try{reader.releaseLock();}catch{} try{writer.releaseLock();}catch{} try{await _cupPort.close();}catch{} }
|
||||
}
|
||||
|
||||
// ── Serial monitor ───────────────────────────────────────────────────────────
|
||||
function _cupMonAppendSys(msg) {
|
||||
const out = document.getElementById('cup-mon-output'); if (!out) return;
|
||||
const div = document.createElement('div');
|
||||
div.style.color = '#907050'; div.style.fontStyle = 'italic'; div.textContent = msg;
|
||||
out.appendChild(div);
|
||||
if (document.getElementById('cup-mon-auto')?.checked) out.scrollTop = out.scrollHeight;
|
||||
}
|
||||
|
||||
function cupMonAppend(text) {
|
||||
const out = document.getElementById('cup-mon-output'); if (!out) return;
|
||||
const showTs = document.getElementById('cup-mon-ts')?.checked;
|
||||
const autoScroll = document.getElementById('cup-mon-auto')?.checked;
|
||||
_cupMonBuf += text;
|
||||
const parts = _cupMonBuf.split('\n'); _cupMonBuf = parts.pop();
|
||||
for (const raw of parts) {
|
||||
const div = document.createElement('div'); div.style.color = '#c8e8c0';
|
||||
if (showTs) {
|
||||
const d=new Date(), 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 s=document.createElement('span'); s.style.cssText='color:#907050;font-size:11px;margin-right:6px;opacity:.7;user-select:none'; s.textContent=ts;
|
||||
div.appendChild(s); div.appendChild(document.createTextNode(raw.replace(/\r/g,'')));
|
||||
} else { div.textContent = raw.replace(/\r/g,''); }
|
||||
out.appendChild(div);
|
||||
}
|
||||
if (autoScroll) out.scrollTop = out.scrollHeight;
|
||||
}
|
||||
|
||||
async function _cupMonReadLoop() {
|
||||
while (_cupMonActive && _cupMonReader) {
|
||||
try { const {value,done}=await _cupMonReader.read(); if(done)break; cupMonAppend(_cupDecoder.decode(value,{stream:true})); }
|
||||
catch(e) { if (_cupMonActive) _cupMonAppendSys(`[오류: ${e.message}]`); break; }
|
||||
}
|
||||
if (_cupMonActive) cupMonClose();
|
||||
}
|
||||
|
||||
async function cupMonToggle() { if (_cupMonActive) await cupMonClose(); else await cupMonOpen(); }
|
||||
|
||||
async function cupMonOpen() {
|
||||
if (!_cupPort) { _cupMonAppendSys('[포트를 먼저 선택하세요]'); return; }
|
||||
if (_cupBusy) { _cupMonAppendSys('[업로드 중입니다. 완료 후 연결하세요]'); return; }
|
||||
const baud = parseInt(document.getElementById('cup-mon-baud')?.value) || 115200;
|
||||
try {
|
||||
await _cupPort.open({ baudRate: baud });
|
||||
_cupMonReader = _cupPort.readable.getReader();
|
||||
_cupMonWriter = _cupPort.writable.getWriter();
|
||||
_cupMonActive = true;
|
||||
const btn = document.getElementById('cup-mon-btn');
|
||||
if (btn) { btn.textContent='■ 연결 해제'; btn.style.color='#e87070'; btn.style.borderColor='color-mix(in srgb,#e87070 35%,#241a0e)'; }
|
||||
const inp = document.getElementById('cup-mon-input'); if (inp) inp.disabled = false;
|
||||
_cupMonAppendSys(`[연결됨 — ${baud} baud]`); _cupMonReadLoop();
|
||||
} catch(e) { _cupMonAppendSys(`[연결 실패: ${e.message}]`); }
|
||||
}
|
||||
|
||||
async function cupMonClose() {
|
||||
_cupMonActive = false;
|
||||
try{await _cupMonReader?.cancel();}catch{} try{_cupMonReader?.releaseLock();}catch{}
|
||||
try{_cupMonWriter?.releaseLock();}catch{} try{await _cupPort?.close();}catch{}
|
||||
_cupMonReader=null; _cupMonWriter=null;
|
||||
const btn=document.getElementById('cup-mon-btn');
|
||||
if (btn) { btn.textContent='▶ 연결'; btn.style.color='#4ade80'; btn.style.borderColor='color-mix(in srgb,#4ade80 35%,#241a0e)'; }
|
||||
const inp=document.getElementById('cup-mon-input'); if(inp) inp.disabled=true;
|
||||
_cupMonAppendSys('[연결 해제됨]');
|
||||
}
|
||||
|
||||
async function cupMonSend() {
|
||||
if (!_cupMonWriter) return;
|
||||
const input=document.getElementById('cup-mon-input'); if(!input) return;
|
||||
const endings={none:'',nl:'\n',cr:'\r',crnl:'\r\n'};
|
||||
const suffix=endings[document.getElementById('cup-mon-line-end')?.value]??'\r\n';
|
||||
const text=input.value+suffix; input.value='';
|
||||
try {
|
||||
await _cupMonWriter.write(new TextEncoder().encode(text));
|
||||
const div=document.createElement('div'); div.style.color='#94b0d8'; div.textContent=text.replace(/[\r\n]/g,'');
|
||||
document.getElementById('cup-mon-output')?.appendChild(div);
|
||||
} catch(e) { _cupMonAppendSys(`[전송 오류: ${e.message}]`); }
|
||||
}
|
||||
|
||||
function cupMonClear() { const el=document.getElementById('cup-mon-output'); if(el) el.innerHTML=''; _cupMonBuf=''; }
|
||||
|
||||
// Upload panel resize drag
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const drag = document.getElementById('code-upload-drag');
|
||||
const panel = document.getElementById('code-upload-panel');
|
||||
if (!drag || !panel) return;
|
||||
let startY=0, startH=0;
|
||||
drag.addEventListener('mousedown', e => {
|
||||
startY=e.clientY; startH=panel.offsetHeight; e.preventDefault();
|
||||
const onMove = e2 => { const dy=startY-e2.clientY; panel.style.height=Math.max(80,Math.min(window.innerHeight*.6,startH+dy))+'px'; };
|
||||
const onUp = () => { document.removeEventListener('mousemove',onMove); document.removeEventListener('mouseup',onUp); drag.style.background=''; };
|
||||
drag.addEventListener('mouseover', ()=>drag.style.background='rgba(200,125,48,.4)');
|
||||
document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp);
|
||||
});
|
||||
});
|
||||
|
||||
// Here we run them once the DOM is ready since this page IS the code view.
|
||||
(function bootCodePage() {
|
||||
async function boot() {
|
||||
|
||||
Reference in New Issue
Block a user