v3.0.3: 아두이노 업로더 통합 — 팝업 제거, 코드 에디터 하단 패널로 이전

- arduino-upload.html 삭제, 모든 로직을 code.js로 이식
- ⚡ 업로드 버튼 → 인라인 패널 오픈 (팝업·sessionStorage 릴레이 제거)
- OUTPUT / 옵션 / 시리얼 탭, 드래그 리사이즈, ESP32·AVR·STM32 업로드
- server-v2.ts 중복 /api/settings/paths 라우트 제거 (설정 저장 오류 수정)
- 업로드 패널 색상·폰트 개선 (배경 밝게, 12~13px)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
kim
2026-06-18 23:59:46 +09:00
co-authored by Claude Sonnet 4.6
parent a994ac1b73
commit 4caa35aac2
4 changed files with 817 additions and 1337 deletions
+6 -369
View File
@@ -17,6 +17,9 @@ import crypto from 'crypto';
import { WebSocketServer, WebSocket } from 'ws';
import { registerArduinoRoutes } from './routes-arduino';
import { registerPptxRoutes } from './routes-pptx';
import { registerVoiceRoutes } from './routes-voice';
import { registerMusicRoutes } from './routes-music';
import { registerMCPRoutes } from './routes-mcp';
import {
getConfig,
getAgents,
@@ -9582,26 +9585,6 @@ app.get('/api/files/{*filePath}', (req: express.Request, res: express.Response)
registerPptxRoutes(app, getSessionUser, requireGatewayAuth, imageSearch);
// ── Settings: paths (code dir) ───────────────────────────────────────────
app.get('/api/settings/paths', (_req: express.Request, res: express.Response) => {
const cfg = getConfig().getConfig() as any;
res.json({ code_dir: cfg?.workspace?.codeDir || 'code' });
});
app.post('/api/settings/paths', (req: express.Request, res: express.Response) => {
try {
const { code_dir } = req.body || {};
if (!code_dir || typeof code_dir !== 'string') {
res.status(400).json({ success: false, error: 'code_dir required' }); return;
}
const safe = code_dir.replace(/[^a-zA-Z0-9가-힣._-]/g, '_').replace(/^_|_$/g, '') || 'code';
const cfg = getConfig().getConfig() as any;
getConfig().updateConfig({ workspace: { ...(cfg?.workspace || {}), codeDir: safe } } as any);
res.json({ success: true, code_dir: safe });
} catch (e: any) {
res.status(500).json({ success: false, error: String(e?.message || e) });
}
});
// ── Code editor: save file to workspace/code/ ────────────────────────────
function getCodeWorkspacePath(req: express.Request): string {
@@ -9741,80 +9724,7 @@ app.put('/api/files/{*filePath}', (req: express.Request, res: express.Response)
}
});
// ── Voice: STT (Whisper) ──────────────────────────────────────────────────
app.post('/api/voice/stt', async (req: express.Request, res: express.Response) => {
const contentType = String(req.headers['content-type'] || 'audio/webm');
const fmt = contentType.includes('ogg') ? 'ogg'
: contentType.includes('wav') ? 'wav'
: contentType.includes('mp4') || contentType.includes('m4a') ? 'm4a'
: 'webm';
const chunks: Buffer[] = [];
req.on('data', (c: Buffer) => chunks.push(c));
req.on('end', async () => {
const buf = Buffer.concat(chunks);
if (!buf.length) { res.status(400).json({ success: false, error: 'No audio data' }); return; }
try {
const result = await transcribeAudio(buf, fmt);
res.json({ success: true, text: result.text.trim(), language: result.language });
} catch (e: any) {
res.status(500).json({ success: false, error: String(e?.message || e) });
}
});
req.on('error', (e: any) => res.status(500).json({ success: false, error: String(e?.message || e) }));
});
// ── Voice: TTS (Piper → WAV) ─────────────────────────────────────────────
app.post('/api/voice/tts', async (req: express.Request, res: express.Response) => {
const { text } = req.body || {};
if (!text || typeof text !== 'string') { res.status(400).json({ success: false, error: 'text required' }); return; }
const cfg = (getConfig().getConfig() as any)?.voice;
if (!cfg?.tts) { res.status(503).json({ success: false, error: 'TTS not configured' }); return; }
const provider = cfg.tts.provider || 'piper';
try {
const os = await import('os');
const { spawn: spawnProc } = await import('child_process');
if (provider === 'edge_tts') {
const voice = cfg.tts.voice || 'ko-KR-SunHiNeural';
const mp3Path = path.join(os.tmpdir(), `tts_${Date.now()}.mp3`);
const scriptPath = path.join(__dirname, '../../src/tools/edge_tts_synth.py');
await new Promise<void>((resolve, reject) => {
const py = spawnProc('python3', [scriptPath, voice, mp3Path]);
py.stdin.write(text.slice(0, 2000), 'utf8');
py.stdin.end();
let stderr = '';
py.stderr.on('data', (d: Buffer) => { stderr += d.toString('utf8'); });
py.on('close', (code: number | null) => code === 0 ? resolve() : reject(new Error(`edge-tts exited ${code}: ${stderr}`)));
py.on('error', reject);
});
const mp3 = fs.readFileSync(mp3Path);
try { fs.unlinkSync(mp3Path); } catch {}
res.set('Content-Type', 'audio/mpeg');
res.send(mp3);
} else {
// piper
const piperPath = cfg.tts.piperPath || 'piper';
const modelPath = cfg.tts.modelPath || '';
const configPath = cfg.tts.configPath || '';
if (!modelPath) { res.status(503).json({ success: false, error: 'TTS model path not configured' }); return; }
const wavPath = path.join(os.tmpdir(), `tts_${Date.now()}.wav`);
const piperArgs = ['--model', modelPath, '--output_file', wavPath];
if (configPath) piperArgs.push('--config', configPath);
await new Promise<void>((resolve, reject) => {
const piper = spawnProc(piperPath, piperArgs);
piper.stdin.write(text.slice(0, 2000), 'utf8');
piper.stdin.end();
piper.on('close', (code: number | null) => code === 0 ? resolve() : reject(new Error(`Piper exited ${code}`)));
piper.on('error', reject);
});
const wav = fs.readFileSync(wavPath);
try { fs.unlinkSync(wavPath); } catch {}
res.set('Content-Type', 'audio/wav');
res.send(wav);
}
} catch (e: any) {
res.status(500).json({ success: false, error: String(e?.message || e) });
}
});
registerVoiceRoutes(app);
// ── Canvas code execution ──────────────────────────────────────────────────
const CANVAS_EXEC_EXT: Record<string, string> = {
@@ -12440,215 +12350,7 @@ app.post('/api/models/test', async (req, res) => {
}
});
// ─── Music rendering ───────────────────────────────────────────────────────────
// POST /api/music/render — render ABC/LilyPond source → SVG + PDF + MIDI
app.post('/api/music/render', (req, res) => {
try {
const source = String(req.body?.source || '').trim();
if (!source) { res.status(400).json({ error: 'Missing source' }); return; }
const result = renderMusic(source);
// Save MIDI to workspace/music/<title>/ when user has a workspace
let savedTo: string | null = null;
const renderUser = (req as any).user;
if (result.midiPath && renderUser?.workspace) {
const titleMatch = source.match(/^T:\s*(.+)$/m);
const title = (titleMatch?.[1] || 'untitled')
.replace(/[/\\:*?"<>|]/g, '_').trim() || 'untitled';
const musicDir = path.join(renderUser.workspace, 'music', title);
fs.mkdirSync(musicDir, { recursive: true });
const destPath = path.join(musicDir, title + '.mid');
fs.copyFileSync(result.midiPath, destPath);
savedTo = destPath;
}
res.json({
id: result.id,
page_count: result.svgPages.length,
svg_url: `/api/music/file/${result.id}/svg`,
pdf_url: result.pdfPath ? `/api/music/file/${result.id}/pdf` : null,
midi_url: result.midiPath ? `/api/music/file/${result.id}/midi` : null,
saved_to: savedTo,
});
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
// POST /api/music/midi — upload MIDI file → save to workspace + render dir, return action picker data
app.post('/api/music/midi', (req, res) => {
const contentType = String(req.headers['content-type'] || '');
if (!contentType.includes('multipart/form-data')) {
res.status(400).json({ error: 'Content-Type must be multipart/form-data' }); return;
}
const boundary = contentType.split('boundary=')[1];
if (!boundary) { res.status(400).json({ error: 'Missing boundary' }); return; }
const chunks: Buffer[] = [];
req.on('data', (chunk: Buffer) => chunks.push(chunk));
req.on('end', () => {
try {
const raw = Buffer.concat(chunks).toString('binary');
const parts = raw.split('--' + boundary);
let fileData: Buffer | null = null;
let originalFilename = 'upload.midi';
for (const part of parts) {
const headerEnd = part.indexOf('\r\n\r\n');
if (headerEnd === -1) continue;
const header = part.substring(0, headerEnd);
if (!header.includes('name="file"') && !header.includes('name="midi"')) continue;
const fnMatch = header.match(/filename\*=UTF-8''([^\r\n]+)/i)
?? header.match(/filename="([^"]+)"/i);
if (fnMatch) {
const raw8 = decodeURIComponent(fnMatch[1]) === fnMatch[1]
? Buffer.from(fnMatch[1], 'binary').toString('utf-8')
: decodeURIComponent(fnMatch[1]);
originalFilename = raw8;
}
const bodyStart = headerEnd + 4;
const bodyEnd = part.lastIndexOf('\r\n');
if (bodyEnd <= bodyStart) continue;
fileData = Buffer.from(part.substring(bodyStart, bodyEnd), 'binary');
break;
}
if (!fileData) { res.status(400).json({ error: 'No MIDI file found' }); return; }
if (fileData.length > 10 * 1024 * 1024) { res.status(400).json({ error: 'File too large (max 10MB)' }); return; }
// Save to render dir (original.midi) — no LilyPond yet
const info = saveMidiFile(fileData);
// Save to workspace/music/<songName>/
let savedTo: string | null = null;
const uploadUser = (req as any).user;
if (uploadUser?.workspace) {
const songName = originalFilename
.replace(/\.midi?$/i, '')
.replace(/[/\\:*?"<>|]/g, '_')
.trim() || 'untitled';
const musicDir = path.join(uploadUser.workspace, 'music', songName);
fs.mkdirSync(musicDir, { recursive: true });
const safeName = originalFilename.replace(/[/\\:*?"<>|]/g, '_');
const destPath = path.join(musicDir, safeName);
if (!fs.existsSync(destPath)) fs.writeFileSync(destPath, fileData);
savedTo = destPath;
}
res.json({
id: info.id,
filename: originalFilename,
midi_url: info.midiUrl,
has_svg: info.hasSvg,
page_count: info.pageCount,
svg_url: info.hasSvg ? `/api/music/file/${info.id}/svg` : null,
pdf_url: info.pdfUrl,
saved_to: savedTo,
});
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
});
// POST /api/music/midi-from-path — internal-only: register an already-saved MIDI file by path
app.post('/api/music/midi-from-path', (req, res) => {
const ip = req.socket.remoteAddress || '';
if (!ip.includes('127.0.0.1') && !ip.includes('::1') && !ip.includes('::ffff:127.0.0.1')) {
res.status(403).json({ error: 'Internal endpoint — localhost only' }); return;
}
try {
const filePath = String(req.body?.path || '').trim();
const filename = String(req.body?.filename || path.basename(filePath));
if (!filePath) { res.status(400).json({ error: 'Missing path' }); return; }
if (!fs.existsSync(filePath)) { res.status(404).json({ error: 'File not found: ' + filePath }); return; }
const fileData = fs.readFileSync(filePath);
const info = saveMidiFile(fileData);
res.json({
id: info.id,
filename,
midi_url: info.midiUrl,
has_svg: info.hasSvg,
page_count: info.pageCount,
svg_url: info.hasSvg ? `/api/music/file/${info.id}/svg` : null,
pdf_url: info.pdfUrl,
saved_to: filePath,
});
} catch (err: any) {
res.status(500).json({ error: err.message });
}
});
// POST /api/music/midi-score — on-demand sheet music render for uploaded MIDI
app.post('/api/music/midi-score', (req, res) => {
const { id } = req.body as { id: string };
if (!id || !id.startsWith('midi-')) { res.status(400).json({ error: 'Invalid id' }); return; }
const { getRenderFile: _rf, ..._ } = { getRenderFile };
const origPath = getRenderFile(id, 'midi');
if (!origPath) { res.status(404).json({ error: 'MIDI not found — 파일을 다시 업로드하세요' }); return; }
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('X-Accel-Buffering', 'no');
res.flushHeaders();
const hb = setInterval(() => { try { res.write(': ping\n\n'); } catch {} }, 20_000);
const send = (data: object) => { try { res.write(`data: ${JSON.stringify(data)}\n\n`); } catch {}; };
const finish = () => { clearInterval(hb); try { res.end(); } catch {} };
renderMidiAsync(
origPath,
(msg) => send({ type: 'progress', message: msg }),
(result) => {
send({
type: 'done',
id: result.id,
page_count: result.svgPages.length,
svg_url: `/api/music/file/${result.id}/svg`,
pdf_url: result.pdfPath ? `/api/music/file/${result.id}/pdf` : null,
midi_url: `/api/music/file/${result.id}/midi`,
});
finish();
},
(err) => { send({ type: 'error', message: err.message }); finish(); },
);
});
// GET /api/music/file/:id/svg — serve page 1 SVG
app.get('/api/music/file/:id/svg', (req, res) => {
const filePath = getRenderFile(req.params.id, 'svg');
if (!filePath) { res.status(404).json({ error: 'Not found' }); return; }
res.setHeader('Content-Type', 'image/svg+xml');
res.sendFile(filePath);
});
// GET /api/music/file/:id/svg/:page — serve specific page (1-indexed)
app.get('/api/music/file/:id/svg/:page', (req, res) => {
const page = parseInt(req.params.page, 10);
const filePath = isNaN(page) ? null : getRenderPageFile(req.params.id, page);
if (!filePath) { res.status(404).json({ error: 'Not found' }); return; }
res.setHeader('Content-Type', 'image/svg+xml');
res.sendFile(filePath);
});
// GET /api/music/file/:id/pdf — serve rendered PDF
app.get('/api/music/file/:id/pdf', (req, res) => {
const filePath = getRenderFile(req.params.id, 'pdf');
if (!filePath) { res.status(404).json({ error: 'Not found' }); return; }
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', 'attachment; filename="score.pdf"');
res.sendFile(filePath);
});
// GET /api/music/file/:id/midi — serve rendered MIDI
app.get('/api/music/file/:id/midi', (req, res) => {
const filePath = getRenderFile(req.params.id, 'midi');
if (!filePath) { res.status(404).json({ error: 'Not found' }); return; }
res.setHeader('Content-Type', 'audio/midi');
res.sendFile(filePath);
});
registerMusicRoutes(app);
// GET /api/auth/openai/status — is the user connected via OAuth?
app.get('/api/auth/openai/status', (_req, res) => {
@@ -12770,72 +12472,7 @@ app.get('/api/ppt/skins/{*skinPath}', (req, res) => {
// POST /api/settings/voice — save voice config
// GET /api/settings/session — return session config
// POST /api/settings/session — save session config
app.get('/api/mcp/servers', (_req, res) => {
try {
const mgr = getMCPManager();
const configs = mgr.getConfigs();
const status = mgr.getStatus();
const merged = configs.map(cfg => {
const s = status.find(x => x.id === cfg.id);
return { ...cfg, status: s?.status || 'disconnected', toolCount: s?.tools || 0, toolNames: s?.toolNames || [], error: s?.error };
});
res.json({ success: true, servers: merged });
} catch (err: any) {
res.status(500).json({ success: false, error: err.message });
}
});
app.post('/api/mcp/servers', (req, res) => {
try {
const mgr = getMCPManager();
const cfg = req.body;
if (!cfg.id || !cfg.name) { res.status(400).json({ success: false, error: 'id and name are required' }); return; }
if (!cfg.id.match(/^[a-z0-9_-]+$/i)) { res.status(400).json({ success: false, error: 'id must be alphanumeric/underscore/dash only' }); return; }
mgr.upsertConfig(cfg);
res.json({ success: true });
} catch (err: any) {
res.status(500).json({ success: false, error: err.message });
}
});
app.delete('/api/mcp/servers/:id', (req, res) => {
try {
const mgr = getMCPManager();
const deleted = mgr.deleteConfig(req.params.id);
res.json({ success: deleted, error: deleted ? undefined : 'Server not found' });
} catch (err: any) {
res.status(500).json({ success: false, error: err.message });
}
});
app.post('/api/mcp/servers/:id/connect', async (req, res) => {
try {
const mgr = getMCPManager();
const result = await mgr.connect(req.params.id);
res.json(result);
} catch (err: any) {
res.status(500).json({ success: false, error: err.message });
}
});
app.post('/api/mcp/servers/:id/disconnect', async (req, res) => {
try {
const mgr = getMCPManager();
await mgr.disconnect(req.params.id);
res.json({ success: true });
} catch (err: any) {
res.status(500).json({ success: false, error: err.message });
}
});
app.get('/api/mcp/tools', (_req, res) => {
try {
const mgr = getMCPManager();
res.json({ success: true, tools: mgr.getAllTools() });
} catch (err: any) {
res.status(500).json({ success: false, error: err.message });
}
});
registerMCPRoutes(app);
// Mounted dynamically so the path is always read fresh from config.
// Must be registered BEFORE the SPA catch-all below.
-911
View File
@@ -1,911 +0,0 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Arduino Uploader</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&display=swap');
:root { --bg:#0c0c0c; --panel:#1a1a2e; --accent:#3b82f6; --green:#22c55e; --red:#ef4444; --yellow:#fbbf24; --muted:#6b7280; --orange:#f97316; }
* { margin:0; padding:0; box-sizing:border-box; }
body { background:var(--bg); color:#e5e7eb; font-family:'IBM Plex Mono',monospace; height:100vh; display:flex; flex-direction:column; overflow:hidden; }
.header { display:flex; align-items:center; gap:12px; padding:10px 16px; background:var(--panel); border-bottom:1px solid #2d2d4a; flex-shrink:0; flex-wrap:wrap; }
.header h1 { font-size:14px; font-weight:600; }
.header .file { font-size:12px; color:var(--accent); }
.status { font-size:11px; padding:2px 8px; border-radius:4px; font-weight:600; }
.status.idle { background:#252540; color:var(--muted); }
.status.ready { background:#1a3a2a; color:var(--green); }
.status.compiling{ background:#3a2a1a; color:var(--orange); animation:pulse 1.5s infinite; }
.status.uploading{ background:#3a3a1a; color:var(--yellow); animation:pulse 1.5s infinite; }
.status.done { background:#1a3a2a; color:var(--green); }
.status.error { background:#3a1a1a; color:var(--red); }
@keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.6} }
.toolbar { display:flex; gap:8px; margin-left:auto; align-items:center; flex-wrap:wrap; }
.btn { background:#252540; border:1px solid #3a3a5a; color:#e5e7eb; border-radius:6px; padding:6px 14px; font-size:12px; cursor:pointer; font-family:inherit; transition:all .15s; white-space:nowrap; }
.btn:hover:not(:disabled) { background:#353555; border-color:#4a4a7a; }
.btn:disabled { opacity:.4; cursor:not-allowed; }
.btn.primary { background:#1a3a2a; border-color:#2d5a3a; color:var(--green); }
.btn.primary:hover:not(:disabled) { background:#224a3a; }
.btn.accent { background:#1a2a3a; border-color:#2d4a6a; color:var(--accent); }
.btn.accent:hover:not(:disabled) { background:#223a4a; }
.main { flex:1; display:flex; overflow:hidden; }
.left-panel { width:280px; flex-shrink:0; border-right:1px solid #2d2d4a; display:flex; flex-direction:column; background:#111122; overflow-y:auto; }
.section { padding:14px 16px; border-bottom:1px solid #1e1e36; }
.section-title { font-size:10px; color:var(--muted); font-weight:600; letter-spacing:.08em; margin-bottom:10px; }
.info-row { display:flex; justify-content:space-between; font-size:11px; padding:2px 0; gap:6px; }
.info-label { color:var(--muted); white-space:nowrap; }
.info-val { color:#e5e7eb; font-weight:500; word-break:break-all; text-align:right; }
.port-name { font-size:11px; color:var(--green); margin-top:8px; min-height:16px; word-break:break-all; }
.hint { font-size:10px; color:var(--muted); margin-top:6px; line-height:1.6; }
.method-badge { display:inline-block; padding:2px 7px; border-radius:4px; font-size:10px; font-weight:600; }
.serial-panel { flex:1; display:flex; flex-direction:column; overflow:hidden; }
.log-section { flex:2; display:flex; flex-direction:column; overflow:hidden; border-bottom:2px solid #2d2d4a; min-height:80px; }
.monitor-section { flex:3; display:flex; flex-direction:column; overflow:hidden; min-height:120px; }
.serial-header { padding:8px 16px; font-size:11px; color:var(--muted); font-weight:600; letter-spacing:.08em; border-bottom:1px solid #1e1e36; display:flex; align-items:center; gap:8px; flex-shrink:0; }
.serial-clear { background:none; border:none; color:var(--muted); cursor:pointer; font-size:11px; font-family:inherit; }
.serial-clear:hover { color:#e5e7eb; }
.log-output { flex:1; overflow-y:auto; padding:10px 16px; font-size:12px; line-height:1.7; white-space:pre-wrap; word-break:break-all; }
.log-output .info { color:var(--muted); font-style:italic; }
.log-output .ok { color:var(--green); }
.log-output .err { color:var(--red); }
.log-output .prog { color:#60a5fa; }
.log-output .warn { color:var(--yellow); }
.footer { padding:7px 16px; border-top:1px solid #2d2d4a; font-size:11px; color:var(--muted); background:var(--panel); flex-shrink:0; }
.chip-badge { display:inline-block; padding:2px 8px; border-radius:4px; font-size:10px; font-weight:600; background:#1a3a1a; color:var(--green); border:1px solid #2d5a2d; }
progress { width:100%; height:4px; border:none; border-radius:2px; margin-top:8px; }
progress::-webkit-progress-bar { background:#1e1e36; border-radius:2px; }
progress::-webkit-progress-value { background:var(--accent); border-radius:2px; transition:width .2s; }
#prog-bar { display:none; }
/* Serial monitor */
.mon-select { background:#1a1a30; border:1px solid #3a3a5a; color:#e5e7eb; border-radius:4px; padding:2px 6px; font-size:11px; font-family:inherit; }
.mon-btn { background:#1a2a1a; border:1px solid #2d4a2d; color:var(--green); border-radius:4px; padding:2px 10px; font-size:11px; cursor:pointer; font-family:inherit; }
.mon-btn:hover { background:#223a22; }
.mon-btn.active { background:#3a1a1a; border-color:#5a2d2d; color:var(--red); }
.mon-cb-label { display:flex; align-items:center; gap:4px; font-size:11px; color:var(--muted); cursor:pointer; user-select:none; }
.monitor-output { flex:1; overflow-y:auto; padding:8px 16px; font-size:12px; line-height:1.6; font-family:'IBM Plex Mono',monospace; }
.monitor-output div { white-space:pre-wrap; word-break:break-all; }
.monitor-output .mon-rx { color:#d1fae5; }
.monitor-output .mon-tx { color:#93c5fd; }
.monitor-output .mon-sys { color:var(--muted); font-style:italic; }
.mon-ts { color:#4b5563; font-size:10px; margin-right:6px; user-select:none; }
.monitor-input-row { display:flex; gap:6px; padding:8px 12px; border-top:1px solid #1e1e36; flex-shrink:0; background:#0d0d1a; }
.monitor-input-row input { flex:1; background:#1a1a2e; border:1px solid #3a3a5a; color:#e5e7eb; border-radius:4px; padding:5px 10px; font-size:12px; font-family:inherit; outline:none; }
.monitor-input-row input:focus { border-color:var(--accent); }
.monitor-input-row input:disabled { opacity:.4; }
.mon-send-btn { background:#1a2a3a; border:1px solid #2d4a6a; color:var(--accent); border-radius:4px; padding:5px 14px; font-size:12px; cursor:pointer; font-family:inherit; }
.mon-send-btn:hover { background:#223a4a; }
.opt-row { display:flex; align-items:center; gap:6px; padding:3px 0; }
.opt-label { font-size:10px; color:var(--muted); flex:1; min-width:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.opt-select { flex:1.4; background:#1a1a2e; border:1px solid #3a3a5a; color:#e5e7eb; border-radius:3px; padding:2px 4px; font-size:10px; font-family:inherit; outline:none; }
.opt-select:focus { border-color:var(--accent); }
</style>
</head>
<body>
<div class="header">
<h1>⚡ Arduino Uploader</h1>
<span class="file" id="filename">sketch.ino</span>
<span class="chip-badge" id="board-badge">-</span>
<span class="status idle" id="status">대기 중</span>
<div class="toolbar">
<button class="btn accent" id="btn-port" onclick="selectPort()">🔌 포트 선택</button>
<button class="btn" id="btn-download" onclick="doDownload()" disabled>⬇ 다운로드</button>
<button class="btn primary" id="btn-upload" onclick="doUpload()" disabled>⚡ 업로드</button>
</div>
</div>
<div class="main">
<div class="left-panel">
<div class="section">
<div class="section-title">포트 (클라이언트)</div>
<button class="btn" style="width:100%" onclick="selectPort()">🔌 포트 선택 (WebSerial)</button>
<div class="port-name" id="port-name">포트가 선택되지 않음</div>
<div class="hint">Chrome/Edge에서 USB 케이블로 보드 연결 후 포트를 선택하세요.</div>
</div>
<div class="section">
<div class="section-title">업로드 방식</div>
<div class="info-row"><span class="info-label">방식</span><span class="info-val" id="info-method">-</span></div>
<div class="info-row"><span class="info-label">보드</span><span class="info-val" id="info-board">-</span></div>
<div class="info-row"><span class="info-label">FQBN</span><span class="info-val" id="info-fqbn" style="font-size:10px">-</span></div>
<div class="info-row"><span class="info-label">파일</span><span class="info-val" id="info-file">-</span></div>
</div>
<div class="section" id="board-options-section" style="display:none">
<div class="section-title" style="cursor:pointer;user-select:none" onclick="toggleBoardOptions()">
보드 옵션 <span id="board-options-toggle" style="float:right;font-size:10px;color:var(--muted)">▼ 펼치기</span>
</div>
<div id="board-options-body" style="display:none">
<div id="board-options-list"></div>
</div>
</div>
<div class="section" id="stm32-hint" style="display:none">
<div class="section-title">STM32 부트로더 진입</div>
<div class="hint">UART 부트로더 업로드 시:<br>① BOOT0 핀을 3.3V에 연결<br>② RESET 버튼 누른 후 놓기<br>③ ⚡ 업로드 버튼 클릭<br><br>업로드 후 BOOT0를 GND로 복원하고 RESET 누르면 프로그램이 실행됩니다.</div>
</div>
<div class="section" id="uf2-hint" style="display:none">
<div class="section-title">UF2 업로드 방법</div>
<div class="hint">① BOOTSEL 버튼을 누른 채 USB 연결<br>② BOOT 드라이브가 나타남<br>③ ⬇ 다운로드로 .uf2 파일 받기<br>④ 파일을 BOOT 드라이브에 복사</div>
</div>
<div class="section">
<progress id="prog-bar" value="0" max="100"></progress>
</div>
</div>
<div class="serial-panel">
<!-- Upload log -->
<div class="log-section">
<div class="serial-header">
OUTPUT
<button class="serial-clear" onclick="clearLog()">지우기</button>
</div>
<div class="log-output" id="log-output">
<span class="info">🔌 포트 선택 버튼으로 보드를 선택하세요.\n클라이언트(PC)의 USB 포트가 표시됩니다.\n</span>
</div>
</div>
<!-- Serial monitor -->
<div class="monitor-section">
<div class="serial-header">
SERIAL MONITOR
<select class="mon-select" id="mon-baud" title="보드레이트">
<option value="9600">9600</option>
<option value="19200">19200</option>
<option value="38400">38400</option>
<option value="57600">57600</option>
<option value="115200" selected>115200</option>
<option value="230400">230400</option>
<option value="921600">921600</option>
</select>
<select class="mon-select" id="mon-line-end" title="줄 끝 문자">
<option value="none">줄끝 없음</option>
<option value="nl">NL (LF)</option>
<option value="cr">CR</option>
<option value="crnl" selected>CR+NL</option>
</select>
<button class="mon-btn" id="btn-monitor" onclick="toggleMonitor()">▶ 연결</button>
<label class="mon-cb-label"><input type="checkbox" id="mon-timestamp"> 시각</label>
<label class="mon-cb-label"><input type="checkbox" id="mon-autoscroll" checked> 자동스크롤</label>
<button class="serial-clear" onclick="clearMonitor()">지우기</button>
</div>
<div class="monitor-output" id="monitor-output">
<div class="mon-sys">포트 선택 후 ▶ 연결을 눌러 시리얼 모니터를 시작하세요.</div>
</div>
<div class="monitor-input-row">
<input type="text" id="monitor-input" placeholder="전송할 데이터..." disabled
onkeydown="if(event.key==='Enter')monitorSend()">
<button class="mon-send-btn" onclick="monitorSend()">전송</button>
</div>
</div>
</div>
</div>
<div class="footer" id="footer-status">포트를 선택해주세요</div>
<script type="module">
let _code = '', _fqbn = '', _boardName = '', _fname = '';
let _port = null;
let _compiledData = null; // { hex?, bin?, boardType }
let _busy = false;
let _boardOptions = []; // [{ id, label, values:[{value,label,selected}] }]
let _selOptions = {}; // { optionId: value }
function getEffectiveFqbn() {
const keys = Object.keys(_selOptions);
if (!keys.length) return _fqbn;
return _fqbn + ':' + keys.map(k => k + '=' + _selOptions[k]).join(',');
}
function toggleBoardOptions() {
const body = document.getElementById('board-options-body');
const tog = document.getElementById('board-options-toggle');
const open = body.style.display === 'none';
body.style.display = open ? '' : 'none';
tog.textContent = open ? '▲ 접기' : '▼ 펼치기';
}
function renderBoardOptions() {
const sec = document.getElementById('board-options-section');
const list = document.getElementById('board-options-list');
if (!_boardOptions.length) { sec.style.display = 'none'; return; }
sec.style.display = '';
list.innerHTML = _boardOptions.map(o => {
const sel = _selOptions[o.id] ?? (o.values.find(v => v.selected)?.value ?? o.values[0]?.value ?? '');
const opts = o.values.map(v =>
`<option value="${v.value}"${v.value === sel ? ' selected' : ''}>${v.label}</option>`
).join('');
return `<div class="opt-row">
<span class="opt-label" title="${o.label}">${o.label}</span>
<select class="opt-select" data-opt="${o.id}" onchange="if(this.value!==this.dataset.def)_selOptions[this.dataset.opt]=this.value;else delete _selOptions[this.dataset.opt];document.getElementById('info-fqbn').textContent=getEffectiveFqbn()" data-def="${o.values.find(v=>v.selected)?.value??o.values[0]?.value??''}">${opts}</select>
</div>`;
}).join('');
}
async function loadBoardOptions() {
const baseFqbn = _fqbn.split(':').slice(0, 3).join(':');
try {
const r = await fetch(`/api/arduino/board/options?fqbn=${encodeURIComponent(baseFqbn)}`, { credentials: 'include' });
const d = await r.json();
_boardOptions = d.options || [];
// _selOptions는 비워둠 — 사용자가 명시적으로 바꾼 것만 FQBN에 추가
_selOptions = {};
renderBoardOptions();
} catch {}
}
// ── logging ──────────────────────────────────────────────────────────────────
function log(msg, cls = 'info') {
const out = document.getElementById('log-output');
const span = document.createElement('span');
span.className = cls;
span.textContent = msg + '\n';
out.appendChild(span);
out.scrollTop = out.scrollHeight;
}
function clearLog() { document.getElementById('log-output').innerHTML = ''; }
function setStatus(cls, text) {
const el = document.getElementById('status');
el.className = 'status ' + cls;
el.textContent = text;
}
function setProgress(pct) {
const bar = document.getElementById('prog-bar');
if (pct == null) { bar.style.display = 'none'; return; }
bar.style.display = 'block';
bar.value = pct;
}
function footer(t) { document.getElementById('footer-status').textContent = t; }
// ── board type helpers ────────────────────────────────────────────────────────
function getUploadMethod(fqbn) {
const arch = fqbn.split(':')[1] || '';
const vendor = fqbn.split(':')[0] || '';
if (arch === 'avr') return 'avr-stk500';
if (vendor === 'STMicroelectronics') return 'stm32-uart';
if (arch === 'samd' || arch === 'sam') return 'download-bin';
if (arch === 'mbed_rp2040' || arch === 'rp2040') return 'download-uf2';
if (arch === 'esp32' || arch === 'esp8266') return 'esp32-flash';
return 'download-bin';
}
function methodLabel(m) {
return { 'avr-stk500': 'STK500 (WebSerial)', 'stm32-uart': 'STM32 UART 부트로더', 'download-bin': '.bin 다운로드', 'download-uf2': 'UF2 드래그&드롭', 'esp32-flash': 'esptool-js (브라우저)' }[m] || m;
}
// ── port selection ────────────────────────────────────────────────────────────
async function selectPort() {
if (!navigator.serial) {
log('❌ WebSerial 미지원 브라우저입니다. Chrome 또는 Edge를 사용하세요.', 'err');
return;
}
try {
_port = await navigator.serial.requestPort();
const info = _port.getInfo();
const vid = info.usbVendorId ? `VID:0x${info.usbVendorId.toString(16).padStart(4,'0')}` : '';
const pid = info.usbProductId ? `PID:0x${info.usbProductId.toString(16).padStart(4,'0')}` : '';
const label = [vid, pid].filter(Boolean).join(' ') || '(선택됨)';
document.getElementById('port-name').textContent = '✓ ' + label;
document.getElementById('port-name').style.color = 'var(--green)';
document.getElementById('btn-upload').disabled = false;
document.getElementById('btn-download').disabled = false;
log(`포트 선택됨: ${label}`, 'ok');
setStatus('ready', '준비됨');
footer(`포트 선택됨: ${label}`);
} catch(e) {
if (e.name !== 'NotFoundError') log('포트 선택 오류: ' + e.message, 'err');
}
}
// ── compile on server ─────────────────────────────────────────────────────────
async function compile() {
// Auto-correct fqbn from code content to avoid cross-platform header errors
const _baseFqbn = _fqbn.split(':').slice(0, 3).join(':');
const _codeArch = _baseFqbn.split(':')[1] || '';
if (/ESP8266WiFi|ESP8266WebServer|ESP8266HTTPClient/i.test(_code) && _codeArch !== 'esp8266') {
_fqbn = 'esp8266:esp8266:nodemcuv2';
_selOptions = {};
log(`⚠ ESP8266 스케치 감지 → FQBN을 ${_fqbn}으로 자동 변경`, 'warn');
document.getElementById('info-fqbn').textContent = _fqbn;
loadBoardOptions();
} else if (/^#include\s+<WiFi\.h>/m.test(_code) && _codeArch !== 'esp32' && _codeArch !== 'esp8266') {
_fqbn = 'esp32:esp32:esp32';
_selOptions = {};
log(`⚠ ESP32 WiFi 스케치 감지 → FQBN을 ${_fqbn}으로 자동 변경`, 'warn');
document.getElementById('info-fqbn').textContent = _fqbn;
loadBoardOptions();
}
log('서버에서 컴파일 중...', 'prog');
setStatus('compiling', '컴파일 중...');
footer('컴파일 중...');
const r = await fetch('/api/arduino/compile', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code: _code, fqbn: getEffectiveFqbn() }),
});
const data = await r.json();
if (data.error) {
log('컴파일 오류:\n' + data.error, 'err');
setStatus('error', '컴파일 실패');
footer('컴파일 실패');
return null;
}
const kb = data.size ? ` (${(data.size / 1024).toFixed(1)} KB)` : '';
log(`✅ 컴파일 완료${kb} — boardType: ${data.boardType}`, 'ok');
return data;
}
// ── ESP32 browser-side flash via esptool-js (WebSerial) ──────────────────────
async function uploadESP32() {
if (_busy) return;
_busy = true;
document.getElementById('btn-upload').disabled = true;
document.getElementById('btn-download').disabled = true;
clearLog();
setProgress(0);
let transport = null;
let success = false;
try {
// 1. Compile on server
const data = await compile();
if (!data) return;
_compiledData = data;
const b64 = data.merged || data.bin;
if (!b64) throw new Error('컴파일 결과에 바이너리가 없습니다');
// 2. Load esptool-js (local bundle)
log('esptool-js 로딩 중...', 'prog');
const { ESPLoader, Transport, HardReset } = await import('/esptool-bundle.js');
// 3. Connect & enter bootloader
log('보드 연결 중 (자동 리셋)...', 'prog');
setStatus('uploading', '연결 중...');
transport = new Transport(_port, true);
const esploader = new ESPLoader({
transport,
baudrate: 921600,
romBaudrate: 115200,
enableTracing: false,
terminal: {
clean: () => {},
writeLine: (msg) => { if (msg.trim()) log(msg, 'info'); },
write: (msg) => { if (msg.trim()) log(msg, 'info'); },
},
});
const chip = await esploader.main();
log(`칩 감지: ${chip}`, 'ok');
setProgress(10);
// 4. Flash merged.bin at 0x0
setStatus('uploading', '업로드 중...');
footer('플래싱 중...');
const binData = base64ToBytes(b64); // Uint8Array
await esploader.writeFlash({
fileArray: [{ data: binData, address: 0x0 }],
flashSize: 'keep',
flashMode: 'keep',
flashFreq: 'keep',
eraseAll: false,
compress: true,
reportProgress: (_fi, written, total) => {
const pct = Math.round(written / total * 100);
setProgress(10 + pct * 0.88);
footer(`플래싱 중... ${written.toLocaleString()} / ${total.toLocaleString()} 바이트 (${pct}%)`);
},
});
// 5. Reset to app
try { await new HardReset(transport).reset(); } catch {}
log('✅ 업로드 완료! 보드가 재시작됩니다.', 'ok');
setStatus('done', '완료');
footer('업로드 완료');
setProgress(100);
success = true;
} catch(e) {
log('❌ 오류: ' + e.message, 'err');
setStatus('error', '업로드 실패');
footer('오류: ' + e.message);
setProgress(null);
} finally {
try { if (transport) await transport.disconnect(); } catch {}
_busy = false;
document.getElementById('btn-upload').disabled = false;
document.getElementById('btn-download').disabled = false;
setTimeout(() => setProgress(null), 3000);
}
// Auto-connect serial monitor after upload (must be after finally so _busy=false and port is free)
if (success) {
monSys('[업로드 완료 — 시리얼 모니터 자동 연결 중...]');
await new Promise(r => setTimeout(r, 1500));
await openMonitor();
}
}
// ── download binary ───────────────────────────────────────────────────────────
async function doDownload() {
const data = await compile();
if (!data) return;
_compiledData = data;
if (data.hex) {
const blob = new Blob([data.hex], { type: 'text/plain' });
triggerDownload(blob, _fname.replace(/\.[^.]+$/, '') + '.hex');
} else if (data.bin) {
const bytes = base64ToBytes(data.bin || data.merged);
const blob = new Blob([bytes], { type: 'application/octet-stream' });
const ext = data.boardType === 'uf2' ? '.uf2' : '.bin';
triggerDownload(blob, _fname.replace(/\.[^.]+$/, '') + ext);
}
setStatus('done', '다운로드됨');
}
function triggerDownload(blob, name) {
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = name;
a.click();
setTimeout(() => URL.revokeObjectURL(a.href), 5000);
log(`다운로드: ${name}`, 'ok');
}
// ── main upload ───────────────────────────────────────────────────────────────
async function doUpload() {
if (_busy) return;
if (!_port) { log('포트를 먼저 선택해주세요 (🔌 포트 선택 버튼)', 'err'); return; }
const method = getUploadMethod(_fqbn);
if (method === 'esp32-flash') {
await uploadESP32();
return;
}
if (method === 'download-uf2') {
await doDownload(); return;
}
if (method === 'download-bin') {
await doDownload(); return;
}
_busy = true;
document.getElementById('btn-upload').disabled = true;
document.getElementById('btn-download').disabled = true;
clearLog();
setProgress(0);
try {
const data = await compile();
if (!data) return;
_compiledData = data;
log(`\n--- 업로드 시작 [${_boardName}] ---`, 'prog');
setStatus('uploading', '업로드 중...');
footer('업로드 중...');
setProgress(5);
if (method === 'avr-stk500') {
await uploadAVR(data.hex);
} else if (method === 'stm32-uart') {
await uploadSTM32(base64ToBytes(data.bin));
}
} catch(e) {
log('❌ 오류: ' + e.message, 'err');
setStatus('error', '업로드 실패');
footer('업로드 실패: ' + e.message);
setProgress(null);
} finally {
_busy = false;
document.getElementById('btn-upload').disabled = false;
document.getElementById('btn-download').disabled = false;
}
}
// ── utilities ─────────────────────────────────────────────────────────────────
function delay(ms) { return new Promise(r => setTimeout(r, ms)); }
function base64ToBytes(b64) {
const raw = atob(b64);
const out = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
return out;
}
function parseIntelHex(hexText) {
// Returns Map<pageIndex(128B), Uint8Array(128)>
const pages = new Map();
let extAddr = 0;
for (const line of hexText.split('\n')) {
const t = line.trim();
if (!t.startsWith(':') || t.length < 11) continue;
const bytes = [];
for (let i = 1; i < t.length - 1; i += 2) bytes.push(parseInt(t.substr(i, 2), 16));
const len = bytes[0], addr = (bytes[1] << 8) | bytes[2], type = bytes[3];
if (type === 1) break;
if (type === 4) { extAddr = ((bytes[4] << 8) | bytes[5]) << 16; continue; }
if (type !== 0) continue;
const fullAddr = extAddr + addr;
for (let i = 0; i < len; i++) {
const a = fullAddr + i;
const pg = a >> 7;
const off = a & 0x7f;
if (!pages.has(pg)) pages.set(pg, new Uint8Array(128).fill(0xff));
pages.get(pg)[off] = bytes[4 + i];
}
}
return pages;
}
// ── WebSerial helpers ─────────────────────────────────────────────────────────
async function _write(writer, data) {
await writer.write(data instanceof Uint8Array ? data : new Uint8Array(Array.isArray(data) ? data : [data]));
}
async function _read(reader, n, ms = 5000) {
const buf = new Uint8Array(n);
let pos = 0;
const deadline = Date.now() + ms;
while (pos < n) {
if (Date.now() > deadline) throw new Error(`시리얼 타임아웃 (${n}바이트 중 ${pos}바이트 수신)`);
const { value, done } = await reader.read();
if (done) throw new Error('포트가 닫혔습니다');
const take = Math.min(value.length, n - pos);
buf.set(value.slice(0, take), pos);
pos += take;
}
return buf;
}
// ── AVR STK500 v1 uploader ────────────────────────────────────────────────────
async function uploadAVR(hexText) {
const INSYNC = 0x14, OK = 0x10, EOP = 0x20;
const CMD_SYNC = 0x30, CMD_SET_DEV = 0x42, CMD_ENTER = 0x50;
const CMD_LEAVE = 0x51, CMD_ADDR = 0x55, CMD_PROG = 0x64;
await _port.open({ baudRate: 115200, dataBits: 8, stopBits: 1, parity: 'none' });
const reader = _port.readable.getReader();
const writer = _port.writable.getWriter();
const send = async (...b) => _write(writer, b);
const recv = async (n, ms) => _read(reader, n, ms);
const cmdOk = async (bytes, extra = 0, ms = 3000) => {
await _write(writer, new Uint8Array([...bytes, EOP]));
const r = await recv(2 + extra, ms);
if (r[0] !== INSYNC) throw new Error(`STK500: INSYNC 없음 (0x${r[0].toString(16)})`);
if (r[1 + extra] !== OK) throw new Error(`STK500: OK 없음 (0x${r[1+extra].toString(16)})`);
return r.slice(1, 1 + extra);
};
try {
// DTR 토글로 리셋
if (_port.setSignals) {
await _port.setSignals({ dataTerminalReady: false, requestToSend: false });
await delay(100);
await _port.setSignals({ dataTerminalReady: true, requestToSend: true });
await delay(50);
await _port.setSignals({ dataTerminalReady: false });
}
await delay(250);
// Sync loop
log('동기화 중...', 'prog');
let synced = false;
for (let i = 0; i < 12 && !synced; i++) {
try {
await send(CMD_SYNC, EOP);
const r = await recv(2, 300);
if (r[0] === INSYNC && r[1] === OK) synced = true;
} catch {}
}
if (!synced) throw new Error('보드에 연결할 수 없습니다. 케이블/DTR 리셋을 확인하세요.');
log('✓ 동기화됨', 'ok');
// SET_DEVICE — ATmega328P params
await cmdOk([CMD_SET_DEV, 0x86,0x00,0x07,0x7f,0x83,0xff,0x00,0x01,0x01,0x01,0x01,0x03,0xff,0x00,0x80,0x04,0x00,0x00,0x00,0x80,0x00]);
// ENTER_PROGMODE
await cmdOk([CMD_ENTER]);
log('✓ 프로그래밍 모드 진입', 'ok');
const pages = parseIntelHex(hexText);
const sorted = [...pages.entries()].sort((a, b) => a[0] - b[0]);
let done = 0;
for (const [pg, data] of sorted) {
const waddr = pg << 6; // word address = byte_page_addr / 2
await cmdOk([CMD_ADDR, waddr & 0xff, (waddr >> 8) & 0xff]);
await cmdOk([CMD_PROG, 0x00, 0x80, 0x46, ...data]); // 0x80=128 bytes, 0x46='F'
done++;
const pct = 5 + Math.round(done / sorted.length * 90);
setProgress(pct);
footer(`업로드 중... ${done}/${sorted.length} 페이지`);
}
await cmdOk([CMD_LEAVE]);
setProgress(100);
log(`✅ 업로드 완료! (${sorted.length} 페이지)`, 'ok');
setStatus('done', '완료 ✓');
footer('업로드 완료');
await delay(500);
setProgress(null);
} finally {
try { reader.releaseLock(); } catch {}
try { writer.releaseLock(); } catch {}
try { await _port.close(); } catch {}
}
}
// ── STM32 UART bootloader (AN3155) uploader ───────────────────────────────────
async function uploadSTM32(binData) {
const ACK = 0x79;
await _port.open({ baudRate: 115200, dataBits: 8, stopBits: 1, parity: 'even' });
const reader = _port.readable.getReader();
const writer = _port.writable.getWriter();
const w = async (d) => _write(writer, d);
const readByte = async (ms = 6000) => (await _read(reader, 1, ms))[0];
const waitAck = async (ms = 12000) => {
const b = await readByte(ms);
if (b === ACK) return;
if (b === 0x1F) throw new Error('STM32 NACK — 명령 거부. 부트로더 모드 진입 여부 확인.');
throw new Error(`예상치 못한 응답: 0x${b.toString(16)}`);
};
const xor4 = (a) => a[0] ^ a[1] ^ a[2] ^ a[3];
const addrPkt = (addr) => {
const b = [(addr>>24)&0xff,(addr>>16)&0xff,(addr>>8)&0xff,addr&0xff];
return new Uint8Array([...b, xor4(b)]);
};
try {
// Sync
log('STM32 부트로더 동기화 중...', 'prog');
log('(BOOT0=HIGH 상태에서 RESET 눌렀다 뗐는지 확인하세요)', 'warn');
let synced = false;
for (let i = 0; i < 5 && !synced; i++) {
try {
await w([0x7F]);
const b = await readByte(1200);
if (b === ACK || b === 0x1F) synced = true;
} catch {}
}
if (!synced) throw new Error('동기화 실패 — BOOT0 핀을 HIGH로 하고 RESET하세요.');
log('✓ 동기화됨', 'ok');
// GET command to verify bootloader
await w([0x00, 0xFF]);
await waitAck(3000);
const n = await readByte();
const cmdBytes = await _read(reader, n + 1, 3000);
await waitAck(3000);
log(`✓ 부트로더 버전: 0x${cmdBytes[0].toString(16).padStart(2,'0')}`, 'ok');
// Extended Erase — global mass erase
log('플래시 전체 지우는 중... (최대 30초)', 'prog');
setProgress(10);
await w([0x44, 0xBB]);
await waitAck(5000);
await w([0xFF, 0xFF, 0x00]); // special global erase
await waitAck(30000);
log('✓ 플래시 지움 완료', 'ok');
setProgress(20);
// Write Memory
const START = 0x08000000;
const CHUNK = 256;
const total = Math.ceil(binData.length / CHUNK);
log(`${binData.length} 바이트 쓰는 중 (${total} 블록)...`, 'prog');
for (let i = 0; i < binData.length; i += CHUNK) {
const raw = binData.slice(i, i + CHUNK);
const padded = new Uint8Array(CHUNK).fill(0xff);
padded.set(raw);
await w([0x31, 0xCE]);
await waitAck(5000);
await w(addrPkt(START + i));
await waitAck(5000);
// [N-1, data[0..N-1], XOR(N-1 ^ data...)]
let ck = CHUNK - 1;
for (const b of padded) ck ^= b;
const pkt = new Uint8Array(CHUNK + 2);
pkt[0] = CHUNK - 1;
pkt.set(padded, 1);
pkt[CHUNK + 1] = ck;
await w(pkt);
await waitAck(10000);
const blk = Math.floor(i / CHUNK) + 1;
const pct = 20 + Math.round(blk / total * 75);
setProgress(pct);
footer(`업로드 중... ${blk}/${total} 블록`);
}
// GO — jump to application
setProgress(97);
log('실행 중...', 'prog');
await w([0x21, 0xDE]);
await waitAck(5000);
await w(addrPkt(START));
try { await waitAck(2000); } catch {} // GO 후 응답 없을 수 있음
setProgress(100);
log('✅ 업로드 완료! BOOT0 핀을 GND로 복원하세요.', 'ok');
setStatus('done', '완료 ✓');
footer('업로드 완료 — BOOT0=GND 후 RESET 하세요');
await delay(500);
setProgress(null);
} finally {
try { reader.releaseLock(); } catch {}
try { writer.releaseLock(); } catch {}
try { await _port.close(); } catch {}
}
}
// ── Serial Monitor ────────────────────────────────────────────────────────────
let _monActive = false;
let _monReader = null;
let _monWriter = null;
let _monBuf = '';
const _monDecoder = new TextDecoder();
function monAppend(text, cls = 'mon-rx') {
const out = document.getElementById('monitor-output');
const showTs = document.getElementById('mon-timestamp').checked;
const autoScroll = document.getElementById('mon-autoscroll').checked;
// Buffer partial lines; flush on \n
_monBuf += text;
const parts = _monBuf.split('\n');
_monBuf = parts.pop(); // keep last incomplete segment
for (const raw of parts) {
const line = raw.replace(/\r/g, '');
const div = document.createElement('div');
div.className = cls;
if (showTs) {
const d = new Date();
const ts = `${d.getHours().toString().padStart(2,'0')}:${d.getMinutes().toString().padStart(2,'0')}:${d.getSeconds().toString().padStart(2,'0')}.${d.getMilliseconds().toString().padStart(3,'0')}`;
const tsSpan = document.createElement('span');
tsSpan.className = 'mon-ts';
tsSpan.textContent = ts;
div.appendChild(tsSpan);
div.appendChild(document.createTextNode(line));
} else {
div.textContent = line;
}
out.appendChild(div);
}
if (autoScroll) out.scrollTop = out.scrollHeight;
}
function monSys(msg) {
const out = document.getElementById('monitor-output');
const div = document.createElement('div');
div.className = 'mon-sys';
div.textContent = msg;
out.appendChild(div);
if (document.getElementById('mon-autoscroll').checked) out.scrollTop = out.scrollHeight;
}
async function _monReadLoop() {
while (_monActive && _monReader) {
try {
const { value, done } = await _monReader.read();
if (done) break;
monAppend(_monDecoder.decode(value, { stream: true }));
} catch(e) {
if (_monActive) monSys(`[오류: ${e.message}]`);
break;
}
}
if (_monActive) closeMonitor();
}
async function toggleMonitor() {
if (_monActive) await closeMonitor();
else await openMonitor();
}
async function openMonitor() {
if (!_port) { monSys('[포트를 먼저 선택해주세요]'); return; }
if (_busy) { monSys('[업로드 중입니다. 완료 후 연결하세요]'); return; }
const baud = parseInt(document.getElementById('mon-baud').value) || 115200;
try {
await _port.open({ baudRate: baud });
_monReader = _port.readable.getReader();
_monWriter = _port.writable.getWriter();
_monActive = true;
const btn = document.getElementById('btn-monitor');
btn.textContent = '■ 연결 해제';
btn.classList.add('active');
document.getElementById('monitor-input').disabled = false;
monSys(`[연결됨 — ${baud} baud]`);
_monReadLoop();
} catch(e) {
monSys(`[연결 실패: ${e.message}]`);
}
}
async function closeMonitor() {
_monActive = false;
try { await _monReader?.cancel(); } catch {}
try { _monReader?.releaseLock(); } catch {}
try { _monWriter?.releaseLock(); } catch {}
try { await _port?.close(); } catch {}
_monReader = null;
_monWriter = null;
const btn = document.getElementById('btn-monitor');
btn.textContent = '▶ 연결';
btn.classList.remove('active');
document.getElementById('monitor-input').disabled = true;
monSys('[연결 해제됨]');
}
async function monitorSend() {
if (!_monWriter) return;
const input = document.getElementById('monitor-input');
const endings = { none: '', nl: '\n', cr: '\r', crnl: '\r\n' };
const suffix = endings[document.getElementById('mon-line-end').value] ?? '\r\n';
const text = input.value + suffix;
input.value = '';
try {
await _monWriter.write(new TextEncoder().encode(text));
monAppend(text, 'mon-tx');
} catch(e) { monSys(`[전송 오류: ${e.message}]`); }
}
function clearMonitor() {
document.getElementById('monitor-output').innerHTML = '';
_monBuf = '';
}
// ── boot ──────────────────────────────────────────────────────────────────────
function boot() {
_code = sessionStorage.getItem('arduino_upload_code') || '';
_fqbn = sessionStorage.getItem('arduino_upload_fqbn') || 'arduino:avr:uno';
_boardName = sessionStorage.getItem('arduino_upload_board') || _fqbn;
_fname = sessionStorage.getItem('arduino_upload_file') || 'sketch.ino';
sessionStorage.removeItem('arduino_upload_code');
sessionStorage.removeItem('arduino_upload_fqbn');
sessionStorage.removeItem('arduino_upload_board');
sessionStorage.removeItem('arduino_upload_file');
document.getElementById('filename').textContent = _fname;
document.getElementById('board-badge').textContent = _boardName;
document.getElementById('info-file').textContent = _fname;
document.getElementById('info-board').textContent = _boardName;
document.getElementById('info-fqbn').textContent = _fqbn;
const method = getUploadMethod(_fqbn);
document.getElementById('info-method').textContent = methodLabel(method);
document.getElementById('stm32-hint').style.display = method === 'stm32-uart' ? '' : 'none';
document.getElementById('uf2-hint').style.display = method === 'download-uf2' ? '' : 'none';
if (!_code) {
log('코드가 없습니다. 코드 에디터에서 다시 시도해주세요.', 'err');
setStatus('error', '코드 없음');
return;
}
if (!navigator.serial) {
log('⚠ 이 브라우저는 WebSerial을 지원하지 않습니다.\nChrome 또는 Edge를 사용하세요.', 'warn');
}
loadBoardOptions();
log(`보드: ${_boardName}`, 'info');
log(`업로드 방식: ${methodLabel(method)}`, 'info');
if (method === 'stm32-uart')
log('STM32: BOOT0=HIGH 후 RESET → ⚡ 업로드', 'warn');
else if (method === 'avr-stk500')
log('AVR: 포트 선택 후 ⚡ 업로드 (자동 DTR 리셋)', 'info');
else if (method === 'download-uf2' || method === 'download-bin')
log('이 보드는 WebSerial 업로드 대신 ⬇ 다운로드 후 수동 복사 방식을 사용합니다.', 'warn');
else if (method === 'esp32-flash')
log('ESP32/ESP8266: 🔌 포트 선택(COM포트/ttyUSB) 후 ⚡ 업로드\n 컴파일: 서버 | 플래싱: 브라우저 esptool-js (WebSerial)', 'info');
}
boot();
// Expose functions for inline onclick handlers (required by type="module" scope)
Object.assign(window, { selectPort, doUpload, doDownload, clearLog, toggleMonitor, clearMonitor, monitorSend, toggleBoardOptions });
</script>
</body>
</html>
+61 -5
View File
@@ -282,6 +282,7 @@
<button id="code-emu-btn" class="csb-term-btn" onclick="openArduinoEmulator()" title="Arduino 에뮬레이터 열기">아두이노</button>
<button id="code-compile-btn" class="csb-term-btn" onclick="csbCompileOnly()" title="실제 보드용 컴파일 (오류 시 AI 채팅으로 전송)">🔨 컴파일</button>
<button id="code-upload-btn" class="csb-term-btn" onclick="csbUploadToBoard()" title="보드에 업로드 (USB 연결 필요)" style="background:var(--brand);color:#fff;border-color:var(--brand)">⚡ 업로드</button>
</div>
</div>
</div>
@@ -301,11 +302,17 @@
<button class="code-sb-tab active" id="csb-libtab-installed" onclick="csbLibTab('installed')" style="flex:1;font-size:10px;padding:5px 0">설치됨</button>
<button class="code-sb-tab" id="csb-libtab-search" onclick="csbLibTab('search')" style="flex:1;font-size:10px;padding:5px 0">검색/설치</button>
</div>
<div id="csb-lib-filter-row" style="display:flex;padding:4px 6px;gap:4px;border-bottom:1px solid var(--line);flex-shrink:0;flex-wrap:wrap;align-items:center">
<span style="font-size:9px;color:var(--muted);margin-right:2px">필터:</span>
<button id="csb-libf-all" onclick="csbLibFilter('all')" class="csb-libf-btn active" style="font-size:9px;padding:2px 7px;border-radius:3px;border:1px solid var(--line);background:var(--brand);color:#fff;cursor:pointer;font-family:inherit">전체</button>
<button id="csb-libf-compat" onclick="csbLibFilter('compat')" class="csb-libf-btn" style="font-size:9px;padding:2px 7px;border-radius:3px;border:1px solid var(--line);background:var(--panel-2);color:var(--muted);cursor:pointer;font-family:inherit">이 보드만</button>
<button id="csb-libf-incompat" onclick="csbLibFilter('incompat')" class="csb-libf-btn" style="font-size:9px;padding:2px 7px;border-radius:3px;border:1px solid var(--line);background:var(--panel-2);color:var(--muted);cursor:pointer;font-family:inherit">비호환</button>
<div id="csb-lib-filter-row" style="display:flex;flex-direction:column;padding:4px 6px;gap:3px;border-bottom:1px solid var(--line);flex-shrink:0">
<div style="display:flex;align-items:center;gap:4px;flex-wrap:wrap">
<span style="font-size:9px;color:var(--muted)">보기:</span>
<button id="csb-libv-flat" onclick="csbLibView('flat')" style="font-size:9px;padding:2px 7px;border-radius:3px;border:1px solid var(--brand);background:var(--brand);color:#fff;cursor:pointer;font-family:inherit">목록</button>
<button id="csb-libv-category" onclick="csbLibView('category')" style="font-size:9px;padding:2px 7px;border-radius:3px;border:1px solid var(--line);background:var(--panel-2);color:var(--muted);cursor:pointer;font-family:inherit">종류별</button>
<span style="font-size:9px;color:var(--muted);margin-left:4px">보드:</span>
<button id="csb-libf-all" onclick="csbLibFilter('all')" style="font-size:9px;padding:2px 7px;border-radius:3px;border:1px solid var(--brand);background:var(--brand);color:#fff;cursor:pointer;font-family:inherit">전체</button>
<button id="csb-libf-compat" onclick="csbLibFilter('compat')" style="font-size:9px;padding:2px 7px;border-radius:3px;border:1px solid var(--line);background:var(--panel-2);color:var(--muted);cursor:pointer;font-family:inherit">이 보드만</button>
<button id="csb-libf-incompat" onclick="csbLibFilter('incompat')" style="font-size:9px;padding:2px 7px;border-radius:3px;border:1px solid var(--line);background:var(--panel-2);color:var(--muted);cursor:pointer;font-family:inherit">비호환</button>
</div>
<div id="csb-lib-arch-row" style="display:none;flex-wrap:wrap;gap:3px"></div>
</div>
<div id="csb-lib-search-row" style="display:none;padding:6px 8px;border-bottom:1px solid var(--line);gap:5px;flex-shrink:0;align-items:center">
<input id="csb-lib-q" placeholder="키워드 또는 라이브러리명..." onkeydown="if(event.key==='Enter')csbLibSearch()"
@@ -503,6 +510,55 @@
</div>
<iframe id="code-preview-iframe" src="" style="flex:1;border:none;background:#fff;min-height:0" sandbox="allow-scripts allow-same-origin allow-forms allow-modals allow-popups"></iframe>
</div>
<!-- ⚡ Upload panel -->
<div id="code-upload-panel" style="display:none;flex-direction:column;flex-shrink:0;height:300px;min-height:100px;border-top:2px solid #7a4e20;background:#1e1810;position:relative">
<div id="code-upload-drag" style="position:absolute;top:0;left:0;right:0;height:5px;cursor:ns-resize;z-index:2;transition:background .15s"></div>
<div style="display:flex;align-items:center;height:32px;background:#2a1e10;border-bottom:1px solid #5a3820;flex-shrink:0;padding:0 8px;gap:6px;overflow-x:auto">
<span id="cup-status" style="padding:2px 8px;border-radius:4px;font-size:11px;font-weight:700;background:#3a2810;color:#e09040;flex-shrink:0;white-space:nowrap">대기 중</span>
<span id="cup-port-name" style="font-size:12px;color:#c8a060;font-family:'IBM Plex Mono',monospace;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1">포트 없음</span>
<button id="cup-tbtn-log" onclick="cupTab('log')" style="background:none;border:none;color:#e89040;cursor:pointer;font-size:12px;font-family:'IBM Plex Mono',monospace;padding:0 8px;height:100%;border-bottom:2px solid #e89040;white-space:nowrap;flex-shrink:0">OUTPUT</button>
<button id="cup-tbtn-opts" onclick="cupTab('opts')" style="background:none;border:none;color:#8a7050;cursor:pointer;font-size:12px;font-family:'IBM Plex Mono',monospace;padding:0 8px;height:100%;border-bottom:2px solid transparent;white-space:nowrap;flex-shrink:0">옵션</button>
<button id="cup-tbtn-mon" onclick="cupTab('mon')" style="background:none;border:none;color:#8a7050;cursor:pointer;font-size:12px;font-family:'IBM Plex Mono',monospace;padding:0 8px;height:100%;border-bottom:2px solid transparent;white-space:nowrap;flex-shrink:0">시리얼</button>
<button onclick="cupSelectPort()" style="background:#3a2818;border:1px solid #6a4820;color:#e89040;border-radius:5px;padding:3px 10px;font-size:12px;cursor:pointer;font-family:'IBM Plex Mono',monospace;flex-shrink:0;white-space:nowrap">🔌 포트</button>
<button id="cup-btn-download" onclick="cupDoDownload()" disabled style="background:#3a2818;border:1px solid #4a3820;color:#907050;border-radius:5px;padding:3px 9px;font-size:12px;cursor:pointer;font-family:'IBM Plex Mono',monospace;flex-shrink:0">⬇</button>
<button id="cup-btn-upload" onclick="cupDoUpload()" disabled style="background:color-mix(in srgb,#e89040 20%,#2a1e10);border:1px solid #e89040;color:#e89040;border-radius:5px;padding:3px 10px;font-size:12px;cursor:pointer;font-family:'IBM Plex Mono',monospace;font-weight:700;flex-shrink:0;white-space:nowrap">⚡ 업로드</button>
<button onclick="toggleUploadPanel()" style="background:none;border:none;color:#8a6840;cursor:pointer;font-size:16px;padding:0 6px;line-height:1;flex-shrink:0">&times;</button>
</div>
<progress id="cup-progress" value="0" max="100" style="display:none;width:100%;height:4px;border:none;border-radius:0;flex-shrink:0;accent-color:#e89040"></progress>
<!-- OUTPUT tab -->
<div id="cup-tab-log" style="flex:1;overflow-y:auto;padding:10px 14px;font-size:13px;line-height:1.8;white-space:pre-wrap;word-break:break-all;font-family:'IBM Plex Mono',monospace"></div>
<!-- 옵션 tab -->
<div id="cup-tab-opts" style="display:none;flex:1;overflow-y:auto;padding:12px 14px;font-size:13px;font-family:'IBM Plex Mono',monospace">
<div id="cup-info-rows" style="display:flex;flex-direction:column;gap:5px;margin-bottom:12px"></div>
<div id="cup-board-opts-list"></div>
<div id="cup-stm32-hint" style="display:none;margin-top:10px;padding:10px 12px;background:#2a1e10;border:1px solid #5a3820;border-radius:6px;color:#c8a060;font-size:13px;line-height:1.7">STM32 부트로더:<br>① BOOT0=3.3V ② RESET → ⚡ 업로드<br>업로드 후 BOOT0=GND → RESET</div>
<div id="cup-uf2-hint" style="display:none;margin-top:10px;padding:10px 12px;background:#2a1e10;border:1px solid #5a3820;border-radius:6px;color:#c8a060;font-size:13px;line-height:1.7">UF2 업로드:<br>① BOOTSEL 누른채 USB 연결<br>② BOOT 드라이브 나타남<br>③ ⬇ 다운로드 후 드라이브에 복사</div>
</div>
<!-- 시리얼 tab -->
<div id="cup-tab-mon" style="display:none;flex-direction:column;flex:1;min-height:0">
<div style="display:flex;align-items:center;padding:5px 10px;border-bottom:1px solid #5a3820;flex-shrink:0;gap:7px;flex-wrap:wrap;background:#241a0e">
<select id="cup-mon-baud" style="background:#3a2818;border:1px solid #5a3820;color:#c8a060;border-radius:4px;padding:3px 6px;font-size:12px;font-family:'IBM Plex Mono',monospace">
<option value="9600">9600</option><option value="19200">19200</option><option value="38400">38400</option>
<option value="57600">57600</option><option value="115200" selected>115200</option>
<option value="230400">230400</option><option value="921600">921600</option>
</select>
<select id="cup-mon-line-end" style="background:#3a2818;border:1px solid #5a3820;color:#c8a060;border-radius:4px;padding:3px 6px;font-size:12px;font-family:'IBM Plex Mono',monospace">
<option value="none">줄끝없음</option><option value="nl">LF</option><option value="cr">CR</option><option value="crnl" selected>CR+LF</option>
</select>
<button id="cup-mon-btn" onclick="cupMonToggle()" style="background:color-mix(in srgb,#22c55e 15%,#241a0e);border:1px solid color-mix(in srgb,#22c55e 35%,#241a0e);color:#4ade80;border-radius:4px;padding:3px 10px;font-size:12px;cursor:pointer;font-family:'IBM Plex Mono',monospace">▶ 연결</button>
<label style="display:flex;align-items:center;gap:4px;font-size:12px;color:#907050;cursor:pointer"><input type="checkbox" id="cup-mon-ts"> 시각</label>
<label style="display:flex;align-items:center;gap:4px;font-size:12px;color:#907050;cursor:pointer"><input type="checkbox" id="cup-mon-auto" checked> 자동스크롤</label>
<button onclick="cupMonClear()" style="background:none;border:none;color:#907050;cursor:pointer;font-size:12px;font-family:'IBM Plex Mono',monospace">지우기</button>
</div>
<div id="cup-mon-output" style="flex:1;overflow-y:auto;padding:8px 14px;font-size:13px;line-height:1.7;font-family:'IBM Plex Mono',monospace"></div>
<div style="display:flex;gap:7px;padding:7px 10px;border-top:1px solid #3a2818;background:#1e1408;flex-shrink:0">
<input type="text" id="cup-mon-input" placeholder="전송할 데이터..." disabled
onkeydown="if(event.key==='Enter')cupMonSend()"
style="flex:1;background:#2a1e10;border:1px solid #5a3820;color:#e8d0a0;border-radius:5px;padding:6px 10px;font-size:13px;font-family:'IBM Plex Mono',monospace;outline:none">
<button onclick="cupMonSend()" style="background:color-mix(in srgb,#c8a060 20%,#1e1408);border:1px solid #8a6840;color:#c8a060;border-radius:5px;padding:5px 14px;font-size:13px;cursor:pointer;font-family:'IBM Plex Mono',monospace">전송</button>
</div>
</div>
</div>
</div>
</div>
+750 -52
View File
@@ -1004,13 +1004,7 @@ function csbUploadToBoard() {
if (!file || !file.name.endsWith('.ino')) {
codeAiAppend('system', '⚠️ .ino 파일을 선택해주세요'); return;
}
const content = file.model ? file.model.getValue() : (file.content || '');
sessionStorage.setItem('arduino_upload_code', content);
sessionStorage.setItem('arduino_upload_fqbn', _csbSelBoard.fqbn);
sessionStorage.setItem('arduino_upload_board', _csbSelBoard.name);
sessionStorage.setItem('arduino_upload_file', file.name);
const w = window.open('/arduino-upload.html', '_blank');
if (!w) codeAiAppend('system', '⚠️ 팝업 차단됨');
cupOpenWith(file);
}
function codePreviewRefresh() {
@@ -4226,9 +4220,26 @@ function setCodeSbTab(tab) {
// ── 라이브러리/보드 매니저 (code editor sidebar) ──────────────────────────────
let _csbLibCurrentTab = 'installed';
let _csbLibFilterMode = 'all'; // 'all' | 'compat' | 'incompat'
let _csbLibViewMode = 'flat'; // 'flat' | 'category'
let _csbLibArchFilter = ''; // '' = 전체, 'esp32' | 'esp8266' | 'avr' ...
let _csbLibCache = [];
let _csbArdSection = 'lib';
const _CAT_KO = {
'Communication': '통신',
'Data Processing': '데이터 처리',
'Data Storage': '데이터 저장',
'Device Control': '장치 제어',
'Display': '디스플레이',
'Encryption': '암호화',
'Imaging': '이미징',
'Other': '기타',
'Sensors': '센서',
'Signal Input/Output':'신호 입출력',
'Timing': '타이밍',
'Uncategorized': '미분류',
};
function csbArdSection(sec) {
_csbArdSection = sec;
['lib','board'].forEach(s => {
@@ -4269,6 +4280,48 @@ function csbLibFilter(mode) {
_csbLibRenderInstalled(_csbLibCache);
}
function csbLibView(mode) {
_csbLibViewMode = mode;
['flat','category'].forEach(m => {
const btn = document.getElementById('csb-libv-' + m);
if (!btn) return;
const active = m === mode;
btn.style.background = active ? 'var(--brand)' : 'var(--panel-2)';
btn.style.color = active ? '#fff' : 'var(--muted)';
btn.style.borderColor = active ? 'var(--brand)' : 'var(--line)';
});
_csbLibRenderInstalled(_csbLibCache);
}
function _csbLibSetArch(arch) {
_csbLibArchFilter = arch;
_csbLibUpdateArchChips(_csbLibCache);
_csbLibRenderInstalled(_csbLibCache);
}
function _csbLibUpdateArchChips(libs) {
const archRow = document.getElementById('csb-lib-arch-row');
if (!archRow) return;
// Count non-wildcard archs
const archCount = {};
for (const l of libs) {
for (const a of (l.architectures || [])) {
if (a !== '*') archCount[a] = (archCount[a] || 0) + 1;
}
}
// Show archs with ≥2 libs, sorted by count desc
const major = Object.entries(archCount).filter(([,c]) => c >= 2).sort((a,b) => b[1]-a[1]).map(([a]) => a);
if (!major.length) { archRow.style.display = 'none'; return; }
const all = ['', ...major];
archRow.innerHTML = all.map(a => {
const label = a ? a.toUpperCase() : '전체';
const cnt = a ? (archCount[a] || 0) : libs.length;
const active = _csbLibArchFilter === a;
return `<button onclick="_csbLibSetArch('${a}')" style="font-size:9px;padding:1px 6px;border-radius:3px;border:1px solid ${active?'var(--brand)':'var(--line)'};background:${active?'var(--brand)':'var(--panel-2)'};color:${active?'#fff':'var(--muted)'};cursor:pointer;font-family:inherit" title="${cnt}개">${label}</button>`;
}).join('');
archRow.style.display = 'flex';
}
function _csbLibListEl() { return document.getElementById('csb-lib-list'); }
function _csbLibStatus(msg) { const el = document.getElementById('csb-lib-status'); if (el) el.textContent = msg; }
function _csbEsc(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;').replace(/'/g,'&#39;'); }
@@ -4280,6 +4333,8 @@ async function csbLibLoad() {
const r = await fetch('/api/arduino/libs?all=1' + fqbn, { headers: authHeaders() });
const data = await r.json();
_csbLibCache = data.libs || [];
_csbLibArchFilter = '';
_csbLibUpdateArchChips(_csbLibCache);
_csbLibRenderInstalled(_csbLibCache);
} catch { _csbLibListEl().innerHTML = '<div style="padding:16px;color:var(--muted);font-size:11px;text-align:center">오류</div>'; }
}
@@ -4308,70 +4363,238 @@ function _csbLibRenderInstalled(libs) {
const el = _csbLibListEl();
if (!libs.length) { el.innerHTML = '<div style="padding:16px;color:var(--muted);font-size:11px;text-align:center">설치된 라이브러리 없음</div>'; return; }
// Apply filter
// Apply board compat filter
let filtered = libs;
if (_csbLibFilterMode === 'compat') filtered = libs.filter(l => _csbIsCompat(l));
if (_csbLibFilterMode === 'incompat') filtered = libs.filter(l => !_csbIsCompat(l));
// Sort: platform/core first, then compatible, then incompatible
if (_csbLibFilterMode === 'all') {
filtered = [...libs].sort((a, b) => {
const pa = a.location === 'platform', pb = b.location === 'platform';
if (pa && !pb) return -1;
if (!pa && pb) return 1;
const ca = _csbIsCompat(a), cb = _csbIsCompat(b);
if (ca && !cb) return -1;
if (!ca && cb) return 1;
return a.name.localeCompare(b.name);
// Apply architecture filter
if (_csbLibArchFilter) {
filtered = filtered.filter(l => {
const archs = l.architectures || [];
return archs.includes('*') || archs.length === 0 || archs.includes(_csbLibArchFilter) ||
archs.some(a => a.startsWith(_csbLibArchFilter) || _csbLibArchFilter.startsWith(a));
});
}
// Dedup by name when a specific arch or compat filter is active
// Priority: user-installed > platform, specific arch > wildcard *, newer version
if (_csbLibArchFilter || _csbLibFilterMode === 'compat') {
const byName = new Map();
for (const l of filtered) {
if (!byName.has(l.name)) { byName.set(l.name, []); }
byName.get(l.name).push(l);
}
filtered = [...byName.values()].map(group => group.reduce((best, l) => {
if (l.location === 'user' && best.location !== 'user') return l;
if (best.location === 'user' && l.location !== 'user') return best;
const lWild = !(l.architectures||[]).length || (l.architectures||[]).includes('*');
const bWild = !(best.architectures||[]).length || (best.architectures||[]).includes('*');
if (!lWild && bWild) return l;
if (lWild && !bWild) return best;
return (l.version||'') >= (best.version||'') ? l : best;
}));
}
// Sort: platform/core first, then compatible, then name
filtered = [...filtered].sort((a, b) => {
const pa = a.location === 'platform', pb = b.location === 'platform';
if (pa && !pb) return -1;
if (!pa && pb) return 1;
const ca = _csbIsCompat(a), cb = _csbIsCompat(b);
if (ca && !cb) return -1;
if (!ca && cb) return 1;
return a.name.localeCompare(b.name);
});
if (!filtered.length) {
const msg = _csbLibFilterMode === 'compat' ? `${_csbSelBoard.name}과 호환되는 라이브러리 없음` : '비호환 라이브러리 없음';
const msg = _csbLibFilterMode === 'compat' ? `${_csbSelBoard.name}과 호환되는 라이브러리 없음`
: _csbLibArchFilter ? `${_csbLibArchFilter.toUpperCase()} 라이브러리 없음`
: '비호환 라이브러리 없음';
el.innerHTML = `<div style="padding:16px;color:var(--muted);font-size:11px;text-align:center">${msg}</div>`;
return;
}
if (_csbLibViewMode === 'category') { _csbLibRenderGrouped(filtered); return; }
const fqbn = _csbSelBoard?.fqbn || '';
el.innerHTML = filtered.map(l => {
const sid = 'csbex_' + l.name.replace(/\W/g,'_');
const archs = l.architectures || [];
const compat = _csbIsCompat(l);
const isPlatform = l.location === 'platform';
const incompatWarn = !compat ? `<div style="font-size:10px;color:#f87171;margin-top:3px">⚠ 현재 보드(${_csbEsc(_csbSelBoard.name)})와 호환되지 않을 수 있음</div>` : '';
const platformBadge = isPlatform ? `<span style="font-size:9px;padding:1px 5px;border-radius:3px;background:#1a2a3a;color:#60a5fa;border:1px solid #2d4a6a">코어</span>` : '';
const exRows = (l.examples||[]).map(ex =>
`<div style="display:flex;align-items:center;padding:3px 0;gap:6px">
<span style="font-size:10px;color:var(--muted);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">📄 ${_csbEsc(ex)}</span>
<button onclick="csbLibOpenExample('${_csbEsc(l.name)}','${_csbEsc(ex)}',${compat},'${_csbEsc(fqbn)}')"
style="font-size:10px;padding:2px 7px;background:none;border:1px solid var(--line);color:${compat?'var(--brand)':'#f87171'};border-radius:4px;cursor:pointer;font-family:inherit;white-space:nowrap"
title="${compat?'':'⚠ 비호환 예제 — 열면 AI가 자동으로 변환해드립니다'}">
${compat?'열기':'열기 ⚠'}</button>
</div>`).join('');
const hasEx = l.examples?.length > 0;
return `<div style="padding:8px 10px;border-bottom:1px solid var(--line)${!compat?';border-left:2px solid #7f1d1d':''}">
<div style="display:flex;align-items:flex-start;gap:6px">
<div style="flex:1;min-width:0">
<div style="display:flex;align-items:center;gap:5px;flex-wrap:wrap">
<span style="font-size:11px;font-weight:700;color:var(--text)">${_csbEsc(l.name)}</span>
${platformBadge}${_csbArchBadge(archs)}
</div>
<div style="font-size:10px;color:var(--muted);margin-top:1px">${_csbEsc(l.author||'')} · v${_csbEsc(l.version||'?')}${l.category?' · '+_csbEsc(l.category):''}</div>
${l.sentence?`<div style="font-size:10px;color:var(--muted);margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${_csbEsc(l.sentence)}</div>`:''}
${incompatWarn}
</div>
<div style="display:flex;gap:4px;flex-shrink:0">
${hasEx?`<button onclick="document.getElementById('${sid}').style.display=document.getElementById('${sid}').style.display==='none'?'block':'none'"
style="font-size:10px;padding:2px 7px;background:none;border:1px solid var(--line);color:var(--muted);border-radius:4px;cursor:pointer;font-family:inherit">예제</button>`:''}
${isPlatform?'':`<button onclick="csbLibUninstall('${_csbEsc(l.name)}')"
style="font-size:10px;padding:2px 7px;background:none;border:1px solid var(--line);color:#f87171;border-radius:4px;cursor:pointer;font-family:inherit">삭제</button>`}
el.innerHTML = _csbBuiltinCard() + filtered.map(l => _csbLibCard(l, fqbn)).join('');
}
function _csbLibCard(l, fqbn) {
const sid = 'csbex_' + l.name.replace(/\W/g,'_');
const archs = l.architectures || [];
const compat = _csbIsCompat(l);
const isPlatform = l.location === 'platform';
const incompatWarn = !compat ? `<div style="font-size:10px;color:#f87171;margin-top:3px">⚠ 현재 보드(${_csbEsc(_csbSelBoard.name)})와 호환되지 않을 수 있음</div>` : '';
const platformBadge = isPlatform ? `<span style="font-size:9px;padding:1px 5px;border-radius:3px;background:#1a2a3a;color:#60a5fa;border:1px solid #2d4a6a">코어</span>` : '';
const exRows = (l.examples||[]).map(ex =>
`<div style="display:flex;align-items:center;padding:3px 0;gap:6px">
<span style="font-size:10px;color:var(--muted);flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">📄 ${_csbEsc(ex)}</span>
<button onclick="csbLibOpenExample('${_csbEsc(l.name)}','${_csbEsc(ex)}',${compat},'${_csbEsc(fqbn)}')"
style="font-size:10px;padding:2px 7px;background:none;border:1px solid var(--line);color:${compat?'var(--brand)':'#f87171'};border-radius:4px;cursor:pointer;font-family:inherit;white-space:nowrap"
title="${compat?'':'⚠ 비호환 예제 — 열면 AI가 자동으로 변환해드립니다'}">${compat?'열기':'열기 ⚠'}</button>
</div>`).join('');
const hasEx = l.examples?.length > 0;
return `<div style="padding:8px 10px;border-bottom:1px solid var(--line)${!compat?';border-left:2px solid #7f1d1d':''}">
<div style="display:flex;align-items:flex-start;gap:6px">
<div style="flex:1;min-width:0">
<div style="display:flex;align-items:center;gap:5px;flex-wrap:wrap">
<span style="font-size:11px;font-weight:700;color:var(--text)">${_csbEsc(l.name)}</span>
${platformBadge}${_csbArchBadge(archs)}
</div>
<div style="font-size:10px;color:var(--muted);margin-top:1px">${_csbEsc(l.author||'')} · v${_csbEsc(l.version||'?')}${l.category?' · '+_csbEsc(l.category):''}</div>
${l.sentence?`<div style="font-size:10px;color:var(--muted);margin-top:2px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${_csbEsc(l.sentence)}</div>`:''}
${incompatWarn}
</div>
${hasEx?`<div id="${sid}" style="display:none;margin-top:5px;padding:5px 8px;background:var(--panel-2);border-radius:5px">${exRows}</div>`:''}
<div style="display:flex;gap:4px;flex-shrink:0">
${hasEx?`<button onclick="document.getElementById('${sid}').style.display=document.getElementById('${sid}').style.display==='none'?'block':'none'"
style="font-size:10px;padding:2px 7px;background:none;border:1px solid var(--line);color:var(--muted);border-radius:4px;cursor:pointer;font-family:inherit">예제</button>`:''}
${isPlatform?'':`<button onclick="csbLibUninstall('${_csbEsc(l.name)}')"
style="font-size:10px;padding:2px 7px;background:none;border:1px solid var(--line);color:#f87171;border-radius:4px;cursor:pointer;font-family:inherit">삭제</button>`}
</div>
</div>
${hasEx?`<div id="${sid}" style="display:none;margin-top:5px;padding:5px 8px;background:var(--panel-2);border-radius:5px">${exRows}</div>`:''}
</div>`;
}
function _csbLibRenderGrouped(libs) {
const el = _csbLibListEl();
const fqbn = _csbSelBoard?.fqbn || '';
// Group by category
const groups = {};
for (const l of libs) {
const cat = l.category || 'Uncategorized';
if (!groups[cat]) groups[cat] = [];
groups[cat].push(l);
}
// Sort categories: put Uncategorized/Other last
const last = new Set(['Uncategorized', 'Other', '']);
const sorted = Object.keys(groups).sort((a,b) => {
if (last.has(a) && !last.has(b)) return 1;
if (!last.has(a) && last.has(b)) return -1;
return a.localeCompare(b);
});
el.innerHTML = _csbBuiltinCard() + sorted.map(cat => {
const ko = _CAT_KO[cat] || cat;
const gid = 'csbg_' + cat.replace(/\W/g,'_');
const cards = groups[cat].map(l => _csbLibCard(l, fqbn)).join('');
const cnt = groups[cat].length;
return `<div>
<div onclick="const b=document.getElementById('${gid}');b.style.display=b.style.display==='none'?'block':'none'"
style="display:flex;align-items:center;gap:6px;padding:6px 10px;cursor:pointer;background:var(--panel-2);border-bottom:1px solid var(--line);position:sticky;top:0;z-index:1">
<span style="font-size:11px;font-weight:700;color:var(--text)">${_csbEsc(ko)}</span>
${cat !== ko ? `<span style="font-size:9px;color:var(--muted)">${_csbEsc(cat)}</span>` : ''}
<span style="font-size:10px;color:var(--muted);margin-left:auto">${cnt}개</span>
</div>
<div id="${gid}">${cards}</div>
</div>`;
}).join('');
}
const _BUILTIN_EX = [
{ name:'Blink.ino', desc:'LED 깜빡이기', code:
`void setup() {
pinMode(LED_BUILTIN, OUTPUT);
}
void loop() {
digitalWrite(LED_BUILTIN, HIGH);
delay(1000);
digitalWrite(LED_BUILTIN, LOW);
delay(1000);
}` },
{ name:'Button.ino', desc:'버튼으로 LED 제어', code:
`const int BTN = 0;
const int LED = LED_BUILTIN;
void setup() {
pinMode(BTN, INPUT_PULLUP);
pinMode(LED, OUTPUT);
}
void loop() {
digitalWrite(LED, digitalRead(BTN) == LOW ? HIGH : LOW);
}` },
{ name:'AnalogRead.ino', desc:'아날로그 읽기', code:
`void setup() {
Serial.begin(115200);
}
void loop() {
int val = analogRead(A0);
Serial.println(val);
delay(200);
}` },
{ name:'Fade.ino', desc:'LED 밝기 조절 (PWM)', code:
`const int LED = LED_BUILTIN;
int brightness = 0, step = 5;
void setup() {
pinMode(LED, OUTPUT);
}
void loop() {
analogWrite(LED, brightness);
brightness += step;
if (brightness <= 0 || brightness >= 255) step = -step;
delay(15);
}` },
{ name:'BlinkWithoutDelay.ino', desc:'딜레이 없는 깜빡이기', code:
`const int LED = LED_BUILTIN;
bool state = false;
unsigned long prev = 0;
void setup() {
pinMode(LED, OUTPUT);
}
void loop() {
unsigned long now = millis();
if (now - prev >= 1000) {
prev = now;
state = !state;
digitalWrite(LED, state);
}
}` },
{ name:'SerialHello.ino', desc:'시리얼 출력', code:
`void setup() {
Serial.begin(115200);
Serial.println("Hello, World!");
}
void loop() {
if (Serial.available()) {
String s = Serial.readStringUntil('\\n');
Serial.print("받음: ");
Serial.println(s);
}
}` },
];
function csbLibOpenBuiltin(idx) {
const ex = _BUILTIN_EX[idx];
if (!ex) return;
codeNewFile(ex.name, ex.code);
setCodeSbTab('files');
}
function _csbBuiltinCard() {
const rows = _BUILTIN_EX.map((ex, i) =>
`<div style="display:flex;align-items:center;padding:3px 0;gap:6px">
<span style="font-size:10px;color:var(--muted);flex:1">📄 ${_csbEsc(ex.desc)}</span>
<button onclick="csbLibOpenBuiltin(${i})"
style="font-size:10px;padding:2px 7px;background:none;border:1px solid var(--line);color:var(--brand);border-radius:4px;cursor:pointer;font-family:inherit;white-space:nowrap">열기</button>
</div>`).join('');
return `<div style="padding:8px 10px;border-bottom:2px solid var(--line);border-left:3px solid #facc15">
<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px">
<span style="font-size:11px;font-weight:700;color:var(--text)">Arduino 기본 예제</span>
<span style="font-size:9px;padding:1px 5px;border-radius:3px;background:#2a2010;color:#facc15;border:1px solid #4a3a10">기본</span>
</div>
<div style="margin-top:4px;padding:5px 8px;background:var(--panel-2);border-radius:5px">${rows}</div>
</div>`;
}
async function csbLibOpenExample(libName, exName, isCompat = true, fqbn = '') {
_csbLibStatus(`예제 로딩: ${exName}...`);
try {
@@ -5596,6 +5819,481 @@ _esp32CodeChannel.onmessage = (e) => {
_esp32SyncBusy = false;
};
// ── Upload Panel (⚡) ─────────────────────────────────────────────────────────
let _cupOpen = false;
let _cupPort = null;
let _cupBusy = false;
let _cupCode = '', _cupFqbn = '', _cupBoardName = '', _cupFname = '';
let _cupCompiledData = null;
let _cupBoardOptions = [];
let _cupSelOptions = {};
let _cupMonActive = false;
let _cupMonReader = null;
let _cupMonWriter = null;
let _cupMonBuf = '';
const _cupDecoder = new TextDecoder();
function toggleUploadPanel() {
const panel = document.getElementById('code-upload-panel');
if (!panel) return;
_cupOpen = !_cupOpen;
panel.style.display = _cupOpen ? 'flex' : 'none';
const btn = document.getElementById('code-upload-btn');
if (btn) btn.classList.toggle('active', _cupOpen);
if (monacoEditor) setTimeout(() => monacoEditor.layout(), 60);
}
function cupOpenWith(file) {
_cupCode = file.model ? file.model.getValue() : (file.content || '');
_cupFqbn = _csbSelBoard?.fqbn || 'arduino:avr:uno';
_cupBoardName = _csbSelBoard?.name || _cupFqbn;
_cupFname = file.name;
_cupSelOptions = {};
const panel = document.getElementById('code-upload-panel');
if (!_cupOpen) {
_cupOpen = true;
panel.style.display = 'flex';
const btn = document.getElementById('code-upload-btn');
if (btn) btn.classList.add('active');
if (monacoEditor) setTimeout(() => monacoEditor.layout(), 60);
}
cupTab('log');
document.getElementById('cup-tab-log').innerHTML = '';
const method = _cupGetUploadMethod(_cupFqbn);
_cupUpdateInfoRows();
document.getElementById('cup-stm32-hint').style.display = method === 'stm32-uart' ? '' : 'none';
document.getElementById('cup-uf2-hint').style.display = method === 'download-uf2' ? '' : 'none';
document.getElementById('cup-port-name').textContent = _cupPort ? '✓ 포트 선택됨' : '포트 없음';
document.getElementById('cup-btn-upload').disabled = !_cupPort;
document.getElementById('cup-btn-download').disabled = !_cupPort;
cupLog(`보드: ${_cupBoardName}`, 'info');
cupLog(`방식: ${_cupMethodLabel(method)}`, 'info');
if (method === 'stm32-uart') cupLog('STM32: BOOT0=HIGH 후 RESET → ⚡ 업로드', 'warn');
else if (method === 'avr-stk500') cupLog('AVR: 포트 선택 후 ⚡ 업로드 (자동 DTR 리셋)', 'info');
else if (method === 'download-uf2' || method === 'download-bin') cupLog('이 보드는 ⬇ 다운로드 후 수동 복사 방식입니다.', 'warn');
else if (method === 'esp32-flash') cupLog('ESP32/ESP8266: 🔌 포트 선택 후 ⚡ 업로드\n 컴파일: 서버 | 플래싱: 브라우저 esptool-js', 'info');
if (!navigator.serial) cupLog('⚠ WebSerial 미지원 브라우저입니다. Chrome/Edge를 사용하세요.', 'warn');
_cupLoadBoardOptions();
}
function cupTab(name) {
const tabs = { log: 'block', opts: 'block', mon: 'flex' };
Object.keys(tabs).forEach(t => {
const p = document.getElementById('cup-tab-' + t);
const b = document.getElementById('cup-tbtn-' + t);
if (p) p.style.display = t === name ? tabs[t] : 'none';
if (b) { b.style.color = t === name ? '#e89040' : '#8a7050'; b.style.borderBottom = t === name ? '2px solid #e89040' : '2px solid transparent'; }
});
}
function cupLog(msg, cls = 'info') {
const out = document.getElementById('cup-tab-log');
if (!out) return;
const colors = { info: '#9aacb8', ok: '#5dc48a', err: '#e07070', prog: '#80a8e0', warn: '#e8c860' };
const span = document.createElement('span');
span.style.color = colors[cls] || '#6a7880';
span.style.display = 'block';
span.textContent = msg;
out.appendChild(span);
out.scrollTop = out.scrollHeight;
}
function _cupSetStatus(cls, text) {
const el = document.getElementById('cup-status');
if (!el) return;
const styles = { idle:{ bg:'#2a1806', color:'#6a5030' }, ready:{ bg:'#1a3020', color:'#4d9e74' }, compiling:{ bg:'#2a1e08', color:'#ccaa50' }, uploading:{ bg:'#2a1208', color:'#c87d30' }, done:{ bg:'#1a3020', color:'#4d9e74' }, error:{ bg:'#2a1010', color:'#c46060' } };
const s = styles[cls] || styles.idle;
el.style.background = s.bg; el.style.color = s.color; el.textContent = text;
}
function _cupSetProgress(pct) {
const bar = document.getElementById('cup-progress');
if (!bar) return;
if (pct == null) { bar.style.display = 'none'; return; }
bar.style.display = 'block'; bar.value = pct;
}
function _cupGetUploadMethod(fqbn) {
const arch = fqbn.split(':')[1] || '', vendor = fqbn.split(':')[0] || '';
if (arch === 'avr') return 'avr-stk500';
if (vendor === 'STMicroelectronics') return 'stm32-uart';
if (arch === 'samd' || arch === 'sam') return 'download-bin';
if (arch === 'mbed_rp2040' || arch === 'rp2040') return 'download-uf2';
if (arch === 'esp32' || arch === 'esp8266') return 'esp32-flash';
return 'download-bin';
}
function _cupMethodLabel(m) {
return { 'avr-stk500':'STK500 (WebSerial)', 'stm32-uart':'STM32 UART', 'download-bin':'.bin 다운로드', 'download-uf2':'UF2 드래그&드롭', 'esp32-flash':'esptool-js (브라우저)' }[m] || m;
}
function _cupGetEffectiveFqbn() {
const keys = Object.keys(_cupSelOptions);
if (!keys.length) return _cupFqbn;
return _cupFqbn + ':' + keys.map(k => k + '=' + _cupSelOptions[k]).join(',');
}
function _cupUpdateInfoRows() {
const el = document.getElementById('cup-info-rows');
if (!el) return;
const method = _cupGetUploadMethod(_cupFqbn);
el.innerHTML = [['방식', _cupMethodLabel(method)], ['보드', _cupBoardName], ['FQBN', _cupGetEffectiveFqbn()], ['파일', _cupFname]]
.map(([k, v]) => `<div style="display:flex;gap:10px;font-size:13px"><span style="color:#907050;min-width:40px">${k}</span><span style="color:#c8a060;word-break:break-all">${v}</span></div>`).join('');
}
async function _cupLoadBoardOptions() {
const baseFqbn = _cupFqbn.split(':').slice(0, 3).join(':');
try {
const r = await fetch(`/api/arduino/board/options?fqbn=${encodeURIComponent(baseFqbn)}`, { credentials: 'include', headers: authHeaders() });
const d = await r.json();
_cupBoardOptions = d.options || []; _cupSelOptions = {};
_cupRenderBoardOptions();
} catch {}
}
function _cupRenderBoardOptions() {
const list = document.getElementById('cup-board-opts-list');
if (!list) return;
if (!_cupBoardOptions.length) { list.innerHTML = ''; return; }
list.innerHTML = '<div style="font-size:12px;font-weight:700;color:#907050;margin-bottom:8px;text-transform:uppercase;letter-spacing:.05em">보드 옵션</div>' +
_cupBoardOptions.map(o => {
const sel = _cupSelOptions[o.id] ?? (o.values.find(v => v.selected)?.value ?? o.values[0]?.value ?? '');
const opts = o.values.map(v => `<option value="${v.value}"${v.value === sel ? ' selected' : ''}>${v.label}</option>`).join('');
const optId = JSON.stringify(o.id), defVal = o.values.find(v => v.selected)?.value ?? o.values[0]?.value ?? '';
return `<div style="display:flex;align-items:center;gap:8px;padding:4px 0"><span style="color:#907050;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px" title="${o.label}">${o.label}</span><select style="background:#3a2818;border:1px solid #5a3820;color:#c8a060;border-radius:4px;padding:3px 6px;font-size:12px;font-family:'IBM Plex Mono',monospace;flex:1.4" onchange="if(this.value!==this.dataset.def)_cupSelOptions[${optId}]=this.value;else delete _cupSelOptions[${optId}];_cupUpdateInfoRows()" data-def="${defVal}">${opts}</select></div>`;
}).join('');
}
async function cupSelectPort() {
if (!navigator.serial) { cupLog('❌ WebSerial 미지원. Chrome/Edge를 사용하세요.', 'err'); return; }
try {
_cupPort = await navigator.serial.requestPort();
const info = _cupPort.getInfo();
const vid = info.usbVendorId ? `VID:0x${info.usbVendorId.toString(16).padStart(4,'0')}` : '';
const pid = info.usbProductId ? `PID:0x${info.usbProductId.toString(16).padStart(4,'0')}` : '';
const label = [vid, pid].filter(Boolean).join(' ') || '선택됨';
document.getElementById('cup-port-name').textContent = '✓ ' + label;
document.getElementById('cup-btn-upload').disabled = false;
document.getElementById('cup-btn-download').disabled = false;
cupLog(`포트 선택됨: ${label}`, 'ok');
_cupSetStatus('ready', '준비됨');
} catch(e) { if (e.name !== 'NotFoundError') cupLog('포트 선택 오류: ' + e.message, 'err'); }
}
async function _cupCompile() {
const arch = _cupFqbn.split(':')[1] || '';
if (/ESP8266WiFi|ESP8266WebServer|ESP8266HTTPClient/i.test(_cupCode) && arch !== 'esp8266') {
_cupFqbn = 'esp8266:esp8266:nodemcuv2'; _cupSelOptions = {};
cupLog(`⚠ ESP8266 감지 → FQBN 자동 변경: ${_cupFqbn}`, 'warn'); _cupUpdateInfoRows(); _cupLoadBoardOptions();
} else if (/^#include\s+<WiFi\.h>/m.test(_cupCode) && arch !== 'esp32' && arch !== 'esp8266') {
_cupFqbn = 'esp32:esp32:esp32'; _cupSelOptions = {};
cupLog(`⚠ ESP32 WiFi 감지 → FQBN 자동 변경: ${_cupFqbn}`, 'warn'); _cupUpdateInfoRows(); _cupLoadBoardOptions();
}
cupLog('서버에서 컴파일 중...', 'prog'); _cupSetStatus('compiling', '컴파일 중...');
const r = await fetch('/api/arduino/compile', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify({ code: _cupCode, fqbn: _cupGetEffectiveFqbn() }),
});
const data = await r.json();
if (data.error) { cupLog('컴파일 오류:\n' + data.error, 'err'); _cupSetStatus('error', '컴파일 실패'); return null; }
const kb = data.size ? ` (${(data.size/1024).toFixed(1)} KB)` : '';
cupLog(`✅ 컴파일 완료${kb}`, 'ok');
return data;
}
async function cupDoDownload() {
cupTab('log');
const data = await _cupCompile(); if (!data) return;
_cupCompiledData = data;
const isHex = !!data.hex;
const content = isHex ? data.hex : _cupBase64ToBytes(data.bin || data.merged);
const ext = data.boardType === 'uf2' ? '.uf2' : (isHex ? '.hex' : '.bin');
const blob = new Blob([content], { type: isHex ? 'text/plain' : 'application/octet-stream' });
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = _cupFname.replace(/\.[^.]+$/, '') + ext;
a.click(); setTimeout(() => URL.revokeObjectURL(a.href), 5000);
cupLog(`다운로드: ${a.download}`, 'ok'); _cupSetStatus('done', '다운로드됨');
}
async function cupDoUpload() {
if (_cupBusy) return;
if (!_cupPort) { cupLog('포트를 먼저 선택하세요 (🔌 포트 버튼)', 'err'); return; }
cupTab('log');
const method = _cupGetUploadMethod(_cupGetEffectiveFqbn());
if (method === 'esp32-flash') { await _cupUploadESP32(); return; }
if (method === 'download-uf2' || method === 'download-bin') { await cupDoDownload(); return; }
_cupBusy = true;
document.getElementById('cup-btn-upload').disabled = true;
document.getElementById('cup-btn-download').disabled = true;
document.getElementById('cup-tab-log').innerHTML = '';
_cupSetProgress(0);
try {
const data = await _cupCompile(); if (!data) return;
_cupCompiledData = data;
cupLog(`\n--- 업로드 시작 [${_cupBoardName}] ---`, 'prog'); _cupSetStatus('uploading', '업로드 중...'); _cupSetProgress(5);
if (method === 'avr-stk500') await _cupUploadAVR(data.hex);
else if (method === 'stm32-uart') await _cupUploadSTM32(_cupBase64ToBytes(data.bin));
} catch(e) { cupLog('❌ 오류: ' + e.message, 'err'); _cupSetStatus('error', '업로드 실패'); _cupSetProgress(null); }
finally { _cupBusy = false; document.getElementById('cup-btn-upload').disabled = false; document.getElementById('cup-btn-download').disabled = false; }
}
async function _cupUploadESP32() {
_cupBusy = true;
document.getElementById('cup-btn-upload').disabled = true;
document.getElementById('cup-btn-download').disabled = true;
document.getElementById('cup-tab-log').innerHTML = '';
_cupSetProgress(0);
let transport = null, success = false;
try {
const data = await _cupCompile(); if (!data) return;
_cupCompiledData = data;
const b64 = data.merged || data.bin;
if (!b64) throw new Error('컴파일 결과에 바이너리가 없습니다');
cupLog('esptool-js 로딩 중...', 'prog');
const { ESPLoader, Transport, HardReset } = await import('/esptool-bundle.js');
cupLog('보드 연결 중 (자동 리셋)...', 'prog'); _cupSetStatus('uploading', '연결 중...');
transport = new Transport(_cupPort, true);
const terminal = { clean:()=>{}, writeLine:m=>{ if(m.trim()) cupLog(m,'info'); }, write:m=>{ if(m.trim()) cupLog(m,'info'); } };
const esploader = new ESPLoader({ transport, baudrate:115200, romBaudrate:115200, enableTracing:false, terminal });
let chip;
try { chip = await esploader.main(); }
catch(e) { cupLog('⚠ 자동 리셋 실패 — BOOT 누른채 RST → BOOT 놓기 후 재시도.', 'warn'); throw e; }
cupLog(`칩 감지: ${chip}`, 'ok'); _cupSetProgress(10); _cupSetStatus('uploading', '업로드 중...');
const binData = _cupBase64ToBytes(b64);
let _t0 = Date.now(), _w0 = 0;
await esploader.writeFlash({
fileArray: [{ data: binData, address: 0x0 }],
flashSize:'keep', flashMode:'keep', flashFreq:'keep', eraseAll:false, compress:true,
reportProgress: (_fi, written, total) => {
const pct = Math.round(written/total*100); _cupSetProgress(10 + pct*0.88);
const now = Date.now();
if (_w0===0 && written>0) { _t0=now; _w0=written; }
const kbps = (now-_t0)>200 ? ((written-_w0)/1024/((now-_t0)/1000)).toFixed(1) : '...';
document.getElementById('cup-port-name').textContent = `플래싱 ${(written/1024).toFixed(1)}/${(total/1024).toFixed(1)} KB (${pct}%) · ${kbps} KB/s`;
},
});
try { await new HardReset(transport).reset(); } catch {}
cupLog('✅ 업로드 완료! 보드가 재시작됩니다.', 'ok'); _cupSetStatus('done', '완료'); _cupSetProgress(100); success = true;
} catch(e) { cupLog('❌ 오류: ' + e.message, 'err'); _cupSetStatus('error', '업로드 실패'); _cupSetProgress(null); }
finally {
try { if (transport) await transport.disconnect(); } catch {}
_cupBusy = false;
document.getElementById('cup-btn-upload').disabled = false;
document.getElementById('cup-btn-download').disabled = false;
document.getElementById('cup-port-name').textContent = _cupPort ? '✓ 포트 선택됨' : '포트 없음';
setTimeout(() => _cupSetProgress(null), 3000);
}
if (success) {
cupTab('mon'); _cupMonAppendSys('[업로드 완료 — 시리얼 모니터 자동 연결 중...]');
await new Promise(r => setTimeout(r, 1500));
await cupMonOpen();
}
}
function _cupBase64ToBytes(b64) {
const raw = atob(b64), out = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
return out;
}
function _cupParseIntelHex(hexText) {
const pages = new Map(); let extAddr = 0;
for (const line of hexText.split('\n')) {
const t = line.trim();
if (!t.startsWith(':') || t.length < 11) continue;
const bytes = []; for (let i=1; i<t.length-1; i+=2) bytes.push(parseInt(t.substr(i,2),16));
const len=bytes[0], addr=(bytes[1]<<8)|bytes[2], type=bytes[3];
if (type===1) break; if (type===4) { extAddr=((bytes[4]<<8)|bytes[5])<<16; continue; } if (type!==0) continue;
const fullAddr = extAddr+addr;
for (let i=0; i<len; i++) { const a=fullAddr+i, pg=a>>7, off=a&0x7f; if (!pages.has(pg)) pages.set(pg,new Uint8Array(128).fill(0xff)); pages.get(pg)[off]=bytes[4+i]; }
}
return pages;
}
async function _cupWrite(writer, data) { await writer.write(data instanceof Uint8Array ? data : new Uint8Array(Array.isArray(data) ? data : [data])); }
async function _cupRead(reader, n, ms=5000) {
const buf=new Uint8Array(n); let pos=0; const deadline=Date.now()+ms;
while (pos<n) {
if (Date.now()>deadline) throw new Error(`시리얼 타임아웃 (${n}B 중 ${pos}B)`);
const {value,done} = await reader.read(); if (done) throw new Error('포트가 닫혔습니다');
const take=Math.min(value.length,n-pos); buf.set(value.slice(0,take),pos); pos+=take;
}
return buf;
}
async function _cupUploadAVR(hexText) {
const INSYNC=0x14,OK=0x10,EOP=0x20,CMD_SYNC=0x30,CMD_SET_DEV=0x42,CMD_ENTER=0x50,CMD_LEAVE=0x51,CMD_ADDR=0x55,CMD_PROG=0x64;
await _cupPort.open({ baudRate:115200, dataBits:8, stopBits:1, parity:'none' });
const reader=_cupPort.readable.getReader(), writer=_cupPort.writable.getWriter();
const cmdOk = async (bytes,extra=0,ms=3000) => {
await _cupWrite(writer, new Uint8Array([...bytes,EOP]));
const r=await _cupRead(reader,2+extra,ms);
if (r[0]!==INSYNC) throw new Error(`STK500: INSYNC 없음 (0x${r[0].toString(16)})`);
if (r[1+extra]!==OK) throw new Error(`STK500: OK 없음 (0x${r[1+extra].toString(16)})`);
return r.slice(1,1+extra);
};
try {
if (_cupPort.setSignals) {
await _cupPort.setSignals({ dataTerminalReady:false, requestToSend:false });
await new Promise(r=>setTimeout(r,100));
await _cupPort.setSignals({ dataTerminalReady:true, requestToSend:true });
await new Promise(r=>setTimeout(r,50));
await _cupPort.setSignals({ dataTerminalReady:false });
}
await new Promise(r=>setTimeout(r,250));
cupLog('동기화 중...', 'prog');
let synced=false;
for (let i=0; i<12&&!synced; i++) { try { await _cupWrite(writer,[CMD_SYNC,EOP]); const r=await _cupRead(reader,2,300); if(r[0]===INSYNC&&r[1]===OK) synced=true; } catch {} }
if (!synced) throw new Error('보드에 연결할 수 없습니다. 케이블/DTR 리셋을 확인하세요.');
cupLog('✓ 동기화됨', 'ok');
await cmdOk([CMD_SET_DEV,0x86,0x00,0x07,0x7f,0x83,0xff,0x00,0x01,0x01,0x01,0x01,0x03,0xff,0x00,0x80,0x04,0x00,0x00,0x00,0x80,0x00]);
await cmdOk([CMD_ENTER]); cupLog('✓ 프로그래밍 모드 진입', 'ok');
const pages=_cupParseIntelHex(hexText), sorted=[...pages.entries()].sort((a,b)=>a[0]-b[0]);
let done=0;
for (const [pg, pgData] of sorted) {
const waddr=pg<<6; await cmdOk([CMD_ADDR,waddr&0xff,(waddr>>8)&0xff]); await cmdOk([CMD_PROG,0x00,0x80,0x46,...pgData]);
done++; _cupSetProgress(5+Math.round(done/sorted.length*90));
document.getElementById('cup-port-name').textContent = `업로드 중... ${done}/${sorted.length} 페이지`;
}
await cmdOk([CMD_LEAVE]); _cupSetProgress(100); cupLog(`✅ 업로드 완료! (${sorted.length} 페이지)`, 'ok');
_cupSetStatus('done', '완료 ✓'); document.getElementById('cup-port-name').textContent = '✓ 포트 선택됨';
await new Promise(r=>setTimeout(r,500)); _cupSetProgress(null);
} finally { try{reader.releaseLock();}catch{} try{writer.releaseLock();}catch{} try{await _cupPort.close();}catch{} }
}
async function _cupUploadSTM32(binData) {
const ACK=0x79;
await _cupPort.open({ baudRate:115200, dataBits:8, stopBits:1, parity:'even' });
const reader=_cupPort.readable.getReader(), writer=_cupPort.writable.getWriter();
const w = async d=>_cupWrite(writer,d), readByte=async(ms=6000)=>(await _cupRead(reader,1,ms))[0];
const waitAck = async(ms=12000)=>{ const b=await readByte(ms); if(b===ACK)return; if(b===0x1F)throw new Error('STM32 NACK'); throw new Error(`응답: 0x${b.toString(16)}`); };
const xor4=a=>a[0]^a[1]^a[2]^a[3], addrPkt=addr=>{ const b=[(addr>>24)&0xff,(addr>>16)&0xff,(addr>>8)&0xff,addr&0xff]; return new Uint8Array([...b,xor4(b)]); };
try {
cupLog('STM32 동기화 중...', 'prog'); cupLog('(BOOT0=HIGH 상태에서 RESET 확인)', 'warn');
let synced=false;
for (let i=0; i<5&&!synced; i++) { try{await w([0x7F]);const b=await readByte(1200);if(b===ACK||b===0x1F)synced=true;}catch{} }
if (!synced) throw new Error('동기화 실패 — BOOT0=HIGH 후 RESET 하세요.');
cupLog('✓ 동기화됨', 'ok');
await w([0x00,0xFF]); await waitAck(3000); const n=await readByte(); await _cupRead(reader,n+1,3000); await waitAck(3000);
cupLog('플래시 지우는 중... (최대 30초)', 'prog'); _cupSetProgress(10);
await w([0x44,0xBB]); await waitAck(5000); await w([0xFF,0xFF,0x00]); await waitAck(30000);
cupLog('✓ 플래시 지움', 'ok'); _cupSetProgress(20);
const START=0x08000000, CHUNK=256, total=Math.ceil(binData.length/CHUNK);
cupLog(`${binData.length} 바이트 쓰는 중 (${total} 블록)...`, 'prog');
for (let i=0; i<binData.length; i+=CHUNK) {
const raw=binData.slice(i,i+CHUNK), padded=new Uint8Array(CHUNK).fill(0xff); padded.set(raw);
await w([0x31,0xCE]); await waitAck(5000); await w(addrPkt(START+i)); await waitAck(5000);
let ck=CHUNK-1; for(const b of padded)ck^=b;
const pkt=new Uint8Array(CHUNK+2); pkt[0]=CHUNK-1; pkt.set(padded,1); pkt[CHUNK+1]=ck;
await w(pkt); await waitAck(10000);
const blk=Math.floor(i/CHUNK)+1; _cupSetProgress(20+Math.round(blk/total*75));
document.getElementById('cup-port-name').textContent = `업로드 중... ${blk}/${total} 블록`;
}
_cupSetProgress(97); cupLog('실행 중...', 'prog');
await w([0x21,0xDE]); await waitAck(5000); await w(addrPkt(START)); try{await waitAck(2000);}catch{}
_cupSetProgress(100); cupLog('✅ 업로드 완료! BOOT0=GND 후 RESET 하세요.', 'ok');
_cupSetStatus('done', '완료 ✓'); document.getElementById('cup-port-name').textContent = '✓ 포트 선택됨';
await new Promise(r=>setTimeout(r,500)); _cupSetProgress(null);
} finally { try{reader.releaseLock();}catch{} try{writer.releaseLock();}catch{} try{await _cupPort.close();}catch{} }
}
// ── Serial monitor ───────────────────────────────────────────────────────────
function _cupMonAppendSys(msg) {
const out = document.getElementById('cup-mon-output'); if (!out) return;
const div = document.createElement('div');
div.style.color = '#907050'; div.style.fontStyle = 'italic'; div.textContent = msg;
out.appendChild(div);
if (document.getElementById('cup-mon-auto')?.checked) out.scrollTop = out.scrollHeight;
}
function cupMonAppend(text) {
const out = document.getElementById('cup-mon-output'); if (!out) return;
const showTs = document.getElementById('cup-mon-ts')?.checked;
const autoScroll = document.getElementById('cup-mon-auto')?.checked;
_cupMonBuf += text;
const parts = _cupMonBuf.split('\n'); _cupMonBuf = parts.pop();
for (const raw of parts) {
const div = document.createElement('div'); div.style.color = '#c8e8c0';
if (showTs) {
const d=new Date(), ts=`${d.getHours().toString().padStart(2,'0')}:${d.getMinutes().toString().padStart(2,'0')}:${d.getSeconds().toString().padStart(2,'0')}.${d.getMilliseconds().toString().padStart(3,'0')}`;
const s=document.createElement('span'); s.style.cssText='color:#907050;font-size:11px;margin-right:6px;opacity:.7;user-select:none'; s.textContent=ts;
div.appendChild(s); div.appendChild(document.createTextNode(raw.replace(/\r/g,'')));
} else { div.textContent = raw.replace(/\r/g,''); }
out.appendChild(div);
}
if (autoScroll) out.scrollTop = out.scrollHeight;
}
async function _cupMonReadLoop() {
while (_cupMonActive && _cupMonReader) {
try { const {value,done}=await _cupMonReader.read(); if(done)break; cupMonAppend(_cupDecoder.decode(value,{stream:true})); }
catch(e) { if (_cupMonActive) _cupMonAppendSys(`[오류: ${e.message}]`); break; }
}
if (_cupMonActive) cupMonClose();
}
async function cupMonToggle() { if (_cupMonActive) await cupMonClose(); else await cupMonOpen(); }
async function cupMonOpen() {
if (!_cupPort) { _cupMonAppendSys('[포트를 먼저 선택하세요]'); return; }
if (_cupBusy) { _cupMonAppendSys('[업로드 중입니다. 완료 후 연결하세요]'); return; }
const baud = parseInt(document.getElementById('cup-mon-baud')?.value) || 115200;
try {
await _cupPort.open({ baudRate: baud });
_cupMonReader = _cupPort.readable.getReader();
_cupMonWriter = _cupPort.writable.getWriter();
_cupMonActive = true;
const btn = document.getElementById('cup-mon-btn');
if (btn) { btn.textContent='■ 연결 해제'; btn.style.color='#e87070'; btn.style.borderColor='color-mix(in srgb,#e87070 35%,#241a0e)'; }
const inp = document.getElementById('cup-mon-input'); if (inp) inp.disabled = false;
_cupMonAppendSys(`[연결됨 — ${baud} baud]`); _cupMonReadLoop();
} catch(e) { _cupMonAppendSys(`[연결 실패: ${e.message}]`); }
}
async function cupMonClose() {
_cupMonActive = false;
try{await _cupMonReader?.cancel();}catch{} try{_cupMonReader?.releaseLock();}catch{}
try{_cupMonWriter?.releaseLock();}catch{} try{await _cupPort?.close();}catch{}
_cupMonReader=null; _cupMonWriter=null;
const btn=document.getElementById('cup-mon-btn');
if (btn) { btn.textContent='▶ 연결'; btn.style.color='#4ade80'; btn.style.borderColor='color-mix(in srgb,#4ade80 35%,#241a0e)'; }
const inp=document.getElementById('cup-mon-input'); if(inp) inp.disabled=true;
_cupMonAppendSys('[연결 해제됨]');
}
async function cupMonSend() {
if (!_cupMonWriter) return;
const input=document.getElementById('cup-mon-input'); if(!input) return;
const endings={none:'',nl:'\n',cr:'\r',crnl:'\r\n'};
const suffix=endings[document.getElementById('cup-mon-line-end')?.value]??'\r\n';
const text=input.value+suffix; input.value='';
try {
await _cupMonWriter.write(new TextEncoder().encode(text));
const div=document.createElement('div'); div.style.color='#94b0d8'; div.textContent=text.replace(/[\r\n]/g,'');
document.getElementById('cup-mon-output')?.appendChild(div);
} catch(e) { _cupMonAppendSys(`[전송 오류: ${e.message}]`); }
}
function cupMonClear() { const el=document.getElementById('cup-mon-output'); if(el) el.innerHTML=''; _cupMonBuf=''; }
// Upload panel resize drag
document.addEventListener('DOMContentLoaded', function() {
const drag = document.getElementById('code-upload-drag');
const panel = document.getElementById('code-upload-panel');
if (!drag || !panel) return;
let startY=0, startH=0;
drag.addEventListener('mousedown', e => {
startY=e.clientY; startH=panel.offsetHeight; e.preventDefault();
const onMove = e2 => { const dy=startY-e2.clientY; panel.style.height=Math.max(80,Math.min(window.innerHeight*.6,startH+dy))+'px'; };
const onUp = () => { document.removeEventListener('mousemove',onMove); document.removeEventListener('mouseup',onUp); drag.style.background=''; };
drag.addEventListener('mouseover', ()=>drag.style.background='rgba(200,125,48,.4)');
document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp);
});
});
// Here we run them once the DOM is ready since this page IS the code view.
(function bootCodePage() {
async function boot() {