Files
homeclaw/web-ui/esp32-flasher.html
T
kimandClaude Sonnet 4.6 d74614cee1 v3.2.0: 카카오톡 연동 + 탐정앱 업그레이드 + 모바일 대응 강화
- 카카오톡 채널 챗봇 (오픈빌더 비동기 콜백 방식, 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>
2026-06-26 11:15:27 +09:00

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>