first commit

This commit is contained in:
root
2026-05-17 03:27:30 +09:00
commit ea41d0d1ed
1216 changed files with 126475 additions and 0 deletions

395
frontend/js/app.js Normal file
View 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,'&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');
})();