Add new_install_improv_wait_ms: 0 to manifest — prevents esp-web-tools from waiting for Improv WiFi provisioning response after installation, so the completion dialog shows only Close instead of Next. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
198 lines
6.3 KiB
JavaScript
198 lines
6.3 KiB
JavaScript
const express = require('express');
|
|
const multer = require('multer');
|
|
const path = require('path');
|
|
const fs = require('fs');
|
|
const cors = require('cors');
|
|
const crypto = require('crypto');
|
|
const { v4: uuidv4 } = require('uuid');
|
|
|
|
const app = express();
|
|
const PORT = process.env.PORT || 3000;
|
|
const FIRMWARE_DIR = path.join(__dirname, 'uploads');
|
|
const META_FILE = path.join(FIRMWARE_DIR, '_metadata.json');
|
|
const ALLOWED_ORIGIN = process.env.ALLOWED_ORIGIN || '*';
|
|
|
|
if (!fs.existsSync(FIRMWARE_DIR)) {
|
|
fs.mkdirSync(FIRMWARE_DIR, { recursive: true });
|
|
}
|
|
|
|
// ── Middleware ──────────────────────────────────────────────────────────────
|
|
app.use(cors({ origin: ALLOWED_ORIGIN }));
|
|
app.use(express.json());
|
|
|
|
// 펌웨어 바이너리 정적 서빙 (nginx 없이 직접 접근 시 사용)
|
|
app.use('/firmware/files', express.static(FIRMWARE_DIR));
|
|
|
|
// ── Firmware metadata (파일 기반, MVP용) ────────────────────────────────────
|
|
function loadMeta() {
|
|
if (!fs.existsSync(META_FILE)) return [];
|
|
try { return JSON.parse(fs.readFileSync(META_FILE, 'utf8')); }
|
|
catch { return []; }
|
|
}
|
|
|
|
function saveMeta(list) {
|
|
fs.writeFileSync(META_FILE, JSON.stringify(list, null, 2));
|
|
}
|
|
|
|
// ── Multer 설정 ─────────────────────────────────────────────────────────────
|
|
const storage = multer.diskStorage({
|
|
destination: FIRMWARE_DIR,
|
|
filename: (_req, file, cb) => {
|
|
const safe = Date.now() + '-' + crypto.randomBytes(4).toString('hex');
|
|
cb(null, safe + path.extname(file.originalname));
|
|
},
|
|
});
|
|
|
|
const upload = multer({
|
|
storage,
|
|
fileFilter: (_req, file, cb) => {
|
|
if (path.extname(file.originalname).toLowerCase() === '.bin') {
|
|
cb(null, true);
|
|
} else {
|
|
cb(new Error('.bin 파일만 업로드 가능합니다'));
|
|
}
|
|
},
|
|
limits: { fileSize: 32 * 1024 * 1024 }, // 32 MB
|
|
});
|
|
|
|
// ── 라우트 ──────────────────────────────────────────────────────────────────
|
|
|
|
// 헬스 체크
|
|
app.get('/api/health', (_req, res) => {
|
|
res.json({ status: 'ok', timestamp: new Date().toISOString() });
|
|
});
|
|
|
|
// 펌웨어 목록
|
|
app.get('/api/firmware', (_req, res) => {
|
|
res.json(loadMeta());
|
|
});
|
|
|
|
// 펌웨어 업로드
|
|
// 지원 필드: 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: 'boot_app0', maxCount: 1 },
|
|
]),
|
|
(req, res) => {
|
|
try {
|
|
const files = req.files || {};
|
|
if (!files.firmware) {
|
|
return res.status(400).json({ error: '펌웨어 파일(firmware)이 필요합니다' });
|
|
}
|
|
|
|
const { name, version, description, chipFamily, flashAddress } = req.body;
|
|
const list = loadMeta();
|
|
const id = uuidv4();
|
|
|
|
const parts = [];
|
|
|
|
if (files.bootloader) {
|
|
parts.push({
|
|
file: files.bootloader[0].filename,
|
|
offset: '0x0000',
|
|
label: 'Bootloader',
|
|
});
|
|
}
|
|
|
|
if (files.partitions) {
|
|
parts.push({
|
|
file: files.partitions[0].filename,
|
|
offset: '0x8000',
|
|
label: 'Partition Table',
|
|
});
|
|
}
|
|
|
|
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');
|
|
|
|
parts.push({
|
|
file: files.firmware[0].filename,
|
|
offset: appOffset,
|
|
label: 'Application',
|
|
size: files.firmware[0].size,
|
|
});
|
|
|
|
const entry = {
|
|
id,
|
|
name: name || '이름 없음',
|
|
version: version || '1.0.0',
|
|
description: description || '',
|
|
chipFamily: chipFamily || 'ESP32-S3',
|
|
createdAt: new Date().toISOString(),
|
|
parts,
|
|
};
|
|
|
|
list.unshift(entry);
|
|
saveMeta(list);
|
|
|
|
res.json({ success: true, id, firmware: entry });
|
|
} catch (err) {
|
|
res.status(500).json({ error: err.message });
|
|
}
|
|
}
|
|
);
|
|
|
|
// esp-web-tools manifest.json 생성
|
|
app.get('/api/firmware/:id/manifest', (req, res) => {
|
|
const firmware = loadMeta().find(f => f.id === req.params.id);
|
|
if (!firmware) return res.status(404).json({ error: '펌웨어를 찾을 수 없습니다' });
|
|
|
|
// esp-web-tools manifest 스펙
|
|
const manifest = {
|
|
name: firmware.name,
|
|
version: firmware.version,
|
|
new_install_prompt_erase: true,
|
|
new_install_improv_wait_ms: 0, // Improv WiFi 단계 건너뜀 → 완료 후 Close 버튼만 표시
|
|
builds: [{
|
|
chipFamily: firmware.chipFamily,
|
|
parts: firmware.parts.map(p => ({
|
|
path: `/firmware/files/${p.file}`,
|
|
offset: parseInt(p.offset, 16),
|
|
})),
|
|
}],
|
|
};
|
|
|
|
// CORS 허용 헤더 (esp-web-tools가 fetch할 때 필요)
|
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
res.json(manifest);
|
|
});
|
|
|
|
// 단일 펌웨어 삭제
|
|
app.delete('/api/firmware/:id', (req, res) => {
|
|
const list = loadMeta();
|
|
const index = list.findIndex(f => f.id === req.params.id);
|
|
if (index === -1) return res.status(404).json({ error: '펌웨어를 찾을 수 없습니다' });
|
|
|
|
list[index].parts.forEach(p => {
|
|
const fp = path.join(FIRMWARE_DIR, p.file);
|
|
if (fs.existsSync(fp)) fs.unlinkSync(fp);
|
|
});
|
|
|
|
list.splice(index, 1);
|
|
saveMeta(list);
|
|
res.json({ success: true });
|
|
});
|
|
|
|
// ── 오류 핸들러 ─────────────────────────────────────────────────────────────
|
|
app.use((err, _req, res, _next) => {
|
|
console.error(err.message);
|
|
res.status(400).json({ error: err.message });
|
|
});
|
|
|
|
app.listen(PORT, () => {
|
|
console.log(`ESP32 Flasher Backend → http://localhost:${PORT}`);
|
|
});
|