feat: support merged.bin, boot_app0, upload mode toggle + flash guide

- 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>
This commit is contained in:
root
2026-05-19 05:26:53 +09:00
parent cb9df54abb
commit 3a662affb6
4 changed files with 842 additions and 47 deletions

View File

@@ -52,7 +52,7 @@ const upload = multer({
cb(new Error('.bin 파일만 업로드 가능합니다'));
}
},
limits: { fileSize: 8 * 1024 * 1024 }, // 8 MB
limits: { fileSize: 32 * 1024 * 1024 }, // 32 MB
});
// ── 라우트 ──────────────────────────────────────────────────────────────────
@@ -68,13 +68,14 @@ app.get('/api/firmware', (_req, res) => {
});
// 펌웨어 업로드
// 지원 필드: firmware(필수), bootloader(선택), partitions(선택)
// 지원 필드: firmware(필수), bootloader(선택), partitions(선택), boot_app0(선택)
app.post(
'/api/firmware/upload',
upload.fields([
{ name: 'firmware', maxCount: 1 },
{ name: 'bootloader', maxCount: 1 },
{ name: 'partitions', maxCount: 1 },
{ name: 'firmware', maxCount: 1 },
{ name: 'bootloader', maxCount: 1 },
{ name: 'partitions', maxCount: 1 },
{ name: 'boot_app0', maxCount: 1 },
]),
(req, res) => {
try {
@@ -105,7 +106,15 @@ app.post(
});
}
// 부트로더가 따로 없으면 0x0, 있으면 0x10000
if (files.boot_app0) {
parts.push({
file: files.boot_app0[0].filename,
offset: '0xe000',
label: 'Boot App0',
});
}
// 부트로더가 따로 없으면(병합 바이너리) 0x0, 있으면 0x10000
const appOffset = flashAddress
|| (files.bootloader ? '0x10000' : '0x0000');

717
flash-guide.html Normal file
View File

@@ -0,0 +1,717 @@
<!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>
<style>
:root {
--bg: #0d1117;
--surf: #161b22;
--surf2: #21262d;
--border: #30363d;
--accent: #00c896;
--blue: #58a6ff;
--danger: #f85149;
--warn: #e3b341;
--text: #e6edf3;
--muted: #8b949e;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--text);
font-family: 'Segoe UI', 'Apple SD Gothic Neo', sans-serif;
font-size: 15px;
line-height: 1.7;
}
/* ── 헤더 ── */
header {
background: linear-gradient(135deg, #0e2a23 0%, #111820 100%);
border-bottom: 1px solid var(--border);
padding: 32px 24px 28px;
text-align: center;
}
header h1 {
font-size: 1.9rem;
font-weight: 700;
background: linear-gradient(135deg, var(--accent), var(--blue));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
header p { color: var(--muted); margin-top: 8px; font-size: 14px; }
/* ── 레이아웃 ── */
.wrap { max-width: 860px; margin: 0 auto; padding: 40px 20px 80px; }
/* ── 섹션 ── */
section { margin-bottom: 52px; }
section h2 {
font-size: 1.15rem;
font-weight: 700;
display: flex;
align-items: center;
gap: 10px;
padding-bottom: 12px;
border-bottom: 2px solid var(--border);
margin-bottom: 22px;
}
.sec-num {
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px; height: 30px;
border-radius: 8px;
background: var(--accent);
color: #000;
font-size: 14px;
font-weight: 800;
flex-shrink: 0;
}
h3 { font-size: .95rem; font-weight: 600; color: var(--accent); margin: 20px 0 8px; }
/* ── 파일 테이블 ── */
table { width: 100%; border-collapse: collapse; font-size: 13.5px; margin-bottom: 16px; }
th { background: var(--surf2); color: var(--muted); font-size: 11px; text-transform: uppercase;
letter-spacing: .8px; padding: 9px 14px; text-align: left; border: 1px solid var(--border); }
td { padding: 10px 14px; border: 1px solid var(--border); vertical-align: top; }
tr:nth-child(even) td { background: rgba(255,255,255,.02); }
code {
background: var(--surf2);
padding: 2px 7px;
border-radius: 4px;
font-size: 12px;
font-family: 'JetBrains Mono', Consolas, monospace;
color: var(--blue);
}
/* ── 배지 ── */
.ok { background: rgba(0,200,150,.15); color: var(--accent); padding: 2px 8px; border-radius: 12px; font-size: 11px; font-weight: 700; }
.skip { background: rgba(139,148,158,.12); color: var(--muted); padding: 2px 8px; border-radius: 12px; font-size: 11px; font-weight: 700; }
.need { background: rgba(248,81,73,.12); color: var(--danger); padding: 2px 8px; border-radius: 12px; font-size: 11px; font-weight: 700; }
.opt { background: rgba(227,179,65,.12); color: var(--warn); padding: 2px 8px; border-radius: 12px; font-size: 11px; font-weight: 700; }
/* ── 카드 ── */
.card {
background: var(--surf);
border: 1px solid var(--border);
border-radius: 10px;
padding: 18px 22px;
margin-bottom: 14px;
}
.card-title { font-size: 12px; font-weight: 700; text-transform: uppercase;
letter-spacing: .8px; color: var(--muted); margin-bottom: 10px; }
/* ── 스텝 박스 ── */
.step {
display: flex;
gap: 16px;
padding: 16px 0;
border-bottom: 1px solid var(--border);
}
.step:last-child { border-bottom: none; }
.step-n {
width: 32px; height: 32px;
border-radius: 50%;
background: rgba(0,200,150,.15);
border: 2px solid var(--accent);
color: var(--accent);
font-weight: 800;
font-size: 13px;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0;
margin-top: 2px;
}
.step-body .title { font-weight: 600; font-size: 14px; margin-bottom: 5px; }
.step-body .desc { font-size: 13px; color: var(--muted); }
/* ── 경고/팁 박스 ── */
.alert {
display: flex;
gap: 12px;
padding: 13px 16px;
border-radius: 8px;
font-size: 13px;
margin: 14px 0;
}
.alert-warn { background: rgba(227,179,65,.1); border: 1px solid rgba(227,179,65,.3); color: var(--text); }
.alert-tip { background: rgba(0,200,150,.08); border: 1px solid rgba(0,200,150,.25); color: var(--text); }
.alert-info { background: rgba(88,166,255,.08); border: 1px solid rgba(88,166,255,.25); color: var(--text); }
.alert-icon { font-size: 18px; flex-shrink: 0; }
/* ── 코드 블록 ── */
pre {
background: var(--surf2);
border: 1px solid var(--border);
border-radius: 8px;
padding: 14px 18px;
overflow-x: auto;
font-family: 'JetBrains Mono', Consolas, monospace;
font-size: 12.5px;
line-height: 1.65;
color: #c9d1d9;
margin: 10px 0 14px;
}
pre .c { color: var(--muted); }
pre .h { color: var(--accent); }
pre .v { color: var(--warn); }
/* ── 방법 탭 스타일 ── */
.method-tab {
display: flex;
gap: 2px;
margin-bottom: 0;
border-bottom: 2px solid var(--border);
}
.method-lbl {
padding: 9px 20px;
font-size: 13px;
font-weight: 600;
cursor: default;
border-radius: 8px 8px 0 0;
}
.method-a { background: rgba(0,200,150,.12); color: var(--accent); border: 1px solid var(--accent); border-bottom: none; }
.method-b { background: var(--surf2); color: var(--muted); }
/* ── 그리드 ── */
.g2 { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
@media(max-width:640px) { .g2 { grid-template-columns: 1fr; } }
/* ── 파일 아이콘 행 ── */
.file-row {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 14px;
background: var(--surf2);
border-radius: 8px;
margin-bottom: 6px;
}
.file-icon { font-size: 18px; }
.file-name { font-family: Consolas, monospace; font-size: 13px; font-weight: 600; }
.file-desc { font-size: 12px; color: var(--muted); }
.file-offset { margin-left: auto; font-family: Consolas, monospace; font-size: 12px; color: var(--warn); }
/* ── 스크린샷 박스 ── */
.mockup {
background: var(--surf2);
border: 1px solid var(--border);
border-radius: 10px;
padding: 16px;
font-family: Consolas, monospace;
font-size: 12px;
line-height: 2;
margin: 10px 0;
}
.m-ok { color: var(--accent); }
.m-info { color: var(--muted); }
.m-warn { color: var(--warn); }
.m-err { color: var(--danger); }
/* ── 푸터 ── */
footer { text-align: center; color: var(--muted); font-size: 12px; padding: 20px; border-top: 1px solid var(--border); }
@media print {
body { background: #fff; color: #111; }
header { background: #f0f0f0; }
.card, pre, .mockup, .file-row { background: #f9f9f9; border-color: #ddd; }
th { background: #eee; }
}
</style>
</head>
<body>
<header>
<h1>⚡ ESP32 Web Flasher 사용 설명서</h1>
<p>Arduino IDE 빌드 파일을 웹 브라우저에서 ESP32에 플래시하는 방법</p>
</header>
<div class="wrap">
<!-- ══════════════════════════════════════════════
1. 빌드 파일 설명
══════════════════════════════════════════════ -->
<section id="s1">
<h2><span class="sec-num">1</span> Arduino IDE 빌드 파일 설명</h2>
<p style="color:var(--muted);margin-bottom:16px;">
<strong style="color:var(--text);">Sketch → Export Compiled Binary</strong> 클릭 후 빌드 폴더
(<code>build/esp32.esp32.esp32s3/</code> 등)에 생성되는 파일들입니다.
</p>
<table>
<thead>
<tr>
<th>파일명</th>
<th>크기 (예시)</th>
<th>플래시 주소</th>
<th>용도</th>
<th>사용 여부</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>*.ino.merged.bin</code></td>
<td>~16 MB</td>
<td><code>0x0000</code></td>
<td>전체 병합 바이너리 (모든 파일 합본)</td>
<td><span class="ok">✓ 권장</span></td>
</tr>
<tr>
<td><code>*.ino.bootloader.bin</code></td>
<td>~20 KB</td>
<td><code>0x0000</code></td>
<td>부트로더</td>
<td><span class="opt">분리 방식 시</span></td>
</tr>
<tr>
<td><code>*.ino.partitions.bin</code></td>
<td>~3 KB</td>
<td><code>0x8000</code></td>
<td>파티션 테이블</td>
<td><span class="opt">분리 방식 시</span></td>
</tr>
<tr>
<td><code>boot_app0.bin</code></td>
<td>~8 KB</td>
<td><code>0xe000</code></td>
<td>OTA 부트 선택자</td>
<td><span class="opt">분리 방식 시</span></td>
</tr>
<tr>
<td><code>*.ino.bin</code></td>
<td>~1 MB</td>
<td><code>0x10000</code></td>
<td>애플리케이션 (실제 코드)</td>
<td><span class="opt">분리 방식 시</span></td>
</tr>
<tr>
<td><code>*.ino.elf</code></td>
<td>~15 MB</td>
<td></td>
<td>디버그 심볼 (GDB용)</td>
<td><span class="skip">불필요</span></td>
</tr>
<tr>
<td><code>*.ino.map</code></td>
<td>~17 MB</td>
<td></td>
<td>메모리 맵 (분석용)</td>
<td><span class="skip">불필요</span></td>
</tr>
<tr>
<td><code>flash_args</code></td>
<td>~200 B</td>
<td></td>
<td>esptool 플래시 인수 (참고용)</td>
<td><span class="skip">참고용</span></td>
</tr>
</tbody>
</table>
<div class="alert alert-tip">
<span class="alert-icon">💡</span>
<div>
<strong>flash_args 파일을 열면</strong> 어떤 파일이 어느 주소에 들어가는지 정확히 기록되어 있습니다.
<pre style="margin:8px 0 0;font-size:12px;">--flash-mode dio --flash-freq 80m --flash-size 16MB
0x0 bootloader.bin
0x8000 partitions.bin
0xe000 boot_app0.bin
0x10000 app.bin</pre>
</div>
</div>
</section>
<!-- ══════════════════════════════════════════════
2. 업로드 방식 선택
══════════════════════════════════════════════ -->
<section id="s2">
<h2><span class="sec-num">2</span> 업로드 방식 선택</h2>
<div class="g2">
<div class="card" style="border-color:var(--accent);">
<div class="card-title" style="color:var(--accent);">방법 A — 병합 바이너리 (권장)</div>
<p style="font-size:13px;color:var(--muted);margin-bottom:12px;">
모든 파티션이 하나로 합쳐진 <code>*.merged.bin</code> 파일 하나만 업로드합니다.
가장 간단하고 오류 가능성이 낮습니다.
</p>
<div style="font-size:13px;">
<div style="color:var(--accent);">✓ 장점</div>
<ul style="padding-left:18px;color:var(--muted);line-height:2;">
<li>파일 하나로 완전한 플래시</li>
<li>주소 설정 불필요</li>
<li>파일 누락 실수 없음</li>
</ul>
</div>
<div style="font-size:13px;margin-top:8px;">
<div style="color:var(--warn);">⚠ 주의</div>
<ul style="padding-left:18px;color:var(--muted);line-height:2;">
<li>파일 크기가 클 수 있음 (Flash 크기 = 파일 크기)</li>
</ul>
</div>
</div>
<div class="card">
<div class="card-title">방법 B — 분리 파일</div>
<p style="font-size:13px;color:var(--muted);margin-bottom:12px;">
bootloader, partitions, boot_app0, app을 각각 업로드합니다.
파일 크기가 작고 부분 업데이트가 가능합니다.
</p>
<div style="font-size:13px;">
<div style="color:var(--accent);">✓ 장점</div>
<ul style="padding-left:18px;color:var(--muted);line-height:2;">
<li>앱만 따로 업데이트 가능</li>
<li>파일 크기 작음 (앱만 ~1 MB)</li>
</ul>
</div>
<div style="font-size:13px;margin-top:8px;">
<div style="color:var(--warn);">⚠ 주의</div>
<ul style="padding-left:18px;color:var(--muted);line-height:2;">
<li>4개 파일을 정확히 맞춰야 함</li>
<li>파일 누락 시 부팅 실패</li>
</ul>
</div>
</div>
</div>
</section>
<!-- ══════════════════════════════════════════════
3. 방법 A 단계별 가이드
══════════════════════════════════════════════ -->
<section id="s3">
<h2><span class="sec-num">3</span> 방법 A — 병합 바이너리 플래시 (권장)</h2>
<div class="method-tab">
<span class="method-lbl method-a">병합 바이너리 모드</span>
</div>
<div class="card" style="border-radius:0 8px 8px 8px;border-top:none;">
<div class="step">
<div class="step-n">1</div>
<div class="step-body">
<div class="title">브라우저 준비</div>
<div class="desc">
<strong>Chrome 89+</strong> 또는 <strong>Edge 89+</strong>를 사용하세요.
<br>Web Flasher 주소: <code>http://localhost:3100</code> (Docker와 같은 PC인 경우)
<br><span style="color:var(--danger);">Firefox / Safari는 Web Serial API 미지원으로 플래시 불가</span>
</div>
</div>
</div>
<div class="step">
<div class="step-n">2</div>
<div class="step-body">
<div class="title">ESP32 보드 USB 연결</div>
<div class="desc">
ESP32-S3는 <strong>내장 USB 포트</strong>를 사용하면 별도 드라이버가 필요 없습니다.
<br>USB-UART 변환기(CP2102, CH340)를 사용하는 경우 칩 드라이버를 미리 설치하세요.
</div>
</div>
</div>
<div class="step">
<div class="step-n">3</div>
<div class="step-body">
<div class="title">시리얼 연결 확인 (STEP 1)</div>
<div class="desc">
웹 플래셔의 <strong>포트 연결</strong> 버튼을 클릭하고 브라우저 포트 선택창에서 ESP32 포트를 선택합니다.
<br>아래와 같이 Espressif 확인 메시지가 나오면 정상입니다.
<div class="mockup">
<span class="m-ok">✓ 포트 연결 성공</span><br>
<span class="m-info">&nbsp; VID: 0x303A PID: 0x1001</span><br>
<span class="m-ok">&nbsp; Espressif 장치 감지됨 (ESP32S3 가능성 높음)</span><br>
<span class="m-info">&nbsp; 포트를 닫았습니다 (플래시 시 자동 재연결)</span>
</div>
</div>
</div>
</div>
<div class="step">
<div class="step-n">4</div>
<div class="step-body">
<div class="title">업로드 방식 선택 — "병합 바이너리" 선택</div>
<div class="desc">
<strong>펌웨어 업로드</strong> 탭에서 <strong style="color:var(--accent);">병합 바이너리</strong> 버튼이 선택되어 있는지 확인합니다.
</div>
</div>
</div>
<div class="step">
<div class="step-n">5</div>
<div class="step-body">
<div class="title">merged.bin 파일 선택</div>
<div class="desc">
빌드 폴더에서 <code>CANFD_Logger.ino.merged.bin</code> (또는 프로젝트명.ino.merged.bin)을 드래그하거나 클릭하여 선택합니다.
<div class="file-row" style="margin-top:10px;">
<span class="file-icon">📦</span>
<div>
<div class="file-name">CANFD_Logger.ino.merged.bin</div>
<div class="file-desc">전체 병합 바이너리 — 이 파일 하나만 필요</div>
</div>
<div class="file-offset">offset 0x0</div>
</div>
</div>
</div>
</div>
<div class="step">
<div class="step-n">6</div>
<div class="step-body">
<div class="title">메타데이터 입력 후 업로드</div>
<div class="desc">
이름, 버전, 칩 패밀리(ESP32-S3)를 입력하고 <strong>서버에 업로드</strong>를 클릭합니다.
<br>업로드 완료 후 자동으로 <strong>플래시</strong> 탭으로 이동합니다.
</div>
</div>
</div>
<div class="step">
<div class="step-n">7</div>
<div class="step-body">
<div class="title">플래시 실행</div>
<div class="desc">
<strong>플래시</strong> 탭에서 방금 업로드한 펌웨어를 클릭하여 선택한 뒤,
<strong>⚡ 플래시 실행</strong> 버튼을 누릅니다.
<br>esp-web-tools 창에서 포트를 선택하면 자동으로 플래싱이 진행됩니다.
<div class="alert alert-warn" style="margin-top:10px;">
<span class="alert-icon"></span>
<div>플래시 중 USB 케이블 분리 금지. 일부 보드는 BOOT 버튼을 누른 채로 연결해야 합니다.</div>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- ══════════════════════════════════════════════
4. 방법 B 단계별 가이드
══════════════════════════════════════════════ -->
<section id="s4">
<h2><span class="sec-num">4</span> 방법 B — 분리 파일 플래시</h2>
<div class="method-tab">
<span class="method-lbl method-b">분리 파일 모드</span>
</div>
<div class="card" style="border-radius:0 8px 8px 8px;border-top:none;">
<p style="font-size:13px;color:var(--muted);margin-bottom:16px;">
단계 1~3(브라우저 준비 → USB 연결 → 시리얼 연결 확인)은 방법 A와 동일합니다.
<strong style="color:var(--text);">4단계부터</strong> 차이가 있습니다.
</p>
<div class="step">
<div class="step-n">4</div>
<div class="step-body">
<div class="title">업로드 방식 — "분리 파일" 선택</div>
<div class="desc">
업로드 탭 상단에서 <strong>분리 파일</strong>을 선택합니다.
bootloader, partitions, boot_app0, app 4개 드롭존이 표시됩니다.
</div>
</div>
</div>
<div class="step">
<div class="step-n">5</div>
<div class="step-body">
<div class="title">파일 4개 각각 선택</div>
<div class="desc">
빌드 폴더에서 아래 파일들을 각 드롭존에 넣습니다.
<div class="file-row" style="margin-top:10px;">
<span class="file-icon">🔧</span>
<div>
<div class="file-name">CANFD_Logger.ino.bootloader.bin</div>
<div class="file-desc">① 부트로더 드롭존에 넣기</div>
</div>
<div class="file-offset">0x0000</div>
</div>
<div class="file-row">
<span class="file-icon">📋</span>
<div>
<div class="file-name">CANFD_Logger.ino.partitions.bin</div>
<div class="file-desc">② 파티션 테이블 드롭존에 넣기</div>
</div>
<div class="file-offset">0x8000</div>
</div>
<div class="file-row">
<span class="file-icon">🔁</span>
<div>
<div class="file-name">boot_app0.bin</div>
<div class="file-desc">③ Boot App0 드롭존에 넣기 (OTA 미사용 시 생략 가능)</div>
</div>
<div class="file-offset">0xe000</div>
</div>
<div class="file-row">
<span class="file-icon">📦</span>
<div>
<div class="file-name">CANFD_Logger.ino.bin</div>
<div class="file-desc">④ 애플리케이션 드롭존에 넣기</div>
</div>
<div class="file-offset">0x10000</div>
</div>
<div class="alert alert-warn" style="margin-top:12px;">
<span class="alert-icon"></span>
<div>
<code>boot_app0.bin</code>은 빌드 폴더가 아닌 Arduino ESP32 코어 폴더에 있을 수 있습니다.<br>
<code>%LOCALAPPDATA%\Arduino15\packages\esp32\hardware\esp32\버전\tools\partitions\boot_app0.bin</code>
</div>
</div>
</div>
</div>
</div>
<div class="step">
<div class="step-n">6</div>
<div class="step-body">
<div class="title">메타데이터 입력 후 업로드 → 플래시</div>
<div class="desc">
방법 A의 6~7단계와 동일하게 진행합니다.
</div>
</div>
</div>
</div>
</section>
<!-- ══════════════════════════════════════════════
5. 트러블슈팅
══════════════════════════════════════════════ -->
<section id="s5">
<h2><span class="sec-num">5</span> 자주 발생하는 문제</h2>
<table>
<thead>
<tr><th>증상</th><th>원인</th><th>해결 방법</th></tr>
</thead>
<tbody>
<tr>
<td>포트 연결 버튼이 비활성화</td>
<td>Firefox/Safari 사용</td>
<td>Chrome 또는 Edge 89+ 사용</td>
</tr>
<tr>
<td>"지원되지 않는 브라우저" 배너</td>
<td>HTTP + 외부 IP 접속</td>
<td>Docker와 같은 PC에서 <code>localhost:3100</code>으로 접속</td>
</tr>
<tr>
<td>포트 선택창에 ESP32가 안 보임</td>
<td>드라이버 미설치 또는 케이블 불량</td>
<td>데이터 전송 가능한 USB 케이블 사용, CP2102/CH340 드라이버 설치</td>
</tr>
<tr>
<td>플래시 후 부팅 안 됨</td>
<td>Flash 크기 불일치</td>
<td>Arduino IDE 보드 설정의 Flash Size가 실제 보드와 일치하는지 확인 후 재컴파일</td>
</tr>
<tr>
<td>업로드 실패 (파일 크기 초과)</td>
<td>merged.bin이 32 MB 초과</td>
<td>분리 파일 방식 사용 (앱만 ~1 MB)</td>
</tr>
<tr>
<td>플래시 중 연결 끊김</td>
<td>USB 전원 불안정</td>
<td>허브 대신 PC 직접 연결, 전원 공급이 충분한 포트 사용</td>
</tr>
<tr>
<td>BOOT 버튼 관련 오류</td>
<td>ROM 부트로더 진입 실패</td>
<td>BOOT 버튼을 누른 상태로 ESP32 연결 또는 RST 후 BOOT 누르기</td>
</tr>
</tbody>
</table>
</section>
<!-- ══════════════════════════════════════════════
6. Flash 플래그 설명
══════════════════════════════════════════════ -->
<section id="s6">
<h2><span class="sec-num">6</span> flash_args 플래그 설명</h2>
<p style="color:var(--muted);margin-bottom:14px;">
<code>flash_args</code> 파일에 기록된 플래그는 Arduino IDE가 컴파일 시 설정한 값입니다.
웹 플래셔는 이 값을 바이너리에서 자동으로 읽으므로 별도 입력이 필요 없습니다.
</p>
<table>
<thead>
<tr><th>플래그</th><th>값 (예시)</th><th>의미</th><th>웹 플래셔 입력 필요?</th></tr>
</thead>
<tbody>
<tr>
<td><code>--flash-mode</code></td>
<td>dio</td>
<td>SPI 플래시 통신 방식 (DIO = Dual I/O)</td>
<td><span class="skip">불필요 (바이너리 내장)</span></td>
</tr>
<tr>
<td><code>--flash-freq</code></td>
<td>80m</td>
<td>플래시 클럭 주파수 (80 MHz)</td>
<td><span class="skip">불필요 (바이너리 내장)</span></td>
</tr>
<tr>
<td><code>--flash-size</code></td>
<td>16MB</td>
<td>플래시 메모리 용량 (실제 보드와 일치해야 함)</td>
<td><span class="skip">불필요 (바이너리 내장)</span></td>
</tr>
<tr>
<td>PSRAM 설정</td>
<td>컴파일 옵션</td>
<td>PSRAM 활성화 여부 (ESP32-S3 4MB/8MB PSRAM)</td>
<td><span class="skip">불필요 (바이너리 내장)</span></td>
</tr>
<tr>
<td>CPU 주파수</td>
<td>240 MHz</td>
<td>프로세서 동작 클럭</td>
<td><span class="skip">불필요 (바이너리 내장)</span></td>
</tr>
</tbody>
</table>
<div class="alert alert-tip">
<span class="alert-icon">💡</span>
<div>
Arduino IDE에서 PSRAM, CPU 주파수, Flash 크기 등을 설정하면 모두 <strong>.bin 파일 안에 컴파일되어 저장</strong>됩니다.
웹 플래셔는 이 .bin 파일을 그대로 ESP32에 써넣기만 하므로, 이 설정들을 다시 입력할 필요가 없습니다.
</div>
</div>
</section>
<!-- ══════════════════════════════════════════════
7. 브라우저 호환성
══════════════════════════════════════════════ -->
<section id="s7">
<h2><span class="sec-num">7</span> 브라우저 및 환경 호환성</h2>
<table>
<thead>
<tr><th>환경</th><th>지원</th><th>비고</th></tr>
</thead>
<tbody>
<tr><td>Chrome 89+</td><td><span class="ok">✓ 지원</span></td><td>권장</td></tr>
<tr><td>Edge 89+</td><td><span class="ok">✓ 지원</span></td><td>권장</td></tr>
<tr><td>Firefox</td><td><span class="need">✗ 미지원</span></td><td>Web Serial API 없음</td></tr>
<tr><td>Safari</td><td><span class="need">✗ 미지원</span></td><td>Web Serial API 없음</td></tr>
<tr><td>http://localhost</td><td><span class="ok">✓ 동작</span></td><td>보안 컨텍스트 예외</td></tr>
<tr><td>http://192.168.x.x (IP)</td><td><span class="need">✗ 미동작</span></td><td>HTTPS 필요</td></tr>
<tr><td>https://도메인</td><td><span class="ok">✓ 동작</span></td><td>SSL 인증서 필요</td></tr>
</tbody>
</table>
</section>
</div>
<footer>
ESP32 Web Flasher 사용 설명서 — 2025년 5월
</footer>
</body>
</html>

View File

@@ -110,14 +110,32 @@
<div class="alert alert-info" style="margin-bottom:16px;">
<span></span>
<div>
Arduino IDE <strong>Sketch → Export Compiled Binary</strong> 로 생성한 .bin 파일을 업로드하세요.
<br>병합 바이너리(merged.bin)는 '펌웨어' 하나만 업로드해도 됩니다.
Arduino IDE <strong>Sketch → Export Compiled Binary</strong> 후 빌드 폴더에서 파일을 선택하세요.
<br><small style="color:var(--text-muted);">
분리 파일 사용 시: bootloader(0x0000) + partition-table(0x8000) + app(0x10000)
병합 바이너리: <code>*.merged.bin</code> 하나만 업로드 (권장) |
분리 파일: bootloader + partitions + boot_app0 + app
</small>
</div>
</div>
<!-- 업로드 방식 선택 -->
<div style="display:flex;gap:10px;margin-bottom:18px;">
<label id="lbl-merged" style="flex:1;display:flex;align-items:center;gap:10px;padding:12px 16px;border:2px solid var(--accent);border-radius:8px;cursor:pointer;background:rgba(0,200,150,.06);">
<input type="radio" name="upload-mode" id="mode-merged" value="merged" checked style="accent-color:var(--accent);">
<div>
<div style="font-size:13px;font-weight:600;">병합 바이너리 <span style="color:var(--accent);font-size:11px;">권장</span></div>
<div style="font-size:11px;color:var(--text-muted);">*.merged.bin 하나 업로드 — offset 0x0</div>
</div>
</label>
<label id="lbl-split" style="flex:1;display:flex;align-items:center;gap:10px;padding:12px 16px;border:2px solid var(--border);border-radius:8px;cursor:pointer;">
<input type="radio" name="upload-mode" id="mode-split" value="split" style="accent-color:var(--accent);">
<div>
<div style="font-size:13px;font-weight:600;">분리 파일</div>
<div style="font-size:11px;color:var(--text-muted);">bootloader + partitions + boot_app0 + app</div>
</div>
</label>
</div>
<form id="upload-form">
<div class="form-grid">
<div class="form-group">
@@ -161,40 +179,49 @@
</div>
<!-- 드롭존: 펌웨어 (필수) -->
<div class="form-group">
<label>펌웨어 바이너리 <span style="color:var(--danger);">*</span></label>
<div class="form-group" id="zone-firmware">
<label id="lbl-firmware">펌웨어 바이너리 <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 class="drop-label" id="firmware-hint">
<span id="firmware-hint-text">*.merged.bin 파일을 드래그하거나 클릭하세요</span>
<div style="font-size:11px;margin-top:4px;color:var(--text-muted);">(최대 32 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>
<!-- 분리 파일 섹션 (mode-split 선택 시 표시) -->
<div id="split-files" style="display:none;flex-direction:column;gap:12px;margin-bottom:14px;">
<div class="form-group" style="margin:0;">
<label>① 부트로더 — offset 0x0000 <span style="color:var(--danger);">*</span></label>
<div class="drop-zone" id="drop-bootloader" style="padding:16px;">
<input type="file" id="file-bootloader" accept=".bin" />
<div class="drop-label">*.bootloader.bin 드래그 또는 클릭</div>
</div>
</div>
</details>
<div class="form-group" style="margin:0;">
<label>② 파티션 테이블 — offset 0x8000 <span style="color:var(--danger);">*</span></label>
<div class="drop-zone" id="drop-partitions" style="padding:16px;">
<input type="file" id="file-partitions" accept=".bin" />
<div class="drop-label">*.partitions.bin 드래그 또는 클릭</div>
</div>
</div>
<div class="form-group" style="margin:0;">
<label>③ Boot App0 — offset 0xe000 <span style="color:var(--text-muted);font-size:11px;">(OTA 사용 시 필요)</span></label>
<div class="drop-zone" id="drop-boot-app0" style="padding:16px;">
<input type="file" id="file-boot-app0" accept=".bin" />
<div class="drop-label">boot_app0.bin 드래그 또는 클릭</div>
</div>
</div>
<div class="form-group" style="margin:0;">
<label>④ 애플리케이션 — offset 0x10000 <span style="color:var(--danger);">*</span></label>
<div class="drop-zone" id="drop-app" style="padding:16px;">
<input type="file" id="file-app" accept=".bin" />
<div class="drop-label">*.ino.bin 드래그 또는 클릭</div>
</div>
</div>
</div>
<div id="upload-progress" style="display:none;margin-bottom:14px;">
<div class="progress-wrap"><div class="progress-bar" id="upload-bar"></div></div>

View File

@@ -190,29 +190,71 @@ function setupDropZone(zoneId, inputId) {
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 fwFile = $('#file-firmware').files[0];
if (!fwFile) {
alert('펌웨어(.bin) 파일을 선택하세요.');
return;
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 || fwFile.name.replace('.bin',''));
fd.append('version', $('#fw-version').value || '1.0.0');
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('flashAddress', '0x10000');
fd.append('firmware', fwFile);
fd.append('chipFamily', $('#fw-chip').value);
fd.append('firmware', finalFwFile);
// flashAddress 미전송 → 서버가 bootloader 유무로 자동 결정 (merged:0x0 / split:0x10000)
const blFile = $('#file-bootloader').files[0];
if (blFile) fd.append('bootloader', blFile);
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 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%';