/* ────────────────────────────────────────────────────────────── ESP32 Web Flasher – app.js Web Serial API + esp-web-tools 연동 ────────────────────────────────────────────────────────────── */ // 백엔드 API 베이스 URL (nginx 리버스 프록시 경유) const API = ''; // ── 상태 ────────────────────────────────────────────────────── const state = { port: null, // 현재 열린 시리얼 포트 selectedFwId: null, // 선택된 펌웨어 ID firmwareList: [], }; // ── DOM 헬퍼 ────────────────────────────────────────────────── const $ = (sel, ctx = document) => ctx.querySelector(sel); const $$ = (sel, ctx = document) => [...ctx.querySelectorAll(sel)]; // ── 시리얼 모니터 ───────────────────────────────────────────── const monitor = $('#monitor'); function log(text, type = '') { const line = document.createElement('span'); if (type) line.className = `line-${type}`; line.textContent = text + '\n'; monitor.appendChild(line); monitor.scrollTop = monitor.scrollHeight; } function logClear() { monitor.innerHTML = ''; } // ── 브라우저 호환성 체크 ────────────────────────────────────── function checkBrowserCompat() { const supported = 'serial' in navigator; const banner = $('#browser-warning'); if (!supported) { banner.style.display = 'flex'; $$('.needs-serial').forEach(el => el.disabled = true); log('⚠ Web Serial API 미지원 브라우저입니다. Chrome 또는 Edge를 사용하세요.', 'err'); } else { log('✓ 브라우저 Web Serial API 지원 확인', 'ok'); banner.style.display = 'none'; } return supported; } // ── STEP 1: 시리얼 연결 확인 ────────────────────────────────── const btnConnect = $('#btn-connect'); const btnDisconnect = $('#btn-disconnect'); const connDot = $('#conn-dot'); const connText = $('#conn-text'); const deviceInfo = $('#device-info'); function setConnStatus(status) { connDot.className = 'dot'; if (status === 'ok') { connDot.classList.add('dot-ok'); connText.textContent = '연결됨'; btnConnect.style.display = 'none'; btnDisconnect.style.display = 'inline-flex'; } else if (status === 'loading') { connDot.classList.add('dot-loading'); connText.textContent = '연결 중…'; } else if (status === 'error') { connDot.classList.add('dot-error'); connText.textContent = '연결 실패'; btnConnect.style.display = 'inline-flex'; btnDisconnect.style.display = 'none'; } else { connDot.classList.add('dot-idle'); connText.textContent = '연결 안 됨'; btnConnect.style.display = 'inline-flex'; btnDisconnect.style.display = 'none'; deviceInfo.textContent = ''; } } btnConnect.addEventListener('click', async () => { if (!('serial' in navigator)) return; setConnStatus('loading'); logClear(); log('시리얼 포트 선택 대화상자 열기…', 'info'); try { // ESP32S3 USB VID: 0x303A (Espressif) // 필터 없이 모든 포트 허용 (테스트용) state.port = await navigator.serial.requestPort(); await state.port.open({ baudRate: 115200 }); const info = state.port.getInfo(); const vid = info.usbVendorId != null ? `0x${info.usbVendorId.toString(16).toUpperCase().padStart(4,'0')}` : 'N/A'; const pid = info.usbProductId != null ? `0x${info.usbProductId.toString(16).toUpperCase().padStart(4,'0')}` : 'N/A'; const isEspressif = info.usbVendorId === 0x303A; setConnStatus('ok'); deviceInfo.textContent = `VID: ${vid} PID: ${pid} ${isEspressif ? '(Espressif ✓)' : ''}`; log(`✓ 포트 연결 성공`, 'ok'); log(` VID: ${vid} PID: ${pid}`, 'info'); if (isEspressif) { log(' Espressif 장치 감지됨 (ESP32S3 가능성 높음)', 'ok'); } else { log('⚠ Espressif VID가 아닙니다. USB-UART 변환기일 수 있습니다.', 'warn'); } // 연결 확인 후 포트 닫기 (esp-web-tools가 재사용할 수 있도록) await state.port.close(); log(' 포트를 닫았습니다 (플래시 시 자동 재연결)', 'info'); updateStep1Badge('done'); } catch (err) { if (err.name === 'NotFoundError') { log('ℹ 포트 선택이 취소되었습니다.', 'info'); setConnStatus('idle'); } else { log(`✗ 연결 실패: ${err.message}`, 'err'); setConnStatus('error'); } state.port = null; } }); btnDisconnect.addEventListener('click', async () => { try { if (state.port) { if (state.port.readable || state.port.writable) { await state.port.close(); } state.port = null; } } catch {} setConnStatus('idle'); updateStep1Badge('pending'); log('포트 연결 해제됨', 'info'); }); function updateStep1Badge(status) { const badge = $('#step1-badge'); badge.className = 'step-badge' + (status === 'done' ? ' done' : ''); badge.textContent = status === 'done' ? '✓' : '1'; } // ── STEP 2: 펌웨어 업로드 ───────────────────────────────────── const uploadForm = $('#upload-form'); const progressWrap = $('#upload-progress'); const progressBar = $('#upload-bar'); const progressPct = $('#upload-pct'); // Drag & Drop 처리 function setupDropZone(zoneId, inputId) { const zone = $(`#${zoneId}`); const input = $(`#${inputId}`); const label = zone.querySelector('.drop-label'); zone.addEventListener('click', () => input.click()); zone.addEventListener('dragover', e => { e.preventDefault(); zone.classList.add('dragover'); }); zone.addEventListener('dragleave', () => zone.classList.remove('dragover')); zone.addEventListener('drop', e => { e.preventDefault(); zone.classList.remove('dragover'); const file = e.dataTransfer.files[0]; if (file && file.name.endsWith('.bin')) { const dt = new DataTransfer(); dt.items.add(file); input.files = dt.files; label.innerHTML = `📎 ${file.name} (${formatBytes(file.size)})`; } }); input.addEventListener('change', () => { if (input.files[0]) { label.innerHTML = `📎 ${input.files[0].name} (${formatBytes(input.files[0].size)})`; } }); } setupDropZone('drop-firmware', 'file-firmware'); setupDropZone('drop-bootloader', 'file-bootloader'); setupDropZone('drop-partitions', 'file-partitions'); setupDropZone('drop-boot-app0', 'file-boot-app0'); setupDropZone('drop-app', 'file-app'); // ── 업로드 모드 토글 ───────────────────────────────────────── const modeMerged = $('#mode-merged'); const modeSplit = $('#mode-split'); const splitFiles = $('#split-files'); const lblMerged = $('#lbl-merged'); const lblSplit = $('#lbl-split'); const firmwareHintText = $('#firmware-hint-text'); function applyUploadMode() { const isMerged = modeMerged.checked; splitFiles.style.display = isMerged ? 'none' : 'flex'; lblMerged.style.border = isMerged ? '2px solid var(--accent)' : '2px solid var(--border)'; lblMerged.style.background = isMerged ? 'rgba(0,200,150,.06)' : ''; lblSplit.style.border = isMerged ? '2px solid var(--border)' : '2px solid var(--accent)'; lblSplit.style.background = isMerged ? '' : 'rgba(0,200,150,.06)'; firmwareHintText.textContent = isMerged ? '*.merged.bin 파일을 드래그하거나 클릭하세요' : '앱 바이너리 (*.ino.bin)를 드래그하거나 클릭하세요'; } modeMerged.addEventListener('change', applyUploadMode); modeSplit.addEventListener('change', applyUploadMode); uploadForm.addEventListener('submit', async e => { e.preventDefault(); const isMerged = modeMerged.checked; const fwFile = $('#file-firmware').files[0]; if (isMerged) { if (!fwFile) { alert('merged.bin 파일을 선택하세요.'); return; } } else { const appFile = $('#file-app').files[0]; if (!fwFile && !appFile) { alert('앱 바이너리(.bin) 파일을 선택하세요.'); return; } // 분리 모드에서는 app 드롭존 파일을 firmware로 사용 if (!fwFile && appFile) { const dt = new DataTransfer(); dt.items.add(appFile); $('#file-firmware').files = dt.files; } } const finalFwFile = $('#file-firmware').files[0] || $('#file-app').files[0]; const fd = new FormData(); fd.append('name', $('#fw-name').value || finalFwFile.name.replace(/\.bin$/i,'')); fd.append('version', $('#fw-version').value || '1.0.0'); fd.append('description', $('#fw-desc').value); fd.append('chipFamily', $('#fw-chip').value); fd.append('firmware', finalFwFile); // flashAddress 미전송 → 서버가 bootloader 유무로 자동 결정 (merged:0x0 / split:0x10000) if (!isMerged) { const blFile = $('#file-bootloader').files[0]; if (blFile) fd.append('bootloader', blFile); const ptFile = $('#file-partitions').files[0]; if (ptFile) fd.append('partitions', ptFile); const baFile = $('#file-boot-app0').files[0]; if (baFile) fd.append('boot_app0', baFile); } progressWrap.style.display = 'block'; progressBar.style.width = '0%'; progressPct.textContent = '업로드 중…'; try { const resp = await fetchWithProgress(`${API}/api/firmware/upload`, { method: 'POST', body: fd, }, pct => { progressBar.style.width = `${pct}%`; progressPct.textContent = `${pct}%`; }); if (!resp.ok) { const err = await resp.json(); throw new Error(err.error || '업로드 실패'); } const data = await resp.json(); progressPct.textContent = '✓ 업로드 완료'; progressBar.style.width = '100%'; log(`✓ 펌웨어 업로드 완료: ${data.firmware.name} (ID: ${data.id})`, 'ok'); uploadForm.reset(); $$('.drop-label').forEach(l => { if (!l.classList.contains('file-selected')) return; l.innerHTML = l.closest('[id^=drop-firmware]') ? '📁 펌웨어 .bin 파일을 드래그하거나 클릭하세요' : '📁 선택 (선택사항)'; }); await loadFirmwareList(); selectFirmware(data.id); // 업로드 직후 자동 선택 switchTab('tab-flash'); } catch (err) { progressPct.textContent = `✗ ${err.message}`; progressBar.style.background = 'var(--danger)'; log(`✗ 업로드 오류: ${err.message}`, 'err'); } }); function fetchWithProgress(url, options, onProgress) { return new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); xhr.open(options.method || 'GET', url); xhr.upload.onprogress = e => { if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100)); }; xhr.onload = () => { const r = new Response(xhr.responseText, { status: xhr.status }); resolve(r); }; xhr.onerror = () => reject(new Error('네트워크 오류')); xhr.send(options.body); }); } // ── STEP 3: 펌웨어 목록 & 플래시 ──────────────────────────────── const fwListEl = $('#fw-list'); const installBtn = $('#esp-install-btn'); async function loadFirmwareList() { try { const resp = await fetch(`${API}/api/firmware`); state.firmwareList = await resp.json(); renderFwList(); // 목록 로드 후 자동 선택 적용 (선택된 것이 있으면 manifest 재설정) if (state.selectedFwId) selectFirmware(state.selectedFwId); } catch (err) { log(`✗ 펌웨어 목록 로드 실패: ${err.message}`, 'err'); } } function renderFwList() { if (!state.firmwareList.length) { fwListEl.innerHTML = `
📦
업로드된 펌웨어가 없습니다.
먼저 펌웨어 업로드 탭에서 .bin 파일을 업로드하세요.
`; return; } // 선택된 펌웨어가 없고 하나만 있으면 자동 선택 if (!state.selectedFwId && state.firmwareList.length === 1) { state.selectedFwId = state.firmwareList[0].id; } fwListEl.innerHTML = ''; state.firmwareList.forEach(fw => { const el = document.createElement('div'); el.className = 'fw-item' + (fw.id === state.selectedFwId ? ' selected' : ''); el.dataset.id = fw.id; el.innerHTML = `
💾
${escHtml(fw.name)}
v${escHtml(fw.version)} · ${escHtml(fw.chipFamily)} · ${fw.parts.length}개 파트 · ${new Date(fw.createdAt).toLocaleString('ko-KR')}
`; el.addEventListener('click', e => { if (e.target.classList.contains('btn-delete')) return; selectFirmware(fw.id); }); el.querySelector('.btn-delete').addEventListener('click', e => { e.stopPropagation(); deleteFirmware(fw.id); }); fwListEl.appendChild(el); }); } function selectFirmware(id) { state.selectedFwId = id; $$('.fw-item').forEach(el => el.classList.toggle('selected', el.dataset.id === id)); const manifestUrl = `${location.origin}/api/firmware/${id}/manifest`; installBtn.setAttribute('manifest', manifestUrl); const fw = state.firmwareList.find(f => f.id === id); $('#selected-fw-info').textContent = `선택됨: ${fw?.name}`; $('#flash-btn-wrap').style.display = 'block'; $('#no-fw-warning').style.display = 'none'; log(`✓ 플래시 대상 선택: ${fw?.name}`, 'ok'); log(` Manifest URL: ${manifestUrl}`, 'info'); } async function deleteFirmware(id) { if (!confirm('이 펌웨어를 삭제하시겠습니까?')) return; try { await fetch(`${API}/api/firmware/${id}`, { method: 'DELETE' }); if (state.selectedFwId === id) { state.selectedFwId = null; installBtn.removeAttribute('manifest'); $('#selected-fw-info').textContent = '선택된 펌웨어 없음 — 위 목록에서 클릭하여 선택하세요'; $('#flash-btn-wrap').style.display = 'none'; $('#no-fw-warning').style.display = 'block'; } await loadFirmwareList(); log(`✓ 펌웨어 삭제 완료`, 'ok'); } catch (err) { log(`✗ 삭제 실패: ${err.message}`, 'err'); } } // ── 탭 전환 ─────────────────────────────────────────────────── function switchTab(tabId) { $$('.tab-btn').forEach(b => b.classList.toggle('active', b.dataset.tab === tabId)); $$('.tab-panel').forEach(p => p.classList.toggle('active', p.id === tabId)); } $$('.tab-btn').forEach(btn => { btn.addEventListener('click', () => switchTab(btn.dataset.tab)); }); // ── 로그 지우기 ─────────────────────────────────────────────── $('#btn-clear-log').addEventListener('click', logClear); // ── 유틸 ────────────────────────────────────────────────────── function formatBytes(bytes) { if (bytes < 1024) return `${bytes} B`; if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; return `${(bytes / 1024 / 1024).toFixed(2)} MB`; } function escHtml(str) { return String(str) .replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); } // ── 초기화 ──────────────────────────────────────────────────── (async () => { checkBrowserCompat(); setConnStatus('idle'); btnDisconnect.style.display = 'none'; await loadFirmwareList(); log('ESP32 Web Flasher 준비 완료', 'ok'); })();