first commit
This commit is contained in:
395
frontend/js/app.js
Normal file
395
frontend/js/app.js
Normal file
@@ -0,0 +1,395 @@
|
||||
/* ──────────────────────────────────────────────────────────────
|
||||
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');
|
||||
|
||||
uploadForm.addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
|
||||
const fwFile = $('#file-firmware').files[0];
|
||||
if (!fwFile) {
|
||||
alert('펌웨어(.bin) 파일을 선택하세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
const fd = new FormData();
|
||||
fd.append('name', $('#fw-name').value || fwFile.name.replace('.bin',''));
|
||||
fd.append('version', $('#fw-version').value || '1.0.0');
|
||||
fd.append('description', $('#fw-desc').value);
|
||||
fd.append('chipFamily', $('#fw-chip').value);
|
||||
fd.append('flashAddress', '0x10000');
|
||||
fd.append('firmware', fwFile);
|
||||
|
||||
const blFile = $('#file-bootloader').files[0];
|
||||
if (blFile) fd.append('bootloader', blFile);
|
||||
|
||||
const ptFile = $('#file-partitions').files[0];
|
||||
if (ptFile) fd.append('partitions', ptFile);
|
||||
|
||||
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');
|
||||
})();
|
||||
Reference in New Issue
Block a user