diff --git a/src/gateway/server-v2.ts b/src/gateway/server-v2.ts index b4084a8..576b445 100644 --- a/src/gateway/server-v2.ts +++ b/src/gateway/server-v2.ts @@ -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((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((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 = { @@ -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// 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. diff --git a/web-ui/arduino-upload.html b/web-ui/arduino-upload.html deleted file mode 100644 index 89b667d..0000000 --- a/web-ui/arduino-upload.html +++ /dev/null @@ -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 - - - - -
-

⚡ Arduino Uploader

- sketch.ino - - - 대기 중 -
- - - -
-
- -
-
-
-
포트 (클라이언트)
- -
포트가 선택되지 않음
-
Chrome/Edge에서 USB 케이블로 보드 연결 후 포트를 선택하세요.
-
-
-
업로드 방식
-
방식-
-
보드-
-
FQBN-
-
파일-
-
- - - -
- -
-
- -
- -
-
- OUTPUT - -
-
- 🔌 포트 선택 버튼으로 보드를 선택하세요.\n클라이언트(PC)의 USB 포트가 표시됩니다.\n -
-
- -
-
- SERIAL MONITOR - - - - - - -
-
-
포트 선택 후 ▶ 연결을 눌러 시리얼 모니터를 시작하세요.
-
-
- - -
-
-
-
- - - - - - diff --git a/web-ui/code.html b/web-ui/code.html index 42fb4f8..f3236fa 100644 --- a/web-ui/code.html +++ b/web-ui/code.html @@ -282,6 +282,7 @@ + @@ -301,11 +302,17 @@ -
- 필터: - - - +
+
+ 보기: + + + 보드: + + + +
+
+ +
diff --git a/web-ui/code.js b/web-ui/code.js index 3b64af8..4eba25f 100644 --- a/web-ui/code.js +++ b/web-ui/code.js @@ -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 ``; + }).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,'&').replace(//g,'>').replace(/"/g,'"').replace(/'/g,'''); } @@ -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 = '
오류
'; } } @@ -4308,70 +4363,238 @@ function _csbLibRenderInstalled(libs) { const el = _csbLibListEl(); if (!libs.length) { el.innerHTML = '
설치된 라이브러리 없음
'; 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 = `
${msg}
`; 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 ? `
⚠ 현재 보드(${_csbEsc(_csbSelBoard.name)})와 호환되지 않을 수 있음
` : ''; - const platformBadge = isPlatform ? `코어` : ''; - const exRows = (l.examples||[]).map(ex => - `
- 📄 ${_csbEsc(ex)} - -
`).join(''); - const hasEx = l.examples?.length > 0; - return `
-
-
-
- ${_csbEsc(l.name)} - ${platformBadge}${_csbArchBadge(archs)} -
-
${_csbEsc(l.author||'')} · v${_csbEsc(l.version||'?')}${l.category?' · '+_csbEsc(l.category):''}
- ${l.sentence?`
${_csbEsc(l.sentence)}
`:''} - ${incompatWarn} -
-
- ${hasEx?``:''} - ${isPlatform?'':``} + 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 ? `
⚠ 현재 보드(${_csbEsc(_csbSelBoard.name)})와 호환되지 않을 수 있음
` : ''; + const platformBadge = isPlatform ? `코어` : ''; + const exRows = (l.examples||[]).map(ex => + `
+ 📄 ${_csbEsc(ex)} + +
`).join(''); + const hasEx = l.examples?.length > 0; + return `
+
+
+
+ ${_csbEsc(l.name)} + ${platformBadge}${_csbArchBadge(archs)}
+
${_csbEsc(l.author||'')} · v${_csbEsc(l.version||'?')}${l.category?' · '+_csbEsc(l.category):''}
+ ${l.sentence?`
${_csbEsc(l.sentence)}
`:''} + ${incompatWarn}
- ${hasEx?``:''} +
+ ${hasEx?``:''} + ${isPlatform?'':``} +
+
+ ${hasEx?``:''} +
`; +} + +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 `
+
+ ${_csbEsc(ko)} + ${cat !== ko ? `${_csbEsc(cat)}` : ''} + ${cnt}개 +
+
${cards}
`; }).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) => + `
+ 📄 ${_csbEsc(ex.desc)} + +
`).join(''); + return `
+
+ Arduino 기본 예제 + 기본 +
+
${rows}
+
`; +} + 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]) => `
${k}${v}
`).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 = '
보드 옵션
' + + _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 => ``).join(''); + const optId = JSON.stringify(o.id), defVal = o.values.find(v => v.selected)?.value ?? o.values[0]?.value ?? ''; + return `
${o.label}
`; + }).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+/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>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 (posdeadline) 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; isetTimeout(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() {