- 카카오톡 채널 챗봇 (오픈빌더 비동기 콜백 방식, 5초 제한 우회) - 카카오 "나에게 보내기" OAuth 알림 (kakao-notify.ts) - 채널 설정 UI + userMap으로 다중 사용자 계정 매핑 - 탐정앱: 탭(메모/체크리스트/타임라인), 우선순위, 검색, 보고서/고소장/접수안내 생성 - 모바일 브레이크포인트 640px → 1024px (갤럭시 폴드 대응) - 프롬프트 파일 제한 상향 (USER.md 3000, SOUL.md/AGENTS.md 4000) - 교통앱 마지막 선택 지역 localStorage 저장/복원 - docs/kakao-setup.md 작성 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
526 lines
24 KiB
HTML
526 lines
24 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="ko">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>ESP32 Flasher</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; }
|
|
.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.compiling{ background:#1e3a5f; color:#60a5fa; animation:pulse 1.5s infinite; }
|
|
.status.ready { background:#1a3a2a; color:var(--green); }
|
|
.status.flashing { 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; }
|
|
.btn { background:#252540; border:1px solid #3a3a5a; color:#e5e7eb; border-radius:6px; padding:6px 16px; font-size:12px; cursor:pointer; font-family:inherit; transition:all .15s; }
|
|
.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.danger { background:#3a1a1a; border-color:#5a2d2d; color:var(--red); }
|
|
select { background:#252540; border:1px solid #3a3a5a; color:#e5e7eb; border-radius:6px; padding:5px 10px; font-size:12px; font-family:inherit; cursor:pointer; }
|
|
|
|
.main { flex:1; display:flex; overflow:hidden; }
|
|
|
|
/* Left panel */
|
|
.left-panel { width:300px; flex-shrink:0; border-right:1px solid #2d2d4a; display:flex; flex-direction:column; background:#111122; }
|
|
.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; }
|
|
|
|
/* Port row */
|
|
.port-row { display:flex; gap:8px; align-items:center; }
|
|
.port-row select { flex:1; }
|
|
|
|
/* Progress */
|
|
.progress-wrap { margin-top:12px; }
|
|
.progress-label { font-size:11px; color:var(--muted); margin-bottom:6px; display:flex; justify-content:space-between; }
|
|
.progress-bar { height:6px; background:#1e1e36; border-radius:3px; overflow:hidden; }
|
|
.progress-fill { height:100%; background:var(--accent); border-radius:3px; transition:width .3s; width:0%; }
|
|
|
|
/* Firmware info */
|
|
.info-row { display:flex; justify-content:space-between; font-size:11px; padding:2px 0; }
|
|
.info-label { color:var(--muted); }
|
|
.info-val { color:#e5e7eb; }
|
|
|
|
/* Serial monitor */
|
|
.serial-panel { flex:1; display:flex; flex-direction:column; }
|
|
.serial-header { padding:10px 16px; font-size:11px; color:var(--muted); font-weight:600; letter-spacing:.08em; border-bottom:1px solid #1e1e36; display:flex; align-items:center; gap:10px; }
|
|
.baud-sel { margin-left:auto; }
|
|
.serial-clear { background:none; border:none; color:var(--muted); cursor:pointer; font-size:11px; font-family:inherit; }
|
|
.serial-clear:hover { color:#e5e7eb; }
|
|
.serial-output { flex:1; overflow-y:auto; padding:12px 16px; font-size:12px; line-height:1.7; white-space:pre-wrap; word-break:break-all; }
|
|
.serial-output .info { color:var(--muted); font-style:italic; }
|
|
.serial-output .ok { color:var(--green); }
|
|
.serial-output .err { color:var(--red); }
|
|
.serial-output .data { color:#e5e7eb; }
|
|
.serial-output .prog { color:#60a5fa; }
|
|
.serial-input-row { display:flex; gap:8px; padding:10px 16px; border-top:1px solid #1e1e36; flex-shrink:0; }
|
|
.serial-input { flex:1; background:#1a1a2e; border:1px solid #3a3a5a; color:#e5e7eb; border-radius:6px; padding:7px 12px; font-family:inherit; font-size:12px; }
|
|
.serial-input:focus { outline:none; border-color:var(--accent); }
|
|
.serial-send { background:#1a3a2a; border:1px solid #2d5a3a; color:var(--green); border-radius:6px; padding:7px 14px; font-family:inherit; font-size:12px; cursor:pointer; }
|
|
|
|
.footer { padding:7px 16px; border-top:1px solid #2d2d4a; font-size:11px; color:var(--muted); background:var(--panel); flex-shrink:0; display:flex; justify-content:space-between; }
|
|
|
|
/* Overlay */
|
|
.overlay { position:fixed; inset:0; background:rgba(0,0,0,.7); display:flex; align-items:center; justify-content:center; z-index:100; }
|
|
.overlay-box { background:var(--panel); border:1px solid #3a3a5a; border-radius:10px; padding:28px 36px; text-align:center; min-width:320px; max-width:500px; }
|
|
.overlay-box h2 { font-size:15px; margin-bottom:12px; }
|
|
.overlay-box p { font-size:12px; color:var(--muted); margin-bottom:18px; white-space:pre-wrap; text-align:left; max-height:220px; overflow-y:auto; }
|
|
.overlay-box button { background:#1a3a2a; border:1px solid #2d5a3a; color:var(--green); border-radius:6px; padding:8px 20px; font-family:inherit; font-size:13px; cursor:pointer; }
|
|
.spinner { width:28px; height:28px; border:3px solid #3a3a5a; border-top-color:var(--accent); border-radius:50%; animation:spin .8s linear infinite; margin:0 auto 16px; }
|
|
@keyframes spin { to{transform:rotate(360deg)} }
|
|
|
|
.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; }
|
|
|
|
/* ── mob-tab (inline since styles.css not loaded) ── */
|
|
.mob-tab{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:3px;border:none;background:transparent;color:var(--muted);font-family:inherit;font-size:10px;font-weight:700;letter-spacing:.04em;cursor:pointer;padding:6px 4px;border-top:2px solid transparent;transition:color .15s,border-color .15s;-webkit-tap-highlight-color:transparent;}
|
|
.mob-tab.active{color:var(--accent);border-top-color:var(--accent);}
|
|
|
|
/* ── Mobile ── */
|
|
@media(max-width:1024px){
|
|
.header{flex-wrap:wrap;padding:7px 10px;gap:6px;}
|
|
.header h1{font-size:13px;}
|
|
.toolbar{gap:4px;}
|
|
.toolbar .btn{padding:5px 8px;font-size:11px;}
|
|
.left-panel{
|
|
position:fixed;top:0;left:0;bottom:0;z-index:500;
|
|
width:84vw;max-width:310px;
|
|
transform:translateX(-100%);transition:transform .25s ease;
|
|
box-shadow:4px 0 24px rgba(0,0,0,.6);
|
|
}
|
|
.left-panel.mob-open{transform:translateX(0);}
|
|
#mob-backdrop{display:none;position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:499;}
|
|
#mob-backdrop.show{display:block;}
|
|
#mob-tabbar{display:flex !important;position:fixed;bottom:0;left:0;right:0;z-index:490;background:var(--panel);border-top:1px solid #2d2d4a;height:52px;}
|
|
.main{padding-bottom:52px;}
|
|
.serial-panel{border:none;}
|
|
input,select{font-size:16px !important;}
|
|
}
|
|
@media(min-width:1025px){
|
|
#mob-backdrop{display:none !important;}
|
|
#mob-tabbar{display:none !important;}
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
|
|
<div id="mob-backdrop" onclick="mobClosePanel()"></div>
|
|
<div id="mob-tabbar" style="display:none">
|
|
<button class="mob-tab" id="mobtab-ctrl" onclick="mobTogglePanel()"><span style="font-size:18px">⚡</span>플래시</button>
|
|
<button class="mob-tab active" id="mobtab-term" onclick="mobClosePanel()"><span style="font-size:18px">📟</span>터미널</button>
|
|
</div>
|
|
|
|
<div class="header">
|
|
<h1>⚡ ESP32 Flasher</h1>
|
|
<span class="file" id="filename">sketch.ino</span>
|
|
<span class="chip-badge" id="chip-badge">ESP32</span>
|
|
<span class="status idle" id="status">대기 중</span>
|
|
<div class="toolbar">
|
|
<button class="btn primary" id="btn-connect" onclick="connectPort()">🔌 포트 연결</button>
|
|
<button class="btn primary" id="btn-flash" onclick="flashDevice()" disabled>⚡ 플래시</button>
|
|
<button class="btn danger" id="btn-disconnect" onclick="disconnectPort()" disabled>연결 해제</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="main">
|
|
<div class="left-panel">
|
|
<!-- Connection -->
|
|
<div class="section">
|
|
<div class="section-title">연결 설정</div>
|
|
<div class="port-row">
|
|
<select id="baud-flash">
|
|
<option value="921600" selected>921600 baud (권장)</option>
|
|
<option value="460800">460800 baud</option>
|
|
<option value="230400">230400 baud</option>
|
|
<option value="115200">115200 baud</option>
|
|
</select>
|
|
</div>
|
|
<div style="margin-top:8px;font-size:11px;color:var(--muted)">Chrome/Edge에서 USB 연결 필요</div>
|
|
</div>
|
|
|
|
<!-- Firmware info -->
|
|
<div class="section">
|
|
<div class="section-title">펌웨어 정보</div>
|
|
<div class="info-row"><span class="info-label">파일</span><span class="info-val" id="info-file">-</span></div>
|
|
<div class="info-row"><span class="info-label">크기</span><span class="info-val" id="info-size">-</span></div>
|
|
<div class="info-row"><span class="info-label">플래시 주소</span><span class="info-val">0x0000 (merged)</span></div>
|
|
<div class="info-row"><span class="info-label">칩</span><span class="info-val" id="info-chip">-</span></div>
|
|
</div>
|
|
|
|
<!-- Progress -->
|
|
<div class="section">
|
|
<div class="section-title">플래시 진행률</div>
|
|
<div class="progress-wrap">
|
|
<div class="progress-label">
|
|
<span id="prog-label">대기 중</span>
|
|
<span id="prog-pct">0%</span>
|
|
</div>
|
|
<div class="progress-bar"><div class="progress-fill" id="prog-fill"></div></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Serial monitor settings -->
|
|
<div class="section" style="flex:1">
|
|
<div class="section-title">플래시 후 시리얼 모니터</div>
|
|
<div style="display:flex;gap:8px;align-items:center">
|
|
<select id="baud-serial" style="flex:1">
|
|
<option value="115200" selected>115200 baud</option>
|
|
<option value="9600">9600 baud</option>
|
|
<option value="57600">57600 baud</option>
|
|
<option value="230400">230400 baud</option>
|
|
</select>
|
|
<button class="btn" id="btn-serial" onclick="toggleSerial()" disabled>시리얼 열기</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Serial output -->
|
|
<div class="serial-panel">
|
|
<div class="serial-header">
|
|
CONSOLE
|
|
<button class="serial-clear" onclick="clearLog()">지우기</button>
|
|
</div>
|
|
<div class="serial-output" id="log-output">
|
|
<span class="info">ESP32 Flasher 준비됨. 포트를 연결하고 ⚡ 플래시를 누르세요.</span>\n
|
|
</div>
|
|
<div class="serial-input-row">
|
|
<input class="serial-input" id="serial-input" placeholder="시리얼 입력 (플래시 후 연결 시 활성화)" disabled onkeydown="if(event.key==='Enter')sendSerial()"/>
|
|
<button class="serial-send" onclick="sendSerial()" id="btn-send" disabled>전송</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="footer">
|
|
<span>esptool-js · Web Serial API · ESP32</span>
|
|
<span id="footer-status">포트 미연결</span>
|
|
</div>
|
|
|
|
<!-- Overlay -->
|
|
<div class="overlay" id="overlay">
|
|
<div class="overlay-box">
|
|
<div class="spinner" id="overlay-spinner"></div>
|
|
<h2 id="overlay-title">컴파일 중...</h2>
|
|
<p id="overlay-msg" style="display:none"></p>
|
|
<button id="overlay-btn" onclick="closeOverlay()" style="display:none">닫기</button>
|
|
</div>
|
|
</div>
|
|
|
|
<script type="module">
|
|
import { ESPLoader, Transport } from 'https://esm.sh/esptool-js@0.4.1';
|
|
|
|
// ── State ──────────────────────────────────────────────────────────────────
|
|
let mergedBin = null; // Uint8Array
|
|
let port = null;
|
|
let transport = null;
|
|
let esploader = null;
|
|
let serialReader = null;
|
|
let serialActive = false;
|
|
|
|
// ── 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;
|
|
}
|
|
window.clearLog = () => { document.getElementById('log-output').innerHTML = ''; };
|
|
|
|
// ── Status helpers ─────────────────────────────────────────────────────────
|
|
function setStatus(cls, text) {
|
|
const el = document.getElementById('status');
|
|
el.className = 'status ' + cls;
|
|
el.textContent = text;
|
|
}
|
|
function setProgress(pct, label) {
|
|
document.getElementById('prog-fill').style.width = pct + '%';
|
|
document.getElementById('prog-pct').textContent = Math.round(pct) + '%';
|
|
document.getElementById('prog-label').textContent = label || '플래시 중...';
|
|
}
|
|
|
|
// ── Overlay ────────────────────────────────────────────────────────────────
|
|
function showOverlay(title) {
|
|
document.getElementById('overlay-spinner').style.display = '';
|
|
document.getElementById('overlay-title').textContent = title;
|
|
document.getElementById('overlay-msg').style.display = 'none';
|
|
document.getElementById('overlay-btn').style.display = 'none';
|
|
document.getElementById('overlay').style.display = 'flex';
|
|
}
|
|
function showOverlayError(msg) {
|
|
document.getElementById('overlay-spinner').style.display = 'none';
|
|
document.getElementById('overlay-title').textContent = '⚠️ 오류';
|
|
const p = document.getElementById('overlay-msg');
|
|
p.textContent = msg;
|
|
p.style.display = 'block';
|
|
document.getElementById('overlay-btn').style.display = 'inline-block';
|
|
}
|
|
window.closeOverlay = () => { document.getElementById('overlay').style.display = 'none'; };
|
|
|
|
// ── Uint8Array ↔ binary string ─────────────────────────────────────────────
|
|
function uint8ToBinStr(buf) {
|
|
let s = '';
|
|
for (let i = 0; i < buf.length; i++) s += String.fromCharCode(buf[i]);
|
|
return s;
|
|
}
|
|
function base64ToUint8(b64) {
|
|
const bin = atob(b64);
|
|
const buf = new Uint8Array(bin.length);
|
|
for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i);
|
|
return buf;
|
|
}
|
|
|
|
// ── Connect / Disconnect ───────────────────────────────────────────────────
|
|
window.connectPort = async () => {
|
|
if (!('serial' in navigator)) {
|
|
showOverlayError('Web Serial API 미지원.\nChrome 또는 Edge 브라우저를 사용해주세요.');
|
|
return;
|
|
}
|
|
try {
|
|
port = await navigator.serial.requestPort();
|
|
log('포트 선택됨. 연결 중...', 'info');
|
|
document.getElementById('btn-connect').disabled = true;
|
|
document.getElementById('btn-disconnect').disabled = false;
|
|
document.getElementById('footer-status').textContent = '포트 연결됨';
|
|
if (mergedBin) {
|
|
document.getElementById('btn-flash').disabled = false;
|
|
}
|
|
document.getElementById('btn-serial').disabled = false;
|
|
setStatus('ready', '포트 연결됨');
|
|
log('포트 연결 완료. ⚡ 플래시 버튼을 누르세요.', 'ok');
|
|
} catch (e) {
|
|
if (e.name !== 'NotFoundError') log('포트 선택 취소 또는 오류: ' + e.message, 'err');
|
|
}
|
|
};
|
|
|
|
window.disconnectPort = async () => {
|
|
await cleanupSerial();
|
|
if (transport) { try { await transport.disconnect(); } catch {} transport = null; }
|
|
if (port) { try { await port.close(); } catch {} port = null; }
|
|
esploader = null;
|
|
document.getElementById('btn-connect').disabled = false;
|
|
document.getElementById('btn-disconnect').disabled = true;
|
|
document.getElementById('btn-flash').disabled = true;
|
|
document.getElementById('btn-serial').disabled = true;
|
|
document.getElementById('footer-status').textContent = '포트 미연결';
|
|
setStatus('idle', '대기 중');
|
|
log('연결 해제됨.', 'info');
|
|
};
|
|
|
|
// ── Flash ──────────────────────────────────────────────────────────────────
|
|
window.flashDevice = async () => {
|
|
if (!port || !mergedBin) return;
|
|
|
|
document.getElementById('btn-flash').disabled = true;
|
|
document.getElementById('btn-serial').disabled = true;
|
|
setStatus('flashing', '플래시 중...');
|
|
setProgress(0, '연결 중...');
|
|
log('\n--- 플래시 시작 ---', 'prog');
|
|
|
|
const baudrate = parseInt(document.getElementById('baud-flash').value);
|
|
const terminal = {
|
|
clean() {},
|
|
writeLine(data) { log(data, 'prog'); },
|
|
write(data) {
|
|
const out = document.getElementById('log-output');
|
|
let last = out.lastElementChild;
|
|
if (!last || last.className !== 'prog') { last = document.createElement('span'); last.className = 'prog'; out.appendChild(last); }
|
|
last.textContent += data;
|
|
out.scrollTop = out.scrollHeight;
|
|
},
|
|
};
|
|
|
|
try {
|
|
transport = new Transport(port, true);
|
|
esploader = new ESPLoader({ transport, baudrate, terminal });
|
|
|
|
setProgress(5, 'ESP32 연결 중...');
|
|
const chip = await esploader.main();
|
|
log('칩: ' + chip, 'ok');
|
|
document.getElementById('info-chip').textContent = chip;
|
|
document.getElementById('chip-badge').textContent = chip;
|
|
|
|
setProgress(10, '플래시 초기화 중...');
|
|
|
|
const fileArray = [{ data: uint8ToBinStr(mergedBin), address: 0x0 }];
|
|
|
|
await esploader.write_flash({
|
|
fileArray,
|
|
flashSize: 'keep',
|
|
flashMode: 'keep',
|
|
flashFreq: 'keep',
|
|
eraseAll: false,
|
|
compress: true,
|
|
reportProgress(fileIndex, written, total) {
|
|
const pct = 10 + (written / total) * 88;
|
|
setProgress(pct, `플래시 중... ${Math.round(written/1024)}/${Math.round(total/1024)} KB`);
|
|
},
|
|
calculateMD5Hash(image) {
|
|
// simple passthrough (esptool-js handles verification internally)
|
|
return '';
|
|
},
|
|
});
|
|
|
|
setProgress(100, '완료');
|
|
setStatus('done', '플래시 완료 ✓');
|
|
log('✅ 플래시 완료!', 'ok');
|
|
log('ESP32가 재시작됩니다.', 'info');
|
|
|
|
await transport.disconnect();
|
|
transport = null;
|
|
document.getElementById('btn-flash').disabled = false;
|
|
document.getElementById('btn-serial').disabled = false;
|
|
|
|
} catch (e) {
|
|
setStatus('error', '오류');
|
|
log('❌ 플래시 오류: ' + e.message, 'err');
|
|
document.getElementById('btn-flash').disabled = false;
|
|
if (transport) { try { await transport.disconnect(); } catch {} transport = null; }
|
|
}
|
|
};
|
|
|
|
// ── Serial Monitor (post-flash) ─────────────────────────────────────────────
|
|
async function cleanupSerial() {
|
|
serialActive = false;
|
|
if (serialReader) { try { await serialReader.cancel(); } catch {} serialReader = null; }
|
|
document.getElementById('serial-input').disabled = true;
|
|
document.getElementById('btn-send').disabled = true;
|
|
document.getElementById('btn-serial').textContent = '시리얼 열기';
|
|
}
|
|
|
|
window.toggleSerial = async () => {
|
|
if (serialActive) { await cleanupSerial(); return; }
|
|
if (!port) return;
|
|
try {
|
|
const baud = parseInt(document.getElementById('baud-serial').value);
|
|
await port.open({ baudRate: baud });
|
|
serialActive = true;
|
|
document.getElementById('btn-serial').textContent = '시리얼 닫기';
|
|
document.getElementById('serial-input').disabled = false;
|
|
document.getElementById('btn-send').disabled = false;
|
|
log(`\n--- 시리얼 모니터 열림 (${baud} baud) ---`, 'info');
|
|
|
|
const decoder = new TextDecoderStream();
|
|
port.readable.pipeTo(decoder.writable);
|
|
serialReader = decoder.readable.getReader();
|
|
|
|
(async () => {
|
|
while (serialActive) {
|
|
try {
|
|
const { value, done } = await serialReader.read();
|
|
if (done) break;
|
|
if (value) {
|
|
const out = document.getElementById('log-output');
|
|
let last = out.lastElementChild;
|
|
if (!last || last.className !== 'data') {
|
|
last = document.createElement('span'); last.className = 'data'; out.appendChild(last);
|
|
}
|
|
last.textContent += value;
|
|
out.scrollTop = out.scrollHeight;
|
|
}
|
|
} catch { break; }
|
|
}
|
|
})();
|
|
} catch (e) {
|
|
log('시리얼 오류: ' + e.message, 'err');
|
|
}
|
|
};
|
|
|
|
window.sendSerial = async () => {
|
|
const inp = document.getElementById('serial-input');
|
|
if (!inp.value || !port || !serialActive) return;
|
|
try {
|
|
const writer = port.writable.getWriter();
|
|
await writer.write(new TextEncoder().encode(inp.value + '\n'));
|
|
writer.releaseLock();
|
|
inp.value = '';
|
|
} catch (e) { log('전송 오류: ' + e.message, 'err'); }
|
|
};
|
|
|
|
// ── Boot: get firmware ──────────────────────────────────────────────────────
|
|
async function boot() {
|
|
const sketch = sessionStorage.getItem('arduino_sketch');
|
|
const fname = sessionStorage.getItem('arduino_filename') || 'sketch.ino';
|
|
const hexDirect= sessionStorage.getItem('arduino_hex');
|
|
const mergedB64= sessionStorage.getItem('arduino_merged');
|
|
sessionStorage.removeItem('arduino_sketch');
|
|
sessionStorage.removeItem('arduino_filename');
|
|
sessionStorage.removeItem('arduino_board');
|
|
sessionStorage.removeItem('arduino_hex');
|
|
sessionStorage.removeItem('arduino_merged');
|
|
|
|
document.getElementById('filename').textContent = fname;
|
|
document.getElementById('info-file').textContent = fname;
|
|
|
|
if (mergedB64) {
|
|
mergedBin = base64ToUint8(mergedB64);
|
|
document.getElementById('info-size').textContent = (mergedBin.length / 1024).toFixed(1) + ' KB';
|
|
setStatus('ready', '펌웨어 준비됨');
|
|
log('펌웨어 로드됨 (' + (mergedBin.length/1024).toFixed(1) + ' KB). 포트를 연결하세요.', 'ok');
|
|
closeOverlay();
|
|
return;
|
|
}
|
|
|
|
if (!sketch) {
|
|
showOverlayError('스케치가 없습니다.\n코드 에디터에서 .ino 파일을 열고 실행하세요.');
|
|
return;
|
|
}
|
|
|
|
showOverlay('컴파일 중...');
|
|
log('ESP32 컴파일 중...', 'info');
|
|
|
|
try {
|
|
const r = await fetch('/api/arduino/compile', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ code: sketch, board: 'esp32' }),
|
|
});
|
|
const data = await r.json();
|
|
if (data.error) {
|
|
showOverlayError(data.error + (data.install ? '\n\n설치 필요:\n' + data.install : ''));
|
|
return;
|
|
}
|
|
mergedBin = base64ToUint8(data.merged);
|
|
document.getElementById('info-size').textContent = (mergedBin.length / 1024).toFixed(1) + ' KB';
|
|
setStatus('ready', '펌웨어 준비됨');
|
|
log('✅ 컴파일 완료 (' + (mergedBin.length/1024).toFixed(1) + ' KB). 포트를 연결하고 플래시하세요.', 'ok');
|
|
closeOverlay();
|
|
} catch (e) {
|
|
showOverlayError('서버 오류: ' + e.message);
|
|
}
|
|
}
|
|
|
|
boot();
|
|
|
|
// ── Mobile ──────────────────────────────────────────────────────────────────
|
|
function mobClosePanel(){
|
|
document.querySelector('.left-panel').classList.remove('mob-open');
|
|
document.getElementById('mob-backdrop').classList.remove('show');
|
|
document.getElementById('mobtab-ctrl').classList.remove('active');
|
|
document.getElementById('mobtab-term').classList.add('active');
|
|
}
|
|
function mobTogglePanel(){
|
|
if(window.innerWidth>640)return;
|
|
const open=document.querySelector('.left-panel').classList.contains('mob-open');
|
|
if(open){mobClosePanel();}
|
|
else{
|
|
document.querySelector('.left-panel').classList.add('mob-open');
|
|
document.getElementById('mob-backdrop').classList.add('show');
|
|
document.getElementById('mobtab-ctrl').classList.add('active');
|
|
document.getElementById('mobtab-term').classList.remove('active');
|
|
}
|
|
}
|
|
</script>
|
|
</body>
|
|
</html>
|