- server.js: add boot_app0 field at 0xe000, raise file limit 8→32 MB - index.html: add 병합/분리 mode toggle, boot_app0 drop zone, numbered split zones - app.js: dynamic mode logic, remove hardcoded flashAddress 0x10000, server now auto-selects 0x0 (merged) or 0x10000 (split) - flash-guide.html: step-by-step Korean flash guide with file table, method A/B walkthrough, flash_args explanation, troubleshooting Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
438 lines
16 KiB
JavaScript
438 lines
16 KiB
JavaScript
/* ──────────────────────────────────────────────────────────────
|
||
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();
|
||
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();
|
||
} 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;
|
||
}
|
||
|
||
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);
|
||
$('#selected-fw-info').textContent = `선택됨: ${state.firmwareList.find(f=>f.id===id)?.name}`;
|
||
|
||
log(`✓ 플래시 대상 선택: ${state.firmwareList.find(f=>f.id===id)?.name}`, 'ok');
|
||
log(` Manifest: ${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 = '선택된 펌웨어 없음';
|
||
}
|
||
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,'>').replace(/"/g,'"');
|
||
}
|
||
|
||
// ── 초기화 ────────────────────────────────────────────────────
|
||
(async () => {
|
||
checkBrowserCompat();
|
||
setConnStatus('idle');
|
||
btnDisconnect.style.display = 'none';
|
||
await loadFirmwareList();
|
||
log('ESP32 Web Flasher 준비 완료', 'ok');
|
||
})();
|