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

6
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,6 @@
FROM nginx:alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY index.html /usr/share/nginx/html/
COPY css/ /usr/share/nginx/html/css/
COPY js/ /usr/share/nginx/html/js/
EXPOSE 80

387
frontend/css/style.css Normal file
View File

@@ -0,0 +1,387 @@
/* ──────────────────────────────────────────────────────────────
ESP32 Web Flasher style.css
────────────────────────────────────────────────────────────── */
:root {
--bg: #0d1117;
--surface: #161b22;
--surface2: #21262d;
--border: #30363d;
--accent: #00c896;
--accent-dim: #00a07a;
--danger: #f85149;
--warn: #e3b341;
--text: #e6edf3;
--text-muted: #8b949e;
--radius: 8px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* ── Header ─────────────────────────────────────────────────── */
header {
background: var(--surface);
border-bottom: 1px solid var(--border);
padding: 0 24px;
height: 56px;
display: flex;
align-items: center;
justify-content: space-between;
position: sticky;
top: 0;
z-index: 100;
}
.logo {
display: flex;
align-items: center;
gap: 10px;
font-weight: 700;
font-size: 18px;
color: var(--accent);
text-decoration: none;
}
.logo svg { width: 28px; height: 28px; }
.header-badge {
font-size: 11px;
background: var(--accent);
color: #000;
padding: 2px 8px;
border-radius: 20px;
font-weight: 600;
}
/* ── Layout ──────────────────────────────────────────────────── */
main {
flex: 1;
max-width: 960px;
width: 100%;
margin: 0 auto;
padding: 32px 16px 64px;
display: flex;
flex-direction: column;
gap: 24px;
}
/* ── Cards ───────────────────────────────────────────────────── */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.card-header {
padding: 14px 20px;
background: var(--surface2);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 10px;
}
.card-header h2 {
font-size: 15px;
font-weight: 600;
}
.step-badge {
width: 24px;
height: 24px;
border-radius: 50%;
background: var(--accent);
color: #000;
font-size: 12px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.step-badge.done { background: #238636; color: #fff; }
.step-badge.locked { background: var(--border); color: var(--text-muted); }
.card-body { padding: 20px; }
/* ── Buttons ─────────────────────────────────────────────────── */
.btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 18px;
border-radius: 6px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
border: 1px solid transparent;
transition: opacity .15s, filter .15s;
text-decoration: none;
}
.btn:disabled { opacity: .4; cursor: not-allowed; }
.btn:not(:disabled):hover { filter: brightness(1.1); }
.btn-primary { background: var(--accent); color: #000; border-color: var(--accent); }
.btn-secondary { background: transparent; color: var(--text); border-color: var(--border); }
.btn-danger { background: var(--danger); color: #fff; }
.btn-sm { padding: 5px 12px; font-size: 12px; }
/* ── Status indicator ────────────────────────────────────────── */
.status-row {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-radius: 6px;
background: var(--surface2);
border: 1px solid var(--border);
font-size: 14px;
}
.dot {
width: 10px;
height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
.dot-idle { background: var(--border); }
.dot-ok { background: var(--accent); box-shadow: 0 0 6px var(--accent); }
.dot-error { background: var(--danger); box-shadow: 0 0 6px var(--danger); }
.dot-loading { background: var(--warn); animation: pulse 1s infinite; }
@keyframes pulse { 0%,100%{ opacity:1 } 50%{ opacity:.3 } }
/* ── Forms ───────────────────────────────────────────────────── */
.form-group { display: flex; flex-direction: column; gap: 6px; margin-bottom: 14px; }
.form-group label { font-size: 13px; color: var(--text-muted); }
input[type="text"],
input[type="file"],
select,
textarea {
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
border-radius: 6px;
padding: 8px 12px;
font-size: 14px;
width: 100%;
outline: none;
}
input:focus, select:focus, textarea:focus {
border-color: var(--accent);
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
@media (max-width: 600px) { .form-grid { grid-template-columns: 1fr; } }
/* ── File drop zone ──────────────────────────────────────────── */
.drop-zone {
border: 2px dashed var(--border);
border-radius: 6px;
padding: 28px;
text-align: center;
cursor: pointer;
transition: border-color .2s, background .2s;
color: var(--text-muted);
font-size: 14px;
}
.drop-zone:hover, .drop-zone.dragover {
border-color: var(--accent);
background: rgba(0,200,150,.05);
}
.drop-zone input { display: none; }
.drop-zone .icon { font-size: 32px; margin-bottom: 8px; }
.drop-zone .file-selected {
color: var(--accent);
font-weight: 600;
word-break: break-all;
}
/* ── Progress bar ────────────────────────────────────────────── */
.progress-wrap {
background: var(--surface2);
border-radius: 20px;
height: 8px;
overflow: hidden;
border: 1px solid var(--border);
}
.progress-bar {
height: 100%;
background: linear-gradient(90deg, var(--accent), var(--accent-dim));
border-radius: 20px;
width: 0%;
transition: width .3s;
}
.progress-label {
font-size: 12px;
color: var(--text-muted);
margin-top: 6px;
display: flex;
justify-content: space-between;
}
/* ── Serial monitor ──────────────────────────────────────────── */
#monitor {
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', monospace;
font-size: 12px;
background: #0d1117;
color: #58a6ff;
border: 1px solid var(--border);
border-radius: 6px;
padding: 12px;
height: 200px;
overflow-y: auto;
white-space: pre-wrap;
word-break: break-all;
line-height: 1.6;
}
#monitor .line-ok { color: var(--accent); }
#monitor .line-err { color: var(--danger); }
#monitor .line-warn { color: var(--warn); }
#monitor .line-info { color: var(--text-muted); }
/* ── Firmware list ───────────────────────────────────────────── */
.fw-list { display: flex; flex-direction: column; gap: 10px; }
.fw-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 14px;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 6px;
cursor: pointer;
transition: border-color .15s;
}
.fw-item:hover { border-color: var(--accent); }
.fw-item.selected { border-color: var(--accent); background: rgba(0,200,150,.08); }
.fw-item .fw-icon {
font-size: 28px;
flex-shrink: 0;
}
.fw-item .fw-info { flex: 1; overflow: hidden; }
.fw-item .fw-name {
font-weight: 600;
font-size: 14px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.fw-item .fw-meta {
font-size: 12px;
color: var(--text-muted);
margin-top: 2px;
}
.fw-item .fw-actions { display: flex; gap: 6px; flex-shrink: 0; }
.chip-badge {
background: var(--surface);
border: 1px solid var(--border);
color: var(--text-muted);
font-size: 11px;
padding: 2px 8px;
border-radius: 20px;
}
/* ── Alert banner ────────────────────────────────────────────── */
.alert {
padding: 10px 16px;
border-radius: 6px;
font-size: 13px;
display: flex;
align-items: flex-start;
gap: 10px;
line-height: 1.5;
}
.alert-info { background: rgba(56,139,253,.1); border: 1px solid rgba(56,139,253,.3); }
.alert-warn { background: rgba(227,179,65,.1); border: 1px solid rgba(227,179,65,.3); }
/* ── esp-web-install-button override ────────────────────────── */
esp-web-install-button {
display: block;
margin-top: 14px;
}
esp-web-install-button[active] > button {
background: var(--accent);
color: #000;
border: none;
padding: 10px 22px;
border-radius: 6px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 8px;
}
/* ── Tabs ────────────────────────────────────────────────────── */
.tab-bar {
display: flex;
gap: 4px;
border-bottom: 1px solid var(--border);
padding: 0 20px;
background: var(--surface2);
}
.tab-btn {
padding: 10px 16px;
font-size: 13px;
color: var(--text-muted);
background: none;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
transition: color .15s;
margin-bottom: -1px;
}
.tab-btn:hover { color: var(--text); }
.tab-btn.active { color: var(--accent); border-bottom-color: var(--accent); }
.tab-panel { display: none; padding: 20px; }
.tab-panel.active { display: block; }
/* ── Empty state ─────────────────────────────────────────────── */
.empty-state {
text-align: center;
padding: 32px;
color: var(--text-muted);
}
.empty-state .icon { font-size: 40px; margin-bottom: 12px; }

355
frontend/index.html Normal file
View File

@@ -0,0 +1,355 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>ESP32 Web Flasher</title>
<link rel="stylesheet" href="css/style.css" />
<!--
esp-web-tools (Espressif 공식)
Web Serial API를 통해 ESP32 시리얼 플래시 수행
Chrome 89+ / Edge 89+ 필요
-->
<script
type="module"
src="https://unpkg.com/esp-web-tools@10/dist/web/install-button.js">
</script>
</head>
<body>
<!-- ═══════════════════════════════════════════════════════════════
HEADER
══════════════════════════════════════════════════════════════════ -->
<header>
<a class="logo" href="#">
<svg viewBox="0 0 32 32" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="32" height="32" rx="6" fill="#00c896" fill-opacity=".15"/>
<rect x="6" y="6" width="20" height="20" rx="3" stroke="#00c896" stroke-width="2"/>
<rect x="10" y="10" width="5" height="5" fill="#00c896"/>
<rect x="17" y="10" width="5" height="5" fill="#00c896" fill-opacity=".5"/>
<rect x="10" y="17" width="5" height="5" fill="#00c896" fill-opacity=".5"/>
<rect x="17" y="17" width="5" height="5" fill="#00c896"/>
<line x1="2" y1="12" x2="6" y2="12" stroke="#00c896" stroke-width="1.5"/>
<line x1="2" y1="20" x2="6" y2="20" stroke="#00c896" stroke-width="1.5"/>
<line x1="26" y1="12" x2="30" y2="12" stroke="#00c896" stroke-width="1.5"/>
<line x1="26" y1="20" x2="30" y2="20" stroke="#00c896" stroke-width="1.5"/>
</svg>
ESP32 Web Flasher
</a>
<span class="header-badge">ESP32-S3</span>
</header>
<!-- ═══════════════════════════════════════════════════════════════
MAIN
══════════════════════════════════════════════════════════════════ -->
<main>
<!-- 브라우저 경고 배너 -->
<div id="browser-warning" class="alert alert-warn" style="display:none;">
<span style="font-size:18px;"></span>
<div>
<strong>지원되지 않는 브라우저</strong><br>
Web Serial API는 <strong>Chrome 89+</strong> 또는 <strong>Edge 89+</strong>에서만 동작합니다.
현재 브라우저에서는 시리얼 연결 및 플래시 기능을 사용할 수 없습니다.
</div>
</div>
<!-- ─────────────────────────────────────────────────────────────
STEP 1: 시리얼 연결 확인
──────────────────────────────────────────────────────────────── -->
<div class="card">
<div class="card-header">
<div class="step-badge" id="step1-badge">1</div>
<h2>시리얼 연결 확인</h2>
</div>
<div class="card-body">
<div class="alert alert-info" style="margin-bottom:16px;">
<span></span>
<div>
ESP32S3를 USB 케이블로 PC에 연결한 후 아래 버튼을 눌러 연결 상태를 확인하세요.
<br><small style="color:var(--text-muted);">내장 USB 포트 또는 USB-UART 변환기 모두 지원</small>
</div>
</div>
<div class="status-row">
<div class="dot dot-idle" id="conn-dot"></div>
<span id="conn-text">연결 안 됨</span>
<span id="device-info" style="margin-left:auto;font-size:12px;color:var(--text-muted);"></span>
</div>
<div style="display:flex;gap:10px;margin-top:14px;">
<button id="btn-connect" class="btn btn-primary needs-serial">
🔌 포트 연결
</button>
<button id="btn-disconnect" class="btn btn-secondary" style="display:none;">
⏏ 연결 해제
</button>
</div>
</div>
</div>
<!-- ─────────────────────────────────────────────────────────────
STEP 2 & 3: 펌웨어 업로드 / 플래시 (탭)
──────────────────────────────────────────────────────────────── -->
<div class="card">
<div class="card-header">
<div class="step-badge" id="step2-badge">2</div>
<h2>펌웨어 관리 및 플래시</h2>
</div>
<!-- 탭 바 -->
<div class="tab-bar">
<button class="tab-btn active" data-tab="tab-upload">📤 펌웨어 업로드</button>
<button class="tab-btn" data-tab="tab-flash">⚡ 플래시</button>
</div>
<!-- ── 탭: 펌웨어 업로드 ──────────────────────────────── -->
<div class="tab-panel active" id="tab-upload">
<div class="alert alert-info" style="margin-bottom:16px;">
<span></span>
<div>
Arduino IDE <strong>Sketch → Export Compiled Binary</strong> 로 생성한 .bin 파일을 업로드하세요.
<br>병합 바이너리(merged.bin)는 '펌웨어' 하나만 업로드해도 됩니다.
<br><small style="color:var(--text-muted);">
분리 파일 사용 시: bootloader(0x0000) + partition-table(0x8000) + app(0x10000)
</small>
</div>
</div>
<form id="upload-form">
<div class="form-grid">
<div class="form-group">
<label>펌웨어 이름</label>
<input id="fw-name" type="text" placeholder="예: MyProduct v1.0" />
</div>
<div class="form-group">
<label>버전</label>
<input id="fw-version" type="text" placeholder="예: 1.0.0" />
</div>
</div>
<div class="form-group">
<label>설명 (선택)</label>
<input id="fw-desc" type="text" placeholder="간단한 설명" />
</div>
<div class="form-group">
<label>칩 패밀리</label>
<select id="fw-chip">
<option value="ESP32-S3" selected>ESP32-S3</option>
<option value="ESP32-S2">ESP32-S2</option>
<option value="ESP32-C3">ESP32-C3</option>
<option value="ESP32">ESP32</option>
</select>
</div>
<!-- 드롭존: 펌웨어 (필수) -->
<div class="form-group">
<label>펌웨어 바이너리 <span style="color:var(--danger);">*</span></label>
<div class="drop-zone" id="drop-firmware">
<input type="file" id="file-firmware" accept=".bin" />
<div class="icon">📦</div>
<div class="drop-label">
펌웨어 .bin 파일을 드래그하거나 클릭하세요
<div style="font-size:11px;margin-top:4px;color:var(--text-muted);">(최대 8 MB)</div>
</div>
</div>
</div>
<!-- 분리 파일 섹션 (선택) -->
<details style="margin-bottom:14px;">
<summary style="cursor:pointer;font-size:13px;color:var(--text-muted);margin-bottom:8px;">
▸ 분리 바이너리 파일 추가 (선택)
</summary>
<div style="padding-top:10px;display:flex;flex-direction:column;gap:12px;">
<div class="form-group" style="margin:0;">
<label>부트로더 (bootloader.bin) — offset 0x0000</label>
<div class="drop-zone" id="drop-bootloader" style="padding:16px;">
<input type="file" id="file-bootloader" accept=".bin" />
<div class="drop-label">부트로더 .bin (선택사항)</div>
</div>
</div>
<div class="form-group" style="margin:0;">
<label>파티션 테이블 (partition-table.bin) — offset 0x8000</label>
<div class="drop-zone" id="drop-partitions" style="padding:16px;">
<input type="file" id="file-partitions" accept=".bin" />
<div class="drop-label">파티션 테이블 .bin (선택사항)</div>
</div>
</div>
</div>
</details>
<div id="upload-progress" style="display:none;margin-bottom:14px;">
<div class="progress-wrap"><div class="progress-bar" id="upload-bar"></div></div>
<div class="progress-label">
<span id="upload-pct">0%</span>
</div>
</div>
<button type="submit" class="btn btn-primary">
📤 서버에 업로드
</button>
</form>
</div>
<!-- ── 탭: 플래시 ─────────────────────────────────────── -->
<div class="tab-panel" id="tab-flash">
<div class="alert alert-info" style="margin-bottom:16px;">
<span></span>
<div>
업로드된 펌웨어를 선택 후 <strong>플래시 실행</strong> 버튼을 누르세요.
<br><small style="color:var(--text-muted);">
esp-web-tools가 시리얼 포트 선택 대화상자를 표시하고 자동으로 플래싱합니다.
</small>
</div>
</div>
<!-- 업로드된 펌웨어 목록 -->
<div style="margin-bottom:16px;">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;">
<span style="font-size:14px;font-weight:600;">업로드된 펌웨어</span>
<button class="btn btn-secondary btn-sm" onclick="loadFirmwareList()">🔄 새로고침</button>
</div>
<div id="fw-list" class="fw-list"></div>
</div>
<!-- 선택 상태 & 플래시 버튼 -->
<div style="border-top:1px solid var(--border);padding-top:16px;margin-top:4px;">
<div class="status-row" style="margin-bottom:12px;">
<span>💾</span>
<span id="selected-fw-info" style="font-size:14px;">선택된 펌웨어 없음 — 위 목록에서 선택하세요</span>
</div>
<!--
esp-web-install-button 웹 컴포넌트
manifest 속성에 백엔드 manifest URL을 동적으로 설정
펌웨어 선택 시 selectFirmware() 함수가 manifest를 업데이트
-->
<esp-web-install-button id="esp-install-btn">
<button
slot="activate"
class="btn btn-primary needs-serial"
style="width:100%;justify-content:center;padding:12px;"
>
⚡ ESP32S3 플래시 실행
</button>
<span slot="unsupported" style="color:var(--danger);font-size:13px;">
⚠ 이 브라우저는 Web Serial을 지원하지 않습니다 (Chrome/Edge 필요)
</span>
</esp-web-install-button>
<div class="alert alert-warn" style="margin-top:14px;">
<span></span>
<div style="font-size:12px;">
<strong>플래시 전 확인 사항:</strong>
<ul style="margin:4px 0 0 16px;line-height:1.8;">
<li>ESP32S3가 USB로 연결되어 있어야 합니다</li>
<li>다른 프로그램(Arduino IDE, esptool)이 해당 포트를 사용 중이면 안 됩니다</li>
<li>플래시 중 USB 케이블을 분리하지 마세요</li>
<li>일부 보드는 BOOT 버튼을 누른 상태로 연결해야 합니다</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<!-- ─────────────────────────────────────────────────────────────
시리얼 모니터 / 로그
──────────────────────────────────────────────────────────────── -->
<div class="card">
<div class="card-header" style="justify-content:space-between;">
<div style="display:flex;align-items:center;gap:10px;">
<span>🖥</span>
<h2>로그 / 상태</h2>
</div>
<button id="btn-clear-log" class="btn btn-secondary btn-sm">지우기</button>
</div>
<div class="card-body" style="padding:12px;">
<pre id="monitor"></pre>
</div>
</div>
<!-- ─────────────────────────────────────────────────────────────
도움말
──────────────────────────────────────────────────────────────── -->
<div class="card">
<div class="card-header">
<span>📖</span>
<h2>사용 방법 & 플래시 파일 준비</h2>
</div>
<div class="card-body">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:20px;">
<div>
<h3 style="font-size:14px;margin-bottom:10px;color:var(--accent);">Arduino IDE에서 .bin 내보내기</h3>
<ol style="font-size:13px;line-height:2;color:var(--text-muted);padding-left:18px;">
<li>Arduino IDE 2.x 실행</li>
<li>ESP32S3 보드 선택 후 코드 작성</li>
<li><strong>Sketch → Export Compiled Binary</strong></li>
<li>스케치 폴더에 <code>.bin</code> 파일 생성됨</li>
<li>위 업로드 탭에서 해당 파일 업로드</li>
</ol>
</div>
<div>
<h3 style="font-size:14px;margin-bottom:10px;color:var(--accent);">병합 바이너리(merged.bin) 생성</h3>
<pre style="font-size:11px;background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:10px;overflow:auto;color:var(--text-muted);">esptool.py --chip esp32s3 \
merge_bin \
-o merged.bin \
0x0 bootloader.bin \
0x8000 partitions.bin \
0x10000 app.bin</pre>
<p style="font-size:12px;color:var(--text-muted);margin-top:8px;">
병합 후 merged.bin 하나만 업로드하면 됩니다.
</p>
</div>
<div>
<h3 style="font-size:14px;margin-bottom:10px;color:var(--accent);">Flash 암호화 (보안 강화)</h3>
<pre style="font-size:11px;background:var(--bg);border:1px solid var(--border);border-radius:6px;padding:10px;overflow:auto;color:var(--text-muted);"># Flash Encryption 활성화
espefuse.py --chip esp32s3 \
burn_efuse DIS_DOWNLOAD_PLAIN_DECRYPT</pre>
<p style="font-size:12px;color:var(--text-muted);margin-top:8px;">
활성화 후 덤프 시 암호화된 데이터만 읽힙니다.
</p>
</div>
<div>
<h3 style="font-size:14px;margin-bottom:10px;color:var(--accent);">지원 환경</h3>
<table style="font-size:12px;width:100%;border-collapse:collapse;color:var(--text-muted);">
<tr>
<td style="padding:4px 0;">Chrome 89+</td>
<td style="color:var(--accent);">✓ 지원</td>
</tr>
<tr>
<td style="padding:4px 0;">Edge 89+</td>
<td style="color:var(--accent);">✓ 지원</td>
</tr>
<tr>
<td style="padding:4px 0;">Firefox</td>
<td style="color:var(--danger);">✗ 미지원</td>
</tr>
<tr>
<td style="padding:4px 0;">Safari</td>
<td style="color:var(--danger);">✗ 미지원</td>
</tr>
<tr>
<td style="padding:4px 0;">HTTP (non-localhost)</td>
<td style="color:var(--danger);">✗ HTTPS 필요</td>
</tr>
</table>
</div>
</div>
</div>
</div>
</main>
<script src="js/app.js"></script>
</body>
</html>

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');
})();

27
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,27 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# 정적 파일 서빙
location / {
try_files $uri $uri/ /index.html;
}
# 백엔드 API 프록시
location /api/ {
proxy_pass http://backend:3000/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# 펌웨어 파일 프록시
location /firmware/ {
proxy_pass http://backend:3000/firmware/;
proxy_set_header Host $host;
}
}