Files
webflash/frontend/js/app.js
root 88094afe06 fix: auto-select firmware after upload, guard flash button without selection
- Auto-select uploaded firmware immediately after upload completes
- Auto-select single firmware on list load (no manual click needed)
- Hide flash button and show warning when no firmware is selected
- Re-apply manifest on list reload to avoid stale state
- Restore warning banner on firmware delete

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 05:39:23 +09:00

452 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* ──────────────────────────────────────────────────────────────
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 = `<span class="file-selected">📎 ${file.name} (${formatBytes(file.size)})</span>`;
}
});
input.addEventListener('change', () => {
if (input.files[0]) {
label.innerHTML = `<span class="file-selected">📎 ${input.files[0].name} (${formatBytes(input.files[0].size)})</span>`;
}
});
}
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 = `
<div class="empty-state">
<div class="icon">📦</div>
<div>업로드된 펌웨어가 없습니다.</div>
<div style="margin-top:6px;font-size:12px;">먼저 <strong>펌웨어 업로드</strong> 탭에서 .bin 파일을 업로드하세요.</div>
</div>`;
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 = `
<div class="fw-icon">💾</div>
<div class="fw-info">
<div class="fw-name">${escHtml(fw.name)}</div>
<div class="fw-meta">
v${escHtml(fw.version)} · ${escHtml(fw.chipFamily)} ·
<span class="chip-badge">${fw.parts.length}개 파트</span> ·
${new Date(fw.createdAt).toLocaleString('ko-KR')}
</div>
</div>
<div class="fw-actions">
<button class="btn btn-danger btn-sm btn-delete" data-id="${fw.id}">삭제</button>
</div>`;
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,'&amp;').replace(/</g,'&lt;')
.replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// ── 초기화 ────────────────────────────────────────────────────
(async () => {
checkBrowserCompat();
setConnStatus('idle');
btnDisconnect.style.display = 'none';
await loadFirmwareList();
log('ESP32 Web Flasher 준비 완료', 'ok');
})();