Files
homeclaw/web-ui/arduino-emulator.html
T
kimandClaude Sonnet 4.6 a994ac1b73 v3.0.2: server-v2 모듈 분리 — Arduino·PPTX 라우트 추출 (-2923줄)
- routes-arduino.ts: Arduino 컴파일/라이브러리/QEMU/ESP8266 에뮬 전체
- routes-pptx.ts: PPTX 생성/편집/PDF/이미지 API 전체
- mocks-esp32: WiFi.STA(_STAProxy), scanNetworks, DELETE() 수정
- mocks-esp8266: DELETE() 수정
- server-v2.ts: 16106 → 13183줄 (–18%)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 21:00:40 +09:00

6603 lines
318 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Arduino Emulator</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=Noto+Sans+KR:wght@400;500;600&display=swap');
:root {
--bg:#f0f4f8; --panel:#ffffff; --board-bg:#f7fafc;
--accent:#2563eb; --green:#16a34a; --red:#dc2626; --yellow:#d97706; --muted:#64748b; --orange:#ea580c;
--bdr:#cbd5e1; --bdr-in:#e2e8f0; --bdr2:#94a3b8;
--ctrl:#e8eef5;
--board-tint:none;
--text:#1e293b; --text2:#475569;
}
[data-theme="cosmic"] {
--bg:#10101e; --panel:#1e1e38; --board-bg:#181830;
--accent:#5b9cf8; --green:#34d058; --red:#f87171; --yellow:#fcd34d; --muted:#9098b4; --orange:#fb923c;
--bdr:#3a3a60; --bdr-in:#2c2c52; --bdr2:#4a4a72;
--ctrl:#252548;
--board-tint:none;
--text:#e5e7eb; --text2:#9ca3af;
}
[data-theme="matrix"] {
--bg:#061006; --panel:#0e200e; --board-bg:#0b1a0b;
--accent:#4ade80; --green:#86efac; --red:#f87171; --yellow:#fde68a; --muted:#6ab06a; --orange:#86efac;
--bdr:#246024; --bdr-in:#184818; --bdr2:#2e762e;
--ctrl:#102810;
--board-tint:sepia(1) hue-rotate(85deg) saturate(2.5);
--text:#d1fae5; --text2:#6ab06a;
}
[data-theme="amber"] {
--bg:#fffbeb; --panel:#fef3c7; --board-bg:#fefce8;
--accent:#b45309; --green:#15803d; --red:#dc2626; --yellow:#d97706; --muted:#92400e; --orange:#c2410c;
--bdr:#d97706; --bdr-in:#fde68a; --bdr2:#b45309;
--ctrl:#fde68a;
--board-tint:none;
--text:#451a03; --text2:#92400e;
}
[data-theme="ice"] {
--bg:#f0f9ff; --panel:#e0f2fe; --board-bg:#f0f9ff;
--accent:#0369a1; --green:#0891b2; --red:#dc2626; --yellow:#d97706; --muted:#0284c7; --orange:#0284c7;
--bdr:#7dd3fc; --bdr-in:#bae6fd; --bdr2:#38bdf8;
--ctrl:#e0f2fe;
--board-tint:none;
--text:#0c4a6e; --text2:#0369a1;
}
* { margin:0; padding:0; box-sizing:border-box; }
body { background:var(--bg); color:var(--text); font-family:'IBM Plex Mono','Noto Sans KR',monospace; height:100vh; display:flex; flex-direction:column; overflow:hidden; }
/* Header */
.header { display:flex; align-items:center; gap:12px; padding:10px 16px; background:var(--panel); border-bottom:1px solid var(--bdr); flex-shrink:0; }
.header h1 { font-size:14px; font-weight:600; color:var(--text); }
.header .file { font-size:12px; color:var(--accent); }
.status { font-size:11px; padding:2px 8px; border-radius:4px; font-weight:600; }
.status.loading { background:#1e3a5f; color:#60a5fa; }
.status.ready { background:#1a3a2a; color:var(--green); }
.status.running { background:#3a3a1a; color:var(--yellow); animation: pulse 1.5s infinite; }
.status.error { background:#3a1a1a; color:var(--red); }
.status.stopped { background:#2a2a2a; color:var(--muted); }
@keyframes pulse { 0%,100% { opacity:1 } 50% { opacity:.6 } }
.toolbar { display:flex; gap:8px; margin-left:auto; align-items:center; }
.toolbar button { background:var(--ctrl); border:1px solid var(--bdr2); color:var(--text); border-radius:6px; padding:6px 14px; font-size:12px; cursor:pointer; font-family:inherit; transition:all .15s; }
.toolbar button:hover { filter:brightness(0.92); }
.toolbar button:disabled { opacity:.4; cursor:not-allowed; }
/* 보드 SVG 테마 틴트 */
.board-svg svg { filter:var(--board-tint); transition:filter .4s; }
/* SVG 보드 인터랙션 */
#power-switch { cursor:pointer; }
#power-switch:hover #pw-bg { stroke:#5a5a9a; }
#reset-btn { cursor:pointer; }
#reset-btn:hover circle { stroke:#8888cc; fill:#3a3a7a; }
/* 테마 닷 */
.theme-picker { display:flex; gap:4px; align-items:center; margin-left:4px; }
.tdot { width:13px; height:13px; border-radius:50%; background:var(--c,#888); border:2px solid transparent; cursor:pointer; padding:0; flex-shrink:0; transition:transform .12s, border-color .12s; }
.tdot:hover { transform:scale(1.3); }
.tdot.active { border-color:#fff; }
.speed-label { font-size:11px; color:var(--muted); }
select { background:var(--ctrl); border:1px solid var(--bdr2); color:var(--text); border-radius:6px; padding:5px 10px; font-size:12px; font-family:inherit; cursor:pointer; }
/* Main layout */
.main { flex:1; display:flex; gap:0; overflow:hidden; }
/* Editor open: board | serial | drag | code — serial & code share space */
.main.editor-open {
display: flex;
}
.main.editor-open .board-panel { flex:none; }
.main.editor-open .serial-panel { flex:1; min-width:120px; }
.main.editor-open #code-drag { flex-shrink:0; display:block; }
.main.editor-open #code-editor-panel { flex:1; min-width:120px; }
/* When BOTH editor and sch-panel are open: three equal columns */
.main.editor-open .sch-panel.open { flex:1; min-width:120px; }
/* Board panel — resizable */
.board-panel { width:220px; min-width:140px; max-width:480px; flex-shrink:0; background:var(--board-bg); border-right:1px solid var(--bdr); display:flex; flex-direction:column; overflow-y:auto; overflow-x:hidden; resize:horizontal; }
.board-title { padding:10px 14px; font-size:11px; color:var(--muted); font-weight:600; letter-spacing:.08em; border-bottom:1px solid var(--bdr-in); flex-shrink:0; }
.board-svg { padding:10px 14px; flex-shrink:0; }
.board-svg svg { width:100%; }
/* LCD display */
.lcd-section { padding:10px 14px; flex-shrink:0; border-bottom:1px solid var(--bdr-in); }
.lcd-section-title { font-size:10px; color:var(--muted); margin-bottom:8px; font-weight:600; letter-spacing:.08em; display:flex; align-items:center; gap:6px; }
.lcd-wrap { background:#1a2a0a; border:1px solid #2a4a1a; border-radius:5px; padding:8px 10px; display:inline-block; }
.lcd-row { display:flex; gap:2px; margin-bottom:2px; }
.lcd-row:last-child { margin-bottom:0; }
.lcd-cell { width:9px; height:14px; background:#0d1a05; border-radius:1px; display:flex; align-items:center; justify-content:center; font-size:9px; color:#6aff20; line-height:1; font-family:'IBM Plex Mono',monospace; }
.lcd-cell.lit { background:#1a3a08; }
.lcd-off .lcd-cell { color:#0d1a05; background:#0d1a05; }
.lcd-off .lcd-wrap { filter:brightness(.4); }
/* SSD1306 OLED */
.oled-section { padding:10px 14px; flex-shrink:0; border-bottom:1px solid var(--bdr-in); }
.oled-section-title { font-size:10px; color:var(--muted); margin-bottom:6px; font-weight:600; letter-spacing:.08em; display:flex; align-items:center; gap:6px; }
.oled-dot { width:6px; height:6px; border-radius:50%; background:#333; flex-shrink:0; }
.oled-dot.on { background:#60a5fa; box-shadow:0 0 4px #60a5fa; }
.oled-wrap { background:#000; border:2px solid #222; border-radius:4px; padding:4px; position:relative; }
#oled-canvas { display:block; max-width:100%; height:auto; image-rendering:pixelated; }
.oled-placeholder { position:absolute; inset:4px; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:4px; pointer-events:none; }
.oled-placeholder span { font-size:10px; color:#333; letter-spacing:.05em; }
.oled-placeholder .oled-ph-grid { width:100%; height:2px; background:repeating-linear-gradient(90deg,#1a1a1a 0,#1a1a1a 4px,transparent 4px,transparent 8px); }
/* TFT LCD display */
.tft-section { padding:10px 14px; flex-shrink:0; border-bottom:1px solid var(--bdr-in); }
.tft-section-title { font-size:10px; color:var(--muted); margin-bottom:6px; font-weight:600; letter-spacing:.08em; display:flex; align-items:center; gap:6px; }
.tft-dot { width:6px; height:6px; border-radius:50%; background:#333; flex-shrink:0; }
.tft-dot.on { background:#22c55e; box-shadow:0 0 4px #22c55e; }
.tft-wrap { background:#000; border:2px solid #222; border-radius:4px; padding:4px; overflow:hidden; }
#tft-canvas { display:block; width:100%; height:auto; image-rendering:pixelated; }
/* DHT sensor controls */
.dht-section { padding:10px 14px; flex-shrink:0; border-bottom:1px solid var(--bdr-in); }
.dht-section-title { font-size:10px; color:var(--muted); margin-bottom:6px; font-weight:600; letter-spacing:.08em; display:flex; align-items:center; gap:6px; }
.dht-dot { width:6px; height:6px; border-radius:50%; background:#333; flex-shrink:0; }
.dht-dot.on { background:#0891b2; box-shadow:0 0 4px #0891b2; }
.dht-controls { display:flex; flex-direction:column; gap:4px; }
.dht-row { display:flex; align-items:center; gap:6px; font-size:11px; }
.dht-param { width:28px; color:var(--muted); flex-shrink:0; }
.dht-row input[type=range] { flex:1; height:4px; accent-color:var(--accent); }
.dht-val { width:42px; text-align:right; color:var(--accent); font-weight:600; font-size:10px; }
/* Servo display */
.servo-row { display:flex; align-items:center; gap:8px; font-size:11px; margin-bottom:4px; }
.servo-pin-label { width:36px; color:var(--muted); flex-shrink:0; }
.servo-arc-canvas { flex-shrink:0; }
.servo-angle-val { width:38px; text-align:right; color:#dc2626; font-weight:600; font-size:10px; }
/* Sensor/actuator panels (IMU, BME280, DS18B20, Relay, Motor) */
.sensor-section { padding:10px 12px; border-bottom:1px solid var(--bdr-in); display:none; }
.sensor-section-title { display:flex; align-items:center; gap:6px; font-size:11px; font-weight:600; color:#e5e7eb; margin-bottom:8px; }
.sensor-dot { width:8px; height:8px; border-radius:50%; }
.sensor-row { display:flex; align-items:center; gap:8px; font-size:11px; margin-bottom:5px; }
.sensor-param { width:48px; color:var(--muted); flex-shrink:0; }
.sensor-row input[type=range] { flex:1; height:4px; accent-color:var(--accent); }
.sensor-val { width:48px; text-align:right; color:var(--accent); font-weight:600; font-size:10px; }
.sensor-info { font-size:10px; color:var(--muted); margin-top:4px; }
.relay-grid { display:grid; grid-template-columns:repeat(2, 1fr); gap:6px; }
.relay-row { display:flex; align-items:center; gap:6px; font-size:11px; }
/* WiFi status panel */
.wifi-section { padding:10px 12px; border-bottom:1px solid var(--bdr-in); display:none; }
.wifi-section-title { display:flex; align-items:center; gap:6px; font-size:11px; font-weight:600; color:#e5e7eb; margin-bottom:8px; }
.wifi-dot { width:8px; height:8px; border-radius:50%; background:#333; }
.wifi-dot.on { background:#22c55e; box-shadow:0 0 4px #22c55e; }
.wifi-row { display:flex; align-items:center; gap:6px; font-size:11px; margin-bottom:4px; }
.wifi-label { width:46px; color:var(--muted); flex-shrink:0; }
.wifi-val { flex:1; text-align:right; color:var(--text); }
.wifi-btn { flex:1; padding:4px 6px; border:1px solid var(--bdr2); background:var(--panel); color:var(--text); border-radius:4px; cursor:pointer; font-size:10px; }
.wifi-btn.on { background:#2563eb; border-color:#2563eb; color:#fff; }
.wifi-input { flex:1; background:var(--panel); border:1px solid var(--bdr2); color:var(--text); border-radius:4px; padding:4px 6px; font-size:10px; font-family:inherit; }
.relay-btn { flex:1; padding:4px 6px; border:1px solid var(--bdr2); background:var(--panel); color:var(--text); border-radius:4px; cursor:pointer; font-size:10px; }
.relay-btn.on { background:#dc2626; border-color:#dc2626; color:#fff; }
.motor-channel { border:1px solid var(--bdr2); border-radius:6px; padding:6px; margin-bottom:6px; }
.motor-label { font-size:10px; color:var(--muted); margin-bottom:4px; }
.motor-dir-row { display:flex; gap:4px; margin-top:4px; }
.motor-dir-btn { flex:1; padding:3px 4px; border:1px solid var(--bdr2); background:var(--panel); color:var(--text); border-radius:4px; cursor:pointer; font-size:9px; }
.motor-dir-btn.active { background:#2563eb; border-color:#2563eb; color:#fff; }
/* Pin grid (shared by popup) */
.pins-header { font-size:10px; color:var(--muted); margin-bottom:6px; font-weight:600; letter-spacing:.08em; }
.pin-row { display:flex; align-items:center; gap:8px; padding:2px 0; font-size:11px; }
.pin-label { width:32px; color:var(--muted); flex-shrink:0; }
.pin-led { width:10px; height:10px; border-radius:50%; background:#2a2a3a; border:1px solid #3a3a5a; flex-shrink:0; transition:background .08s, box-shadow .08s; }
.pin-led.on { background:var(--green); box-shadow:0 0 5px var(--green); border-color:var(--green); }
.pin-led.pwm { background:var(--yellow); box-shadow:0 0 5px var(--yellow); border-color:var(--yellow); }
.pin-led.in { background:var(--accent); box-shadow:0 0 5px var(--accent); border-color:var(--accent); }
.pin-val { color:#9ca3af; font-size:10px; }
.pin-sep { height:1px; background:#1e1e36; margin:4px 0; }
/* Pin popup floating window */
.pin-popup { position:fixed; top:58px; right:18px; width:210px; max-height:72vh; background:var(--panel); border:1px solid var(--bdr2); border-radius:8px; display:none; flex-direction:column; z-index:50; box-shadow:0 6px 24px rgba(0,0,0,.5); }
.pin-popup.open { display:flex; }
.pin-popup-hdr { padding:7px 12px; font-size:11px; font-weight:600; color:#e5e7eb; background:var(--ctrl); border-bottom:1px solid var(--bdr); cursor:move; display:flex; align-items:center; user-select:none; flex-shrink:0; border-radius:8px 8px 0 0; }
.pin-popup-hdr button { margin-left:auto; background:none; border:none; color:var(--muted); cursor:pointer; font-size:14px; line-height:1; padding:0 2px; }
.pin-popup-hdr button:hover { color:#e5e7eb; }
.pin-popup-body { padding:10px 12px; overflow-y:auto; flex:1; }
/* Serial monitor */
.serial-panel { flex:1; display:flex; flex-direction:column; }
.serial-header { padding:10px 16px; font-size:11px; color:var(--muted); font-weight:600; letter-spacing:.08em; border-bottom:1px solid var(--bdr-in); display:flex; align-items:center; gap:12px; flex-shrink:0; }
.serial-header .baud { font-size:11px; color:#60a5fa; }
.serial-clear { margin-left:auto; background:none; border:none; color:var(--muted); cursor:pointer; font-size:11px; font-family:inherit; }
.serial-clear:hover { color:#e5e7eb; }
.serial-output { flex:1; overflow-y:auto; padding:12px 16px; font-size:13px; line-height:1.7; white-space:pre-wrap; word-break:break-all; }
.serial-output .line { color:var(--text); }
.serial-output .sys { color:var(--muted); font-style:italic; }
.serial-output .error { color:var(--red); }
.serial-input-row { display:flex; gap:8px; padding:10px 16px; border-top:1px solid var(--bdr-in); flex-shrink:0; }
.serial-input { flex:1; background:var(--panel); border:1px solid var(--bdr2); color:var(--text); border-radius:6px; padding:7px 12px; font-family:inherit; font-size:13px; }
.serial-input:focus { outline:none; border-color:var(--accent); }
.serial-send { background:#1a3a2a; border:1px solid #2d5a3a; color:var(--green); border-radius:6px; padding:7px 14px; font-family:inherit; font-size:12px; cursor:pointer; }
.serial-send:hover { background:#224a3a; }
/* Footer */
.footer { padding:7px 16px; border-top:1px solid var(--bdr); font-size:11px; color:var(--muted); background:var(--panel); flex-shrink:0; display:flex; justify-content:space-between; }
.footer .cycles { color:#60a5fa; }
/* Compile overlay */
.overlay { position:fixed; inset:0; background:rgba(0,0,0,.7); display:flex; align-items:center; justify-content:center; z-index:100; }
.overlay-box { background:var(--panel); border:1px solid var(--bdr2); border-radius:10px; padding:28px 36px; text-align:center; min-width:300px; }
.overlay-box h2 { font-size:15px; margin-bottom:12px; }
.overlay-box p { font-size:12px; color:var(--muted); margin-bottom:18px; white-space:pre-wrap; text-align:left; max-height:200px; overflow-y:auto; }
.overlay-box button { background:#1a3a2a; border:1px solid #2d5a3a; color:var(--green); border-radius:6px; padding:8px 20px; font-family:inherit; font-size:13px; cursor:pointer; }
.spinner { width:28px; height:28px; border:3px solid #3a3a5a; border-top-color:var(--accent); border-radius:50%; animation:spin .8s linear infinite; margin:0 auto 16px; }
@keyframes spin { to { transform:rotate(360deg) } }
/* ── Inline schematic panel ─────────────────────────────────────────────────── */
.bb-popup { display:none; } /* legacy stub, panel moved inline */
.sch-panel { flex:1; min-width:200px; display:none; flex-direction:column; border-left:1px solid var(--bdr); background:var(--panel); overflow:hidden; }
.sch-panel.open { display:flex; }
.sch-drag { width:5px; flex-shrink:0; cursor:col-resize; background:var(--bdr); transition:background .15s; }
.sch-drag:hover, .sch-drag.active { background:var(--accent); }
.bb-hdr { padding:6px 10px; font-size:11px; font-weight:600; color:var(--text); background:var(--ctrl); border-bottom:1px solid var(--bdr); display:flex; align-items:center; gap:6px; user-select:none; flex-shrink:0; }
.bb-hdr .bb-close { display:none; }
.sch-file-btn { background:var(--ctrl); border:1px solid var(--bdr2); color:var(--text); border-radius:4px; padding:3px 8px; font-size:10px; cursor:pointer; font-family:inherit; }
.sch-file-btn:hover { filter:brightness(.88); }
.bb-tabs-row { display:flex; gap:2px; margin-left:8px; }
.bb-tab { background:none; border:none; color:var(--muted); cursor:pointer; font-size:11px; padding:3px 10px; border-radius:4px; font-family:inherit; }
.bb-tab:hover { color:var(--text); }
.bb-tab.active { background:var(--bdr); color:var(--text); }
.bb-body { flex:1; display:flex; overflow:hidden; }
.bb-content { display:none; flex:1; flex-direction:column; overflow:hidden; }
.bb-content.active { display:flex; }
/* Schematic tab */
.sch-toolbar { display:flex; gap:4px; padding:7px 10px; flex-shrink:0; border-bottom:1px solid var(--bdr-in); flex-wrap:wrap; align-items:center; }
.sch-tool { background:var(--ctrl); border:1px solid var(--bdr2); color:var(--text); border-radius:5px; padding:4px 9px; font-size:11px; cursor:pointer; font-family:inherit; transition:all .12s; }
.sch-tool:hover { filter:brightness(0.92); box-shadow:0 1px 4px rgba(0,0,0,.12); }
.sch-tool.active { background:var(--accent); color:#fff; border-color:var(--accent); }
.sch-canvas-wrap { flex:1; overflow:hidden; background:#fff; position:relative; }
#sch-canvas { cursor:crosshair; display:block; }
.sch-hint { font-size:10px; color:var(--muted); padding:2px 6px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:260px; }
/* SPICE tab */
.spice-ctrl { padding:8px 10px; display:flex; gap:8px; align-items:center; flex-shrink:0; border-bottom:1px solid var(--bdr-in); }
.spice-ctrl button { background:var(--ctrl); border:1px solid var(--bdr2); color:var(--text); border-radius:5px; padding:4px 10px; font-size:11px; cursor:pointer; font-family:inherit; }
.spice-ctrl button:hover { filter:brightness(0.92); }
.spice-split { flex:1; display:flex; overflow:hidden; }
.spice-netlist { flex:1; background:#f8fafc; border:none; color:#1e40af; font-family:'IBM Plex Mono',monospace; font-size:11px; padding:10px; resize:none; outline:none; border-right:1px solid var(--bdr-in); }
.spice-results { width:270px; flex-shrink:0; overflow-y:auto; padding:10px; font-size:11px; }
.spice-node { display:flex; justify-content:space-between; padding:3px 0; border-bottom:1px solid var(--bdr-in); }
.spice-node-name { color:var(--muted); font-size:10px; }
.spice-node-val { color:var(--accent); font-weight:600; }
.spice-section { font-size:10px; font-weight:600; color:var(--accent); margin:10px 0 4px; letter-spacing:.05em; }
/* Parts list tab */
.parts-list { flex:1; overflow-y:auto; padding:8px; display:flex; flex-direction:column; gap:4px; }
.parts-cat { font-size:10px; font-weight:700; color:var(--accent); letter-spacing:.08em; padding:8px 4px 4px; border-bottom:1px solid var(--bdr-in); margin-bottom:2px; }
.parts-item { display:flex; align-items:center; gap:8px; padding:5px 8px; border-radius:5px; cursor:pointer; border:1px solid transparent; transition:all .12s; }
.parts-item:hover { background:var(--ctrl); border-color:var(--bdr2); }
.parts-item.selected { background:var(--accent); color:#fff; border-color:var(--accent); }
.parts-item.selected .parts-desc { color:rgba(255,255,255,.8); }
.parts-icon { font-size:18px; width:28px; text-align:center; flex-shrink:0; }
.parts-info { flex:1; min-width:0; }
.parts-name { font-size:11px; font-weight:600; color:var(--text); }
.parts-item.selected .parts-name { color:#fff; }
.parts-desc { font-size:10px; color:var(--muted); margin-top:1px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.parts-shortcut { font-size:9px; background:var(--bdr-in); color:var(--muted); border-radius:3px; padding:1px 4px; flex-shrink:0; }
/* Code editor panel */
.code-editor-panel { display:flex; flex-direction:column; background:var(--panel); border-left:1px solid var(--bdr2); flex:1; min-width:200px; }
.code-editor-header { display:flex; align-items:center; gap:8px; padding:6px 12px; background:var(--ctrl); border-bottom:1px solid var(--bdr); font-size:12px; font-weight:600; color:var(--text2); flex-shrink:0; }
.code-editor-filename { font-family:'IBM Plex Mono',monospace; font-size:11px; color:var(--accent); flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.code-editor-close { background:none; border:none; color:var(--muted); font-size:16px; cursor:pointer; padding:2px 6px; border-radius:4px; }
.code-editor-close:hover { background:var(--bdr-in); color:var(--text); }
.code-editor-textarea { flex:1; width:100%; resize:none; font-family:'IBM Plex Mono',monospace; font-size:12px; line-height:1.5; padding:10px 12px; border:none; outline:none; background:var(--bg); color:var(--text); tab-size:4; -moz-tab-size:4; }
.code-editor-textarea:focus { background:color-mix(in srgb, var(--bg) 97%, var(--accent)); }
.code-editor-status { padding:3px 12px; font-size:10px; color:var(--muted); background:var(--ctrl); border-top:1px solid var(--bdr); flex-shrink:0; }
#btn-code.active { background:var(--accent); color:#fff; border-color:var(--accent); }
#btn-compile { background:#1a3a2a; color:var(--green); border:1px solid #2d5a3a; }
#btn-compile:hover { background:#245a3a; }
#btn-compile:disabled { opacity:.5; cursor:wait; }
#btn-stop { background:#3a1a1a; color:#ef4444; border:1px solid #5a2d2d; }
#btn-stop:hover:not(:disabled) { background:#4a2020; }
#btn-reset { background:#1a2a3a; color:#67e8f9; border:1px solid #1a4a6a; }
#btn-reset:hover:not(:disabled) { background:#1f3a4a; }
/* CodeMirror 스케치 에디터 */
.CodeMirror { height:100% !important; font-family:'IBM Plex Mono',monospace !important; font-size:12px !important; line-height:1.5 !important; border:none !important; background:var(--bg) !important; color:var(--text) !important; }
.CodeMirror-scroll { height:100% !important; }
.CodeMirror-gutters { background:var(--board-bg) !important; border-right:1px solid var(--bdr-in) !important; }
.CodeMirror-linenumber { color:var(--muted) !important; padding:0 6px !important; }
.CodeMirror-cursor { border-left-color:var(--accent) !important; }
.CodeMirror-selected { background:rgba(37,99,235,.15) !important; }
.cm-s-default .cm-keyword { color:var(--accent) !important; }
.cm-s-default .cm-string { color:var(--green) !important; }
.cm-s-default .cm-number { color:var(--orange) !important; }
.cm-s-default .cm-comment { color:var(--muted) !important; font-style:italic !important; }
.cm-s-default .cm-def { color:var(--yellow) !important; }
/* 속도 슬라이더 */
#speed-range { width:70px; height:4px; accent-color:var(--accent); vertical-align:middle; cursor:pointer; }
</style>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/codemirror.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/codemirror/5.65.16/mode/clike/clike.min.js"></script>
</head>
<body>
<div class="header">
<h1>🔌 Arduino Emulator</h1>
<span class="file" id="filename">sketch.ino</span>
<span class="status loading" id="status">컴파일 중...</span>
<div class="toolbar">
<button onclick="toggleCodeEditor()" id="btn-code" title="코드 에디터">📝 코드</button>
<button onclick="compileAndRun()" id="btn-compile" title="컴파일 &amp; 실행 (Ctrl+Enter)">▶ 컴파일</button>
<button onclick="togglePower()" id="btn-stop" title="정지 / 시작" disabled>▶ 시작</button>
<button onclick="resetEmulator()" id="btn-reset" title="에뮬레이션 재시작 (리셋)" disabled>↺ 재시작</button>
<button onclick="toggleBB()" id="btn-bb" title="브레드보드 &amp; SPICE 시뮬레이터">🔌 회로</button>
<div class="theme-picker" title="테마">
<button class="tdot active" id="td-light" onclick="setTheme('light')" style="--c:#e5e7eb;border:1px solid #94a3b8" title="라이트"></button>
<button class="tdot" id="td-cosmic" onclick="setTheme('cosmic')" style="--c:#3b82f6" title="Cosmic"></button>
<button class="tdot" id="td-matrix" onclick="setTheme('matrix')" style="--c:#22c55e" title="Matrix"></button>
<button class="tdot" id="td-amber" onclick="setTheme('amber')" style="--c:#f59e0b" title="Amber"></button>
<button class="tdot" id="td-ice" onclick="setTheme('ice')" style="--c:#06b6d4" title="Ice"></button>
</div>
<span class="speed-label">속도 <span id="speed-val-label">1x</span></span>
<input type="range" id="speed-range" min="0" max="100" value="0" oninput="setSpeedSlider(this.value)" title="에뮬레이션 속도 (1x ~ 최대)">
</div>
</div>
<div class="main">
<!-- Board / Pin panel -->
<div class="board-panel">
<div class="board-title">ARDUINO UNO · ATmega328P</div>
<div class="board-svg">
<svg viewBox="0 0 80 140" xmlns="http://www.w3.org/2000/svg">
<!-- PCB body -->
<rect x="1" y="1" width="78" height="138" rx="5" fill="#1a6030" stroke="#3a9050" stroke-width="1.5"/>
<!-- Digital pin header (top) -->
<text x="30" y="8.5" text-anchor="middle" fill="#a0d8a0" font-size="4" font-family="monospace">DIGITAL</text>
<rect x="2" y="10" width="56" height="4.5" rx="1" fill="#0d3018" stroke="#3a7a3a" stroke-width="0.5"/>
<!-- D0-D13 pins - 원형 핀 헤더 자체가 LED 로 점등 -->
<circle id="board-d0-led" cx="4" cy="12.2" r="1.6" fill="#0d2a0d" stroke="#1a5a1a" stroke-width="0.4"/>
<circle id="board-d1-led" cx="7.9" cy="12.2" r="1.6" fill="#0d2a0d" stroke="#1a5a1a" stroke-width="0.4"/>
<circle id="board-d2-led" cx="11.8" cy="12.2" r="1.6" fill="#0d2a0d" stroke="#1a5a1a" stroke-width="0.4"/>
<circle id="board-d3-led" cx="15.7" cy="12.2" r="1.6" fill="#0d2a0d" stroke="#1a5a1a" stroke-width="0.4"/>
<circle id="board-d4-led" cx="19.6" cy="12.2" r="1.6" fill="#0d2a0d" stroke="#1a5a1a" stroke-width="0.4"/>
<circle id="board-d5-led" cx="23.5" cy="12.2" r="1.6" fill="#0d2a0d" stroke="#1a5a1a" stroke-width="0.4"/>
<circle id="board-d6-led" cx="27.4" cy="12.2" r="1.6" fill="#0d2a0d" stroke="#1a5a1a" stroke-width="0.4"/>
<circle id="board-d7-led" cx="31.3" cy="12.2" r="1.6" fill="#0d2a0d" stroke="#1a5a1a" stroke-width="0.4"/>
<circle id="board-d8-led" cx="35.2" cy="12.2" r="1.6" fill="#0d2a0d" stroke="#1a5a1a" stroke-width="0.4"/>
<circle id="board-d9-led" cx="39.1" cy="12.2" r="1.6" fill="#0d2a0d" stroke="#1a5a1a" stroke-width="0.4"/>
<circle id="board-d10-led" cx="43" cy="12.2" r="1.6" fill="#0d2a0d" stroke="#1a5a1a" stroke-width="0.4"/>
<circle id="board-d11-led" cx="46.9" cy="12.2" r="1.6" fill="#0d2a0d" stroke="#1a5a1a" stroke-width="0.4"/>
<circle id="board-d12-led" cx="50.8" cy="12.2" r="1.6" fill="#0d2a0d" stroke="#1a5a1a" stroke-width="0.4"/>
<circle id="board-d13-led" cx="54.7" cy="12.2" r="1.6" fill="#0d2a0d" stroke="#1a5a1a" stroke-width="0.4"/>
<!-- Reset button (clickable) -->
<g id="reset-btn" onclick="resetEmulator()">
<circle cx="68" cy="14" r="4.5" fill="#cc3030" stroke="#ff6060" stroke-width="0.8"/>
<text x="68" y="22" text-anchor="middle" fill="#ffaaaa" font-size="3.2" font-family="monospace">RST</text>
</g>
<!-- Crystal 16MHz -->
<rect x="6" y="30" width="7.5" height="15" rx="2.5" fill="#d0c890" stroke="#e8e080" stroke-width="0.5"/>
<line x1="9.75" y1="30" x2="9.75" y2="45" stroke="#b0a870" stroke-width="0.5"/>
<!-- ATmega328P chip -->
<rect x="18" y="28" width="44" height="46" rx="2.5" fill="#1a1a1a" stroke="#666" stroke-width="1"/>
<!-- chip pin stubs left -->
<line x1="18" y1="33" x2="14" y2="33" stroke="#c0c090" stroke-width="1.2"/>
<line x1="18" y1="38" x2="14" y2="38" stroke="#c0c090" stroke-width="1.2"/>
<line x1="18" y1="43" x2="14" y2="43" stroke="#c0c090" stroke-width="1.2"/>
<line x1="18" y1="48" x2="14" y2="48" stroke="#c0c090" stroke-width="1.2"/>
<line x1="18" y1="53" x2="14" y2="53" stroke="#c0c090" stroke-width="1.2"/>
<line x1="18" y1="58" x2="14" y2="58" stroke="#c0c090" stroke-width="1.2"/>
<line x1="18" y1="63" x2="14" y2="63" stroke="#c0c090" stroke-width="1.2"/>
<!-- chip pin stubs right -->
<line x1="62" y1="33" x2="66" y2="33" stroke="#c0c090" stroke-width="1.2"/>
<line x1="62" y1="38" x2="66" y2="38" stroke="#c0c090" stroke-width="1.2"/>
<line x1="62" y1="43" x2="66" y2="43" stroke="#c0c090" stroke-width="1.2"/>
<line x1="62" y1="48" x2="66" y2="48" stroke="#c0c090" stroke-width="1.2"/>
<line x1="62" y1="53" x2="66" y2="53" stroke="#c0c090" stroke-width="1.2"/>
<line x1="62" y1="58" x2="66" y2="58" stroke="#c0c090" stroke-width="1.2"/>
<line x1="62" y1="63" x2="66" y2="63" stroke="#c0c090" stroke-width="1.2"/>
<!-- notch on chip -->
<ellipse cx="40" cy="28" rx="3" ry="1.5" fill="#333"/>
<text x="40" y="48" text-anchor="middle" fill="#bbb" font-size="5" font-family="monospace">ATmega</text>
<text x="40" y="55" text-anchor="middle" fill="#bbb" font-size="4.5" font-family="monospace">328P</text>
<!-- PWR LED (실행 중 점등) -->
<circle id="board-pwr-led" cx="68" cy="36" r="2.8" fill="#0d2a0d" stroke="#1a5a1a" stroke-width="0.8"/>
<text x="68" y="32.5" text-anchor="middle" fill="#80cc80" font-size="3" font-family="monospace">PWR</text>
<!-- L LED (D13) -->
<circle id="board-led-13" cx="68" cy="50" r="2.8" fill="#2a1a1a" stroke="#553333" stroke-width="0.8"/>
<text x="68" y="46.5" text-anchor="middle" fill="#cc9090" font-size="3" font-family="monospace">L</text>
<!-- ICSP header -->
<rect x="60" y="80" width="15" height="10" rx="1" fill="#0d1a0d" stroke="#3a6a3a" stroke-width="0.5"/>
<circle cx="63" cy="83" r="0.9" fill="#c0c090"/><circle cx="67" cy="83" r="0.9" fill="#c0c090"/><circle cx="71" cy="83" r="0.9" fill="#c0c090"/>
<circle cx="63" cy="87" r="0.9" fill="#c0c090"/><circle cx="67" cy="87" r="0.9" fill="#c0c090"/><circle cx="71" cy="87" r="0.9" fill="#c0c090"/>
<text x="67.5" y="94" text-anchor="middle" fill="#80c880" font-size="3" font-family="monospace">ICSP</text>
<!-- Analog / Power header (bottom) -->
<text x="26" y="111" text-anchor="middle" fill="#a0d8a0" font-size="3.5" font-family="monospace">ANALOG / POWER</text>
<rect x="2" y="112" width="56" height="4.5" rx="1" fill="#0d1820" stroke="#3a5a7a" stroke-width="0.5"/>
<!-- A0-A5 pins - 원형 핀 헤더 자체가 LED 로 점등 (A6=5V, A7=GND 는 제외) -->
<circle id="board-a0-led" cx="4" cy="114.2" r="1.6" fill="#0d2a0d" stroke="#1a5a1a" stroke-width="0.4"/>
<circle id="board-a1-led" cx="10.7" cy="114.2" r="1.6" fill="#0d2a0d" stroke="#1a5a1a" stroke-width="0.4"/>
<circle id="board-a2-led" cx="17.4" cy="114.2" r="1.6" fill="#0d2a0d" stroke="#1a5a1a" stroke-width="0.4"/>
<circle id="board-a3-led" cx="24.1" cy="114.2" r="1.6" fill="#0d2a0d" stroke="#1a5a1a" stroke-width="0.4"/>
<circle id="board-a4-led" cx="30.8" cy="114.2" r="1.6" fill="#0d2a0d" stroke="#1a5a1a" stroke-width="0.4"/>
<circle id="board-a5-led" cx="37.5" cy="114.2" r="1.6" fill="#0d2a0d" stroke="#1a5a1a" stroke-width="0.4"/>
<!-- A6=5V, A7=GND (전원 핀) -->
<circle cx="44.2" cy="114.2" r="1.6" fill="#c0c090" stroke="#e0e0a0" stroke-width="0.4"/>
<circle cx="50.9" cy="114.2" r="1.6" fill="#c0c090" stroke="#e0e0a0" stroke-width="0.4"/>
<!-- Power button (원형 파워 버튼, ICSP 하단) -->
<g id="power-switch" onclick="togglePower()">
<circle id="pw-bg" cx="68" cy="103" r="7.5" fill="#1a0808" stroke="#803030" stroke-width="1.2"/>
<circle cx="68" cy="103" r="5.5" fill="none" id="pw-ring" stroke="#602020" stroke-width="0.8"/>
<path id="pw-arc" d="M 64.8 100.5 A 4 4 0 1 0 71.2 100.5"
stroke="#c0404088" stroke-width="1.6" fill="none" stroke-linecap="round"/>
<line id="pw-line" x1="68" y1="98.5" x2="68" y2="102.5"
stroke="#c0404088" stroke-width="1.6" stroke-linecap="round"/>
<circle cx="68" cy="103" r="7.5" fill="transparent" stroke="transparent"/>
</g>
<!-- USB-B connector -->
<rect x="27" y="126" width="26" height="13" rx="2" fill="#999" stroke="#bbb" stroke-width="0.5"/>
<rect x="30" y="128" width="20" height="9" rx="1.5" fill="#666"/>
<!-- Power barrel jack -->
<ellipse cx="7" cy="131" rx="5.5" ry="7" fill="#444" stroke="#777" stroke-width="0.8"/>
<circle cx="7" cy="131" r="2.5" fill="#222"/>
</svg>
</div>
<!-- SSD1306 OLED -->
<div class="oled-section" id="oled-section" style="display:none">
<div class="oled-section-title">
<span class="oled-dot" id="oled-dot"></span>
SSD1306 OLED 128×64
</div>
<div class="oled-wrap">
<canvas id="oled-canvas" width="256" height="128"></canvas>
</div>
</div>
<!-- TFT LCD display -->
<div class="tft-section" id="tft-section" style="display:none">
<div class="tft-section-title">
<span class="tft-dot" id="tft-dot"></span>
<span id="tft-label">TFT LCD</span>
</div>
<div class="tft-wrap">
<canvas id="tft-canvas" width="128" height="160"></canvas>
</div>
</div>
<!-- LCD display -->
<div class="lcd-section" id="lcd-section" style="display:none">
<div class="lcd-section-title">LCD <span id="lcd-size-label" style="color:#60a5fa"></span> <span id="lcd-backlight-dot" style="font-size:8px">●</span></div>
<div class="lcd-wrap" id="lcd-wrap">
<div id="lcd-display"></div>
</div>
</div>
<!-- Servo motor display -->
<div class="dht-section" id="servo-section" style="display:none">
<div class="dht-section-title">
<span class="dht-dot" style="background:#dc2626"></span>
<span>서보 모터</span>
</div>
<div id="servo-rows"></div>
</div>
<!-- DHT sensor controls -->
<div class="dht-section" id="dht-section" style="display:none">
<div class="dht-section-title">
<span class="dht-dot" id="dht-dot"></span>
<span id="dht-label">DHT 온습도</span>
</div>
<div class="dht-controls">
<div class="dht-row">
<span class="dht-param">온도</span>
<input type="range" id="dht-temp" min="-40" max="80" value="25" oninput="updateDHTValues()">
<span class="dht-val" id="dht-temp-val">25°C</span>
</div>
<div class="dht-row">
<span class="dht-param">습도</span>
<input type="range" id="dht-humid" min="0" max="100" value="50" oninput="updateDHTValues()">
<span class="dht-val" id="dht-humid-val">50%</span>
</div>
</div>
</div>
<!-- MPU-6050 IMU controls -->
<div class="sensor-section" id="imu-section" style="display:none">
<div class="sensor-section-title">
<span class="sensor-dot" style="background:#0891b2"></span>
<span>MPU-6050 IMU</span>
<span class="sensor-info" id="imu-info"></span>
</div>
<div class="sensor-row"><span class="sensor-param">Ax</span><input type="range" id="imu-ax" min="-2" max="2" step="0.01" value="0" oninput="updateIMUValues()"><span class="sensor-val" id="imu-ax-val">0g</span></div>
<div class="sensor-row"><span class="sensor-param">Ay</span><input type="range" id="imu-ay" min="-2" max="2" step="0.01" value="0" oninput="updateIMUValues()"><span class="sensor-val" id="imu-ay-val">0g</span></div>
<div class="sensor-row"><span class="sensor-param">Az</span><input type="range" id="imu-az" min="-2" max="2" step="0.01" value="1" oninput="updateIMUValues()"><span class="sensor-val" id="imu-az-val">1g</span></div>
<div class="sensor-row"><span class="sensor-param">Gx</span><input type="range" id="imu-gx" min="-250" max="250" step="1" value="0" oninput="updateIMUValues()"><span class="sensor-val" id="imu-gx-val">0°/s</span></div>
<div class="sensor-row"><span class="sensor-param">Gy</span><input type="range" id="imu-gy" min="-250" max="250" step="1" value="0" oninput="updateIMUValues()"><span class="sensor-val" id="imu-gy-val">0°/s</span></div>
<div class="sensor-row"><span class="sensor-param">Gz</span><input type="range" id="imu-gz" min="-250" max="250" step="1" value="0" oninput="updateIMUValues()"><span class="sensor-val" id="imu-gz-val">0°/s</span></div>
<div class="sensor-row"><span class="sensor-param">Temp</span><input type="range" id="imu-temp" min="-40" max="85" step="0.5" value="25" oninput="updateIMUValues()"><span class="sensor-val" id="imu-temp-val">25°C</span></div>
</div>
<!-- BME280 environmental controls -->
<div class="sensor-section" id="bme-section" style="display:none">
<div class="sensor-section-title">
<span class="sensor-dot" style="background:#16a34a"></span>
<span>BME280 환경</span>
<span class="sensor-info" id="bme-info"></span>
</div>
<div class="sensor-row"><span class="sensor-param">온도</span><input type="range" id="bme-t" min="-40" max="85" step="0.5" value="25" oninput="updateBMEValues()"><span class="sensor-val" id="bme-t-val">25°C</span></div>
<div class="sensor-row"><span class="sensor-param">습도</span><input type="range" id="bme-h" min="0" max="100" step="1" value="50" oninput="updateBMEValues()"><span class="sensor-val" id="bme-h-val">50%</span></div>
<div class="sensor-row"><span class="sensor-param">기압</span><input type="range" id="bme-p" min="300" max="1100" step="1" value="1013" oninput="updateBMEValues()"><span class="sensor-val" id="bme-p-val">1013hPa</span></div>
</div>
<!-- DS18B20 temperature controls -->
<div class="sensor-section" id="ds18b20-section" style="display:none">
<div class="sensor-section-title">
<span class="sensor-dot" style="background:#6d28d9"></span>
<span>DS18B20 온도</span>
<span class="sensor-info" id="ds18b20-info"></span>
</div>
<div class="sensor-row"><span class="sensor-param">온도</span><input type="range" id="ds18b20-t" min="-55" max="125" step="0.5" value="25" oninput="updateDS18B20Values()"><span class="sensor-val" id="ds18b20-t-val">25°C</span></div>
</div>
<!-- Relay module indicators -->
<div class="sensor-section" id="relay-section" style="display:none">
<div class="sensor-section-title">
<span class="sensor-dot" style="background:#d97706"></span>
<span>릴레이 모듈</span>
</div>
<div class="relay-grid" id="relay-rows"></div>
</div>
<!-- L298N motor controls -->
<div class="sensor-section" id="motor-section" style="display:none">
<div class="sensor-section-title">
<span class="sensor-dot" style="background:#a16207"></span>
<span>L298N 모터</span>
</div>
<div id="motor-rows"></div>
</div>
<!-- WiFi status panel -->
<div class="wifi-section" id="wifi-section" style="display:none">
<div class="wifi-section-title">
<span class="wifi-dot" id="wifi-dot"></span>
<span>WiFi</span>
</div>
<div class="wifi-row"><span class="wifi-label">상태</span><span class="wifi-val" id="wifi-status">연결 안 됨</span></div>
<div class="wifi-row"><span class="wifi-label">SSID</span><span class="wifi-val" id="wifi-ssid">-</span></div>
<div class="wifi-row"><span class="wifi-label">IP</span><span class="wifi-val" id="wifi-ip">-</span></div>
<div class="wifi-row"><span class="wifi-label">RSSI</span><span class="wifi-val" id="wifi-rssi">-</span></div>
<div class="wifi-row" id="wifi-http-row" style="display:none"><span class="wifi-label">HTTP</span><a id="wifi-http-link" class="wifi-val" href="#" target="_blank" style="color:#60a5fa;text-decoration:underline">-</a></div>
<div class="wifi-row" style="margin-top:8px">
<input class="wifi-input" id="wifi-ssid-input" placeholder="SSID" />
<input class="wifi-input" id="wifi-pass-input" type="password" placeholder="비밀번호" />
</div>
<div class="wifi-row">
<button class="wifi-btn" id="wifi-connect-btn" onclick="_wifiConnectClicked()">연결</button>
<button class="wifi-btn" onclick="_wifiDisconnectClicked()">끊기</button>
</div>
</div>
</div>
<!-- Serial monitor -->
<div class="serial-panel">
<div class="serial-header">
SERIAL MONITOR
<span class="baud" id="baud-label">9600 baud</span>
<button class="serial-clear" onclick="clearSerial()">지우기</button>
</div>
<div class="serial-output" id="serial-output">
<span class="sys">에뮬레이터 준비 중...</span>
</div>
<div class="serial-input-row">
<input class="serial-input" id="serial-input" placeholder="직렬 입력 (Enter 전송)" onkeydown="if(event.key==='Enter')sendSerial()"/>
<button class="serial-send" onclick="sendSerial()">전송</button>
</div>
</div>
<!-- Code editor drag handle + panel -->
<div class="sch-drag" id="code-drag"></div>
<!-- Code editor panel (sketch editor, side by side with serial monitor) -->
<div class="code-editor-panel" id="code-editor-panel" style="display:none">
<div class="code-editor-header">
<span>📝 스케치 편집</span>
<span class="code-editor-filename" id="code-editor-filename">sketch.ino</span>
<button class="code-editor-close" onclick="schLoadSketchFile()" title="프로젝트 파일 열기" style="font-size:12px;padding:2px 8px">📂 파일</button>
<button class="code-editor-close" onclick="schSaveSketchFile()" title="현재 스케치를 파일에 저장" style="font-size:12px;padding:2px 8px">💾 저장</button>
<button class="code-editor-close" onclick="toggleCodeEditor()">✕</button>
</div>
<div id="cm-sketch-wrap" style="flex:1;overflow:hidden;min-height:0;position:relative"></div>
<textarea class="code-editor-textarea" id="code-editor-textarea" spellcheck="false" style="display:none"></textarea>
<div class="code-editor-status" id="code-editor-status"></div>
</div>
<!-- Drag handle + inline schematic panel -->
<div class="sch-drag" id="sch-drag"></div>
<div class="sch-panel" id="sch-panel">
<div class="bb-hdr" id="bb-hdr">
🔌 회로도
<div class="bb-tabs-row">
<button class="bb-tab active" id="bb-tab-btn-board" onclick="bbTab('board')">회로도</button>
<button class="bb-tab" id="bb-tab-btn-parts" onclick="bbTab('parts')">부품 목록</button>
<button class="bb-tab" id="bb-tab-btn-spice" onclick="bbTab('spice')">SPICE</button>
</div>
<span style="margin-left:auto;display:flex;gap:4px;align-items:center">
<button class="sch-file-btn" id="sch-gen-btn" onclick="schGenFromCode()" title="스케치 코드를 분석해서 회로도 자동 생성">⚡ 코드→회로</button>
<button class="sch-file-btn" id="sch-gen-code-btn" onclick="schGenCodeFromCircuit()" title="회로도를 분석해서 Arduino 스케치 자동 생성">🤖 회로→코드</button>
<button class="sch-file-btn" onclick="schSaveProject()" title="circuit.json을 프로젝트 폴더에 저장">💾 저장</button>
<button class="sch-file-btn" onclick="schLoadLocal()" title="저장된 회로도 불러오기">📂 불러오기</button>
<button class="sch-file-btn" onclick="schLoadSketchFile()" title="프로젝트의 .ino 파일 열기">📝 스케치</button>
<button class="sch-file-btn" onclick="toggleSchPanel()" style="color:var(--muted)" title="닫기">✕</button>
</span>
</div>
<div class="bb-body" id="bb-body-inner">
<!-- SCHEMATIC TAB -->
<div class="bb-content active" id="bb-content-board">
<div class="sch-toolbar">
<button class="sch-tool" id="stool-select" onclick="schSetTool('select')" title="선택/이동 (V)">↖ 선택</button>
<button class="sch-tool active" id="stool-wire" onclick="schSetTool('wire')" title="와이어 (W)">— 와이어</button>
<button class="sch-tool" id="stool-res" onclick="schSetTool('res')" title="저항 (R)">⊡ R</button>
<button class="sch-tool" id="stool-cap" onclick="schSetTool('cap')" title="커패시터 (C)">⊢⊣ C</button>
<button class="sch-tool" id="stool-led" onclick="schSetTool('led')" title="LED">▷ LED</button>
<button class="sch-tool" id="stool-diode" onclick="schSetTool('diode')" title="다이오드">▷| D</button>
<button class="sch-tool" id="stool-bjt" onclick="schSetTool('bjt')" title="BJT 트랜지스터">⊙ BJT</button>
<button class="sch-tool" id="stool-btn" onclick="schSetTool('btn')" title="버튼">⊙ BTN</button>
<button class="sch-tool" id="stool-vcc" onclick="schSetTool('vcc')" title="+5V 전원" style="color:#dc2626">+VCC</button>
<button class="sch-tool" id="stool-gnd" onclick="schSetTool('gnd')" title="접지 GND" style="color:#2563eb">⏚ GND</button>
<button class="sch-tool" id="stool-ard" onclick="schSetTool('ard')" title="Arduino 핀" style="color:#ea580c">📌 핀</button>
<span style="margin-left:auto;display:flex;gap:6px;align-items:center">
<span class="sch-hint" id="sch-hint">와이어: 클릭→끝점 | R: 회전 | F: 좌우반전 | G: 상하반전 | Del: 삭제 | Esc: 취소 | 우클릭: 편집</span>
<button class="sch-tool" onclick="schRotateSelected()" title="선택 부품 회전 90° (R)">↻</button>
<button class="sch-tool" onclick="schFlipHSelected()" title="선택 부품 좌우 반전 (F)">↔</button>
<button class="sch-tool" onclick="schFlipVSelected()" title="선택 부품 상하 반전 (G)">↕</button>
<button class="sch-tool" onclick="schUndo()" title="되돌리기 (Ctrl+Z)">↩</button>
<button class="sch-tool" onclick="schExport()" title="JSON 내보내기">⬇</button>
<button class="sch-tool" onclick="schImport()" title="JSON 가져오기">⬆</button>
<button class="sch-tool" onclick="schClear()" style="color:var(--red)" title="초기화">🗑</button>
</span>
</div>
<div class="sch-canvas-wrap" id="sch-canvas-wrap">
<canvas id="sch-canvas"></canvas>
</div>
</div>
<!-- PARTS LIST TAB -->
<div class="bb-content" id="bb-content-parts">
<div class="parts-list" id="parts-list">
<div class="parts-cat">기본 수동 부품</div>
<div class="parts-item" onclick="bbSetTool('wire')"><span class="parts-icon">🔌</span><div class="parts-info"><div class="parts-name">와이어 (Wire)</div><div class="parts-desc">두 점을 연결하는 도선</div></div><span class="parts-shortcut">W</span></div>
<div class="parts-item" onclick="bbSetTool('res')"><span class="parts-icon">⊡</span><div class="parts-info"><div class="parts-name">저항 (Resistor)</div><div class="parts-desc">전류 제한. 220Ω, 1kΩ, 10kΩ 등</div></div><span class="parts-shortcut">R</span></div>
<div class="parts-item" onclick="bbSetTool('cap')"><span class="parts-icon">⊢⊣</span><div class="parts-info"><div class="parts-name">커패시터 (Capacitor)</div><div class="parts-desc">전하 저장. 100nF, 10µF 등</div></div><span class="parts-shortcut">C</span></div>
<div class="parts-cat">출력 부품</div>
<div class="parts-item" onclick="bbSetTool('led')"><span class="parts-icon">💡</span><div class="parts-info"><div class="parts-name">LED</div><div class="parts-desc">발광 다이오드</div></div><span class="parts-shortcut">L</span></div>
<div class="parts-item" onclick="bbSetTool('seg7')" id="tool-parts-seg7"><span class="parts-icon">🔢</span><div class="parts-info"><div class="parts-name">7-세그먼트 디스플레이</div><div class="parts-desc">숫자 표시용 LED 배열</div></div></div>
<div class="parts-item" onclick="bbSetTool('buzzer')" id="tool-parts-buzzer"><span class="parts-icon">🔔</span><div class="parts-info"><div class="parts-name">부저 (Buzzer)</div><div class="parts-desc">PWM 신호로 소리 출력</div></div></div>
<div class="parts-item" onclick="bbSetTool('rgb_led')" id="tool-parts-rgb"><span class="parts-icon">🌈</span><div class="parts-info"><div class="parts-name">RGB LED</div><div class="parts-desc">R/G/B 채널 독립 제어</div></div></div>
<div class="parts-item" onclick="bbSetTool('tft_lcd')" id="tool-parts-tft"><span class="parts-icon">🖥</span><div class="parts-info"><div class="parts-name">TFT 컬러 LCD</div><div class="parts-desc">ILI9341/ST7735/ST7789 SPI 디스플레이</div></div></div>
<div class="parts-cat">입력 부품</div>
<div class="parts-item" onclick="bbSetTool('btn')"><span class="parts-icon">⊙</span><div class="parts-info"><div class="parts-name">푸시버튼 (Push Button)</div><div class="parts-desc">누르는 동안 회로 연결</div></div><span class="parts-shortcut">B</span></div>
<div class="parts-item" onclick="bbSetTool('pot')" id="tool-parts-pot"><span class="parts-icon">🎛</span><div class="parts-info"><div class="parts-name">가변저항 (Potentiometer)</div><div class="parts-desc">0~5V 아날로그 전압 분배</div></div></div>
<div class="parts-item" onclick="bbSetTool('sw_toggle')" id="tool-parts-sw"><span class="parts-icon">🔀</span><div class="parts-info"><div class="parts-name">토글 스위치 (Toggle Switch)</div><div class="parts-desc">ON/OFF 유지형 스위치</div></div></div>
<div class="parts-item" onclick="bbSetTool('ldr')" id="tool-parts-ldr"><span class="parts-icon">☀️</span><div class="parts-info"><div class="parts-name">조도 센서 (LDR/CdS)</div><div class="parts-desc">빛에 따라 저항 변화</div></div></div>
<div class="parts-item" onclick="bbSetTool('ntc')" id="tool-parts-ntc"><span class="parts-icon">🌡</span><div class="parts-info"><div class="parts-name">NTC 서미스터</div><div class="parts-desc">온도에 따른 저항 변화</div></div></div>
<div class="parts-cat">반도체</div>
<div class="parts-item" onclick="bbSetTool('bjt')"><span class="parts-icon">🔺</span><div class="parts-info"><div class="parts-name">BJT 트랜지스터 (NPN/PNP)</div><div class="parts-desc">전류 증폭/스위칭</div></div><span class="parts-shortcut">T</span></div>
<div class="parts-item" onclick="bbSetTool('mosfet')" id="tool-parts-mosfet"><span class="parts-icon">📐</span><div class="parts-info"><div class="parts-name">MOSFET (N채널/P채널)</div><div class="parts-desc">전압 구동 스위칭</div></div></div>
<div class="parts-item" onclick="bbSetTool('diode')" id="tool-parts-diode"><span class="parts-icon">▷|</span><div class="parts-info"><div class="parts-name">다이오드 (1N4148/1N4007)</div><div class="parts-desc">단방향 전류</div></div></div>
<div class="parts-item" onclick="bbSetTool('zener')" id="tool-parts-zener"><span class="parts-icon">⚡</span><div class="parts-info"><div class="parts-name">제너 다이오드</div><div class="parts-desc">정전압 기준</div></div></div>
<div class="parts-cat">집적회로 (IC)</div>
<div class="parts-item" onclick="bbSetTool('ic_555')" id="tool-parts-555"><span class="parts-icon">🟥</span><div class="parts-info"><div class="parts-name">555 타이머 IC</div><div class="parts-desc">단안정/비안정 멀티바이브레이터</div></div></div>
<div class="parts-item" onclick="bbSetTool('ic_741')" id="tool-parts-741"><span class="parts-icon">🟦</span><div class="parts-info"><div class="parts-name">741 연산증폭기 (Op-Amp)</div><div class="parts-desc">증폭, 비교기, 필터</div></div></div>
<div class="parts-item" onclick="bbSetTool('ic_7400')" id="tool-parts-7400"><span class="parts-icon">🟩</span><div class="parts-info"><div class="parts-name">74 시리즈 로직 게이트</div><div class="parts-desc">AND/OR/NOT/NAND 등</div></div></div>
<div class="parts-item" onclick="bbSetTool('ic_shift')" id="tool-parts-shift"><span class="parts-icon">↔️</span><div class="parts-info"><div class="parts-name">74HC595 시프트 레지스터</div><div class="parts-desc">SPI 3선→8비트 병렬 출력</div></div></div>
<div class="parts-item" onclick="bbSetTool('ic_l293')" id="tool-parts-l293"><span class="parts-icon">🔄</span><div class="parts-info"><div class="parts-name">L293D 모터 드라이버</div><div class="parts-desc">DC모터 H브리지 구동</div></div></div>
<div class="parts-cat">센서 모듈</div>
<div class="parts-item" onclick="bbSetTool('dht')" id="tool-parts-dht"><span class="parts-icon">💧</span><div class="parts-info"><div class="parts-name">DHT11/DHT22 온습도 센서</div><div class="parts-desc">디지털 1-wire</div></div></div>
<div class="parts-item" onclick="bbSetTool('hcsr')" id="tool-parts-hcsr"><span class="parts-icon">📡</span><div class="parts-info"><div class="parts-name">HC-SR04 초음파 거리 센서</div><div class="parts-desc">Trig/Echo, 2~400cm</div></div></div>
<div class="parts-item" onclick="bbSetTool('pir')" id="tool-parts-pir"><span class="parts-icon">👁</span><div class="parts-info"><div class="parts-name">PIR 동작 감지 센서</div><div class="parts-desc">적외선 수동 감지</div></div></div>
<div class="parts-item" onclick="bbSetTool('ir')" id="tool-parts-ir"><span class="parts-icon">🔴</span><div class="parts-info"><div class="parts-name">IR 적외선 송수신</div><div class="parts-desc">리모컨 수신, 장애물 감지</div></div></div>
<div class="parts-item" onclick="bbSetTool('sw520')" id="tool-parts-sw520"><span class="parts-icon">🔵</span><div class="parts-info"><div class="parts-name">기울기 센서 (SW-520D)</div><div class="parts-desc">각도 감지</div></div></div>
<div class="parts-item" onclick="bbSetTool('servo')" id="tool-parts-servo"><span class="parts-icon">⚙</span><div class="parts-info"><div class="parts-name">서보 모터 (Servo)</div><div class="parts-desc">PWM 제어, SG90/MG996R</div></div></div>
<div class="parts-item" onclick="bbSetTool('relay')" id="tool-parts-relay"><span class="parts-icon">🔌</span><div class="parts-info"><div class="parts-name">릴레이 모듈</div><div class="parts-desc">5V 코일, NO/NC 스위칭</div></div></div>
<div class="parts-item" onclick="bbSetTool('ds18b20')" id="tool-parts-ds18"><span class="parts-icon">🌡</span><div class="parts-info"><div class="parts-name">DS18B20 온도 (1-Wire)</div><div class="parts-desc">-55~125°C, 방수형 가능</div></div></div>
<div class="parts-item" onclick="bbSetTool('mpu6050')" id="tool-parts-mpu"><span class="parts-icon">📐</span><div class="parts-info"><div class="parts-name">MPU-6050 자이로/가속도</div><div class="parts-desc">I2C, 6축 IMU (0x68)</div></div></div>
<div class="parts-item" onclick="bbSetTool('l298n')" id="tool-parts-l298n"><span class="parts-icon">🔄</span><div class="parts-info"><div class="parts-name">L298N 모터 드라이버</div><div class="parts-desc">DC모터 2채널, 2A×2</div></div></div>
<div class="parts-item" onclick="bbSetTool('bme280')" id="tool-parts-bme"><span class="parts-icon">🌤</span><div class="parts-info"><div class="parts-name">BME280 환경 센서</div><div class="parts-desc">I2C, 온도/습도/기압</div></div></div>
<div class="parts-cat">통신 모듈</div>
<div class="parts-item" onclick="bbSetTool('hc05')" id="tool-parts-hc05"><span class="parts-icon">📶</span><div class="parts-info"><div class="parts-name">HC-05/HC-06 블루투스</div><div class="parts-desc">UART 직렬 통신</div></div></div>
<div class="parts-item" onclick="bbSetTool('nrf')" id="tool-parts-nrf"><span class="parts-icon">📻</span><div class="parts-info"><div class="parts-name">nRF24L01 2.4GHz 무선</div><div class="parts-desc">SPI 인터페이스</div></div></div>
<div class="parts-item" onclick="bbSetTool('esp01')" id="tool-parts-esp01"><span class="parts-icon">🌐</span><div class="parts-info"><div class="parts-name">ESP8266 ESP-01 Wi-Fi</div><div class="parts-desc">UART AT 명령</div></div></div>
<div class="parts-cat">개발 보드</div>
<div class="parts-item" onclick="bbSetTool('board_esp32')" id="tool-parts-board32"><span class="parts-icon">🔲</span><div class="parts-info"><div class="parts-name">ESP32 DevKit</div><div class="parts-desc">ESP32 30핀 개발보드</div></div></div>
<div class="parts-item" onclick="bbSetTool('board_esp8266')" id="tool-parts-board8266"><span class="parts-icon">🔳</span><div class="parts-info"><div class="parts-name">ESP8266 개발보드</div><div class="parts-desc">NodeMCU / D1 Mini / ESP-01</div></div></div>
<div class="parts-item" onclick="bbSetTool('board_uno')" id="tool-parts-boarduno"><span class="parts-icon">🟧</span><div class="parts-info"><div class="parts-name">Arduino UNO R3</div><div class="parts-desc">ATmega328P · D0-D13, A0-A5</div></div></div>
<div class="parts-item" onclick="bbSetTool('board_nano')" id="tool-parts-boardnano"><span class="parts-icon">🟨</span><div class="parts-info"><div class="parts-name">Arduino Nano</div><div class="parts-desc">ATmega328P · D0-D13, A0-A7</div></div></div>
<div class="parts-item" onclick="bbSetTool('board_mega')" id="tool-parts-boardmega"><span class="parts-icon">🟦</span><div class="parts-info"><div class="parts-name">Arduino Mega 2560</div><div class="parts-desc">ATmega2560 · D0-D53, A0-A15</div></div></div>
<div class="parts-cat">전원 부품</div>
<div class="parts-item" onclick="bbSetTool('vcc')"><span class="parts-icon" style="color:#dc2626">+</span><div class="parts-info"><div class="parts-name">+5V 전원 (VCC)</div><div class="parts-desc">아두이노 5V 레일</div></div></div>
<div class="parts-item" onclick="bbSetTool('gnd')"><span class="parts-icon" style="color:#2563eb">⏚</span><div class="parts-info"><div class="parts-name">GND (접지)</div><div class="parts-desc">기준 전위 0V</div></div></div>
<div class="parts-item" onclick="bbSetTool('ard')"><span class="parts-icon" style="color:#ea580c">📌</span><div class="parts-info"><div class="parts-name">Arduino 핀 연결</div><div class="parts-desc">D0~D13, A0~A5, 5V, GND</div></div></div>
<div class="parts-item" onclick="bbSetTool('lm7805')" id="tool-parts-vreg"><span class="parts-icon">🔋</span><div class="parts-info"><div class="parts-name">LM7805 전압 레귤레이터</div><div class="parts-desc">7~35V→5V, 1A</div></div></div>
</div>
</div>
<!-- SPICE TAB -->
<div class="bb-content" id="bb-content-spice">
<div class="spice-ctrl">
<span style="font-size:11px;color:var(--muted)">SPICE 넷리스트</span>
<select id="spice-analysis" style="font-size:11px;background:var(--bg2);color:var(--text);border:1px solid var(--bdr);border-radius:4px;padding:2px 6px">
<option value=".op">.op 동작점</option>
<option value=".dc V1 0 5 0.1">.dc DC 스윕</option>
<option value=".tran 1us 1ms">.tran 과도해석</option>
<option value=".ac dec 10 1 1meg">.ac 주파수</option>
</select>
<button onclick="bbRunSpice()">▶ ngspice 실행</button>
<button onclick="bbExportNetlist()">⬇ .cir 내보내기</button>
<span id="spice-status" style="font-size:10px;color:var(--muted);margin-left:4px"></span>
</div>
<div class="spice-split">
<textarea class="spice-netlist" id="spice-netlist" spellcheck="false"></textarea>
<div class="spice-results" id="spice-results">
<div style="color:var(--muted);font-size:11px">▶ ngspice 실행하면<br>노드 전압과 전류가<br>여기에 표시됩니다.</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="footer">
<span id="footer-info">avr8js · ATmega328P @ 16MHz</span>
<span class="cycles" id="footer-cycles">0 cycles</span>
</div>
<!-- Compile overlay -->
<div class="overlay" id="overlay">
<div class="overlay-box">
<div class="spinner" id="overlay-spinner"></div>
<h2 id="overlay-title">컴파일 중...</h2>
<p id="overlay-msg" style="display:none"></p>
<button id="overlay-btn" onclick="closeOverlay()" style="display:none">닫기</button>
</div>
</div>
<!-- GPIO Pin popup -->
<div class="pin-popup" id="pin-popup">
<div class="pin-popup-hdr" id="pin-popup-hdr">
📌 GPIO 핀 상태
<button onclick="togglePinPopup()">✕</button>
</div>
<div class="pin-popup-body" id="pin-popup-body">
<div class="pins-header">DIGITAL PINS</div>
<div id="digital-pins"></div>
<div class="pin-sep"></div>
<div class="pins-header" style="margin-top:8px">ANALOG PINS</div>
<div id="analog-pins"></div>
</div>
</div>
<!-- bb-popup stub: content moved inline into .main as .sch-panel -->
<div id="bb-popup" style="display:none"></div>
<!-- avr8js from CDN -->
<script type="module">
// ── avr8js import ────────────────────────────────────────────────────────────
let avr8jsReady = false;
let CPU, AVRIOPort, AVRUSART, AVRTimer, AVRTWI,
portAConfig, portBConfig, portCConfig, portDConfig,
timer0Config, timer1Config, timer2Config, twiConfig;
try {
const mod = await import('https://esm.sh/avr8js@0.21.0');
let avrInstruction;
({ CPU, AVRIOPort, AVRUSART, AVRTimer, AVRTWI,
portAConfig, portBConfig, portCConfig, portDConfig,
timer0Config, timer1Config, timer2Config, twiConfig,
avrInstruction } = mod);
if (typeof avrInstruction !== 'function') throw new Error('avrInstruction not exported');
window._avrInstruction = avrInstruction;
avr8jsReady = true;
} catch(e) {
showOverlayError('avr8js 로드 실패\n' + e.message);
}
// ── Intel HEX parser ─────────────────────────────────────────────────────────
function parseIntelHex(hex) {
const flash = new Uint8Array(0x8000);
for (const line of hex.split('\n')) {
if (!line.startsWith(':')) continue;
const bytes = line.slice(1).match(/.{2}/g).map(h => parseInt(h, 16));
const count = bytes[0], addr = (bytes[1] << 8) | bytes[2], type = bytes[3];
if (type === 0) for (let i = 0; i < count; i++) flash[addr + i] = bytes[4 + i];
}
return flash;
}
// ── LCD emulator (HD44780 via PCF8574 I2C expander) ─────────────────────────
// PCF8574 bit mapping used by LiquidCrystal_I2C:
// bit0=RS, bit1=RW, bit2=EN, bit3=BL, bit4-7=D4-D7
const LCD_RS = 0, LCD_RW = 1, LCD_EN = 2, LCD_BL = 3;
const LCD_ADDRS = [0x27, 0x3F]; // common I2C addresses
const lcd = {
cols: 16, rows: 2,
ddram: new Uint8Array(128), // HD44780 DDRAM
cgram: new Uint8Array(64), // CGRAM (custom chars)
cursor: 0, displayOn: false,
backlight: false, entryInc: true,
rowOffsets: [0x00, 0x40, 0x10, 0x50],
active: false,
// I2C receive state
_nibbleHigh: null, _lastRS: 0, _inCGRAM: false, _cgramAddr: 0,
};
function lcdReset() {
lcd.ddram.fill(0x20); // space
lcd.cgram.fill(0);
lcd.cursor = 0; lcd.displayOn = false; lcd.backlight = false;
lcd.entryInc = true; lcd._nibbleHigh = null; lcd._inCGRAM = false; lcd._cgramAddr = 0;
lcd.active = false;
document.getElementById('lcd-section').style.display = 'none';
}
function lcdRenderDOM() {
const section = document.getElementById('lcd-section');
const wrap = document.getElementById('lcd-wrap');
const disp = document.getElementById('lcd-display');
section.style.display = 'block';
document.getElementById('lcd-size-label').textContent = `${lcd.cols}×${lcd.rows}`;
document.getElementById('lcd-backlight-dot').style.color = lcd.backlight ? '#fbbf24' : '#333';
wrap.className = 'lcd-wrap' + (lcd.backlight ? '' : ' lcd-off');
let html = '';
for (let r = 0; r < lcd.rows; r++) {
html += '<div class="lcd-row">';
const base = lcd.rowOffsets[r];
for (let c = 0; c < lcd.cols; c++) {
const ch = lcd.ddram[base + c];
const glyph = ch >= 0x20 ? String.fromCharCode(ch) : ' ';
html += `<div class="lcd-cell lit">${glyph === ' ' ? '&nbsp;' : glyph}</div>`;
}
html += '</div>';
}
disp.innerHTML = html;
}
function lcdExecuteCommand(rs, data) {
if (rs === 0) {
// Instruction
if (data & 0x80) { // Set DDRAM address
lcd.cursor = data & 0x7F;
lcd._inCGRAM = false;
return;
}
if (data & 0x40) { // Set CGRAM address
lcd._cgramAddr = data & 0x3F;
lcd._inCGRAM = true;
return;
}
if (data & 0x20) { // Function set
const lines = (data >> 3) & 1;
if (lines) lcd.rows = 2; else lcd.rows = 1;
return;
}
if (data & 0x10) return; // Cursor/display shift — ignore
if (data & 0x08) { // Display on/off
lcd.displayOn = !!(data & 0x04);
lcdRenderDOM();
return;
}
if (data & 0x04) { // Entry mode
lcd.entryInc = !!(data & 0x02);
return;
}
if (data === 0x01) { // Clear display
lcd.ddram.fill(0x20);
lcd.cursor = 0;
lcdRenderDOM();
return;
}
if (data & 0x02) { // Return home
lcd.cursor = 0;
lcdRenderDOM();
return;
}
} else {
// Data write
if (lcd._inCGRAM) {
lcd.cgram[lcd._cgramAddr & 0x3F] = data;
lcd._cgramAddr = (lcd._cgramAddr + 1) & 0x3F;
} else {
lcd.ddram[lcd.cursor & 0x7F] = data;
lcd.cursor = lcd.entryInc ? (lcd.cursor + 1) & 0x7F : (lcd.cursor - 1) & 0x7F;
lcdRenderDOM();
}
}
}
function lcdOnI2CByte(byte) {
// PCF8574 sends nibbles with EN strobe:
// first write: high nibble (EN=1), second write: high nibble (EN=0)
// then: low nibble (EN=1), fourth write: low nibble (EN=0)
const en = (byte >> LCD_EN) & 1;
const rs = (byte >> LCD_RS) & 1;
const bl = (byte >> LCD_BL) & 1;
const nibble = (byte >> 4) & 0x0F;
lcd.backlight = !!bl;
lcd.active = true;
if (en) {
if (lcd._nibbleHigh === null) {
lcd._nibbleHigh = nibble;
lcd._lastRS = rs;
} else {
const full = (lcd._nibbleHigh << 4) | nibble;
lcd._nibbleHigh = null;
lcdExecuteCommand(lcd._lastRS, full);
}
}
}
// ── SSD1306 OLED emulator ─────────────────────────────────────────────────────
// I2C addresses: 0x3C (SA0=0) or 0x3D (SA0=1)
const OLED_ADDRS = [0x3C, 0x3D];
const oled = {
gddram: new Uint8Array(128 * 8), // 128 cols × 8 pages (each page = 8 rows)
displayOn: false, inverse: false,
addrMode: 0x02, // 0=horizontal, 1=vertical, 2=page (default)
col: 0, page: 0,
colStart: 0, colEnd: 127,
pageStart: 0, pageEnd: 7,
// I2C parse state
_ctrlByte: null, _active: false,
};
let oledCtx = null;
let oledDirty = false;
function oledSetActive(on) {
const s = document.getElementById('oled-section');
if (s) s.style.display = on ? '' : 'none';
document.getElementById('oled-dot').className = 'oled-dot' + (on ? ' on' : '');
}
function oledReset() {
oled.gddram.fill(0);
oled.displayOn = false; oled.inverse = false; oled.addrMode = 0x02;
oled.col = 0; oled.page = 0;
oled.colStart = 0; oled.colEnd = 127;
oled.pageStart = 0; oled.pageEnd = 7;
oled._ctrlByte = null; oled._active = false;
oledDirty = false;
oledSetActive(false);
}
function oledFlushCanvas() {
if (!oledCtx || !oledDirty) return;
oledDirty = false;
// 2x 렌더링: 캔버스 256×128, 논리 픽셀 1개 = 2×2 물리 픽셀
const img = oledCtx.createImageData(256, 128);
for (let page = 0; page < 8; page++) {
for (let col = 0; col < 128; col++) {
const byte = oled.gddram[page * 128 + col];
for (let bit = 0; bit < 8; bit++) {
const on = (byte >> bit) & 1;
const row = page * 8 + bit;
const pr = on ? (oled.inverse ? 0xff : 0x60) : 0;
const pg = on ? (oled.inverse ? 0xff : 0xa5) : 0;
const pb = on ? (oled.inverse ? 0xff : 0xfa) : 0;
for (let dy = 0; dy < 2; dy++) {
for (let dx = 0; dx < 2; dx++) {
const idx = ((row * 2 + dy) * 256 + (col * 2 + dx)) * 4;
img.data[idx] = pr;
img.data[idx + 1] = pg;
img.data[idx + 2] = pb;
img.data[idx + 3] = 255;
}
}
}
}
}
oledCtx.putImageData(img, 0, 0);
}
// ── TFT LCD emulator (ST7735 / ILI9341 · hardware SPI) ───────────────────────
// Pins: CS=D10(PB2), DC=D9(PB1), RST=D8(PB0), MOSI=D11, SCK=D13
// Schematic-detected pins (updated from circuit wiring, default=UNO hardware SPI)
let _tftCsGpio = 10, _tftDcGpio = 9;
let tftCtx = null;
const tft = {
cs:true, dc:true,
pendingCmd:-1, pendingArgs:[], pendingNeed:0,
inRamWr:false, pixelByte:0, pixelByteIdx:0,
xs:0, xe:127, ys:0, ye:159, curX:0, curY:0,
w:128, h:160,
canonW:128, canonH:160, // portrait(MV=0) 기준 물리 치수 — MV 전환 시 flip 없이 유지
framebuf:null,
dirty:false, active:false, _sizeReady:false,
madctl:0, inverse:false,
};
// arg byte counts for common init commands
const _TFT_ARGC = {
0x26:1,0x36:1,0x3A:1,0x44:2,0x2A:4,0x2B:4,
0xB0:1,0xB1:3,0xB2:5,0xB3:6,0xB4:1,0xB5:4,0xB6:3,0xB7:1,
0xBB:1,0xBD:1,0xBE:1,
0xC0:1,0xC1:1,0xC2:2,0xC3:1,0xC4:1,0xC5:1,0xC6:1,0xC7:1,
0xD0:2,0xD3:3,
0xE0:14,0xE1:14,0xE2:12,0xE3:4,
0xF0:5,0xF1:5,0xF2:5,0xF3:5,
};
function tftReset() {
Object.assign(tft, {
cs:true, dc:true, pendingCmd:-1, pendingArgs:[], pendingNeed:0,
inRamWr:false, pixelByteIdx:0,
xs:0, xe:127, ys:0, ye:159, curX:0, curY:0,
w:128, h:160, canonW:128, canonH:160,
framebuf: new Uint16Array(480*480),
dirty:false, active:false, _sizeReady:false, madctl:0, inverse:false,
});
const s = document.getElementById('tft-section');
if (s) s.style.display = 'none';
const dot = document.getElementById('tft-dot');
if (dot) dot.classList.remove('on');
const c = document.getElementById('tft-canvas');
if (c) { c.width=128; c.height=160; tftCtx=c.getContext('2d'); tftCtx.fillStyle='#000'; tftCtx.fillRect(0,0,128,160); }
}
function _tftShowSection() {
const s = document.getElementById('tft-section');
if (s && s.style.display==='none') {
s.style.display='';
document.getElementById('tft-dot').classList.add('on');
document.getElementById('tft-label').textContent = `TFT LCD ${tft.w}×${tft.h}`;
}
}
function _tftResizeCanvas() {
const c = document.getElementById('tft-canvas');
if (!c) return;
c.width=tft.w; c.height=tft.h;
tftCtx = c.getContext('2d');
const lbl = document.getElementById('tft-label');
if (lbl) lbl.textContent = `TFT LCD ${tft.w}×${tft.h}`;
}
function _tftCmd(cmd, args) {
switch(cmd) {
case 0x01: // SWRESET — 전체 상태 초기화, 다음 CASET/RASET/RAMWR 에서 크기 재탐색
tft.framebuf.fill(0); tft.dirty=true;
tft._sizeReady=false; tft.canonW=0; tft.canonH=0; tft.madctl=0;
break;
case 0x11: case 0x13: case 0x29: // SLPOUT / NORON / DISPON
tft.active=true; _tftShowSection(); break;
case 0x20: tft.inverse=false; tft.dirty=true; break;
case 0x21: tft.inverse=true; tft.dirty=true; break;
case 0x2A: { // CASET — 크기 미확정 시에만 canon 업데이트, 최대 480 제한
tft.xs=(args[0]<<8)|args[1]; tft.xe=(args[2]<<8)|args[3]; tft.curX=tft.xs;
if (!tft._sizeReady) {
const mv=(tft.madctl>>5)&1, d=Math.min(tft.xe+1, 480);
if (mv) { if(d>tft.canonH) tft.canonH=d; }
else { if(d>tft.canonW) tft.canonW=d; }
}
break;
}
case 0x2B: { // RASET — 크기 미확정 시에만 canon 업데이트, 최대 480 제한
tft.ys=(args[0]<<8)|args[1]; tft.ye=(args[2]<<8)|args[3]; tft.curY=tft.ys;
if (!tft._sizeReady) {
const mv=(tft.madctl>>5)&1, d=Math.min(tft.ye+1, 480);
if (mv) { if(d>tft.canonW) tft.canonW=d; }
else { if(d>tft.canonH) tft.canonH=d; }
}
break;
}
case 0x2C: { // RAMWR — 크기 미확정 시 한 번만 canvas 확정
tft.inRamWr=true; tft.pixelByteIdx=0;
tft.curX=tft.xs; tft.curY=tft.ys;
tft.active=true;
if (!tft._sizeReady) {
const mv=(tft.madctl>>5)&1;
tft.w=mv?tft.canonH:tft.canonW;
tft.h=mv?tft.canonW:tft.canonH;
tft._sizeReady=true;
_tftResizeCanvas();
}
_tftShowSection();
break;
}
case 0x36: { // MADCTL — MV 변경 시 w/h 만 swap (canonW/canonH 는 유지)
const prevMv=(tft.madctl>>5)&1, newMv=(args[0]>>5)&1;
tft.madctl=args[0];
if (prevMv!==newMv && tft._sizeReady) {
// canon 은 유지한 채 w/h 만 swap
const tmp=tft.w; tft.w=tft.h; tft.h=tmp;
_tftResizeCanvas();
}
break;
}
}
}
function tftSPIByte(byte) {
if (!tft.dc) {
// Command byte — cancel any ongoing RAMWR / arg collection
tft.inRamWr=false; tft.pixelByteIdx=0;
const n = _TFT_ARGC[byte]??0;
if (n===0) { _tftCmd(byte,[]); tft.pendingCmd=-1; tft.pendingNeed=0; }
else { tft.pendingCmd=byte; tft.pendingArgs=[]; tft.pendingNeed=n; }
} else {
// Data byte
if (tft.pendingNeed>0) {
tft.pendingArgs.push(byte);
if (--tft.pendingNeed===0) { _tftCmd(tft.pendingCmd,tft.pendingArgs); tft.pendingCmd=-1; tft.pendingArgs=[]; }
return;
}
if (!tft.inRamWr) return;
// RGB565 pixel data (big-endian, 2 bytes per pixel)
if (tft.pixelByteIdx===0) { tft.pixelByte=byte; tft.pixelByteIdx=1; }
else {
tft.pixelByteIdx=0;
let c = (tft.pixelByte<<8)|byte;
if (tft.inverse) c ^= 0xFFFF;
const xi=tft.curX, yi=tft.curY;
if (xi<480 && yi<480) { tft.framebuf[yi*480+xi]=c; tft.dirty=true; }
if (++tft.curX>tft.xe){ tft.curX=tft.xs; if(++tft.curY>tft.ye) tft.curY=tft.ys; }
}
}
}
function tftFlushCanvas() {
if (!tftCtx||!tft.dirty||!tft.active) return;
tft.dirty=false;
const {w,h}=tft, bgr=(tft.madctl>>3)&1;
const img=tftCtx.createImageData(w,h);
for (let y=0;y<h;y++) for (let x=0;x<w;x++) {
const c=tft.framebuf[y*480+x];
let r5=(c>>11)&0x1F, g6=(c>>5)&0x3F, b5=c&0x1F;
if (bgr){ const t=r5; r5=b5; b5=t; }
const i=(y*w+x)*4;
img.data[i] =(r5<<3)|(r5>>2);
img.data[i+1]=(g6<<2)|(g6>>4);
img.data[i+2]=(b5<<3)|(b5>>2);
img.data[i+3]=255;
}
tftCtx.putImageData(img,0,0);
_schTftMarkDirty();
}
// AVR 핀 전압 읽기 (gpio 번호 → boolean)
function _avrPinHigh(gpio) {
if (!portD || !portB || !portC) return false;
if (gpio >= 0 && gpio < 8) return !!pinVoltage(portD, gpio);
if (gpio >= 8 && gpio < 14) return !!pinVoltage(portB, gpio - 8);
if (gpio >= 14 && gpio < 20) return !!pinVoltage(portC, gpio - 14);
return false;
}
// CS/DC 핀 상태 업데이트 (schematic 결선 기반 동적 핀 사용)
function _tftUpdateCsDc() {
const newCs = _avrPinHigh(_tftCsGpio);
const newDc = _avrPinHigh(_tftDcGpio);
if (!tft.cs && newCs) { tft.inRamWr = false; tft.pendingNeed = 0; tft.pendingArgs = []; }
tft.cs = newCs;
tft.dc = newDc;
}
function setupSPI(cpu) {
// Intercept SPDR writes (data addr 0x4E) — simulate instant SPI transfer
if (cpu.writeHooks) {
cpu.writeHooks[0x4E] = (value) => {
cpu.data[0x4D] |= 0x80; // set SPIF (transfer complete)
if (!tft.cs) tftSPIByte(value);
};
}
// Track CS/DC on all ports (pins resolved dynamically from _tftCsGpio/_tftDcGpio)
portB.addListener(() => _tftUpdateCsDc());
portD.addListener(() => _tftUpdateCsDc());
portC.addListener(() => _tftUpdateCsDc());
}
function oledWriteData(byte) {
oled.gddram[oled.page * 128 + oled.col] = byte;
oledDirty = true;
if (oled.addrMode === 0x00) { // horizontal
oled.col++;
if (oled.col > oled.colEnd) {
oled.col = oled.colStart;
oled.page++;
if (oled.page > oled.pageEnd) oled.page = oled.pageStart;
}
} else if (oled.addrMode === 0x01) { // vertical
oled.page++;
if (oled.page > oled.pageEnd) {
oled.page = oled.pageStart;
oled.col++;
if (oled.col > oled.colEnd) oled.col = oled.colStart;
}
} else { // page
oled.col = (oled.col + 1) & 0x7F;
}
}
function oledCommand(cmd, stream) {
// single-byte commands
if (cmd === 0xAE) { oled.displayOn = false; oledSetActive(false); return 0; }
if (cmd === 0xAF) {
oled.displayOn = true;
oled._active = true;
oledSetActive(true);
return 0;
}
if (cmd === 0xA6) { oled.inverse = false; oledDirty = true; return 0; }
if (cmd === 0xA7) { oled.inverse = true; oledDirty = true; return 0; }
if (cmd === 0x2E || cmd === 0x2F) return 0; // scroll stop/start — ignore
if ((cmd & 0xF0) === 0xB0) { oled.page = cmd & 0x07; return 0; } // set page (page mode)
if ((cmd & 0xF0) === 0x00) { oled.col = (oled.col & 0xF0) | (cmd & 0x0F); return 0; } // col low
if ((cmd & 0xF0) === 0x10) { oled.col = (oled.col & 0x0F) | ((cmd & 0x0F) << 4); return 0; } // col high
// two-byte commands (consume next byte from stream)
if (cmd === 0x81) return 1; // contrast — skip 1
if (cmd === 0xD5 || cmd === 0xD9 || cmd === 0xDA || cmd === 0xDB || cmd === 0xD3 || cmd === 0xA8) return 1;
if (cmd === 0x8D) return 1; // charge pump
// three-byte commands
if (cmd === 0x20) { // addressing mode
if (stream.length >= 1) { oled.addrMode = stream[0] & 0x03; return 1; }
return 1;
}
if (cmd === 0x21) { // set column address
if (stream.length >= 2) { oled.colStart = stream[0]; oled.colEnd = stream[1]; oled.col = oled.colStart; return 2; }
return 2;
}
if (cmd === 0x22) { // set page address
if (stream.length >= 2) { oled.pageStart = stream[0]; oled.pageEnd = stream[1]; oled.page = oled.pageStart; return 2; }
return 2;
}
if (cmd === 0x40) { oled.col = 0; oled.page = 0; return 0; } // display start line
return 0;
}
// I2C receive state for SSD1306
let _oledI2cBuf = [];
let _oledI2cMode = null; // null=await control byte, 'cmd'=command stream, 'data'=data stream
let _oledCmdPending = null; // [cmd, bytesLeft]
function oledOnI2CByte(byte) {
if (_oledI2cMode === null) {
// Control byte: Co=bit7, D/C#=bit6
// 0x00 = stream of commands, 0x40 = stream of data, 0x80 = single cmd, 0xC0 = single data
const co = (byte >> 7) & 1;
const dc = (byte >> 6) & 1;
_oledI2cMode = dc ? 'data' : 'cmd';
_oledCmdPending = null;
return;
}
if (_oledI2cMode === 'data') {
oledWriteData(byte);
return;
}
// command mode
if (_oledCmdPending) {
_oledCmdPending.args.push(byte);
_oledCmdPending.left--;
if (_oledCmdPending.left === 0) {
oledCommand(_oledCmdPending.cmd, _oledCmdPending.args);
_oledCmdPending = null;
}
return;
}
const skip = oledCommand(byte, []);
if (skip > 0) _oledCmdPending = { cmd: byte, left: skip, args: [] };
}
function oledOnI2CStart() {
_oledI2cMode = null;
_oledCmdPending = null;
}
function setupTWI(cpu) {
if (!AVRTWI || !twiConfig) return;
try {
const twi = new AVRTWI(cpu, twiConfig, 16e6);
let currentSlave = null;
twi.eventHandler = {
start(restart) { twi.completeStart(); },
stop() { twi.completeStop(); currentSlave = null; },
connectToSlave(addr, write) {
if (LCD_ADDRS.includes(addr)) { currentSlave = 'lcd'; twi.completeConnect(true); return; }
if (OLED_ADDRS.includes(addr)) { currentSlave = 'oled'; oledOnI2CStart(); twi.completeConnect(true); return; }
currentSlave = null; twi.completeConnect(false);
},
writeByte(byte) {
if (currentSlave === 'lcd') lcdOnI2CByte(byte);
if (currentSlave === 'oled') oledOnI2CByte(byte);
twi.completeWrite(true);
},
readByte(ack) { twi.completeRead(0xFF); },
};
} catch(e) {
console.warn('TWI init failed:', e.message);
}
}
// ── DHT Sensor Simulator (cycle-accurate via runFrame tick) ────────────────────
// NOTE: Cannot use cpu.addClockEvent because avr8js tick() overwrites
// nextClockEvent after callback, destroying events scheduled from callbacks.
const DHT_DEBUG = false; // Set true for cycle-level protocol logging
class DHTSimulator {
constructor(cpu, port, pinIndex, model, tempC, humidityPct) {
this.cpu = cpu;
this.port = port;
this.pinIndex = pinIndex;
this.model = model;
this.tempC = tempC;
this.humidityPct = humidityPct;
this.state = 'IDLE';
this.bitIndex = 0;
this.dataBytes = new Uint8Array(5);
this.startCycle = 0;
this.nextCycle = 0; // absolute CPU cycle for next state transition
this.portListener = null;
this._pinReadHook = null;
this._readLog = []; // debug: MCU pin reads during data phase
this._dataPhase = false;
this._attach();
}
_log(...args) { if (DHT_DEBUG) console.log(`[DHT:${this.pinIndex}]`, ...args); }
_attach() {
this.portListener = (newValue, oldValue) => this._onPortChange(newValue, oldValue);
this.port.addListener(this.portListener);
// Hook PIN register reads to track what MCU sees on DHT pin
const pinAddr = this.port.portConfig.PIN;
const bit = 1 << this.pinIndex;
const self = this;
const origReadHook = this.cpu.readHooks[pinAddr];
this.cpu.readHooks[pinAddr] = function(addr) {
const val = self.cpu.data[addr];
if (self._dataPhase) {
const pinVal = (val & bit) ? 1 : 0;
self._readLog.push({ c: self.cpu.cycles, v: pinVal, s: self.state });
}
if (origReadHook) return origReadHook(addr);
return val;
};
this._pinReadHook = { addr: pinAddr, origHook: origReadHook };
}
_onPortChange(newValue, oldValue) {
const bit = 1 << this.pinIndex;
const ddr = this.cpu.data[this.port.portConfig.DDR];
const portVal = this.cpu.data[this.port.portConfig.PORT];
const isOutput = !!(ddr & bit);
const isDrivenLow = isOutput && !(portVal & bit);
this._log(`portChange: state=${this.state} ddr=${ddr.toString(2).padStart(8,'0')} port=${portVal.toString(2).padStart(8,'0')} out=${isOutput} drivenLow=${isDrivenLow} cycles=${this.cpu.cycles}`);
// MCU pulls pin LOW (start of start signal)
if (this.state === 'IDLE' && isDrivenLow) {
this.state = 'MCU_START';
this.startCycle = this.cpu.cycles;
this._log(`MCU_START at cycle ${this.startCycle}`);
return;
}
// MCU releases pin after start signal
if (this.state === 'MCU_START' && !isDrivenLow) {
const duration = this.cpu.cycles - this.startCycle;
this._log(`MCU release: duration=${duration} cycles (${(duration/16).toFixed(1)}us)`);
if (duration >= 8000) { // ≥0.5ms (DHT11:18ms, DHT22:0.8ms)
this._beginResponse();
} else {
this.state = 'IDLE';
}
return;
}
// MCU forces pin LOW while DHT is responding → abort
if (this.state !== 'IDLE' && this.state !== 'MCU_START' && isDrivenLow) {
this._log(`ABORT: MCU drove LOW during ${this.state}`);
this.state = 'IDLE';
this.nextCycle = 0;
this._dataPhase = false;
}
}
_beginResponse() {
this._buildDataFrame();
this.bitIndex = 0;
this._readLog = [];
this._dataPhase = false;
const dataStr = Array.from(this.dataBytes).map(b => b.toString(16).padStart(2,'0')).join(' ');
this._log(`beginResponse: data=[${dataStr}] at cycle ${this.cpu.cycles}`);
// Release HIGH briefly (must be < 55us — MCU's delayMicroseconds(pullTime))
// then pull LOW for response. Real DHT responds within 20-30us.
this.port.setPin(this.pinIndex, 1);
this.state = 'RESPOND_WAIT';
this.nextCycle = this.cpu.cycles + 288; // 18us HIGH before pulling LOW
}
_respondLow() {
// DHT pulls LOW for ~80us
this.port.setPin(this.pinIndex, 0);
this.state = 'RESPOND_LOW';
this.nextCycle = this.cpu.cycles + 1280; // 80us
this._log(`respondLOW at cycle ${this.cpu.cycles}`);
}
_respondHigh() {
this.port.setPin(this.pinIndex, 1);
this.state = 'RESPOND_HIGH';
this.nextCycle = this.cpu.cycles + 1280; // 80us
this._log(`respondHIGH at cycle ${this.cpu.cycles}`);
}
_endFrame() {
// Dump read log for analysis
if (DHT_DEBUG && this._readLog.length > 0) {
const reads = this._readLog.slice(0, 200);
this._log(`MCU pin reads (${reads.length}):`, reads.map(r => `${r.c}:${r.v}`).join(' '));
}
this.port.setPin(this.pinIndex, 1);
this.state = 'IDLE';
this.nextCycle = 0;
this._dataPhase = false;
appendSerial(`[DHT] ${this.model}: 온도 ${this.tempC}°C, 습도 ${this.humidityPct}%\n`, 'sys');
}
_sendBitLow() {
if (this.bitIndex >= 40) {
// Real DHT sends a final 50µs LOW "end-of-frame" pulse so the MCU's
// last expectPulse(HIGH) sees the pin go LOW (not TIMEOUT).
this.port.setPin(this.pinIndex, 0);
this.state = 'END_LOW';
this.nextCycle = this.cpu.cycles + 800; // 50µs LOW
return;
}
// Enable data phase read tracking after response
if (!this._dataPhase && this.bitIndex === 0) this._dataPhase = true;
this.port.setPin(this.pinIndex, 0);
this.state = 'BIT_LOW';
// 55us LOW start-of-bit. Must be longer than bit0 HIGH (28us)
// so that the Adafruit DHT library's "highCycles > lowCycles"
// comparison works correctly:
// bit0: HIGH(28us) < LOW(55us) → 0
// bit1: HIGH(80us) > LOW(55us) → 1
this.nextCycle = this.cpu.cycles + 880; // 55us LOW
}
_sendBitValue() {
const byteIdx = Math.floor(this.bitIndex / 8);
const bitIdx = 7 - (this.bitIndex % 8);
const isOne = (this.dataBytes[byteIdx] >> bitIdx) & 1;
this.port.setPin(this.pinIndex, 1);
this.state = 'BIT_HIGH';
this.bitIndex++;
// bit0: 28us HIGH (< 55us LOW → highCycles < lowCycles → 0)
// bit1: 80us HIGH (> 55us LOW → highCycles > lowCycles → 1)
// Ratio: 28/55 ≈ 0.5 for 0, 80/55 ≈ 1.45 for 1 — clear separation
const highCycles = isOne ? 1280 : 448; // 80us for 1, 28us for 0
this.nextCycle = this.cpu.cycles + highCycles;
}
// Called from runFrame to advance state machine at precise cycle counts
tick() {
if (this.state === 'IDLE' || this.state === 'MCU_START' || this.nextCycle === 0) return;
if (this.cpu.cycles < this.nextCycle) return;
switch (this.state) {
case 'RESPOND_WAIT': this._respondLow(); break;
case 'RESPOND_LOW': this._respondHigh(); break;
case 'RESPOND_HIGH': this._sendBitLow(); break;
case 'BIT_LOW': this._sendBitValue(); break;
case 'BIT_HIGH': this._sendBitLow(); break;
case 'END_LOW': this._endFrame(); break;
}
}
_buildDataFrame() {
if (this.model === 'DHT11') {
const hInt = Math.round(this.humidityPct);
const tInt = Math.round(this.tempC);
this.dataBytes[0] = hInt & 0xFF;
this.dataBytes[1] = 0;
this.dataBytes[2] = tInt & 0xFF;
this.dataBytes[3] = 0;
this.dataBytes[4] = (this.dataBytes[0] + this.dataBytes[1] + this.dataBytes[2] + this.dataBytes[3]) & 0xFF;
} else { // DHT22
const hRaw = Math.round(this.humidityPct * 10);
const tRaw = Math.round(Math.abs(this.tempC) * 10);
const tSign = this.tempC < 0 ? 0x8000 : 0;
this.dataBytes[0] = (hRaw >> 8) & 0xFF;
this.dataBytes[1] = hRaw & 0xFF;
this.dataBytes[2] = ((tRaw >> 8) | (tSign >> 8)) & 0xFF;
this.dataBytes[3] = tRaw & 0xFF;
this.dataBytes[4] = (this.dataBytes[0] + this.dataBytes[1] + this.dataBytes[2] + this.dataBytes[3]) & 0xFF;
}
}
setValues(tempC, humidityPct) {
this.tempC = tempC;
this.humidityPct = humidityPct;
}
detach() {
if (this.portListener) {
if (typeof this.port.removeListener === 'function') {
this.port.removeListener(this.portListener);
}
this.portListener = null;
}
// Restore original PIN read hook
if (this._pinReadHook) {
const { addr, origHook } = this._pinReadHook;
if (origHook) this.cpu.readHooks[addr] = origHook;
else delete this.cpu.readHooks[addr];
this._pinReadHook = null;
}
try { this.port.setPin(this.pinIndex, 1); } catch(e) {}
this.state = 'IDLE';
this._dataPhase = false;
}
}
let dhtSensors = [];
function setupDHTSensors() {
for (const s of dhtSensors) s.detach();
dhtSensors = [];
if (!cpu || !portD || !portB || !portC) return;
const connections = detectDHTConnections();
for (const conn of connections) {
const sensor = new DHTSimulator(cpu, conn.port, conn.pinIndex, conn.model, 25, 50);
dhtSensors.push(sensor);
const vccStr = conn.vccOk ? '✓' : '✗';
const gndStr = conn.gndOk ? '✓' : '✗';
appendSerial(`[DHT] ${conn.model} 감지: ${conn.pinName} 핀 연결 (VCC:${vccStr} GND:${gndStr})\n`, 'sys');
}
updateDHTUI();
}
window.updateDHTValues = function updateDHTValues() {
const temp = parseFloat(document.getElementById('dht-temp').value);
const humid = parseFloat(document.getElementById('dht-humid').value);
document.getElementById('dht-temp-val').textContent = temp + '°C';
document.getElementById('dht-humid-val').textContent = humid + '%';
if (_IS_ESP32) { _esp32SendDHTValues(); return; }
if (_IS_ESP8266) { _esp8266SendDHTValues(); return; }
for (const sensor of dhtSensors) {
sensor.setValues(temp, humid);
}
}
// ── MPU-6050 / BME280 / DS18B20 / Relay / L298N shared state ───────────────
let _imuAddr = 0x68, _bmeAddr = 0x76, _owPin = -1;
let _motorPins = { enA:-1, in1:-1, in2:-1, in3:-1, in4:-1, enB:-1 };
let _wifiState = { connected: false, ssid: '', ip: '', rssi: -99 };
function _sensorReset() {
const ids = ['imu-section','bme-section','ds18b20-section','relay-section','motor-section'];
for (const id of ids) {
const el = document.getElementById(id);
if (el) el.style.display = 'none';
}
const relayRows = document.getElementById('relay-rows');
if (relayRows) relayRows.innerHTML = '';
const motorRows = document.getElementById('motor-rows');
if (motorRows) motorRows.innerHTML = '';
_imuAddr = 0x68; _bmeAddr = 0x76; _owPin = -1;
_motorPins = { enA:-1, in1:-1, in2:-1, in3:-1, in4:-1, enB:-1 };
_wifiReset();
}
function _wifiReset() {
_wifiState = { connected: false, ssid: '', ip: '', rssi: -99 };
const sec = document.getElementById('wifi-section');
if (sec) sec.style.display = 'none';
_wifiUpdateUI();
}
function _wifiUpdateUI() {
const dot = document.getElementById('wifi-dot');
const status = document.getElementById('wifi-status');
const ssid = document.getElementById('wifi-ssid');
const ip = document.getElementById('wifi-ip');
const rssi = document.getElementById('wifi-rssi');
const btn = document.getElementById('wifi-connect-btn');
if (dot) dot.classList.toggle('on', _wifiState.connected);
if (status) status.textContent = _wifiState.connected ? '연결됨' : '연결 안 됨';
if (ssid) ssid.textContent = _wifiState.ssid || '-';
if (ip) ip.textContent = _wifiState.ip || '-';
if (rssi) rssi.textContent = _wifiState.connected ? _wifiState.rssi + ' dBm' : '-';
if (btn) btn.classList.toggle('on', _wifiState.connected);
}
function _wifiShow() {
const sec = document.getElementById('wifi-section');
if (sec) sec.style.display = '';
_wifiUpdateUI();
}
function _wifiHandleEvent(msg) {
_wifiShow();
if (msg.ssid != null) _wifiState.ssid = msg.ssid;
if (msg.ip != null) _wifiState.ip = msg.ip;
if (msg.rssi != null) _wifiState.rssi = msg.rssi;
if (msg.connected != null) _wifiState.connected = msg.connected;
_wifiUpdateUI();
}
function _showHttpPortLink(port) {
_wifiShow();
const row = document.getElementById('wifi-http-row');
const link = document.getElementById('wifi-http-link');
if (!row || !link) return;
const url = 'http://' + location.hostname + ':' + port;
link.href = url;
link.textContent = url;
row.style.display = '';
appendSerial('\n[웹서버 실행 중: ' + url + ']\n', 'sys');
}
function _wifiConnectClicked() {
const ssid = document.getElementById('wifi-ssid-input').value.trim();
const pass = document.getElementById('wifi-pass-input').value;
if (!ssid) return;
if (_IS_ESP32 && _esp32SessionId) {
fetch('/api/arduino/qemu/' + _esp32SessionId + '/wifi', {
method: 'POST', headers: {'Content-Type':'application/json'}, credentials:'include',
body: JSON.stringify({ cmd: 'connect', ssid, pass })
}).catch(()=>{});
} else if (_IS_ESP8266 && _esp8266SessionId) {
fetch('/api/arduino/esp8266/' + _esp8266SessionId + '/wifi', {
method: 'POST', headers: {'Content-Type':'application/json'}, credentials:'include',
body: JSON.stringify({ cmd: 'connect', ssid, pass })
}).catch(()=>{});
}
}
function _wifiDisconnectClicked() {
_wifiState.connected = false;
_wifiUpdateUI();
if (_IS_ESP32 && _esp32SessionId) {
fetch('/api/arduino/qemu/' + _esp32SessionId + '/wifi', {
method: 'POST', headers: {'Content-Type':'application/json'}, credentials:'include',
body: JSON.stringify({ cmd: 'disconnect' })
}).catch(()=>{});
} else if (_IS_ESP8266 && _esp8266SessionId) {
fetch('/api/arduino/esp8266/' + _esp8266SessionId + '/wifi', {
method: 'POST', headers: {'Content-Type':'application/json'}, credentials:'include',
body: JSON.stringify({ cmd: 'disconnect' })
}).catch(()=>{});
}
}
function _showSection(id) {
const el = document.getElementById(id);
if (el) el.style.display = '';
}
window.updateIMUValues = function updateIMUValues() {
const ax = parseFloat(document.getElementById('imu-ax').value);
const ay = parseFloat(document.getElementById('imu-ay').value);
const az = parseFloat(document.getElementById('imu-az').value);
const gx = parseFloat(document.getElementById('imu-gx').value);
const gy = parseFloat(document.getElementById('imu-gy').value);
const gz = parseFloat(document.getElementById('imu-gz').value);
const temp = parseFloat(document.getElementById('imu-temp').value);
document.getElementById('imu-ax-val').textContent = ax.toFixed(2) + 'g';
document.getElementById('imu-ay-val').textContent = ay.toFixed(2) + 'g';
document.getElementById('imu-az-val').textContent = az.toFixed(2) + 'g';
document.getElementById('imu-gx-val').textContent = gx.toFixed(0) + '°/s';
document.getElementById('imu-gy-val').textContent = gy.toFixed(0) + '°/s';
document.getElementById('imu-gz-val').textContent = gz.toFixed(0) + '°/s';
document.getElementById('imu-temp-val').textContent = temp.toFixed(1) + '°C';
if (_IS_ESP32) _esp32SendIMUValues();
else if (_IS_ESP8266) _esp8266SendIMUValues();
}
function _esp32SendIMUValues() {
if (!_esp32SessionId) return;
const body = {
addr: _imuAddr,
ax: parseFloat(document.getElementById('imu-ax').value),
ay: parseFloat(document.getElementById('imu-ay').value),
az: parseFloat(document.getElementById('imu-az').value),
gx: parseFloat(document.getElementById('imu-gx').value),
gy: parseFloat(document.getElementById('imu-gy').value),
gz: parseFloat(document.getElementById('imu-gz').value),
temp: parseFloat(document.getElementById('imu-temp').value)
};
fetch('/api/arduino/qemu/' + _esp32SessionId + '/imu', { method:'POST', headers:{'Content-Type':'application/json'}, credentials:'include', body: JSON.stringify(body) }).catch(()=>{});
}
function _esp8266SendIMUValues() {
if (!_esp8266SessionId) return;
const body = {
addr: _imuAddr,
ax: parseFloat(document.getElementById('imu-ax').value),
ay: parseFloat(document.getElementById('imu-ay').value),
az: parseFloat(document.getElementById('imu-az').value),
gx: parseFloat(document.getElementById('imu-gx').value),
gy: parseFloat(document.getElementById('imu-gy').value),
gz: parseFloat(document.getElementById('imu-gz').value),
temp: parseFloat(document.getElementById('imu-temp').value)
};
fetch('/api/arduino/esp8266/' + _esp8266SessionId + '/imu', { method:'POST', headers:{'Content-Type':'application/json'}, credentials:'include', body: JSON.stringify(body) }).catch(()=>{});
}
function _imuAttach(sda, scl, addr) {
_imuAddr = addr;
_showSection('imu-section');
const info = document.getElementById('imu-info');
if (info) info.textContent = `SDA${sda} SCL${scl} 0x${addr.toString(16)}`;
}
function _imuUpdateValues(v) {
if (v.addr != null) _imuAddr = v.addr;
document.getElementById('imu-ax').value = v.ax ?? 0;
document.getElementById('imu-ay').value = v.ay ?? 0;
document.getElementById('imu-az').value = v.az ?? 1;
document.getElementById('imu-gx').value = v.gx ?? 0;
document.getElementById('imu-gy').value = v.gy ?? 0;
document.getElementById('imu-gz').value = v.gz ?? 0;
document.getElementById('imu-temp').value = v.temp ?? 25;
updateIMUValues();
}
window.updateBMEValues = function updateBMEValues() {
const t = parseFloat(document.getElementById('bme-t').value);
const h = parseFloat(document.getElementById('bme-h').value);
const p = parseFloat(document.getElementById('bme-p').value);
document.getElementById('bme-t-val').textContent = t.toFixed(1) + '°C';
document.getElementById('bme-h-val').textContent = h.toFixed(0) + '%';
document.getElementById('bme-p-val').textContent = p.toFixed(0) + 'hPa';
if (_IS_ESP32) _esp32SendBMEValues();
else if (_IS_ESP8266) _esp8266SendBMEValues();
}
function _esp32SendBMEValues() {
if (!_esp32SessionId) return;
const body = { addr: _bmeAddr, t: parseFloat(document.getElementById('bme-t').value), h: parseFloat(document.getElementById('bme-h').value), p: parseFloat(document.getElementById('bme-p').value) };
fetch('/api/arduino/qemu/' + _esp32SessionId + '/bme', { method:'POST', headers:{'Content-Type':'application/json'}, credentials:'include', body: JSON.stringify(body) }).catch(()=>{});
}
function _esp8266SendBMEValues() {
if (!_esp8266SessionId) return;
const body = { addr: _bmeAddr, t: parseFloat(document.getElementById('bme-t').value), h: parseFloat(document.getElementById('bme-h').value), p: parseFloat(document.getElementById('bme-p').value) };
fetch('/api/arduino/esp8266/' + _esp8266SessionId + '/bme', { method:'POST', headers:{'Content-Type':'application/json'}, credentials:'include', body: JSON.stringify(body) }).catch(()=>{});
}
function _bmeAttach(sda, scl, addr) {
_bmeAddr = addr;
_showSection('bme-section');
const info = document.getElementById('bme-info');
if (info) info.textContent = `SDA${sda} SCL${scl} 0x${addr.toString(16)}`;
}
function _bmeUpdateValues(v) {
if (v.addr != null) _bmeAddr = v.addr;
document.getElementById('bme-t').value = v.t ?? 25;
document.getElementById('bme-h').value = v.h ?? 50;
document.getElementById('bme-p').value = v.p ?? 1013;
updateBMEValues();
}
window.updateDS18B20Values = function updateDS18B20Values() {
const t = parseFloat(document.getElementById('ds18b20-t').value);
document.getElementById('ds18b20-t-val').textContent = t.toFixed(1) + '°C';
if (_IS_ESP32) _esp32SendDS18B20Values();
else if (_IS_ESP8266) _esp8266SendDS18B20Values();
}
function _esp32SendDS18B20Values() {
if (!_esp32SessionId || _owPin < 0) return;
const body = { pin: _owPin, t: parseFloat(document.getElementById('ds18b20-t').value) };
fetch('/api/arduino/qemu/' + _esp32SessionId + '/ds18b20', { method:'POST', headers:{'Content-Type':'application/json'}, credentials:'include', body: JSON.stringify(body) }).catch(()=>{});
}
function _esp8266SendDS18B20Values() {
if (!_esp8266SessionId || _owPin < 0) return;
const body = { pin: _owPin, t: parseFloat(document.getElementById('ds18b20-t').value) };
fetch('/api/arduino/esp8266/' + _esp8266SessionId + '/ds18b20', { method:'POST', headers:{'Content-Type':'application/json'}, credentials:'include', body: JSON.stringify(body) }).catch(()=>{});
}
function _ds18b20Attach(pin) {
_owPin = pin;
_showSection('ds18b20-section');
const info = document.getElementById('ds18b20-info');
if (info) info.textContent = `GPIO${pin}`;
if (_IS_ESP32) setTimeout(() => _esp32SendDS18B20Values(), 500);
else if (_IS_ESP8266) setTimeout(() => _esp8266SendDS18B20Values(), 500);
}
function _ds18b20UpdateValues(v) {
if (v.pin != null) _owPin = v.pin;
document.getElementById('ds18b20-t').value = v.t ?? 25;
updateDS18B20Values();
}
function _relayUpdate(pin, state) {
_showSection('relay-section');
const grid = document.getElementById('relay-rows');
if (!grid) return;
let row = document.getElementById('relay-row-' + pin);
if (!row) {
row = document.createElement('div');
row.className = 'relay-row'; row.id = 'relay-row-' + pin;
const lbl = document.createElement('span');
lbl.className = 'sensor-param'; lbl.textContent = 'P' + pin;
const btn = document.createElement('button');
btn.className = 'relay-btn'; btn.id = 'relay-btn-' + pin;
btn.textContent = state ? 'ON' : 'OFF';
btn.onclick = () => _relayToggle(pin);
row.appendChild(lbl); row.appendChild(btn);
grid.appendChild(row);
}
const btn = document.getElementById('relay-btn-' + pin);
if (btn) {
btn.textContent = state ? 'ON' : 'OFF';
btn.classList.toggle('on', !!state);
}
}
function _relayToggle(pin) {
const btn = document.getElementById('relay-btn-' + pin);
const state = btn && !btn.classList.contains('on');
if (_IS_ESP32 && _esp32SessionId) {
fetch('/api/arduino/qemu/' + _esp32SessionId + '/gpio', { method:'POST', headers:{'Content-Type':'application/json'}, credentials:'include', body: JSON.stringify({ pin, level: state ? 1 : 0 }) }).catch(()=>{});
} else if (_IS_ESP8266 && _esp8266SessionId) {
fetch('/api/arduino/esp8266/' + _esp8266SessionId + '/input', { method:'POST', headers:{'Content-Type':'application/json'}, credentials:'include', body: JSON.stringify({ text: `\x03BTN:${pin}:${state ? 1 : 0}\n` }) }).catch(()=>{});
}
}
function _motorAttach(pins) {
_motorPins = { ...pins };
_showSection('motor-section');
const rows = document.getElementById('motor-rows');
if (!rows) return;
rows.innerHTML = '';
const makeChannel = (ch, en, inA, inB) => {
const wrap = document.createElement('div'); wrap.className = 'motor-channel';
const lbl = document.createElement('div'); lbl.className = 'motor-label'; lbl.textContent = ch + (en >= 0 ? ` (EN${ch.slice(-1)}=P${en})` : '');
const speedRow = document.createElement('div'); speedRow.className = 'sensor-row';
const pLbl = document.createElement('span'); pLbl.className = 'sensor-param'; pLbl.textContent = '속도';
const slider = document.createElement('input'); slider.type = 'range'; slider.min = '0'; slider.max = '255'; slider.value = '0';
slider.id = 'motor-' + ch + '-speed';
slider.oninput = () => _motorSpeed(ch, parseInt(slider.value));
const val = document.createElement('span'); val.className = 'sensor-val'; val.id = 'motor-' + ch + '-val'; val.textContent = '0';
speedRow.appendChild(pLbl); speedRow.appendChild(slider); speedRow.appendChild(val);
const dirRow = document.createElement('div'); dirRow.className = 'motor-dir-row';
const fwd = document.createElement('button'); fwd.className = 'motor-dir-btn'; fwd.textContent = '정';
fwd.onclick = () => _motorDir(ch, 1);
const rev = document.createElement('button'); rev.className = 'motor-dir-btn'; rev.textContent = '역';
rev.onclick = () => _motorDir(ch, -1);
const stop = document.createElement('button'); stop.className = 'motor-dir-btn active'; stop.textContent = '정지';
stop.onclick = () => _motorDir(ch, 0);
dirRow.appendChild(fwd); dirRow.appendChild(stop); dirRow.appendChild(rev);
wrap.appendChild(lbl); wrap.appendChild(speedRow); wrap.appendChild(dirRow);
rows.appendChild(wrap);
};
if (pins.enA >= 0 && pins.in1 >= 0 && pins.in2 >= 0) makeChannel('A', pins.enA, pins.in1, pins.in2);
if (pins.enB >= 0 && pins.in3 >= 0 && pins.in4 >= 0) makeChannel('B', pins.enB, pins.in3, pins.in4);
}
function _motorSpeed(ch, speed) {
const val = document.getElementById('motor-' + ch + '-val');
if (val) val.textContent = speed;
if (ch === 'A') _motorPins.speedA = speed; else _motorPins.speedB = speed;
_motorEmit();
}
function _motorDir(ch, dir) {
// dir: 1=fwd, -1=rev, 0=stop
const wrap = document.getElementById('motor-' + ch + '-speed')?.closest('.motor-channel');
if (wrap) {
const btns = wrap.querySelectorAll('.motor-dir-btn');
btns.forEach((b, i) => b.classList.toggle('active', (dir === 1 && i === 0) || (dir === 0 && i === 1) || (dir === -1 && i === 2)));
}
if (ch === 'A') _motorPins.dirA = dir; else _motorPins.dirB = dir;
_motorEmit();
}
function _motorEmit() {
const p = _motorPins;
const in1 = p.dirA === 1 ? 1 : p.dirA === -1 ? 0 : 0;
const in2 = p.dirA === 1 ? 0 : p.dirA === -1 ? 1 : 0;
const in3 = p.dirB === 1 ? 1 : p.dirB === -1 ? 0 : 0;
const in4 = p.dirB === 1 ? 0 : p.dirB === -1 ? 1 : 0;
const body = { enA: p.speedA ?? 0, in1, in2, in3, in4, enB: p.speedB ?? 0 };
if (_IS_ESP32 && _esp32SessionId) {
fetch('/api/arduino/qemu/' + _esp32SessionId + '/motor', { method:'POST', headers:{'Content-Type':'application/json'}, credentials:'include', body: JSON.stringify(body) }).catch(()=>{});
} else if (_IS_ESP8266 && _esp8266SessionId) {
fetch('/api/arduino/esp8266/' + _esp8266SessionId + '/motor', { method:'POST', headers:{'Content-Type':'application/json'}, credentials:'include', body: JSON.stringify(body) }).catch(()=>{});
}
}
function _motorUpdateState(pins) {
// Optional: reflect state from emulator back to UI
}
// ── Servo display ──────────────────────────────────────────────────────────
const _servoAngles = new Map(); // pin → current angle
function _drawServoArc(canvas, angle) {
const ctx = canvas.getContext('2d');
const W = canvas.width, H = canvas.height;
ctx.clearRect(0, 0, W, H);
const cx = W / 2, cy = H - 3, r = H - 7;
ctx.beginPath(); ctx.arc(cx, cy, r, Math.PI, 0, false);
ctx.strokeStyle = '#334155'; ctx.lineWidth = 3; ctx.stroke();
const rad = Math.PI * (1 - angle / 180);
ctx.beginPath(); ctx.moveTo(cx, cy);
ctx.lineTo(cx + Math.cos(rad) * r, cy - Math.sin(rad) * r);
ctx.strokeStyle = '#dc2626'; ctx.lineWidth = 2; ctx.stroke();
ctx.beginPath(); ctx.arc(cx, cy, 3, 0, Math.PI * 2);
ctx.fillStyle = '#dc2626'; ctx.fill();
}
function _servoAttach(pin) {
const sec = document.getElementById('servo-section');
const rows = document.getElementById('servo-rows');
if (!sec || !rows) return;
sec.style.display = '';
if (document.getElementById('servo-row-' + pin)) return;
_servoAngles.set(pin, 90);
const row = document.createElement('div');
row.className = 'servo-row'; row.id = 'servo-row-' + pin;
const lbl = document.createElement('span'); lbl.className = 'servo-pin-label'; lbl.textContent = 'P' + pin;
const cv = document.createElement('canvas'); cv.width = 60; cv.height = 34;
cv.className = 'servo-arc-canvas'; cv.id = 'servo-arc-' + pin;
const val = document.createElement('span'); val.className = 'servo-angle-val'; val.id = 'servo-val-' + pin; val.textContent = '90°';
row.appendChild(lbl); row.appendChild(cv); row.appendChild(val);
rows.appendChild(row);
_drawServoArc(cv, 90);
}
function _servoUpdateAngle(pin, angle) {
_servoAngles.set(pin, angle);
const val = document.getElementById('servo-val-' + pin);
if (val) val.textContent = angle + '°';
const cv = document.getElementById('servo-arc-' + pin);
if (cv) _drawServoArc(cv, angle);
}
function _servoReset() {
const sec = document.getElementById('servo-section');
const rows = document.getElementById('servo-rows');
if (sec) sec.style.display = 'none';
if (rows) rows.innerHTML = '';
_servoAngles.clear();
}
function updateDHTUI() {
const section = document.getElementById('dht-section');
if (!section) return;
if (dhtSensors.length === 0) { section.style.display = 'none'; return; }
section.style.display = '';
const dot = document.getElementById('dht-dot');
const label = document.getElementById('dht-label');
if (dot) dot.className = 'dht-dot on';
if (label) label.textContent = dhtSensors[0].model + ' 온습도 센서';
const tempInput = document.getElementById('dht-temp');
if (tempInput && dhtSensors[0].model === 'DHT11') { tempInput.min = 0; tempInput.max = 50; }
else if (tempInput) { tempInput.min = -40; tempInput.max = 80; }
}
// ── ESP32 QEMU mode ───────────────────────────────────────────────────────────
let _IS_ESP32 = false;
let _esp32SessionId = null;
let _esp32EvtSource = null;
let _esp32PinStates = {}; // {pin: 0/1}
let _esp32PinModes = {}; // {pin: 'O'/'I'}
let _esp32BootDone = false;
let _esp32SketchCode = ''; // current sketch code for re-compile
let _esp32SyncBusy = false;
const _esp32CodeChannel = new BroadcastChannel('esp32_code_sync');
_esp32CodeChannel.onmessage = (e) => {
if (e.data?.type !== 'code') return;
_esp32SyncBusy = true;
_setEditorCode(e.data.code);
_esp32SyncBusy = false;
};
let _esp32PtyBuf = '';
let _esp32DhtPins = []; // [{gpio, model}] — DHT sensors detected in schematic
const _ESP32_LEFT_PINS = [13, 12, 14, 27, 26, 25, 33, 32, 35, 34, 39, 36, null, null, null];
const _ESP32_RIGHT_PINS = [15, 2, 4, 16, 17, 5, 18, 19, 21, 3, 1, 22, 23, null, null];
const _ESP32_BOARD_SVG = `<svg id="board-svg" viewBox="0 0 90 188" xmlns="http://www.w3.org/2000/svg">
<!-- PCB: medium blue — readable contrast -->
<rect x="1" y="1" width="88" height="186" rx="4" fill="#1a3060" stroke="#4070c0" stroke-width="1.2"/>
<!-- USB micro-B connector -->
<rect x="29" y="0" width="32" height="9" rx="2" fill="#aaa" stroke="#ddd" stroke-width=".5"/>
<rect x="32" y="1.5" width="26" height="5" rx="1" fill="#666"/>
<text x="45" y="7" text-anchor="middle" fill="#eee" font-size="3.2" font-family="monospace">USB</text>
<!-- ESP32-WROOM module -->
<rect x="7" y="11" width="76" height="50" rx="3" fill="#2a2a2a" stroke="#777" stroke-width="1"/>
<!-- Antenna area -->
<rect x="64" y="11" width="19" height="20" rx="2" fill="#1e1e1e" stroke="#555" stroke-width=".5"/>
<line x1="64" y1="14" x2="83" y2="14" stroke="#555" stroke-width=".5"/>
<line x1="64" y1="18" x2="83" y2="18" stroke="#444" stroke-width=".4"/>
<line x1="64" y1="22" x2="83" y2="22" stroke="#444" stroke-width=".4"/>
<line x1="64" y1="26" x2="83" y2="26" stroke="#444" stroke-width=".4"/>
<!-- ESP32 chip -->
<rect x="18" y="20" width="44" height="32" rx="2" fill="#111" stroke="#666" stroke-width=".8"/>
<ellipse cx="40" cy="20" rx="3" ry="1.5" fill="#444"/>
<text x="40" y="33" text-anchor="middle" fill="#bbb" font-size="6" font-family="monospace">ESP32</text>
<text x="40" y="41" text-anchor="middle" fill="#888" font-size="4" font-family="monospace">WROOM-32</text>
<!-- PWR LED (blue, always on when session active) -->
<circle id="esp32-pwr-led" cx="11" cy="15" r="2.5" fill="#1a3a7a" stroke="#4a8aee" stroke-width=".7"/>
<text x="11" y="11" text-anchor="middle" fill="#88bbff" font-size="3.2" font-family="monospace">PWR</text>
<!-- TX LED (yellow, blinks on send) -->
<circle id="esp32-tx-led" cx="79" cy="15" r="2.5" fill="#2a2200" stroke="#665500" stroke-width=".7"/>
<text x="79" y="11" text-anchor="middle" fill="#ccaa44" font-size="3.2" font-family="monospace">TX</text>
<!-- RX LED (green, blinks on receive) -->
<circle id="esp32-rx-led" cx="79" cy="25" r="2.5" fill="#0a2a0a" stroke="#1a5a1a" stroke-width=".7"/>
<text x="79" y="21" text-anchor="middle" fill="#44aa44" font-size="3.2" font-family="monospace">RX</text>
<!-- D2 built-in LED (GPIO2) -->
<circle id="esp32-led-2" cx="11" cy="25" r="2.5" fill="#0a1a3a" stroke="#2a4a7a" stroke-width=".7"/>
<text x="11" y="21" text-anchor="middle" fill="#6699cc" font-size="3.2" font-family="monospace">D2</text>
<!-- RST button — red, clickable -->
<g id="esp32-rst-btn" style="cursor:pointer">
<circle cx="11" cy="56" r="4.5" fill="#7a1a1a" stroke="#ee3333" stroke-width=".9"/>
<text x="11" y="52.5" text-anchor="middle" fill="#ffcccc" font-size="3.2" font-family="monospace">RST</text>
<text x="11" y="64.5" text-anchor="middle" fill="#ee8888" font-size="2.8" font-family="monospace">EN</text>
</g>
<!-- Power ON/OFF button -->
<g id="esp32-pwr-btn" style="cursor:pointer">
<circle id="esp32-pwr-btn-bg" cx="79" cy="56" r="4.5" fill="#1a4a1a" stroke="#44cc44" stroke-width=".9"/>
<text x="79" y="52.5" text-anchor="middle" fill="#aaffaa" font-size="3.2" font-family="monospace">PWR</text>
<text id="esp32-pwr-btn-lbl" x="79" y="64.5" text-anchor="middle" fill="#88ee88" font-size="2.8" font-family="monospace">ON</text>
</g>
<!-- Left pin rail -->
<rect x="1.5" y="66" width="5" height="114" rx="1" fill="#0e1e40" stroke="#4060a0" stroke-width=".5"/>
<!-- Right pin rail -->
<rect x="83.5" y="66" width="5" height="114" rx="1" fill="#0e1e40" stroke="#4060a0" stroke-width=".5"/>
<g id="left-pins">
<circle id="elp0" cx="4" cy="68" r="3.2" fill="#182848" stroke="#5080c0" stroke-width=".7"/><text x="10" y="70.5" fill="#99bbee" font-size="3.8" font-family="monospace">13</text>
<circle id="elp1" cx="4" cy="75.5" r="3.2" fill="#182848" stroke="#5080c0" stroke-width=".7"/><text x="10" y="78" fill="#99bbee" font-size="3.8" font-family="monospace">12</text>
<circle id="elp2" cx="4" cy="83" r="3.2" fill="#182848" stroke="#5080c0" stroke-width=".7"/><text x="10" y="85.5" fill="#99bbee" font-size="3.8" font-family="monospace">14</text>
<circle id="elp3" cx="4" cy="90.5" r="3.2" fill="#182848" stroke="#5080c0" stroke-width=".7"/><text x="10" y="93" fill="#99bbee" font-size="3.8" font-family="monospace">27</text>
<circle id="elp4" cx="4" cy="98" r="3.2" fill="#182848" stroke="#5080c0" stroke-width=".7"/><text x="10" y="100.5" fill="#99bbee" font-size="3.8" font-family="monospace">26</text>
<circle id="elp5" cx="4" cy="105.5" r="3.2" fill="#182848" stroke="#5080c0" stroke-width=".7"/><text x="10" y="108" fill="#99bbee" font-size="3.8" font-family="monospace">25</text>
<circle id="elp6" cx="4" cy="113" r="3.2" fill="#182848" stroke="#5080c0" stroke-width=".7"/><text x="10" y="115.5" fill="#99bbee" font-size="3.8" font-family="monospace">33</text>
<circle id="elp7" cx="4" cy="120.5" r="3.2" fill="#182848" stroke="#5080c0" stroke-width=".7"/><text x="10" y="123" fill="#99bbee" font-size="3.8" font-family="monospace">32</text>
<circle id="elp8" cx="4" cy="128" r="3.2" fill="#182848" stroke="#5080c0" stroke-width=".7"/><text x="10" y="130.5" fill="#99bbee" font-size="3.8" font-family="monospace">35</text>
<circle id="elp9" cx="4" cy="135.5" r="3.2" fill="#182848" stroke="#5080c0" stroke-width=".7"/><text x="10" y="138" fill="#99bbee" font-size="3.8" font-family="monospace">34</text>
<circle id="elp10" cx="4" cy="143" r="3.2" fill="#182848" stroke="#5080c0" stroke-width=".7"/><text x="10" y="145.5" fill="#99bbee" font-size="3.8" font-family="monospace">39</text>
<circle id="elp11" cx="4" cy="150.5" r="3.2" fill="#182848" stroke="#5080c0" stroke-width=".7"/><text x="10" y="153" fill="#99bbee" font-size="3.8" font-family="monospace">36</text>
<circle cx="4" cy="158" r="2.4" fill="#3a2000" stroke="#cc7700" stroke-width=".7"/><text x="10" y="160.5" fill="#ffcc66" font-size="3.8" font-family="monospace">EN</text>
<circle cx="4" cy="165.5" r="2.4" fill="#1a1a1a" stroke="#666" stroke-width=".7"/><text x="10" y="168" fill="#aaaaaa" font-size="3.8" font-family="monospace">G</text>
<circle cx="4" cy="173" r="2.4" fill="#3a2800" stroke="#cc9900" stroke-width=".7"/><text x="10" y="175.5" fill="#ffdd88" font-size="3.8" font-family="monospace">VIN</text>
</g>
<g id="right-pins">
<circle id="erp0" cx="86" cy="68" r="3.2" fill="#182848" stroke="#5080c0" stroke-width=".7"/><text x="80" y="70.5" fill="#99bbee" font-size="3.8" font-family="monospace" text-anchor="end">15</text>
<circle id="erp1" cx="86" cy="75.5" r="3.2" fill="#182848" stroke="#5080c0" stroke-width=".7"/><text x="80" y="78" fill="#88ccff" font-size="3.8" font-family="monospace" text-anchor="end">2★</text>
<circle id="erp2" cx="86" cy="83" r="3.2" fill="#182848" stroke="#5080c0" stroke-width=".7"/><text x="80" y="85.5" fill="#99bbee" font-size="3.8" font-family="monospace" text-anchor="end">4</text>
<circle id="erp3" cx="86" cy="90.5" r="3.2" fill="#182848" stroke="#5080c0" stroke-width=".7"/><text x="80" y="93" fill="#99bbee" font-size="3.8" font-family="monospace" text-anchor="end">RX2</text>
<circle id="erp4" cx="86" cy="98" r="3.2" fill="#182848" stroke="#5080c0" stroke-width=".7"/><text x="80" y="100.5" fill="#99bbee" font-size="3.8" font-family="monospace" text-anchor="end">TX2</text>
<circle id="erp5" cx="86" cy="105.5" r="3.2" fill="#182848" stroke="#5080c0" stroke-width=".7"/><text x="80" y="108" fill="#99bbee" font-size="3.8" font-family="monospace" text-anchor="end">5</text>
<circle id="erp6" cx="86" cy="113" r="3.2" fill="#182848" stroke="#5080c0" stroke-width=".7"/><text x="80" y="115.5" fill="#99bbee" font-size="3.8" font-family="monospace" text-anchor="end">18</text>
<circle id="erp7" cx="86" cy="120.5" r="3.2" fill="#182848" stroke="#5080c0" stroke-width=".7"/><text x="80" y="123" fill="#99bbee" font-size="3.8" font-family="monospace" text-anchor="end">19</text>
<circle id="erp8" cx="86" cy="128" r="3.2" fill="#182848" stroke="#5080c0" stroke-width=".7"/><text x="80" y="130.5" fill="#99bbee" font-size="3.8" font-family="monospace" text-anchor="end">21</text>
<circle id="erp9" cx="86" cy="135.5" r="3.2" fill="#182848" stroke="#5080c0" stroke-width=".7"/><text x="80" y="138" fill="#99bbee" font-size="3.8" font-family="monospace" text-anchor="end">RX0</text>
<circle id="erp10" cx="86" cy="143" r="3.2" fill="#182848" stroke="#5080c0" stroke-width=".7"/><text x="80" y="145.5" fill="#99bbee" font-size="3.8" font-family="monospace" text-anchor="end">TX0</text>
<circle id="erp11" cx="86" cy="150.5" r="3.2" fill="#182848" stroke="#5080c0" stroke-width=".7"/><text x="80" y="153" fill="#99bbee" font-size="3.8" font-family="monospace" text-anchor="end">22</text>
<circle id="erp12" cx="86" cy="158" r="3.2" fill="#182848" stroke="#5080c0" stroke-width=".7"/><text x="80" y="160.5" fill="#99bbee" font-size="3.8" font-family="monospace" text-anchor="end">23</text>
<circle cx="86" cy="165.5" r="2.4" fill="#1a1a1a" stroke="#666" stroke-width=".7"/><text x="80" y="168" fill="#aaaaaa" font-size="3.8" font-family="monospace" text-anchor="end">G</text>
<circle cx="86" cy="173" r="2.4" fill="#3a0000" stroke="#cc2222" stroke-width=".7"/><text x="80" y="175.5" fill="#ff8888" font-size="3.8" font-family="monospace" text-anchor="end">3V3</text>
</g>
</svg>`;
function _esp32PinState(gpio) {
if (gpio === null) return 'pwr';
if (_esp32PinModes[gpio] === undefined && _esp32PinStates[gpio] === undefined) return 'off';
if (_esp32PinStates[gpio] === undefined && _esp32PinModes[gpio] !== 'O') return 'inp';
return _esp32PinStates[gpio] ? 'out-hi' : 'out-lo';
}
// ── ESP8266 state ─────────────────────────────────────────────────────────────
let _IS_ESP8266 = false;
let _esp8266SessionId = null;
let _esp8266EvtSource = null;
let _esp8266PinStates = {};
let _esp8266PinModes = {};
let _esp8266BootDone = false;
let _esp8266SketchCode = '';
let _esp8266DhtPins = [];
const _esp8266PinFlashing = {};
const _esp8266PinTimers = {};
let _esp8266TxTimer = 0, _esp8266RxTimer = 0;
let _esp8266Variant = 'nodemcu'; // 'nodemcu' | 'd1mini' | 'esp01'
// FQBN → variant mapping
const _ESP8266_VARIANT_MAP = {
'nodemcuv2': 'nodemcu', 'nodemcu': 'nodemcu', 'huzzah': 'nodemcu', 'generic': 'nodemcu',
'd1_mini': 'd1mini', 'd1_mini_pro': 'd1mini', 'd1_mini_lite': 'd1mini',
'esp01': 'esp01', 'esp01s': 'esp01', 'esp12e': 'nodemcu', 'esp07': 'nodemcu',
};
function _esp8266VariantFromFqbn(fqbn) {
const bid = (fqbn || '').split(':')[2] || '';
for (const [k, v] of Object.entries(_ESP8266_VARIANT_MAP)) {
if (bid.toLowerCase().includes(k)) return v;
}
return 'nodemcu';
}
// ── ESP8266 variant data ─────────────────────────────────────────────────────
const _ESP8266_VARIANTS = {
nodemcu: {
label: 'NodeMCU v2',
chip: 'ESP-12E',
// Left: 3V3, GND, RST, D1(5), D2(4), D3(0), D4(2), A0, GND, VIN
leftPins: [null, null, null, 5, 4, 0, 2, null, null, null],
leftLabels: ['3V3','GND','RST','D1(5)','D2(4)','D3(0)','D4(2)','A0','GND','VIN'],
leftGpios: [false, false, false, true, true, true, true, false, false, false],
// Right: VIN, GND, 3V3, D5(14), D6(12), D7(13), D8(15), RX(3), TX(1), GND, EN
rightPins: [null, null, null, 14, 12, 13, 15, 3, 1, null, null],
rightLabels: ['VIN','GND','3V3','D5(14)','D6(12)','D7(13)','D8(15)','RX(3)','TX(1)','GND','EN'],
rightGpios: [false, false, false, true, true, true, true, true, true, false, false],
},
d1mini: {
label: 'WEMOS D1 Mini',
chip: 'ESP-12F',
// Left: RST, A0, D0(16), D5(14), D6(12), D7(13), D8(15), 3V3
leftPins: [null, null, 16, 14, 12, 13, 15, null],
leftLabels: ['RST','A0','D0(16)','D5(14)','D6(12)','D7(13)','D8(15)','3V3'],
leftGpios: [false, false, true, true, true, true, true, false],
// Right: TX(1), RX(3), D1(5), D2(4), D3(0), D4(2), GND, 5V
rightPins: [1, 3, 5, 4, 0, 2, null, null],
rightLabels: ['TX(1)','RX(3)','D1(5)','D2(4)','D3(0)','D4(2)','GND','5V'],
rightGpios: [true, true, true, true, true, true, false, false],
},
esp01: {
label: 'ESP-01',
chip: 'ESP8266EX',
// Left: VCC, RST, CH_PD(EN)
leftPins: [null, null, null],
leftLabels: ['VCC','RST','EN'],
leftGpios: [false, false, false],
// Right: TX(1), RX(3), GPIO0(0), GPIO2(2), GND
rightPins: [1, 3, 0, 2, null],
rightLabels: ['TX(1)','RX(3)','IO0(0)','IO2(2)','GND'],
rightGpios: [true, true, true, true, false],
},
};
function _esp8266BoardSvg(variant) {
const v = _ESP8266_VARIANTS[variant] || _ESP8266_VARIANTS.nodemcu;
const isEsp01 = variant === 'esp01';
const isD1 = variant === 'd1mini';
const h = isEsp01 ? 110 : 175; // board height
const pinStartY = isEsp01 ? 55 : 83;
const pinSpacing = 8;
const lx = 4, rx = 86; // left/right pin x
const pcbFill = isEsp01 ? '#1a1a1a' : '#0d2040';
const pcbStroke = isEsp01 ? '#555' : '#1a6080';
let s = `<svg id="board-svg" viewBox="0 0 90 ${h}" xmlns="http://www.w3.org/2000/svg">`;
// PCB outline
s += `<rect x="1" y="1" width="88" height="${h-2}" rx="4" fill="${pcbFill}" stroke="${pcbStroke}" stroke-width="1.2"/>`;
if (!isEsp01) {
// USB micro-B connector
s += `<rect x="32" y="0" width="26" height="7" rx="1.5" fill="#888" stroke="#555" stroke-width=".6"/>`;
s += `<rect x="36" y="1.5" width="18" height="4" rx="1" fill="#444"/>`;
// PWR LED
s += `<circle id="esp8266-pwr-led" cx="20" cy="9" r="2.2" fill="#1a3a5a" stroke="#2a6ab0" stroke-width=".5"/>`;
s += `<text x="20" y="17" fill="#5599cc" font-size="3.5" font-family="monospace" text-anchor="middle">PWR</text>`;
// TX/RX LEDs
s += `<circle id="esp8266-tx-led" cx="42" cy="9" r="1.8" fill="#2a1a00" stroke="#aa6600" stroke-width=".5"/>`;
s += `<text x="42" y="17" fill="#886644" font-size="3.2" font-family="monospace" text-anchor="middle">TX</text>`;
s += `<circle id="esp8266-rx-led" cx="52" cy="9" r="1.8" fill="#2a1a00" stroke="#aa6600" stroke-width=".5"/>`;
s += `<text x="52" y="17" fill="#886644" font-size="3.2" font-family="monospace" text-anchor="middle">RX</text>`;
// RST button
s += `<rect x="66" y="5" width="12" height="6" rx="1.5" fill="#2a2a2a" stroke="#555" stroke-width=".5"/>`;
s += `<text x="72" y="10" fill="#aaa" font-size="3.5" font-family="monospace" text-anchor="middle">RST</text>`;
s += `<rect id="esp8266-rst-btn" x="67" y="5.5" width="10" height="5" rx="1" fill="#333" stroke="#888" stroke-width=".4" style="cursor:pointer"/>`;
// FLASH button
s += `<rect x="66" y="15" width="12" height="6" rx="1.5" fill="#2a2a2a" stroke="#555" stroke-width=".5"/>`;
s += `<text x="72" y="20" fill="#aaa" font-size="3.5" font-family="monospace" text-anchor="middle">FLASH</text>`;
// PWR toggle
s += `<rect id="esp8266-pwr-btn" x="4" y="5" width="12" height="6" rx="1.5" fill="#1a2a1a" stroke="#2a6a2a" stroke-width=".5" style="cursor:pointer"/>`;
s += `<text x="10" y="10" fill="#4a9a4a" font-size="3.2" font-family="monospace" text-anchor="middle">⏻ PWR</text>`;
// Module area
const modY = isD1 ? 25 : 25;
s += `<rect x="18" y="${modY}" width="54" height="48" rx="2" fill="#1a1a2a" stroke="#3a4a6a" stroke-width=".8"/>`;
s += `<text x="45" y="${modY+22}" fill="#4488aa" font-size="5" font-family="monospace" text-anchor="middle" font-weight="bold">${v.chip}</text>`;
s += `<text x="45" y="${modY+31}" fill="#336688" font-size="3.5" font-family="monospace" text-anchor="middle">ESP8266</text>`;
// Antenna
s += `<rect x="60" y="${modY+3}" width="10" height="16" rx="1" fill="#0d1a2a" stroke="#2a4a6a" stroke-width=".6"/>`;
// Built-in LED (GPIO2)
s += `<circle id="esp8266-led-2" cx="37" cy="${modY+17}" r="2.5" fill="#0a1a2a" stroke="#1a4a6a" stroke-width=".5"/>`;
s += `<text x="37" y="${modY+27}" fill="#336688" font-size="3" font-family="monospace" text-anchor="middle">D4</text>`;
} else {
// ESP-01: minimal board — no USB, no flash button, tiny module
// PWR toggle (small)
s += `<rect id="esp8266-pwr-btn" x="4" y="4" width="12" height="5" rx="1" fill="#1a2a1a" stroke="#2a6a2a" stroke-width=".4" style="cursor:pointer"/>`;
s += `<text x="10" y="8" fill="#4a9a4a" font-size="2.8" font-family="monospace" text-anchor="middle">⏻</text>`;
// PWR LED (small)
s += `<circle id="esp8266-pwr-led" cx="22" cy="7" r="1.5" fill="#1a3a5a" stroke="#2a6ab0" stroke-width=".4"/>`;
// TX/RX LEDs (small)
s += `<circle id="esp8266-tx-led" cx="34" cy="7" r="1.2" fill="#2a1a00" stroke="#aa6600" stroke-width=".4"/>`;
s += `<circle id="esp8266-rx-led" cx="42" cy="7" r="1.2" fill="#2a1a00" stroke="#aa6600" stroke-width=".4"/>`;
// RST button
s += `<rect x="52" y="4" width="8" height="5" rx="1" fill="#2a2a2a" stroke="#555" stroke-width=".4"/>`;
s += `<rect id="esp8266-rst-btn" x="52.5" y="4.5" width="7" height="4" rx=".8" fill="#333" stroke="#888" stroke-width=".3" style="cursor:pointer"/>`;
// Module area
s += `<rect x="12" y="16" width="66" height="30" rx="2" fill="#1a1a2a" stroke="#3a4a6a" stroke-width=".8"/>`;
s += `<text x="45" y="34" fill="#4488aa" font-size="4.5" font-family="monospace" text-anchor="middle" font-weight="bold">ESP8266EX</text>`;
s += `<text x="45" y="42" fill="#336688" font-size="3" font-family="monospace" text-anchor="middle">ESP-01</text>`;
// Antenna area
s += `<rect x="12" y="16" width="22" height="30" rx="1" fill="#0d1a2a" stroke="#2a4a6a" stroke-width=".5"/>`;
// Built-in LED (GPIO2)
s += `<circle id="esp8266-led-2" cx="55" cy="30" r="1.8" fill="#0a1a2a" stroke="#1a4a6a" stroke-width=".4"/>`;
}
// Left pins
s += '<g>';
v.leftLabels.forEach((lbl, i) => {
const y = pinStartY + i * pinSpacing;
const isGpio = v.leftGpios[i];
const id = isGpio ? ` id="e86lp${i}"` : '';
const r = isGpio ? 3.2 : 2.4;
const fill = isGpio ? '#182848' : '#1a1a1a';
const stroke = isGpio ? '#1a6080' : '#666';
const txtFill = isGpio ? '#99ccee' : '#aaaaaa';
s += `<circle${id} cx="${lx}" cy="${y}" r="${r}" fill="${fill}" stroke="${stroke}" stroke-width=".7"/>`;
s += `<text x="${lx+6}" y="${y+2.5}" fill="${txtFill}" font-size="3.5" font-family="monospace">${lbl}</text>`;
});
s += '</g>';
// Right pins
s += '<g>';
v.rightLabels.forEach((lbl, i) => {
const y = pinStartY + i * pinSpacing;
const isGpio = v.rightGpios[i];
const id = isGpio ? ` id="e86rp${i}"` : '';
const r = isGpio ? 3.2 : 2.4;
const fill = isGpio ? '#182848' : '#1a1a1a';
const stroke = isGpio ? '#1a6080' : '#666';
const txtFill = isGpio ? '#99ccee' : '#aaaaaa';
s += `<circle${id} cx="${rx}" cy="${y}" r="${r}" fill="${fill}" stroke="${stroke}" stroke-width=".7"/>`;
s += `<text x="${rx-6}" y="${y+2.5}" fill="${txtFill}" font-size="3.5" font-family="monospace" text-anchor="end">${lbl}</text>`;
});
s += '</g>';
s += '</svg>';
return s;
}
function _esp8266PinState(gpio) {
if (gpio === null) return 'pwr';
if (_esp8266PinModes[gpio] === undefined && _esp8266PinStates[gpio] === undefined) return 'off';
if (_esp8266PinStates[gpio] === undefined && _esp8266PinModes[gpio] !== 'O') return 'inp';
return _esp8266PinStates[gpio] ? 'out-hi' : 'out-lo';
}
function _esp8266FlashPin(gpio) {
const v = _ESP8266_VARIANTS[_esp8266Variant] || _ESP8266_VARIANTS.nodemcu;
const idx_l = v.leftPins.indexOf(gpio);
const idx_r = v.rightPins.indexOf(gpio);
const el = idx_l >= 0 ? document.getElementById('e86lp' + idx_l)
: idx_r >= 0 ? document.getElementById('e86rp' + idx_r)
: null;
if (!el) return;
_esp8266PinFlashing[gpio] = true;
el.setAttribute('fill', '#fbbf24');
el.setAttribute('stroke', '#f59e0b');
el.setAttribute('filter', 'drop-shadow(0 0 5px #fbbf24)');
clearTimeout(_esp8266PinTimers[gpio]);
_esp8266PinTimers[gpio] = setTimeout(() => {
delete _esp8266PinTimers[gpio];
delete _esp8266PinFlashing[gpio];
updateEsp8266BoardSvg();
}, 120);
}
function updateEsp8266BoardSvg() {
const HI = { fill:'#4ade80', stroke:'#22c55e' };
const LO = { fill:'#14532d', stroke:'#166534' };
const IN = { fill:'#60a5fa', stroke:'#3b82f6' };
const OFF = { fill:'#182848', stroke:'#1a6080' };
const PW = { fill:'#1a1a1a', stroke:'#3a3a3a' };
const apply = (id, state) => {
const el = document.getElementById(id);
if (!el) return;
const c = state === 'out-hi' ? HI : state === 'out-lo' ? LO : state === 'inp' ? IN : state === 'off' ? OFF : PW;
el.setAttribute('fill', c.fill); el.setAttribute('stroke', c.stroke);
if (state === 'out-hi') el.setAttribute('filter', 'drop-shadow(0 0 6px #4ade80)');
else if (state === 'inp') el.setAttribute('filter', 'drop-shadow(0 0 4px #60a5fa)');
else if (state === 'out-lo') el.setAttribute('filter', 'drop-shadow(0 0 2px #14532d)');
else el.removeAttribute('filter');
};
const v = _ESP8266_VARIANTS[_esp8266Variant] || _ESP8266_VARIANTS.nodemcu;
v.leftPins.forEach((g, i) => { if (!_esp8266PinFlashing[g]) apply('e86lp' + i, g !== null ? _esp8266PinState(g) : 'pwr'); });
v.rightPins.forEach((g, i) => { if (!_esp8266PinFlashing[g]) apply('e86rp' + i, g !== null ? _esp8266PinState(g) : 'pwr'); });
// Built-in LED (GPIO2)
const led2 = document.getElementById('esp8266-led-2');
if (led2) {
if (_esp8266PinStates[2]) {
led2.setAttribute('fill', '#0a3a6a');
led2.setAttribute('filter', 'drop-shadow(0 0 3px #60a5fa)');
} else {
led2.setAttribute('fill', '#0a1a2a');
led2.removeAttribute('filter');
}
}
// PWR LED
const pwr = document.getElementById('esp8266-pwr-led');
if (pwr && _esp8266BootDone) {
pwr.setAttribute('fill', '#0e4a6a'); pwr.setAttribute('stroke', '#1a8ab0');
pwr.setAttribute('filter', 'drop-shadow(0 0 3px #0ea5e9)');
}
}
// ── ESP8266 Serial TX/RX LED blink ──────────────────────────────────────────
function _esp8266BlinkTx() {
const el = document.getElementById('esp8266-tx-led');
if (!el) return;
el.setAttribute('fill', '#eab308'); el.setAttribute('stroke', '#ca8a04');
el.setAttribute('filter', 'drop-shadow(0 0 4px #eab308)');
clearTimeout(_esp8266TxTimer);
_esp8266TxTimer = setTimeout(() => {
el.setAttribute('fill', '#2a1a00'); el.setAttribute('stroke', '#aa6600');
el.removeAttribute('filter');
}, 120);
}
function _esp8266BlinkRx() {
const el = document.getElementById('esp8266-rx-led');
if (!el) return;
el.setAttribute('fill', '#22c55e'); el.setAttribute('stroke', '#16a34a');
el.setAttribute('filter', 'drop-shadow(0 0 4px #22c55e)');
clearTimeout(_esp8266RxTimer);
_esp8266RxTimer = setTimeout(() => {
el.setAttribute('fill', '#2a1a00'); el.setAttribute('stroke', '#aa6600');
el.removeAttribute('filter');
}, 120);
}
// ── Serial TX/RX LED blink ──────────────────────────────────────────────────
let _esp32TxTimer = 0, _esp32RxTimer = 0;
function _esp32BlinkTx() {
const el = document.getElementById('esp32-tx-led');
if (!el) return;
el.setAttribute('fill', '#eab308'); el.setAttribute('stroke', '#ca8a04');
el.setAttribute('filter', 'drop-shadow(0 0 4px #eab308)');
clearTimeout(_esp32TxTimer);
_esp32TxTimer = setTimeout(() => {
el.setAttribute('fill', '#2a2200'); el.setAttribute('stroke', '#665500');
el.removeAttribute('filter');
}, 120);
}
function _esp32BlinkRx() {
const el = document.getElementById('esp32-rx-led');
if (!el) return;
el.setAttribute('fill', '#22c55e'); el.setAttribute('stroke', '#16a34a');
el.setAttribute('filter', 'drop-shadow(0 0 4px #22c55e)');
clearTimeout(_esp32RxTimer);
_esp32RxTimer = setTimeout(() => {
el.setAttribute('fill', '#0a2a0a'); el.setAttribute('stroke', '#1a5a1a');
el.removeAttribute('filter');
}, 120);
}
// Pin flash state — tracks which pins are currently flashing so updateEsp32BoardSvg skips them
const _esp32PinFlashing = {}; // gpio → true while flash is active
const _esp32PinTimers = {};
function _esp32FlashPin(gpio) {
const idx_l = _ESP32_LEFT_PINS.indexOf(gpio);
const idx_r = _ESP32_RIGHT_PINS.indexOf(gpio);
const el = idx_l >= 0 ? document.getElementById('elp' + idx_l)
: idx_r >= 0 ? document.getElementById('erp' + idx_r)
: null;
if (!el) return;
_esp32PinFlashing[gpio] = true;
el.setAttribute('fill', '#fbbf24');
el.setAttribute('stroke', '#f59e0b');
el.setAttribute('filter', 'drop-shadow(0 0 5px #fbbf24)');
clearTimeout(_esp32PinTimers[gpio]);
_esp32PinTimers[gpio] = setTimeout(() => {
delete _esp32PinTimers[gpio];
delete _esp32PinFlashing[gpio];
updateEsp32BoardSvg();
}, 120);
}
function updateEsp32BoardSvg() {
const HI = { fill:'#4ade80', stroke:'#22c55e' };
const LO = { fill:'#14532d', stroke:'#166534' };
const IN = { fill:'#60a5fa', stroke:'#3b82f6' };
const OFF = { fill:'#1e1e2e', stroke:'#3a3a50' };
const PW = { fill:'#1a1a1a', stroke:'#3a3a3a' };
const apply = (id, state) => {
const el = document.getElementById(id);
if (!el) return;
const c = state === 'out-hi' ? HI : state === 'out-lo' ? LO : state === 'inp' ? IN : state === 'off' ? OFF : PW;
el.setAttribute('fill', c.fill); el.setAttribute('stroke', c.stroke);
if (state === 'out-hi') el.setAttribute('filter', 'drop-shadow(0 0 6px #4ade80)');
else if (state === 'inp') el.setAttribute('filter', 'drop-shadow(0 0 4px #60a5fa)');
else if (state === 'out-lo') el.setAttribute('filter', 'drop-shadow(0 0 2px #14532d)');
else el.removeAttribute('filter');
};
_ESP32_LEFT_PINS.forEach((g, i) => { if (!_esp32PinFlashing[g]) apply('elp' + i, g !== null ? _esp32PinState(g) : 'pwr'); });
_ESP32_RIGHT_PINS.forEach((g, i) => { if (!_esp32PinFlashing[g]) apply('erp' + i, g !== null ? _esp32PinState(g) : 'pwr'); });
const pwr = document.getElementById('esp32-pwr-led');
if (pwr && _esp32BootDone) {
pwr.setAttribute('fill', '#1a3a5a'); pwr.setAttribute('stroke', '#2a6ab0');
pwr.setAttribute('filter', 'drop-shadow(0 0 3px #60a5fa)');
}
// D2 built-in LED (GPIO2 = erp1)
const led2 = document.getElementById('esp32-led-2');
if (led2) {
if (_esp32PinStates[2]) {
led2.setAttribute('fill', '#0a3a6a'); led2.setAttribute('stroke', '#2a7acc');
led2.setAttribute('filter', 'drop-shadow(0 0 3px #60a5fa)');
} else {
led2.setAttribute('fill', '#0a1a2a'); led2.setAttribute('stroke', '#1a2a4a');
led2.removeAttribute('filter');
}
}
// 회로도 LED 연동
if (_schLiveMap.led.size > 0) {
let needRedraw = false;
for (const [comp, gpio] of _schLiveMap.led) {
const lit = !!_esp32PinStates[gpio];
if (comp._schLit !== lit) { comp._schLit = lit; needRedraw = true; }
}
if (needRedraw) requestAnimationFrame(schDraw);
}
}
// ── ESP8266 Native emulator functions ─────────────────────────────────────────
function _buildEsp8266PinPopup() {
const popup = document.getElementById('pin-popup');
if (!popup) return;
// Variant-specific pin labels
const _esp8266PinLabels = {
nodemcu: [
{gpio:0, label:'D3/GPIO0'}, {gpio:1, label:'TX/GPIO1'}, {gpio:2, label:'D4/GPIO2'},
{gpio:3, label:'RX/GPIO3'}, {gpio:4, label:'D2/GPIO4'}, {gpio:5, label:'D1/GPIO5'},
{gpio:12,label:'D6/GPIO12'},{gpio:13,label:'D7/GPIO13'},{gpio:14,label:'D5/GPIO14'},
{gpio:15,label:'D8/GPIO15'},{gpio:16,label:'D0/GPIO16'},
],
d1mini: [
{gpio:0, label:'D3/GPIO0'}, {gpio:1, label:'TX/GPIO1'}, {gpio:2, label:'D4/GPIO2'},
{gpio:3, label:'RX/GPIO3'}, {gpio:4, label:'D2/GPIO4'}, {gpio:5, label:'D1/GPIO5'},
{gpio:12,label:'D6/GPIO12'},{gpio:13,label:'D7/GPIO13'},{gpio:14,label:'D5/GPIO14'},
{gpio:15,label:'D8/GPIO15'},{gpio:16,label:'D0/GPIO16'},
],
esp01: [
{gpio:0, label:'GPIO0'}, {gpio:1, label:'TX/GPIO1'}, {gpio:2, label:'GPIO2'},
{gpio:3, label:'RX/GPIO3'},
],
};
const pins = _esp8266PinLabels[_esp8266Variant] || _esp8266PinLabels.nodemcu;
popup.innerHTML = `<div style="font-weight:600;font-size:11px;color:var(--text2);margin-bottom:6px">GPIO 핀 상태</div>` +
pins.map(p => `<div class="pin-row" id="pin-row-${p.gpio}" style="display:flex;gap:6px;align-items:center;padding:2px 0;font-size:11px">
<span style="font-family:monospace;color:var(--accent);width:80px;flex-shrink:0">${p.label}</span>
<span id="pin-val-${p.gpio}" style="color:var(--muted)">-</span>
</div>`).join('');
}
function _updateEsp8266PinPopup() {
const pins = _esp8266Variant === 'esp01' ? [0,1,2,3] : [0,1,2,3,4,5,12,13,14,15,16];
for (const g of pins) {
const el = document.getElementById('pin-val-' + g);
if (el) {
const st = _esp8266PinState(g);
if (st === 'out-hi') { el.textContent = 'HIGH'; el.style.color = '#4ade80'; }
else if (st === 'out-lo') { el.textContent = 'LOW'; el.style.color = '#6b7280'; }
else if (st === 'inp') { el.textContent = 'INPUT'; el.style.color = '#60a5fa'; }
else { el.textContent = '-'; el.style.color = 'var(--muted)'; }
}
}
}
function initEsp8266Mode(sessionId, fname, fqbn) {
_IS_ESP8266 = true;
_esp8266SessionId = sessionId;
_esp8266Variant = _esp8266VariantFromFqbn(fqbn);
if (_esp8266SketchCode && !_getEditorCode().trim()) _setEditorCode(_esp8266SketchCode);
document.querySelector('.header h1').textContent = '🖥️ ESP8266 Emulator';
document.getElementById('filename').textContent = fname;
// Replace board SVG
const v = _ESP8266_VARIANTS[_esp8266Variant] || _ESP8266_VARIANTS.nodemcu;
document.querySelector('.board-title').textContent = v.label + ' · GPIO 상태';
// Chip badge
document.getElementById('chip-badge')?.remove();
const badge = document.createElement('span');
badge.id = 'chip-badge';
badge.style.cssText = 'display:inline-block;padding:2px 7px;border-radius:4px;font-size:10px;font-weight:600;border:1px solid;flex-shrink:0;';
badge.textContent = v.label + ' · Native';
badge.style.background = '#0c3a4a'; badge.style.color = '#67e8f9'; badge.style.borderColor = '#0e7490';
document.getElementById('filename').after(badge);
document.querySelector('.speed-label')?.remove();
document.getElementById('speed-range')?.remove();
document.getElementById('btn-stop')?.style && (document.getElementById('btn-stop').style.display = 'none');
document.getElementById('btn-reset')?.style && (document.getElementById('btn-reset').style.display = 'none');
// Remove previous ESP8266 buttons if any
document.getElementById('esp8266-restart-btn')?.remove();
document.getElementById('esp8266-stop-btn')?.remove();
const toolbar = document.querySelector('.toolbar-right') || document.querySelector('.toolbar');
if (toolbar) {
const restartBtn = document.createElement('button');
restartBtn.id = 'esp8266-restart-btn'; restartBtn.textContent = '↺ 재시작'; restartBtn.onclick = esp8266Restart;
restartBtn.style.cssText = 'font-size:12px;padding:3px 8px;background:#1a2a3a;border:1px solid #1a6080;color:#67e8f9;border-radius:4px;cursor:pointer';
const stopBtn = document.createElement('button');
stopBtn.id = 'esp8266-stop-btn'; stopBtn.textContent = '■ 정지'; stopBtn.onclick = esp8266ToggleStop;
stopBtn.style.cssText = 'font-size:12px;padding:3px 8px;background:#3a1010;border:1px solid #cc4444;color:#ff8888;border-radius:4px;cursor:pointer';
toolbar.append(restartBtn, stopBtn);
}
document.querySelector('.board-svg').innerHTML = _esp8266BoardSvg(_esp8266Variant);
document.getElementById('esp8266-rst-btn')?.addEventListener('click', esp8266Restart);
document.getElementById('esp8266-pwr-btn')?.addEventListener('click', esp8266TogglePower);
_buildEsp8266PinPopup();
// Show DHT slider if sketch uses DHT library, and parse pin from code
const _skCode = _esp8266SketchCode || _getEditorCode() || '';
if (_skCode.includes('DHT.h') || _skCode.includes('DHT ') || _skCode.includes('dht.')) {
const sec = document.getElementById('dht-section');
if (sec) sec.style.display = '';
const dot = document.getElementById('dht-dot');
if (dot) dot.className = 'dht-dot on';
// Parse pin from: DHT dht(D4, DHT22) or DHT dht(4, DHT22)
const _dm = _skCode.match(/DHT\s+\w+\s*\(\s*(D?\d+)\s*,/);
if (_dm && !_esp8266DhtPins.length) {
const _dPinMap = {D0:16,D1:5,D2:4,D3:0,D4:2,D5:14,D6:12,D7:13,D8:15};
const _dGpio = _dPinMap[_dm[1]] ?? parseInt(_dm[1]);
if (!isNaN(_dGpio)) {
_esp8266DhtPins.push({gpio:_dGpio, model:'DHT22'});
const lbl = document.getElementById('dht-label');
if (lbl) lbl.textContent = 'DHT 온습도 (GPIO' + _dGpio + ')';
}
} else {
const lbl = document.getElementById('dht-label');
if (lbl && !_dm) lbl.textContent = 'DHT 온습도';
}
}
// Also try schematic-based detection
setupDHTSensorsEsp8266();
window.sendSerial = () => {
const inp = document.getElementById('serial-input');
if (!inp || !inp.value.trim()) return;
_esp8266BlinkRx();
fetch('/api/arduino/esp8266/' + _esp8266SessionId + '/input', {
method:'POST', credentials:'include',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({ text: inp.value + '\n' })
});
inp.value = '';
};
connectEsp8266Output();
}
function connectEsp8266Output() {
if (_esp8266EvtSource) { _esp8266EvtSource.close(); _esp8266EvtSource = null; }
_servoReset();
_sensorReset();
_esp8266EvtSource = new EventSource('/api/arduino/esp8266/' + _esp8266SessionId + '/output', { withCredentials: true });
// Native binary boots instantly — mark done after 1s or first output
let bootTimer = setTimeout(() => {
_esp8266BootDone = true;
setStatus('running', '실행 중');
updateEsp8266BoardSvg();
}, 1000);
_esp8266EvtSource.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.g) {
if (typeof msg.g.v === 'number') _esp8266PinStates[msg.g.p] = msg.g.v;
if (msg.g.m) _esp8266PinModes[msg.g.p] = msg.g.m;
updateEsp8266BoardSvg();
_updateEsp8266PinPopup();
_esp8266FlashPin(msg.g.p);
return;
}
if (msg.dht) {
const pinN = msg.dht.pin;
if (!_esp8266DhtPins.some(p => p.gpio === pinN))
_esp8266DhtPins.push({ gpio: pinN, model: 'DHT22' });
const sec = document.getElementById('dht-section');
if (sec) sec.style.display = '';
const dot = document.getElementById('dht-dot');
const lbl = document.getElementById('dht-label');
if (dot) dot.className = 'dht-dot on';
if (lbl) lbl.textContent = 'DHT22 온습도 (GPIO' + pinN + ')';
_esp8266FlashPin(pinN);
setTimeout(() => _esp8266SendDHTValues(), 500);
return;
}
if (msg.servo) { _servoAttach(msg.servo.pin); return; }
if (msg.servo_angle) { _servoUpdateAngle(msg.servo_angle.pin, msg.servo_angle.angle); return; }
if (msg.imu) { _imuAttach(msg.imu.sda, msg.imu.scl, msg.imu.addr); setTimeout(() => _esp8266SendIMUValues(), 500); return; }
if (msg.imu_values) { _imuUpdateValues(msg.imu_values); return; }
if (msg.bme) { _bmeAttach(msg.bme.sda, msg.bme.scl, msg.bme.addr); setTimeout(() => _esp8266SendBMEValues(), 500); return; }
if (msg.bme_values) { _bmeUpdateValues(msg.bme_values); return; }
if (msg.onewire) { _ds18b20Attach(msg.onewire.pin); return; }
if (msg.ds18b20) { _ds18b20UpdateValues(msg.ds18b20); return; }
if (msg.relay) { _relayUpdate(msg.relay.pin, msg.relay.state); return; }
if (msg.motor) { _motorAttach(msg.motor); return; }
if (msg.motor_state) { _motorUpdateState(msg.motor_state); return; }
if (msg.wifi) { _wifiHandleEvent(msg.wifi); return; }
if (msg.http_port) { _showHttpPortLink(msg.http_port); return; }
if (msg.tft) { _esp32TftDraw(msg.tft); return; }
const t = msg.t;
if (!t) return;
if (!_esp8266BootDone) {
clearTimeout(bootTimer);
_esp8266BootDone = true;
setStatus('running', '실행 중');
updateEsp8266BoardSvg();
}
_esp8266BlinkTx();
appendSerial(t);
};
_esp8266EvtSource.onerror = () => {
setStatus('stopped', '연결 끊김');
};
}
function esp8266Restart() {
if (!_esp8266SessionId) { appendSerial('[세션 없음 — 재시작 불가]\n', 'error'); return; }
if (_esp8266EvtSource) { _esp8266EvtSource.close(); _esp8266EvtSource = null; }
clearSerial();
appendSerial('[ESP8266 재시작 중...]\n', 'sys');
setStatus('loading', '재시작 중...');
_esp8266BootDone = false;
_esp8266PinStates = {}; _esp8266PinModes = {};
_servoReset();
_sensorReset();
fetch('/api/arduino/esp8266/' + _esp8266SessionId + '/restart', { method:'POST', credentials:'include' })
.then(r => r.json())
.then(d => { if (d.error) throw new Error(d.error); connectEsp8266Output(); })
.catch(e => appendSerial('[재시작 실패: ' + (e.message||'오류') + ']\n', 'error'));
}
function esp8266ToggleStop() {
const btn = document.getElementById('esp8266-stop-btn');
if (_esp8266EvtSource) {
esp8266Stop();
if (btn) { btn.textContent = '▶ 시작'; btn.style.background = '#103a10'; btn.style.color = '#4ade80'; btn.style.borderColor = '#2d5a2d'; }
} else {
esp8266Restart();
if (btn) { btn.textContent = '■ 정지'; btn.style.background = '#3a1010'; btn.style.color = '#ff8888'; btn.style.borderColor = '#cc4444'; }
}
}
function esp8266Stop() {
if (_esp8266EvtSource) { _esp8266EvtSource.close(); _esp8266EvtSource = null; }
if (_esp8266SessionId) {
fetch('/api/arduino/esp8266/' + _esp8266SessionId + '/pause', { method:'POST', credentials:'include' }).catch(()=>{});
}
setStatus('stopped', '정지됨');
}
function esp8266TogglePower() {
if (_esp8266EvtSource) {
if (_esp8266EvtSource) { _esp8266EvtSource.close(); _esp8266EvtSource = null; }
appendSerial('\n[전원 꺼짐]\n', 'sys');
setStatus('stopped', '전원 꺼짐');
} else {
esp8266Restart();
}
}
function _esp8266SendDHTValues() {
if (!_esp8266SessionId || !_esp8266DhtPins.length) return;
const temp = parseFloat(document.getElementById('dht-temp')?.value || '25');
const humid = parseFloat(document.getElementById('dht-humid')?.value || '50');
for (const p of _esp8266DhtPins) {
fetch('/api/arduino/esp8266/' + _esp8266SessionId + '/dht', {
method:'POST', credentials:'include',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({ pin: p.gpio, temp, humid })
});
}
}
function initEsp32Mode(sessionId, fname, fqbn) {
_IS_ESP32 = true;
_esp32SessionId = sessionId;
if (_esp32SketchCode && !_getEditorCode().trim()) _setEditorCode(_esp32SketchCode);
// Update header
document.querySelector('.header h1').textContent = '🖥️ ESP32 Emulator';
document.getElementById('filename').textContent = fname;
// Chip badge
document.getElementById('chip-badge')?.remove();
const badge = document.createElement('span');
badge.id = 'chip-badge';
badge.style.cssText = 'display:inline-block;padding:2px 7px;border-radius:4px;font-size:10px;font-weight:600;border:1px solid;flex-shrink:0;';
if ((fqbn || '').includes('esp32s3')) {
badge.textContent = 'ESP32-S3 · QEMU';
badge.style.background = '#1a2a4a'; badge.style.color = '#60a5fa'; badge.style.borderColor = '#2a4a7a';
} else {
badge.textContent = 'ESP32 · QEMU';
badge.style.background = '#1a3a1a'; badge.style.color = '#22c55e'; badge.style.borderColor = '#2a5a2a';
}
document.getElementById('filename').after(badge);
// Hide AVR-specific toolbar items (keep code + compile buttons)
document.querySelector('.speed-label')?.remove();
document.getElementById('speed-range')?.remove();
document.getElementById('btn-stop')?.style && (document.getElementById('btn-stop').style.display = 'none');
document.getElementById('btn-reset')?.style && (document.getElementById('btn-reset').style.display = 'none');
// Remove previous ESP32 buttons if any (prevent duplicates on re-compile)
document.querySelectorAll('.esp32-ctrl-btn').forEach(b => b.remove());
// Add restart/stop buttons
const toolbar = document.querySelector('.toolbar');
const restartBtn = document.createElement('button');
restartBtn.className = 'toolbar esp32-ctrl-btn';
restartBtn.style.cssText = 'background:var(--ctrl);border:1px solid var(--bdr2);color:var(--text);border-radius:6px;padding:6px 14px;font-size:12px;cursor:pointer;font-family:inherit;';
restartBtn.textContent = '↺ 재시작';
restartBtn.onclick = esp32Restart;
toolbar.insertBefore(restartBtn, toolbar.firstChild);
const stopBtn = document.createElement('button');
stopBtn.id = 'esp32-stop-btn';
stopBtn.className = 'esp32-ctrl-btn';
stopBtn.style.cssText = restartBtn.style.cssText + 'background:#3a1a1a;color:#ef4444;border-color:#5a2d2d;';
stopBtn.textContent = '■ 정지';
stopBtn.onclick = esp32ToggleStop;
toolbar.insertBefore(stopBtn, toolbar.children[1]);
// Swap board SVG
document.querySelector('.board-title').textContent = 'ESP32 DevKit · GPIO 상태';
document.querySelector('.board-svg').innerHTML = _ESP32_BOARD_SVG;
// Re-bind SVG click handlers (innerHTML strips onclick attributes)
document.getElementById('esp32-rst-btn')?.addEventListener('click', esp32Restart);
document.getElementById('esp32-pwr-btn')?.addEventListener('click', esp32TogglePower);
// Hide Arduino pin popup content, build ESP32 pin list
_buildEsp32PinPopup();
// Override sendSerial
window.sendSerial = () => {
const inp = document.getElementById('serial-input');
if (!inp.value || !_esp32SessionId) return;
fetch('/api/arduino/qemu/' + _esp32SessionId + '/input', {
method:'POST', credentials:'include',
headers:{'Content-Type':'application/json'},
body: JSON.stringify({ text: inp.value + '\n' }),
});
_esp32BlinkTx();
inp.value = '';
};
// Connect QEMU output
connectEsp32Output();
_esp32UpdatePwrBtn(true);
// DHT/servo: hidden until sketch sends DINI/SRVA event or circuit has sensor
_esp32DhtPins = [];
_servoReset();
_sensorReset();
document.getElementById('serial-input').disabled = false;
document.querySelector('.serial-send')?.removeAttribute('disabled');
}
function _buildEsp32PinPopup() {
const body = document.getElementById('pin-popup-body');
if (!body) return;
body.innerHTML = '';
const ALL_PINS = [
{gpio:0,'label':'D0/BOOT'},{gpio:1,'label':'TX0'},{gpio:2,'label':'D2'},
{gpio:3,'label':'RX0'},{gpio:4,'label':'D4'},{gpio:5,'label':'D5'},
{gpio:12,'label':'D12'},{gpio:13,'label':'D13'},{gpio:14,'label':'D14'},
{gpio:15,'label':'D15'},{gpio:16,'label':'RX2'},{gpio:17,'label':'TX2'},
{gpio:18,'label':'D18'},{gpio:19,'label':'D19'},{gpio:21,'label':'D21/SDA'},
{gpio:22,'label':'D22/SCL'},{gpio:23,'label':'D23'},{gpio:25,'label':'D25'},
{gpio:26,'label':'D26'},{gpio:27,'label':'D27'},{gpio:32,'label':'D32'},
{gpio:33,'label':'D33'},{gpio:34,'label':'D34[IN]'},{gpio:35,'label':'D35[IN]'},
{gpio:36,'label':'VP36[IN]'},{gpio:39,'label':'VN39[IN]'},
];
body.dataset.esp32 = '1';
for (const p of ALL_PINS) {
const row = document.createElement('div');
row.className = 'pin-row'; row.id = 'pp-row-' + p.gpio;
row.innerHTML = `<span class="pin-num" style="width:28px;color:var(--muted);font-size:10px;text-align:right">${p.gpio}</span>
<div class="pin-led" id="pp-led-${p.gpio}"></div>
<span class="pin-label" style="flex:1;font-size:10px">${p.label}</span>
<span style="font-size:10px;color:#9ca3af;min-width:20px;text-align:right" id="pp-val-${p.gpio}">IN</span>`;
body.appendChild(row);
}
}
function _updateEsp32PinPopup() {
const body = document.getElementById('pin-popup-body');
if (!body || !body.dataset.esp32) return;
for (const gpio of [0,1,2,3,4,5,12,13,14,15,16,17,18,19,21,22,23,25,26,27,32,33,34,35,36,39]) {
const state = _esp32PinState(gpio);
const led = document.getElementById('pp-led-' + gpio);
const val = document.getElementById('pp-val-' + gpio);
if (led) led.className = 'pin-led' + (state === 'out-hi' ? ' on' : '');
if (val) val.textContent = state === 'inp' ? 'IN' : (state === 'out-hi' ? '1' : '0');
}
}
// ── ESP32 TFT LCD renderer ────────────────────────────────────────────────
let _esp32TftW = 240, _esp32TftH = 320, _esp32TftCtx2d = null;
let _schTftDirty = false;
function _schTftMarkDirty() {
if (_schTftDirty) return;
const panel = document.getElementById('sch-panel');
if (!panel || !panel.classList.contains('open')) return; // 회로도 패널 닫혀 있으면 불필요
_schTftDirty = true;
requestAnimationFrame(() => { _schTftDirty = false; if (typeof schDraw === 'function') schDraw(); });
}
function _esp32TftEnsure() {
const c = document.getElementById('tft-canvas');
if (!c) return null;
if (c.width !== _esp32TftW || c.height !== _esp32TftH) {
c.width = _esp32TftW; c.height = _esp32TftH;
_esp32TftCtx2d = c.getContext('2d');
_esp32TftCtx2d.fillStyle = '#000';
_esp32TftCtx2d.fillRect(0, 0, _esp32TftW, _esp32TftH);
}
if (!_esp32TftCtx2d) _esp32TftCtx2d = c.getContext('2d');
const sec = document.getElementById('tft-section');
if (sec && sec.style.display === 'none') {
sec.style.display = '';
const dot = document.getElementById('tft-dot');
const lbl = document.getElementById('tft-label');
if (dot) dot.classList.add('on');
if (lbl) lbl.textContent = 'TFT LCD ' + _esp32TftW + '×' + _esp32TftH;
}
return _esp32TftCtx2d;
}
function _rgb565(c16) {
c16 = c16 & 0xFFFF;
const r = (c16 >> 11) & 0x1F, g = (c16 >> 5) & 0x3F, b = c16 & 0x1F;
return 'rgb(' + ((r << 3) | (r >> 2)) + ',' + ((g << 2) | (g >> 4)) + ',' + ((b << 3) | (b >> 2)) + ')';
}
function _esp32TftDraw(payload) {
const ci = payload.indexOf(':');
const cmd = ci < 0 ? payload : payload.slice(0, ci);
const args = ci < 0 ? [] : payload.slice(ci + 1).split(',').map(Number);
if (cmd === 'I') {
_esp32TftW = args[0] || 240; _esp32TftH = args[1] || 320;
_esp32TftCtx2d = null; // force canvas resize on next ensure
}
const ctx = _esp32TftEnsure();
if (!ctx) return;
switch (cmd) {
case 'I': // init — clear black
ctx.fillStyle = '#000'; ctx.fillRect(0, 0, _esp32TftW, _esp32TftH); break;
case 'F': // fillScreen(color)
ctx.fillStyle = _rgb565(args[0]); ctx.fillRect(0, 0, _esp32TftW, _esp32TftH); break;
case 'B': // fillRect(x,y,w,h,color)
ctx.fillStyle = _rgb565(args[4]); ctx.fillRect(args[0], args[1], args[2], args[3]); break;
case 'P': // drawPixel(x,y,color)
ctx.fillStyle = _rgb565(args[2]); ctx.fillRect(args[0], args[1], 1, 1); break;
case 'L': // drawLine(x0,y0,x1,y1,color)
ctx.strokeStyle = _rgb565(args[4]); ctx.lineWidth = 1;
ctx.beginPath(); ctx.moveTo(args[0] + .5, args[1] + .5); ctx.lineTo(args[2] + .5, args[3] + .5); ctx.stroke(); break;
case 'C': // circle(x,y,r,color,filled)
ctx.beginPath(); ctx.arc(args[0], args[1], Math.max(1, args[2]), 0, Math.PI * 2);
if (args[4]) { ctx.fillStyle = _rgb565(args[3]); ctx.fill(); }
else { ctx.strokeStyle = _rgb565(args[3]); ctx.lineWidth = 1; ctx.stroke(); } break;
case 'T': { // text char: x,y,fg,bg,size,charcode
const [tx, ty, fg, bg, sz, ch] = args, fs = sz * 8;
ctx.font = fs + 'px "Courier New",monospace';
ctx.textBaseline = 'top';
if (bg !== fg) { ctx.fillStyle = _rgb565(bg); ctx.fillRect(tx, ty, Math.ceil(fs * 0.6), fs + 1); }
ctx.fillStyle = _rgb565(fg); ctx.fillText(String.fromCharCode(ch), tx, ty); break;
}
case 'R': { // setRotation(r)
const r = args[0];
if ((r === 1 || r === 3) && _esp32TftW !== _esp32TftH) {
const tmp = _esp32TftW; _esp32TftW = _esp32TftH; _esp32TftH = tmp;
_esp32TftCtx2d = null; _esp32TftEnsure();
} break;
}
}
_schTftMarkDirty();
}
function connectEsp32Output() {
if (_esp32EvtSource) { _esp32EvtSource.close(); _esp32EvtSource = null; }
_esp32BootDone = false; _esp32PinStates = {}; _esp32PinModes = {};
updateEsp32BoardSvg();
const btTimeout = setTimeout(() => {
if (!_esp32BootDone) {
_esp32BootDone = true;
setStatus('running', '실행 중');
appendSerial('\n[앱 실행 중]\n', 'sys');
updateEsp32BoardSvg();
}
}, 8000);
_esp32EvtSource = new EventSource('/api/arduino/qemu/' + _esp32SessionId + '/output', { withCredentials: true });
const BOOT_PAT = /ets |rst:|boot:|configsip|clk_drv|mode:DIO|mode:QIO|load:0x|ho 0 tail|entry 0x/i;
let entryPending = false;
_esp32EvtSource.onmessage = (e) => {
const msg = JSON.parse(e.data);
if (msg.g) {
if (typeof msg.g.v === 'number') _esp32PinStates[msg.g.p] = msg.g.v;
if (msg.g.m) _esp32PinModes[msg.g.p] = msg.g.m;
updateEsp32BoardSvg();
_updateEsp32PinPopup();
// Flash the pin briefly on activity
_esp32FlashPin(msg.g.p);
return;
}
if (msg.dht) {
// dht.begin() fired in firmware — show sliders for this GPIO regardless of schematic
const pinN = msg.dht.pin;
if (!_esp32DhtPins.some(p => p.gpio === pinN))
_esp32DhtPins.push({ gpio: pinN, model: 'DHT22' });
const sec = document.getElementById('dht-section');
if (sec) { sec.style.display = ''; }
const dot = document.getElementById('dht-dot');
const lbl = document.getElementById('dht-label');
if (dot) dot.className = 'dht-dot on';
if (lbl) lbl.textContent = 'DHT22 온습도 (GPIO' + pinN + ')';
_esp32FlashPin(pinN);
// Send DHT values after sketch has fully booted (dht.begin() confirmed)
setTimeout(() => _esp32SendDHTValues(), 1000);
return;
}
if (msg.servo) { _servoAttach(msg.servo.pin); return; }
if (msg.servo_angle) { _servoUpdateAngle(msg.servo_angle.pin, msg.servo_angle.angle); return; }
if (msg.imu) { _imuAttach(msg.imu.sda, msg.imu.scl, msg.imu.addr); setTimeout(() => _esp32SendIMUValues(), 1000); return; }
if (msg.imu_values) { _imuUpdateValues(msg.imu_values); return; }
if (msg.bme) { _bmeAttach(msg.bme.sda, msg.bme.scl, msg.bme.addr); setTimeout(() => _esp32SendBMEValues(), 1000); return; }
if (msg.bme_values) { _bmeUpdateValues(msg.bme_values); return; }
if (msg.onewire) { _ds18b20Attach(msg.onewire.pin); return; }
if (msg.ds18b20) { _ds18b20UpdateValues(msg.ds18b20); return; }
if (msg.relay) { _relayUpdate(msg.relay.pin, msg.relay.state); return; }
if (msg.motor) { _motorAttach(msg.motor); return; }
if (msg.motor_state) { _motorUpdateState(msg.motor_state); return; }
if (msg.wifi) { _wifiHandleEvent(msg.wifi); return; }
if (msg.http_port) { _showHttpPortLink(msg.http_port); return; }
if (msg.tft) { _esp32TftDraw(msg.tft); return; }
const t = msg.t;
if (!t) return;
_esp32BlinkRx();
if (!_esp32BootDone) {
if (entryPending || !BOOT_PAT.test(t)) {
_esp32BootDone = true; entryPending = false;
clearTimeout(btTimeout);
setStatus('running', '실행 중');
updateEsp32BoardSvg();
const isCrash = /Guru Meditation|abort\(\)|panic|LoadProhibited|StoreProhibited/i.test(t);
if (isCrash) setStatus('error', '크래시');
appendSerial(t, isCrash ? 'error' : 'line');
} else {
if (t.includes('entry 0x')) entryPending = true;
appendSerial(t, 'sys');
}
} else {
const isCrash = /Guru Meditation|abort\(\)|panic/i.test(t);
if (isCrash) setStatus('error', '크래시');
appendSerial(t, isCrash ? 'error' : 'line');
}
const out = document.getElementById('serial-output');
if (out) out.scrollTop = out.scrollHeight;
};
_esp32EvtSource.onerror = () => {
clearTimeout(btTimeout);
setStatus('stopped', '종료됨');
};
}
function esp32Restart() {
if (!_esp32SessionId) { appendSerial('[세션 없음 — 재시작 불가]\n', 'error'); return; }
if (_esp32EvtSource) { _esp32EvtSource.close(); _esp32EvtSource = null; }
clearSerial();
appendSerial('[ESP32 재시작 중...]\n', 'sys');
setStatus('loading', '재시작 중...');
_esp32UpdatePwrBtn(true);
fetch('/api/arduino/qemu/' + _esp32SessionId + '/restart', { method:'POST', credentials:'include' })
.then(r => r.json())
.then(d => { if (d.error) throw new Error(d.error); connectEsp32Output(); })
.catch(e => appendSerial('[재시작 실패: ' + (e.message||'오류') + ']\n', 'error'));
}
function esp32ToggleStop() {
const btn = document.getElementById('esp32-stop-btn');
if (_esp32EvtSource) { // 현재 실행 중 → 정지
esp32Stop();
if (btn) { btn.textContent = '▶ 시작'; btn.style.background = '#1a3a2a'; btn.style.color = '#4ade80'; btn.style.borderColor = '#2d5a3a'; }
} else { // 정지 상태 → 재시작
esp32Restart();
if (btn) { btn.textContent = '■ 정지'; btn.style.background = '#3a1a1a'; btn.style.color = '#ef4444'; btn.style.borderColor = '#5a2d2d'; }
}
}
function esp32Stop() {
if (_esp32EvtSource) { _esp32EvtSource.close(); _esp32EvtSource = null; }
if (_esp32SessionId) {
fetch('/api/arduino/qemu/' + _esp32SessionId + '/pause', { method:'POST', credentials:'include' }).catch(()=>{});
}
setStatus('stopped', '정지됨');
_esp32UpdatePwrBtn(false);
}
let _esp32PowerOn = true;
function _esp32UpdatePwrBtn(on) {
_esp32PowerOn = on;
const bg = document.getElementById('esp32-pwr-btn-bg');
const lbl = document.getElementById('esp32-pwr-btn-lbl');
if (bg) { bg.setAttribute('fill', on ? '#1a4a1a' : '#3a1010'); bg.setAttribute('stroke', on ? '#44cc44' : '#cc4444'); }
if (lbl) { lbl.textContent = on ? 'ON' : 'OFF'; lbl.setAttribute('fill', on ? '#88ee88' : '#ee8888'); }
}
function esp32TogglePower() {
if (_esp32PowerOn) {
// Soft stop: kill PTY but keep session & firmware for restart
if (_esp32EvtSource) { _esp32EvtSource.close(); _esp32EvtSource = null; }
if (_esp32SessionId)
fetch('/api/arduino/qemu/' + _esp32SessionId + '/pause', { method:'POST', credentials:'include' }).catch(()=>{});
setStatus('stopped', '전원 꺼짐');
_esp32UpdatePwrBtn(false);
appendSerial('\n[전원 꺼짐]\n', 'sys');
} else {
if (!_esp32SessionId) return;
_esp32UpdatePwrBtn(true);
esp32Restart();
}
}
window.addEventListener('beforeunload', () => {
if (_IS_ESP32 && _esp32SessionId)
fetch('/api/arduino/qemu/' + _esp32SessionId, { method:'DELETE', credentials:'include', keepalive:true });
if (_IS_ESP8266 && _esp8266SessionId)
fetch('/api/arduino/esp8266/' + _esp8266SessionId, { method:'DELETE', credentials:'include', keepalive:true });
});
// ── State ────────────────────────────────────────────────────────────────────
let cpu = null, uart = null, portB = null, portC = null, portD = null;
let rafId = null, ticksPerFrame = 160000, totalCycles = 0, running = false;
let hexData = null, emulatorReady = false;
const PIN_COUNT_D = 14, PIN_COUNT_A = 6;
let serialLineSpan = null;
let _serialDecoder = null; // 스트리밍 TextDecoder — 멀티바이트 UTF-8 + Latin-1 안전 처리
// ── UI helpers ───────────────────────────────────────────────────────────────
function setStatus(cls, text) {
const el = document.getElementById('status');
el.className = 'status ' + cls;
el.textContent = text;
_syncAvrCtrlBtns(cls);
}
function _syncAvrCtrlBtns(cls) {
const stopBtn = document.getElementById('btn-stop');
const resetBtn = document.getElementById('btn-reset');
if (!stopBtn || !resetBtn) return;
if (stopBtn.style.display === 'none') return;
const isRunning = (cls === 'running');
const isReady = (cls !== 'loading');
stopBtn.textContent = isRunning ? '■ 정지' : '▶ 시작';
stopBtn.disabled = !isReady || cls === 'error';
resetBtn.disabled = !isReady || cls === 'error';
}
function appendSerial(text, cls = 'line') {
const out = document.getElementById('serial-output');
const span = document.createElement('span');
span.className = cls;
span.textContent = text;
out.appendChild(span);
out.scrollTop = out.scrollHeight;
}
window.clearSerial = () => { document.getElementById('serial-output').innerHTML = ''; serialLineSpan = null; };
window.sendSerial = () => {
const inp = document.getElementById('serial-input');
if (!inp.value || !uart) return;
for (const ch of inp.value + '\n') uart.writeByte(ch.charCodeAt(0));
inp.value = '';
};
window.showOverlayError = (msg) => {
document.getElementById('overlay-spinner').style.display = 'none';
document.getElementById('overlay-title').textContent = '⚠️ 오류';
const p = document.getElementById('overlay-msg');
p.textContent = msg;
p.style.display = 'block';
document.getElementById('overlay-btn').style.display = 'inline-block';
};
window.closeOverlay = () => { document.getElementById('overlay').style.display = 'none'; };
// ── Pin UI ────────────────────────────────────────────────────────────────────
function buildPinUI() {
const dEl = document.getElementById('digital-pins');
dEl.innerHTML = '';
for (let i = 0; i < PIN_COUNT_D; i++) {
const row = document.createElement('div'); row.className = 'pin-row';
row.innerHTML = `<span class="pin-label">D${i}</span><div class="pin-led" id="dpin-${i}"></div><span class="pin-val" id="dpinv-${i}">0</span>`;
dEl.appendChild(row);
}
const aEl = document.getElementById('analog-pins');
aEl.innerHTML = '';
for (let i = 0; i < PIN_COUNT_A; i++) {
const row = document.createElement('div'); row.className = 'pin-row';
row.innerHTML = `<span class="pin-label">A${i}</span><div class="pin-led" id="apin-${i}"></div><span class="pin-val" id="apinv-${i}">0</span>`;
aEl.appendChild(row);
}
}
// Read actual pin voltage: for OUTPUT pins use MCU value, for INPUT pins read PIN register (includes setPin)
function pinVoltage(port, pinIndex) {
const ddr = port.cpu.data[port.portConfig.DDR];
const isOutput = !!(ddr & (1 << pinIndex));
if (isOutput) {
return !!(port.lastValue & (1 << pinIndex)); // MCU driven
}
return !!(port.cpu.data[port.portConfig.PIN] & (1 << pinIndex)); // external value (setPin)
}
function updatePinUI() {
if (!portD || !portB || !portC) return;
// Digital pins D0-D7 (Port D) - 핀 헤더 자체가 LED
for (let i = 0; i < 8; i++) {
const val = pinVoltage(portD, i);
const led = document.getElementById('dpin-' + i);
const lbl = document.getElementById('dpinv-' + i);
if (led) led.className = 'pin-led' + (val ? ' on' : '');
if (lbl) lbl.textContent = val ? '1' : '0';
// Board SVG 핀 LED
document.getElementById('board-d' + i + '-led')?.setAttribute('fill', val ? '#22c55e' : '#0d2a0d');
}
// Digital pins D8-D13 (Port B)
for (let i = 0; i < 6; i++) {
const val = pinVoltage(portB, i);
const pinNum = 8 + i;
const led = document.getElementById('dpin-' + pinNum);
const lbl = document.getElementById('dpinv-' + pinNum);
if (led) led.className = 'pin-led' + (val ? ' on' : '');
if (lbl) lbl.textContent = val ? '1' : '0';
// Board SVG 핀 LED
document.getElementById('board-d' + pinNum + '-led')?.setAttribute('fill', val ? '#22c55e' : '#0d2a0d');
// D13 built-in LED (온보드 LED)
if (i === 5) {
document.getElementById('board-led-13')?.setAttribute('fill', val ? '#22c55e' : '#1a1a1a');
}
}
// Analog pins A0-A5 (Port C)
for (let i = 0; i < 6; i++) {
const val = pinVoltage(portC, i);
const led = document.getElementById('apin-' + i);
const lbl = document.getElementById('apinv-' + i);
if (led) led.className = 'pin-led' + (val ? ' in' : '');
if (lbl) lbl.textContent = val ? '1' : '0';
// Board SVG 핀 LED
document.getElementById('board-a' + i + '-led')?.setAttribute('fill', val ? '#3b82f6' : '#0d2a0d');
}
// 회로도 LED 연동: GPIO 상태 변화 시 schDraw 요청
if (!_IS_ESP32 && !_IS_ESP8266 && _schLiveMap.led.size > 0) {
let needRedraw = false;
for (const [comp, gpio] of _schLiveMap.led) {
const port = gpio < 8 ? portD : gpio < 14 ? portB : null;
const pin = gpio < 8 ? gpio : gpio < 14 ? gpio - 8 : -1;
const lit = !!(port && pinVoltage(port, pin));
if (comp._schLit !== lit) { comp._schLit = lit; needRedraw = true; }
}
if (needRedraw) requestAnimationFrame(schDraw);
}
}
// ── Emulator init ─────────────────────────────────────────────────────────────
function initEmulator(hex) {
hexData = hex;
if (!avr8jsReady) { showOverlayError('avr8js 미로드'); return; }
const flash = parseIntelHex(hex);
cpu = new CPU(new Uint16Array(flash.buffer));
portB = new AVRIOPort(cpu, portBConfig);
portC = new AVRIOPort(cpu, portCConfig);
portD = new AVRIOPort(cpu, portDConfig);
if (timer0Config) new AVRTimer(cpu, timer0Config);
if (timer1Config) new AVRTimer(cpu, timer1Config);
if (timer2Config) new AVRTimer(cpu, timer2Config);
setupTWI(cpu);
setupSPI(cpu);
uart = new AVRUSART(cpu, {
rxCompleteInterrupt: 0x24, dataRegisterEmptyInterrupt: 0x26, txCompleteInterrupt: 0x28,
UCSRA: 0xc0, UCSRB: 0xc1, UCSRC: 0xc2, UBRRL: 0xc4, UBRRH: 0xc5, UDR: 0xc6,
}, 16e6);
serialLineSpan = null;
_serialDecoder = new TextDecoder('utf-8', { fatal: false });
uart.onByteTransmit = (value) => {
if (value === 0x0D) return; // \r
const chars = _serialDecoder.decode(new Uint8Array([value]), { stream: true });
if (!chars) return; // 멀티바이트 시퀀스 수신 중 — 완성될 때까지 대기
const out = document.getElementById('serial-output');
for (const ch of chars) {
if (ch === '\n') { serialLineSpan = null; continue; }
if (!serialLineSpan) {
serialLineSpan = document.createElement('span');
serialLineSpan.className = 'line';
out.appendChild(serialLineSpan);
}
serialLineSpan.textContent += ch;
}
out.scrollTop = out.scrollHeight;
};
totalCycles = 0;
lcdReset();
oledReset();
tftReset();
_servoReset();
_sensorReset();
const oledCanvas = document.getElementById('oled-canvas');
oledCtx = oledCanvas.getContext('2d');
oledCtx.clearRect(0, 0, 256, 128);
buildPinUI();
let lastD13 = -1;
portB.addListener((value) => {
const d13 = (value >> 5) & 1;
if (d13 !== lastD13) {
lastD13 = d13;
appendSerial(`[D13] ${d13 ? 'HIGH ^' : 'LOW _'}\n`, 'sys');
}
});
emulatorReady = true;
setStatus('ready', '준비');
document.getElementById('overlay').style.display = 'none';
_updatePowerSwitch(); // 초기 상태 표시 (대기 중)
setupDHTSensors(); // DHT 센서 연결 감지
_buildSchLiveMap(); // 회로도 LED/버튼 연동 맵 구축
}
// ── Run loop ──────────────────────────────────────────────────────────────────
function runFrame() {
if (!running || !cpu) return;
try {
const target = cpu.cycles + ticksPerFrame;
while (cpu.cycles < target) {
// Break CPU loop at DHT transition points for cycle-accurate timing
let nextDHT = Infinity;
for (const s of dhtSensors) {
if (s.nextCycle > 0 && s.nextCycle > cpu.cycles && s.nextCycle < nextDHT) nextDHT = s.nextCycle;
}
const batchEnd = Math.min(nextDHT, target);
while (cpu.cycles < batchEnd) {
window._avrInstruction(cpu);
cpu.tick();
// After each instruction, check if a DHT state change (from _onPortChange)
// set a new nextCycle that's now due — break to recalculate batchEnd
let dhtDue = false;
for (const s of dhtSensors) {
if (s.nextCycle > 0 && s.nextCycle <= cpu.cycles) { dhtDue = true; break; }
}
if (dhtDue) break;
}
// Advance DHT state machines at their scheduled cycle
for (const sensor of dhtSensors) sensor.tick();
}
totalCycles = cpu.cycles;
updatePinUI();
oledFlushCanvas();
tftFlushCanvas();
const simSec = (totalCycles / 16e6).toFixed(2);
document.getElementById('footer-cycles').textContent = `${Math.round(totalCycles).toLocaleString()} cycles (${simSec}s)`;
rafId = requestAnimationFrame(runFrame);
} catch(e) {
running = false;
setStatus('error', '오류');
appendSerial('실행 오류: ' + e.message + '\n', 'error');
_updatePowerSwitch();
}
}
window.startEmulator = () => {
if (!cpu || running) return;
running = true;
setStatus('running', '실행 중');
_updatePowerSwitch(); // 메시지 자동 출력
runFrame();
};
window.stopEmulator = () => {
running = false;
if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
setStatus('stopped', '정지');
_updatePowerSwitch(); // 메시지 자동 출력
};
window.resetEmulator = () => {
if (!hexData) return;
// 먼저 실행 정지
running = false;
if (rafId) { cancelAnimationFrame(rafId); rafId = null; }
setStatus('stopped', '정지');
// 리셋 중에는 메시지 출력 억제 (_lastPowerState 를 null 로)
_lastPowerState = null;
appendSerial('\n--- 리셋 ---\n', 'sys');
// 에뮬레이터 재초기화
initEmulator(hexData);
// 리셋 후 자동으로 실행 (파워 스위치 ON 상태 유지)
setTimeout(() => {
if (emulatorReady && !running) {
_lastPowerState = null; // 상태 변화 감지용 초기화
startEmulator();
}
}, 50);
};
// setSpeed/setSpeedSlider: 하단 초기화 블록에서 정의됨
// ── 보드 파워 스위치 ───────────────────────────────────────────────────────────
let _lastPowerState = null;
function _updatePowerSwitch() {
const arc = document.getElementById('pw-arc');
const line = document.getElementById('pw-line');
const bg = document.getElementById('pw-bg');
const ring = document.getElementById('pw-ring');
const pwrLed = document.getElementById('board-pwr-led');
if (!arc) return;
const isRunning = running;
if (isRunning) {
arc.setAttribute('stroke','#22c55e');
line.setAttribute('stroke','#22c55e');
bg.setAttribute('fill','#071a0d');
bg.setAttribute('stroke','#22c55e');
ring.setAttribute('stroke','#166534');
bg.style.filter='drop-shadow(0 0 3px #22c55e)';
if (pwrLed) { pwrLed.setAttribute('fill','#22c55e'); pwrLed.setAttribute('stroke','#22c55e'); pwrLed.style.filter='drop-shadow(0 0 2px #22c55e)'; }
} else {
arc.setAttribute('stroke','#c0404088');
line.setAttribute('stroke','#c0404088');
bg.setAttribute('fill','#1a0808');
bg.setAttribute('stroke','#803030');
ring.setAttribute('stroke','#602020');
bg.style.filter='drop-shadow(0 0 2px #80202060)';
if (pwrLed) { pwrLed.setAttribute('fill','#0d2a0d'); pwrLed.setAttribute('stroke','#1a5a1a'); pwrLed.style.filter=''; }
}
// 파워 상태 변화 시 메시지 출력
if (_lastPowerState !== isRunning && emulatorReady) {
if (isRunning) {
appendSerial('⚡ 전원 ON — 실행 중\n', 'sys');
} else {
appendSerial('⏸ 전원 OFF — 대기 중\n', 'sys');
}
_lastPowerState = isRunning;
}
}
// ── 테마 ───────────────────────────────────────────────────────────────────────
window.setTheme = (name) => {
if (name === 'light') {
document.body.removeAttribute('data-theme');
} else {
document.body.setAttribute('data-theme', name);
}
localStorage.setItem('emu-theme', name);
document.querySelectorAll('.tdot').forEach(d => d.classList.remove('active'));
const el = document.getElementById('td-' + name);
if (el) el.classList.add('active');
};
window.togglePower = () => {
if (running) { stopEmulator(); return; }
if (!emulatorReady) return;
startEmulator();
};
// ── Boot ──────────────────────────────────────────────────────────────────────
async function boot() {
// ── 보드 타이틀 동적 설정 ─────────────────────────────────────────────────────
const params = new URLSearchParams(location.search);
const _urlFqbn = params.get('fqbn') || '';
const _fqbnBoard = (_urlFqbn.split(':')[2] || '').toLowerCase();
const _BOARD_NAMES = {
uno: 'ARDUINO UNO · ATmega328P', nano: 'ARDUINO NANO · ATmega328P',
mega: 'ARDUINO MEGA 2560 · ATmega2560', mega2560: 'ARDUINO MEGA 2560 · ATmega2560',
leonardo: 'ARDUINO LEONARDO · ATmega32U4', micro: 'ARDUINO MICRO · ATmega32U4',
esp32: 'ESP32 · Xtensa LX6', esp32s3: 'ESP32-S3 · Xtensa LX7',
esp8266: 'ESP8266 · L106',
};
if (_fqbnBoard && _BOARD_NAMES[_fqbnBoard]) {
const boardTitleEl = document.querySelector('.board-title');
if (boardTitleEl) boardTitleEl.textContent = _BOARD_NAMES[_fqbnBoard];
const footerEl = document.getElementById('footer-info');
if (footerEl && _fqbnBoard !== 'uno') footerEl.textContent = 'avr8js · ' + _BOARD_NAMES[_fqbnBoard];
}
// ── ESP32 QEMU mode: ?session=<id>&file=<name>&fqbn=<fqbn> ──────────────────
const qemuSession = params.get('session');
if (qemuSession) {
const fname = params.get('file') || 'sketch.ino';
const fqbn = params.get('fqbn') || '';
document.getElementById('overlay').style.display = 'none';
setStatus('loading', '부팅 중...');
if (qemuSession.startsWith('esp8266_')) {
const savedSketch = localStorage.getItem('esp8266_sketch');
if (savedSketch) { _esp8266SketchCode = savedSketch; localStorage.removeItem('esp8266_sketch'); }
initEsp8266Mode(qemuSession, fname, fqbn);
} else {
// Restore sketch code from code editor (localStorage: shared across windows)
const savedSketch = localStorage.getItem('esp32_sketch');
if (savedSketch) { _esp32SketchCode = savedSketch; localStorage.removeItem('esp32_sketch'); }
initEsp32Mode(qemuSession, fname, fqbn);
}
return;
}
if (params.get('loading') === '1') {
// 코드 에디터가 컴파일 후 ?session=으로 redirect 예정 — 스피너만 표시
document.getElementById('overlay-title').textContent = '컴파일 중...';
return;
}
// ── Arduino AVR mode (기존) ──────────────────────────────────────────────────
const sketch = sessionStorage.getItem('arduino_sketch');
const board = sessionStorage.getItem('arduino_board') || 'uno';
const fname = sessionStorage.getItem('arduino_filename') || 'sketch.ino';
const hexDirect = sessionStorage.getItem('arduino_hex');
sessionStorage.removeItem('arduino_sketch');
sessionStorage.removeItem('arduino_board');
sessionStorage.removeItem('arduino_filename');
sessionStorage.removeItem('arduino_hex');
// AVR 모드 보드 타이틀 (URL fqbn이 없는 경우 sessionStorage board로 대체)
if (!_fqbnBoard) {
const _avrBoard = (board || 'uno').toLowerCase();
const _avrName = _BOARD_NAMES[_avrBoard] || _BOARD_NAMES.uno;
const boardTitleEl = document.querySelector('.board-title');
if (boardTitleEl && _avrBoard !== 'uno') boardTitleEl.textContent = _avrName;
}
document.getElementById('filename').textContent = fname;
if (hexDirect) { initEmulator(hexDirect); return; }
if (!sketch) {
showOverlayError('스케치가 없습니다.\n코드 에디터에서 .ino 파일을 열고 실행하세요.');
return;
}
try {
const r = await fetch('/api/arduino/compile', {
method: 'POST', headers: {'Content-Type':'application/json'}, credentials: 'include',
body: JSON.stringify({ code: sketch, board }),
});
const data = await r.json();
if (data.error) {
let msg = data.error;
if (data.install) msg += '\n\n설치 필요:\n' + data.install + '\n' + (data.installCore || '');
showOverlayError(msg);
return;
}
initEmulator(data.hex);
} catch(e) { showOverlayError('서버 오류: ' + e.message); }
}
// 저장된 테마 복원
setTheme(localStorage.getItem('emu-theme') || 'light');
// ── Pin popup ─────────────────────────────────────────────────────────────────
window.togglePinPopup = () => {
document.getElementById('pin-popup').classList.toggle('open');
};
// Drag support for pin popup
(function() {
const popup = document.getElementById('pin-popup');
const hdr = document.getElementById('pin-popup-hdr');
let dragging = false, ox = 0, oy = 0;
hdr.addEventListener('mousedown', e => {
if (e.target.tagName === 'BUTTON') return;
dragging = true;
const r = popup.getBoundingClientRect();
ox = e.clientX - r.left;
oy = e.clientY - r.top;
e.preventDefault();
});
document.addEventListener('mousemove', e => {
if (!dragging) return;
let nx = e.clientX - ox, ny = e.clientY - oy;
nx = Math.max(0, Math.min(nx, window.innerWidth - popup.offsetWidth));
ny = Math.max(0, Math.min(ny, window.innerHeight - popup.offsetHeight));
popup.style.left = nx + 'px';
popup.style.top = ny + 'px';
popup.style.right = 'auto';
});
document.addEventListener('mouseup', () => { dragging = false; });
})();
// ── Schematic Editor ─────────────────────────────────────────────────────────
const SCH = window._SCH = {
GRID: 20,
components: [],
wires: [],
nextId: 1,
tool: 'wire',
ghostRot: 0,
ghostFlipH: false, // horizontal flip for ghost (placing)
ghostFlipV: false, // vertical flip for ghost (placing)
wireStart: null,
mousePt: {x:0, y:0},
selId: null,
selection: [], // 선택된 항목 IDs [{type:'comp'|'wire', id}]
dragComp: null,
dragBox: null, // {x1,y1,x2,y2} 영역 선택 박스
clipboard: null, // 복사된 데이터 {components:[], wires:[], dx,dy}
undoStack: [],
scale: 1, // zoom level
panX: 0, // pan offset X
panY: 0, // pan offset Y
_simCache: null, // LED/btn → GPIO mapping, rebuilt on schUpdateNetlist
_pressedBtn: null, // {gpio, portObj, pinIdx} when btn held
};
const SCH_COLOR = {
wire:'#1d4ed8', res:'#b45309', cap:'#7c3aed', led:'#dc2626',
diode:'#d97706', zener:'#8b5cf6', bjt:'#be185d', mosfet:'#0369a1',
btn:'#15803d', vcc:'#dc2626', gnd:'#2563eb', ard:'#ea580c',
seg7:'#dc2626', buzzer:'#7c3aed', rgb_led:'#db2777',
pot:'#0369a1', sw_toggle:'#15803d', ldr:'#d97706', ntc:'#ea580c',
ic_555:'#b91c1c', ic_741:'#1d4ed8', ic_7400:'#166534',
ic_shift:'#7e22ce', ic_l293:'#b45309',
dht:'#0891b2', hcsr:'#6366f1', pir:'#f59e0b', ir:'#ef4444',
sw520:'#64748b', hc05:'#2563eb', nrf:'#7c3aed', esp01:'#16a34a', lm7805:'#b45309',
tft_lcd:'#0284c7',
board_esp32:'#1d4ed8', board_esp8266:'#0e7490',
board_uno:'#ea580c', board_nano:'#7c3aed', board_mega:'#0e7490',
servo:'#dc2626', relay:'#d97706', ds18b20:'#6d28d9', mpu6050:'#0891b2', l298n:'#a16207', bme280:'#16a34a',
};
// Terminal offsets from component center (px, rot=0). All must be multiples of GRID(20).
const SCH_TERMS = {
res: [{x:-40,y:0},{x:40,y:0}],
cap: [{x:-40,y:0},{x:40,y:0}],
led: [{x:-40,y:0},{x:40,y:0}],
diode: [{x:-40,y:0},{x:40,y:0}],
zener: [{x:-40,y:0},{x:40,y:0}],
btn: [{x:-40,y:0},{x:40,y:0}],
bjt: [{x:-40,y:0},{x:20,y:-40},{x:20,y:40}], // B, C, E
mosfet:[{x:-40,y:0},{x:20,y:-40},{x:20,y:40}], // G, D, S
vcc: [{x:0,y:0}],
gnd: [{x:0,y:0}],
ard: [{x:0,y:0}],
};
// Compute terminal offsets for a DIP-style IC: lN left pins, rN right pins
function _icTermOffsets(lN, rN) {
const N = Math.max(lN, rN, 1), fy = -Math.floor(N/2)*20;
const lx = rN > 0 ? -80 : -60, t = [];
for (let i = 0; i < lN; i++) t.push({x: lx, y: fy + i*20});
for (let i = 0; i < rN; i++) t.push({x: 80, y: fy + i*20});
return t;
}
// Extend SCH_TERMS with IC/module/new-part terminal positions
Object.assign(SCH_TERMS, (()=>{
const icT = _icTermOffsets;
return {
pot: [{x:-40,y:0},{x:0,y:-40},{x:40,y:0}],
sw_toggle:[{x:-40,y:0},{x:40,y:0}],
ldr: [{x:-40,y:0},{x:40,y:0}],
ntc: [{x:-40,y:0},{x:40,y:0}],
buzzer: [{x:-40,y:0},{x:40,y:0}],
rgb_led: [{x:-40,y:-20},{x:-40,y:20},{x:40,y:-20},{x:40,y:20}],
sw520: [{x:-40,y:0},{x:40,y:0}],
seg7: icT(5,4),
ic_555: icT(4,4),
ic_741: icT(4,4),
ic_7400: icT(7,7),
ic_shift: icT(8,8),
ic_l293: icT(8,8),
dht: icT(3,0),
hcsr: icT(4,0),
pir: icT(3,0),
ir: icT(3,0),
hc05: icT(2,2),
nrf: icT(4,4),
esp01: icT(4,4),
lm7805: icT(2,1),
tft_lcd: icT(5,3),
board_esp32: icT(15,15),
board_esp8266: icT(10,11),
board_uno: icT(11,14),
board_nano: icT(14,14),
board_mega: icT(16,16),
servo: icT(3,0),
relay: icT(3,3),
ds18b20: icT(3,0),
mpu6050: icT(5,0),
l298n: icT(8,5),
bme280: icT(4,0),
};
})());
function schRotPt(px, py, rot) {
const r = ((rot||0)%360+360)%360;
if (r===0) return {x:px, y:py};
if (r===90) return {x:-py, y:px};
if (r===180) return {x:-px, y:-py};
if (r===270) return {x:py, y:-px};
return {x:px, y:py};
}
function schTermPositions(comp) {
const raw = SCH_TERMS[comp.type] || [{x:0,y:0}];
const flipH = comp.flipH || false;
return raw.map(t => {
const fx = flipH ? -t.x : t.x;
const r = schRotPt(fx, t.y, comp.rot||0);
return {x: comp.x+r.x, y: comp.y+r.y};
});
}
function schSnap(v) { return Math.round(v / SCH.GRID) * SCH.GRID; }
function schCanvasXY(e) {
const cv = document.getElementById('sch-canvas');
if (!cv) return {x:0,y:0};
const rect = cv.getBoundingClientRect();
const wx = (e.clientX - rect.left - SCH.panX) / SCH.scale;
const wy = (e.clientY - rect.top - SCH.panY) / SCH.scale;
return {x: schSnap(wx), y: schSnap(wy)};
}
function schHitComp(x, y) {
for (let i=SCH.components.length-1; i>=0; i--) {
const c=SCH.components[i];
const terms=SCH_TERMS[c.type]||[{x:0,y:0}];
// Compute x and y hit radii separately (not combined max)
let rx=0, ry=0;
for (const t of terms) { rx=Math.max(rx,Math.abs(t.x)); ry=Math.max(ry,Math.abs(t.y)); }
// Minimum hit area for small components (resistors, LEDs, etc.)
rx=Math.max(rx,20); ry=Math.max(ry,12);
if (Math.abs(x-c.x)<=rx+6 && Math.abs(y-c.y)<=ry+6) return c;
}
return null;
}
function schHitWire(x, y) {
const THRESH = 7;
for (let i=SCH.wires.length-1; i>=0; i--) {
const w=SCH.wires[i];
const dx=w.x2-w.x1, dy=w.y2-w.y1, len2=dx*dx+dy*dy;
if (len2<1) continue;
const t=Math.max(0,Math.min(1,((x-w.x1)*dx+(y-w.y1)*dy)/len2));
const px=w.x1+t*dx-x, py=w.y1+t*dy-y;
if (px*px+py*py < THRESH*THRESH) return w;
}
return null;
}
function ptOnSegStrict(px,py, x1,y1, x2,y2) {
if ((px===x1&&py===y1)||(px===x2&&py===y2)) return false;
const dx=x2-x1, dy=y2-y1, len2=dx*dx+dy*dy;
if (len2<1) return false;
const t=((px-x1)*dx+(py-y1)*dy)/len2;
if (t<0||t>1) return false;
return Math.abs(x1+t*dx-px)<0.5 && Math.abs(y1+t*dy-py)<0.5;
}
// ── Schematic Drawing ────────────────────────────────────────────────────────
function schResizeCanvas() {
const cv=document.getElementById('sch-canvas');
const wrap=document.getElementById('sch-canvas-wrap');
if (!cv||!wrap) return;
const W=Math.max(wrap.clientWidth,400), H=Math.max(wrap.clientHeight,300);
if (cv.width!==W||cv.height!==H) { cv.width=W; cv.height=H; }
}
function schDraw() {
schResizeCanvas();
const cv=document.getElementById('sch-canvas');
if (!cv) return;
const ctx=cv.getContext('2d');
const W=cv.width, H=cv.height, G=SCH.GRID;
const sc=SCH.scale, ox=SCH.panX, oy=SCH.panY;
ctx.clearRect(0,0,W,H);
ctx.fillStyle='#ffffff'; ctx.fillRect(0,0,W,H);
ctx.save();
ctx.setTransform(sc, 0, 0, sc, ox, oy);
// Grid dots (visible world area only)
const gx0=Math.floor(-ox/sc/G)*G-G, gy0=Math.floor(-oy/sc/G)*G-G;
const gx1=gx0+Math.ceil(W/sc)+2*G, gy1=gy0+Math.ceil(H/sc)+2*G;
const dotR=Math.max(0.4, 1/sc);
ctx.fillStyle='#e2e8f0';
for (let x=gx0; x<=gx1; x+=G) for (let y=gy0; y<=gy1; y+=G) {
ctx.beginPath(); ctx.arc(x,y,dotR,0,Math.PI*2); ctx.fill();
}
// Wires
ctx.lineCap='square';
// Collect crossings for omega loops (gap horizontal wire at vertical crossings)
const _hSegs = [], _vSegs = [];
for (const w of SCH.wires) {
if (w.y1 === w.y2) _hSegs.push(w);
else if (w.x1 === w.x2) _vSegs.push(w);
}
// Find crossing points
const _crossings = [];
const _eps = 2;
for (const h of _hSegs) {
const hx1 = Math.min(h.x1, h.x2), hx2 = Math.max(h.x1, h.x2);
for (const v of _vSegs) {
const vx = v.x1, vy1 = Math.min(v.y1, v.y2), vy2 = Math.max(v.y1, v.y2);
// Skip shared endpoints
const shares = (Math.abs(hx1-vx)<_eps && Math.abs(h.y1-vy1)<_eps) ||
(Math.abs(hx2-vx)<_eps && Math.abs(h.y1-vy2)<_eps) ||
(Math.abs(hx1-vx)<_eps && Math.abs(h.y1-vy2)<_eps) ||
(Math.abs(hx2-vx)<_eps && Math.abs(h.y1-vy1)<_eps);
if (shares) continue;
if (vx > hx1 + _eps && vx < hx2 - _eps && h.y1 > vy1 + _eps && h.y1 < vy2 - _eps) {
_crossings.push({ wire: h, cx: vx, cy: h.y1 });
}
}
}
for (const w of SCH.wires) {
const sel=SCH.selId===w.id;
ctx.strokeStyle=sel?'#f59e0b':'#1d4ed8';
ctx.lineWidth=sel?3:2;
// Check if this horizontal wire has crossings — draw with gaps
if (w.y1 === w.y2) {
const myCrossings = _crossings.filter(c => c.wire === w).sort((a,b) => a.cx - b.cx);
if (myCrossings.length > 0) {
const GAP = 6;
const segs = [];
let prevX = Math.min(w.x1, w.x2);
const endX = Math.max(w.x1, w.x2);
for (const c of myCrossings) {
if (c.cx - GAP > prevX) segs.push([prevX, c.cx - GAP]);
prevX = c.cx + GAP;
}
if (prevX < endX) segs.push([prevX, endX]);
for (const [sx, ex] of segs) {
ctx.beginPath(); ctx.moveTo(sx, w.y1); ctx.lineTo(ex, w.y1); ctx.stroke();
}
continue; // drawn with gaps, skip normal draw
}
}
ctx.beginPath(); ctx.moveTo(w.x1,w.y1); ctx.lineTo(w.x2,w.y2); ctx.stroke();
}
ctx.lineCap='round';
// Wire preview
if (SCH.wireStart) {
const {x:sx,y:sy}=SCH.wireStart, {x:mx,y:my}=SCH.mousePt;
ctx.strokeStyle='#93c5fd'; ctx.lineWidth=2;
ctx.setLineDash([5,4]);
ctx.beginPath(); ctx.moveTo(sx,sy); ctx.lineTo(mx,sy); ctx.lineTo(mx,my); ctx.stroke();
ctx.setLineDash([]);
ctx.fillStyle='#3b82f6'; ctx.beginPath(); ctx.arc(sx,sy,4,0,Math.PI*2); ctx.fill();
}
// Junctions
schDrawJunctions(ctx);
// Omega loops: where horizontal wires cross vertical wires, draw a small arc
_schDrawOmegaLoops(ctx);
// Components
for (const c of SCH.components) {
const sel = SCH.selection.some(s => s.type==='comp' && s.id===c.id);
schDrawComp(ctx, c, sel);
}
// Selection box (drag box)
if (SCH.dragBox) {
const {x1,y1,x2,y2} = SCH.dragBox;
ctx.strokeStyle='#2563eb';
ctx.lineWidth=1;
ctx.setLineDash([4,3]);
ctx.strokeRect(x1,y1,x2-x1,y2-y1);
ctx.fillStyle='rgba(37,99,235,0.08)';
ctx.fillRect(x1,y1,x2-x1,y2-y1);
ctx.setLineDash([]);
}
// Ghost
if (!['wire','select'].includes(SCH.tool)) {
ctx.globalAlpha=0.45;
schDrawComp(ctx,{type:SCH.tool,x:SCH.mousePt.x,y:SCH.mousePt.y,rot:SCH.ghostRot,flipH:SCH.ghostFlipH,flipV:SCH.ghostFlipV,value:'',id:-1},false);
ctx.globalAlpha=1;
}
ctx.restore();
}
function schDrawJunctions(ctx) {
const cnt=new Map();
const ap=(x,y)=>{const k=`${x},${y}`; cnt.set(k,(cnt.get(k)||0)+1);};
for (const w of SCH.wires) { ap(w.x1,w.y1); ap(w.x2,w.y2); }
for (const c of SCH.components) for (const t of schTermPositions(c)) ap(t.x,t.y);
for (const w of SCH.wires) for (const w2 of SCH.wires) {
if (w.id===w2.id) continue;
for (const p of [{x:w2.x1,y:w2.y1},{x:w2.x2,y:w2.y2}])
if (ptOnSegStrict(p.x,p.y,w.x1,w.y1,w.x2,w.y2)) ap(p.x,p.y);
}
ctx.fillStyle='#1d4ed8';
for (const [k,n] of cnt) if (n>=3) {
const [x,y]=k.split(',').map(Number);
ctx.beginPath(); ctx.arc(x,y,4,0,Math.PI*2); ctx.fill();
}
}
// ── Omega loop: small semicircular arc where a horizontal wire crosses a vertical wire ──
function _schDrawOmegaLoops(ctx) {
// Collect horizontal and vertical wire segments
const hSegs = [], vSegs = [];
for (const w of SCH.wires) {
if (w.y1 === w.y2) hSegs.push(w); // horizontal
else if (w.x1 === w.x2) vSegs.push(w); // vertical
}
// For each horizontal-vertical crossing that is NOT at a junction (shared endpoint),
// draw a small semicircular arc on the horizontal wire to show it passes over the vertical
const R = 6; // arc radius
const eps = 2;
ctx.strokeStyle = '#1d4ed8';
ctx.lineWidth = 2;
for (const h of hSegs) {
const hx1 = Math.min(h.x1, h.x2), hx2 = Math.max(h.x1, h.x2);
const hy = h.y1; // == h.y2
for (const v of vSegs) {
// Skip if same wire or share an endpoint (that's a real junction, not a crossing)
if (h.id === v.id) continue;
const vx = v.x1; // == v.x2
const vy1 = Math.min(v.y1, v.y2), vy2 = Math.max(v.y1, v.y2);
// Check shared endpoint
const sharesEndpoint = (Math.abs(hx1-vx)<eps && Math.abs(hy-vy1)<eps) ||
(Math.abs(hx2-vx)<eps && Math.abs(hy-vy2)<eps) ||
(Math.abs(hx1-vx)<eps && Math.abs(hy-vy2)<eps) ||
(Math.abs(hx2-vx)<eps && Math.abs(hy-vy1)<eps);
if (sharesEndpoint) continue;
// Do they cross? vertical x within horizontal range, horizontal y within vertical range
if (vx > hx1 + eps && vx < hx2 - eps && hy > vy1 + eps && hy < vy2 - eps) {
// Draw omega loop: gap the horizontal wire, draw arc over vertical
// Arc center at crossing point (vx, hy), bulging upward
ctx.beginPath();
ctx.arc(vx, hy, R, Math.PI, 0, false); // semicircle above
ctx.stroke();
}
}
}
}
function schDrawComp(ctx, comp, isSelected) {
const col=SCH_COLOR[comp.type]||'#475569';
ctx.save();
ctx.translate(comp.x, comp.y);
ctx.rotate((comp.rot||0)*Math.PI/180);
if (comp.flipH && comp.flipV) ctx.scale(-1, -1);
else if (comp.flipH) ctx.scale(-1, 1);
else if (comp.flipV) ctx.scale(1, -1);
switch(comp.type) {
case 'res': _schSymRes(ctx,col); break;
case 'cap': _schSymCap(ctx,col); break;
case 'led': {
const _litCol = comp._schLit ? _schLedLitColor(comp.value) : null;
_schSymDiode(ctx, _litCol || col, true);
if (_litCol) {
ctx.save(); ctx.globalAlpha=0.35;
ctx.fillStyle=_litCol; ctx.shadowColor=_litCol; ctx.shadowBlur=14;
ctx.beginPath(); ctx.arc(0,0,18,0,Math.PI*2); ctx.fill();
ctx.restore();
}
break;
}
case 'diode': _schSymDiode(ctx,col,false); break;
case 'zener': _schSymZener(ctx,col); break;
case 'btn': _schSymBtn(ctx,col); break;
case 'bjt': _schSymBJT(ctx,col,comp.value||'NPN'); break;
case 'mosfet': _schSymMOSFET(ctx,col,comp.value||'N-MOS'); break;
case 'vcc': _schSymVCC(ctx,comp.value||'+5V'); break;
case 'gnd': _schSymGND(ctx); break;
case 'ard': _schSymARD(ctx,col,comp.value||'D?'); break;
case 'pot': _schSymPot(ctx,col); break;
case 'sw_toggle':_schSymToggle(ctx,col); break;
case 'ldr': _schSymLDR(ctx,col); break;
case 'ntc': _schSymNTC(ctx,col); break;
case 'buzzer': _schSymBuzzer(ctx,col); break;
case 'rgb_led': _schSymRGBLed(ctx,col); break;
case 'sw520': _schSymToggle(ctx,col); break;
case 'seg7': _schSymIC(ctx,col,'7-SEG',['a','b','c','d','dp'],['e','f','g','COM']); break;
case 'ic_555': _schSymIC(ctx,col,'NE555',['GND','TRG','OUT','RST'],['VCC','DIS','THR','CV']); break;
case 'ic_741': _schSymIC(ctx,col,'LM741',['OS1','IN-','IN+','V-'],['V+','','OUT','OS2']); break;
case 'ic_7400': _schSymIC(ctx,col,'74LS00',['1A','1B','1Y','2A','2B','2Y','GND'],['4B','4A','4Y','3B','3A','3Y','VCC']); break;
case 'ic_shift': _schSymIC(ctx,col,'74HC595',['QB','QC','QD','QE','QF','QG','QH','GND'],['VCC','QA','SER','/OE','RCK','SCK','/CLR','QH\x60']); break;
case 'ic_l293': _schSymIC(ctx,col,'L293D',['1EN','1A','1Y','GND','GND','2Y','2A','VS'],['VSS','4A','4Y','GND','GND','3Y','3A','3EN']); break;
case 'dht': _schSymIC(ctx,col,'DHT11',['VCC','DAT','GND'],[]); break;
case 'hcsr': _schSymIC(ctx,col,'SR04',['VCC','TRG','ECH','GND'],[]); break;
case 'pir': _schSymIC(ctx,col,'PIR',['VCC','OUT','GND'],[]); break;
case 'ir': _schSymIC(ctx,col,'IR',['VCC','GND','DAT'],[]); break;
case 'hc05': _schSymIC(ctx,col,'HC-05',['VCC','GND'],['TX','RX']); break;
case 'nrf': _schSymIC(ctx,col,'nRF24',['GND','VCC','CE','CSN'],['SCK','MOS','MIS','IRQ']); break;
case 'esp01': _schSymIC(ctx,col,'ESP-01',['GND','IO2','IO0','RX'],['TX','CH','RST','VCC']); break;
case 'lm7805': _schSymIC(ctx,col,'7805',['IN','GND'],['OUT']); break;
case 'tft_lcd': _schSymTFTLCD(ctx, col, (comp.value||'ILI9341').toUpperCase().split(':')[0],
document.getElementById('tft-canvas')); break;
case 'board_esp32': _schSymIC(ctx,col,'ESP32',
['D13','D12','D14','D27','D26','D25','D33','D32','D35','D34','D39','D36','EN ','GND','VIN'],
['D15','D2 ','D4 ','D16','D17','D5 ','D18','D19','D21','RX ','TX ','D22','D23','GND','3V3']); break;
case 'board_esp8266': {
const _v8266 = _ESP8266_VARIANTS[comp.variant || 'nodemcu'] || _ESP8266_VARIANTS.nodemcu;
_schSymIC(ctx, col, _v8266.label, _v8266.leftLabels, _v8266.rightLabels);
break;
}
case 'board_uno': _schSymIC(ctx,col,'UNO',
['VIN','5V','3V3','GND','RST','A0','A1','A2','A3','A4','A5'],
['D13','D12','D11','D10','D9 ','D8 ','D7 ','D6 ','D5 ','D4 ','D3 ','D2 ','D1 ','D0 ']); break;
case 'board_nano': _schSymIC(ctx,col,'Nano',
['VIN','5V ','3V3','GND','RST','AREF','A0 ','A1 ','A2 ','A3 ','A4 ','A5 ','A6 ','A7 '],
['D13','D12','D11','D10','D9 ','D8 ','D7 ','D6 ','D5 ','D4 ','D3 ','D2 ','D1 ','D0 ']); break;
case 'board_mega': _schSymIC(ctx,col,'Mega',
['VIN','5V ','3V3','GND','RST','A0 ','A1 ','A2 ','A3 ','A4 ','A5 ','A6 ','A7 ','A8 ','A9 ','A10'],
['D13','D12','D11','D10','D9 ','D8 ','D7 ','D6 ','D5 ','D4 ','D3 ','D2 ','D1 ','D0 ','D18','D19']); break;
case 'servo': _schSymIC(ctx,col,'Servo',['VCC','GND','SIG'],[]); break;
case 'relay': _schSymIC(ctx,col,'Relay',['VCC','GND','IN '],['COM','NO ','NC ']); break;
case 'ds18b20': _schSymIC(ctx,col,'DS18B20',['VCC','DQ ','GND'],[]); break;
case 'mpu6050': _schSymIC(ctx,col,'MPU6050',['VCC','GND','SCL','SDA','INT'],[]); break;
case 'l298n': _schSymIC(ctx,col,'L298N',
['VCC','GND','ENA','ENB','IN1','IN2','IN3','IN4'],
['5V','OUT1','OUT2','OUT3','OUT4']); break;
case 'bme280': _schSymIC(ctx,col,'BME280',['VCC','GND','SCL','SDA'],[]); break;
default:
ctx.fillStyle=col+'22'; ctx.strokeStyle=col; ctx.lineWidth=1.5;
ctx.beginPath(); ctx.rect(-22,-12,44,24); ctx.fill(); ctx.stroke();
ctx.fillStyle=col; ctx.font='bold 8px IBM Plex Mono'; ctx.textAlign='center'; ctx.textBaseline='middle';
ctx.fillText(comp.type,0,0);
}
if (isSelected) {
const _t=SCH_TERMS[comp.type]||[{x:0,y:0}];
let _rx=0,_ry=0;
for (const _t2 of _t) { _rx=Math.max(_rx,Math.abs(_t2.x)); _ry=Math.max(_ry,Math.abs(_t2.y)); }
_rx=Math.max(_rx,20)+8; _ry=Math.max(_ry,12)+8;
ctx.strokeStyle='#f59e0b'; ctx.lineWidth=1.5; ctx.setLineDash([3,3]);
ctx.beginPath(); ctx.rect(-_rx,-_ry,_rx*2,_ry*2); ctx.stroke(); ctx.setLineDash([]);
}
ctx.restore();
if (comp.value && comp.id!==-1) {
ctx.fillStyle='#475569'; ctx.font='10px IBM Plex Mono,monospace';
ctx.textAlign='center'; ctx.textBaseline='top';
ctx.fillText(String(comp.value).slice(0,12), comp.x, comp.y+46);
}
}
// ── Symbol drawing functions ─────────────────────────────────────────────────
function _schTermDots(ctx, col, terms) {
ctx.fillStyle=col;
for (const t of terms) { ctx.beginPath(); ctx.arc(t.x,t.y,3,0,Math.PI*2); ctx.fill(); }
}
function _schSymRes(ctx, col) {
ctx.strokeStyle=col; ctx.lineWidth=1.8; ctx.lineCap='round';
ctx.beginPath(); ctx.moveTo(-40,0); ctx.lineTo(-18,0); ctx.stroke();
ctx.beginPath(); ctx.moveTo(18,0); ctx.lineTo(40,0); ctx.stroke();
ctx.beginPath(); ctx.moveTo(-18,0);
const pts=[[-18,0],[-13,-9],[-5,9],[3,-9],[11,9],[18,0]];
for (const [x,y] of pts) ctx.lineTo(x,y);
ctx.stroke();
_schTermDots(ctx,col,[{x:-40,y:0},{x:40,y:0}]);
}
function _schSymCap(ctx, col) {
ctx.strokeStyle=col; ctx.lineWidth=1.8; ctx.lineCap='round';
ctx.beginPath(); ctx.moveTo(-40,0); ctx.lineTo(-7,0); ctx.stroke();
ctx.beginPath(); ctx.moveTo(7,0); ctx.lineTo(40,0); ctx.stroke();
ctx.lineWidth=2.5;
ctx.beginPath(); ctx.moveTo(-7,-14); ctx.lineTo(-7,14); ctx.stroke();
ctx.beginPath(); ctx.moveTo(7,-14); ctx.lineTo(7,14); ctx.stroke();
_schTermDots(ctx,col,[{x:-40,y:0},{x:40,y:0}]);
}
function _schSymDiode(ctx, col, isLED, isOn) {
if (isOn) { ctx.save(); ctx.shadowColor=col; ctx.shadowBlur=14; }
ctx.strokeStyle=col; ctx.lineWidth=1.8; ctx.lineCap='round';
ctx.beginPath(); ctx.moveTo(-40,0); ctx.lineTo(-14,0); ctx.stroke();
ctx.beginPath(); ctx.moveTo(14,0); ctx.lineTo(40,0); ctx.stroke();
ctx.fillStyle=isOn ? col+'cc' : col+'66';
ctx.beginPath(); ctx.moveTo(-14,-12); ctx.lineTo(14,0); ctx.lineTo(-14,12); ctx.closePath();
ctx.fill(); ctx.stroke();
ctx.lineWidth=2;
ctx.beginPath(); ctx.moveTo(14,-12); ctx.lineTo(14,12); ctx.stroke();
if (isLED) {
ctx.lineWidth=1.5;
ctx.beginPath(); ctx.moveTo(20,-5); ctx.lineTo(30,-16); ctx.moveTo(22,2); ctx.lineTo(34,-6); ctx.stroke();
ctx.lineWidth=1;
ctx.beginPath(); ctx.moveTo(30,-16); ctx.lineTo(25,-12); ctx.moveTo(30,-16); ctx.lineTo(27,-21); ctx.stroke();
ctx.beginPath(); ctx.moveTo(34,-6); ctx.lineTo(28,-4); ctx.moveTo(34,-6); ctx.lineTo(32,-12); ctx.stroke();
}
if (isOn) ctx.restore();
_schTermDots(ctx,col,[{x:-40,y:0},{x:40,y:0}]);
}
function _schSymZener(ctx, col) {
ctx.strokeStyle=col; ctx.lineWidth=1.8; ctx.lineCap='round';
ctx.beginPath(); ctx.moveTo(-40,0); ctx.lineTo(-14,0); ctx.stroke();
ctx.beginPath(); ctx.moveTo(14,0); ctx.lineTo(40,0); ctx.stroke();
ctx.fillStyle=col+'66';
ctx.beginPath(); ctx.moveTo(-14,-12); ctx.lineTo(14,0); ctx.lineTo(-14,12); ctx.closePath();
ctx.fill(); ctx.stroke();
ctx.lineWidth=2;
ctx.beginPath();
ctx.moveTo(18,-16); ctx.lineTo(14,-12); ctx.lineTo(14,12); ctx.lineTo(10,16);
ctx.stroke();
_schTermDots(ctx,col,[{x:-40,y:0},{x:40,y:0}]);
}
function _schSymBtn(ctx, col) {
ctx.strokeStyle=col; ctx.lineWidth=1.8; ctx.lineCap='round';
ctx.beginPath(); ctx.moveTo(-40,0); ctx.lineTo(-14,0); ctx.stroke();
ctx.beginPath(); ctx.moveTo(14,0); ctx.lineTo(40,0); ctx.stroke();
ctx.lineWidth=2;
ctx.beginPath(); ctx.moveTo(-14,-12); ctx.lineTo(-14,12); ctx.stroke();
ctx.beginPath(); ctx.moveTo(14,-12); ctx.lineTo(14,12); ctx.stroke();
ctx.lineWidth=1.8;
ctx.beginPath(); ctx.moveTo(-14,-17); ctx.lineTo(14,-17); ctx.stroke();
ctx.beginPath(); ctx.moveTo(0,-17); ctx.lineTo(0,-26); ctx.stroke();
ctx.beginPath(); ctx.arc(0,-28,2.5,0,Math.PI*2); ctx.fillStyle=col; ctx.fill();
_schTermDots(ctx,col,[{x:-40,y:0},{x:40,y:0}]);
}
function _schSymBJT(ctx, col, typeStr) {
const isPNP=(typeStr||'').toUpperCase().includes('PNP');
ctx.strokeStyle=col; ctx.lineWidth=1.8; ctx.lineCap='round';
ctx.beginPath(); ctx.arc(8,0,22,0,Math.PI*2);
ctx.fillStyle=col+'15'; ctx.fill(); ctx.stroke();
ctx.lineWidth=2.2;
ctx.beginPath(); ctx.moveTo(-4,-18); ctx.lineTo(-4,18); ctx.stroke();
ctx.lineWidth=1.8;
ctx.beginPath(); ctx.moveTo(-40,0); ctx.lineTo(-4,0); ctx.stroke();
ctx.beginPath(); ctx.moveTo(-4,-12); ctx.lineTo(20,-40); ctx.stroke();
ctx.beginPath(); ctx.moveTo(-4,12); ctx.lineTo(20,40); ctx.stroke();
// Arrow on emitter (NPN pointing out, PNP pointing in)
const ax=11, ay=isPNP?-26:26;
ctx.lineWidth=1.8;
ctx.beginPath();
if (!isPNP) { ctx.moveTo(ax-6,ay-5); ctx.lineTo(ax,ay); ctx.lineTo(ax+5,ay-6); }
else { ctx.moveTo(ax+4,ay+4); ctx.lineTo(ax,ay); ctx.lineTo(ax-4,ay+4); }
ctx.stroke();
ctx.fillStyle=col; ctx.font='bold 8px IBM Plex Mono';
ctx.textAlign='center'; ctx.textBaseline='middle';
ctx.fillText('B',-52,0); ctx.fillText('C',28,-44); ctx.fillText('E',28,44);
_schTermDots(ctx,col,SCH_TERMS.bjt);
}
function _schSymMOSFET(ctx, col, typeStr) {
const isP=(typeStr||'').toUpperCase().includes('P');
ctx.strokeStyle=col; ctx.lineWidth=1.8; ctx.lineCap='round';
ctx.beginPath(); ctx.arc(6,0,20,0,Math.PI*2);
ctx.fillStyle=col+'15'; ctx.fill(); ctx.stroke();
ctx.lineWidth=2;
ctx.beginPath(); ctx.moveTo(-6,-16); ctx.lineTo(-6,16); ctx.stroke();
ctx.lineWidth=1.8;
ctx.beginPath(); ctx.moveTo(-40,0); ctx.lineTo(-6,0); ctx.stroke();
ctx.lineWidth=1.5; ctx.setLineDash([3,3]);
ctx.beginPath(); ctx.moveTo(0,-16); ctx.lineTo(0,16); ctx.stroke();
ctx.setLineDash([]);
ctx.beginPath(); ctx.moveTo(0,-16); ctx.lineTo(20,-40); ctx.stroke();
ctx.beginPath(); ctx.moveTo(0,16); ctx.lineTo(20,40); ctx.stroke();
const ay=isP?-24:24;
ctx.lineWidth=1.6;
ctx.beginPath(); ctx.moveTo(12,ay-6); ctx.lineTo(12,ay); ctx.lineTo(6,ay-4); ctx.stroke();
ctx.fillStyle=col; ctx.font='bold 8px IBM Plex Mono';
ctx.textAlign='center'; ctx.textBaseline='middle';
ctx.fillText('G',-50,0); ctx.fillText('D',28,-44); ctx.fillText('S',28,44);
_schTermDots(ctx,col,SCH_TERMS.mosfet);
}
function _schSymVCC(ctx, label) {
ctx.strokeStyle='#dc2626'; ctx.lineWidth=2; ctx.lineCap='round';
ctx.beginPath(); ctx.moveTo(0,0); ctx.lineTo(0,-20); ctx.stroke();
ctx.lineWidth=2.5;
ctx.beginPath(); ctx.moveTo(-16,-20); ctx.lineTo(0,-38); ctx.lineTo(16,-20); ctx.stroke();
ctx.fillStyle='#dc2626'; ctx.font='bold 11px IBM Plex Mono';
ctx.textAlign='center'; ctx.textBaseline='bottom';
ctx.fillText(label||'+5V', 0, -40);
ctx.beginPath(); ctx.arc(0,0,3,0,Math.PI*2); ctx.fill();
}
function _schSymGND(ctx) {
ctx.strokeStyle='#2563eb'; ctx.lineWidth=2; ctx.lineCap='round';
ctx.beginPath(); ctx.moveTo(0,0); ctx.lineTo(0,20); ctx.stroke();
ctx.lineWidth=2.5;
ctx.beginPath(); ctx.moveTo(-20,20); ctx.lineTo(20,20); ctx.stroke();
ctx.lineWidth=2;
ctx.beginPath(); ctx.moveTo(-14,27); ctx.lineTo(14,27); ctx.stroke();
ctx.lineWidth=1.5;
ctx.beginPath(); ctx.moveTo(-8,34); ctx.lineTo(8,34); ctx.stroke();
ctx.fillStyle='#2563eb'; ctx.beginPath(); ctx.arc(0,0,3,0,Math.PI*2); ctx.fill();
}
function _schSymARD(ctx, col, label) {
ctx.strokeStyle=col; ctx.lineWidth=1.8; ctx.lineCap='round';
ctx.beginPath(); ctx.moveTo(0,0); ctx.lineTo(0,-20); ctx.stroke();
ctx.fillStyle=col+'25'; ctx.strokeStyle=col; ctx.lineWidth=1.5;
ctx.beginPath();
if (ctx.roundRect) ctx.roundRect(-22,-42,44,22,4);
else ctx.rect(-22,-42,44,22);
ctx.fill(); ctx.stroke();
ctx.fillStyle=col; ctx.font='bold 9px IBM Plex Mono';
ctx.textAlign='center'; ctx.textBaseline='middle';
ctx.fillText(label, 0, -31);
ctx.fillStyle=col; ctx.beginPath(); ctx.arc(0,0,3,0,Math.PI*2); ctx.fill();
}
function _schSymPot(ctx, col) {
ctx.strokeStyle=col; ctx.lineWidth=1.8; ctx.lineCap='round';
ctx.beginPath(); ctx.moveTo(-40,0); ctx.lineTo(-18,0); ctx.stroke();
ctx.beginPath(); ctx.moveTo(18,0); ctx.lineTo(40,0); ctx.stroke();
ctx.beginPath(); ctx.moveTo(-18,0);
[[-18,0],[-13,-9],[-5,9],[3,-9],[11,9],[18,0]].forEach(([x,y])=>ctx.lineTo(x,y));
ctx.stroke();
ctx.lineWidth=1.5;
ctx.beginPath(); ctx.moveTo(0,-40); ctx.lineTo(0,-4); ctx.stroke();
ctx.beginPath(); ctx.moveTo(-5,-10); ctx.lineTo(0,-2); ctx.lineTo(5,-10); ctx.stroke();
_schTermDots(ctx,col,SCH_TERMS.pot);
}
function _schSymToggle(ctx, col) {
ctx.strokeStyle=col; ctx.lineWidth=1.8; ctx.lineCap='round';
ctx.beginPath(); ctx.moveTo(-40,0); ctx.lineTo(-12,0); ctx.stroke();
ctx.beginPath(); ctx.moveTo(12,0); ctx.lineTo(40,0); ctx.stroke();
ctx.fillStyle=col;
ctx.beginPath(); ctx.arc(-12,0,3,0,Math.PI*2); ctx.fill();
ctx.beginPath(); ctx.arc(12,0,3,0,Math.PI*2); ctx.fill();
ctx.beginPath(); ctx.moveTo(-12,0); ctx.lineTo(8,-18); ctx.stroke();
_schTermDots(ctx,col,SCH_TERMS.sw_toggle);
}
function _schSymLDR(ctx, col) {
ctx.strokeStyle=col; ctx.lineWidth=1.8; ctx.lineCap='round';
ctx.beginPath(); ctx.moveTo(-40,0); ctx.lineTo(-18,0); ctx.stroke();
ctx.beginPath(); ctx.moveTo(18,0); ctx.lineTo(40,0); ctx.stroke();
ctx.beginPath(); ctx.rect(-18,-8,36,16); ctx.stroke();
ctx.fillStyle=col+'22'; ctx.fill();
ctx.fillStyle=col; ctx.font='bold 8px IBM Plex Mono'; ctx.textAlign='center'; ctx.textBaseline='middle';
ctx.fillText('LDR',0,0);
ctx.lineWidth=1.2;
[[-22,-22],[-10,-22]].forEach(([ax,ay])=>{
ctx.strokeStyle=col; ctx.beginPath(); ctx.moveTo(ax,ay); ctx.lineTo(ax+7,ay+10); ctx.stroke();
ctx.beginPath(); ctx.moveTo(ax+7,ay+10); ctx.lineTo(ax+3,ay+7);
ctx.moveTo(ax+7,ay+10); ctx.lineTo(ax+10,ay+6); ctx.stroke();
});
_schTermDots(ctx,col,SCH_TERMS.ldr);
}
function _schSymNTC(ctx, col) {
ctx.strokeStyle=col; ctx.lineWidth=1.8; ctx.lineCap='round';
ctx.beginPath(); ctx.moveTo(-40,0); ctx.lineTo(-18,0); ctx.stroke();
ctx.beginPath(); ctx.moveTo(18,0); ctx.lineTo(40,0); ctx.stroke();
ctx.beginPath(); ctx.rect(-18,-8,36,16); ctx.stroke();
ctx.fillStyle=col+'22'; ctx.fill();
ctx.fillStyle=col; ctx.font='bold 8px IBM Plex Mono'; ctx.textAlign='center'; ctx.textBaseline='middle';
ctx.fillText('NTC',0,0);
ctx.lineWidth=1.5;
ctx.beginPath(); ctx.moveTo(10,-16); ctx.lineTo(18,-8); ctx.stroke();
ctx.beginPath(); ctx.moveTo(13,-8); ctx.lineTo(18,-8); ctx.lineTo(18,-13); ctx.stroke();
_schTermDots(ctx,col,SCH_TERMS.ntc);
}
function _schSymBuzzer(ctx, col) {
ctx.strokeStyle=col; ctx.lineWidth=1.8; ctx.lineCap='round';
ctx.beginPath(); ctx.moveTo(-40,0); ctx.lineTo(-16,0); ctx.stroke();
ctx.beginPath(); ctx.moveTo(16,0); ctx.lineTo(40,0); ctx.stroke();
ctx.beginPath(); ctx.arc(0,0,16,0,Math.PI*2); ctx.stroke();
ctx.fillStyle=col+'18'; ctx.fill();
ctx.lineWidth=1.2;
[8,12].forEach(r=>{ ctx.beginPath(); ctx.arc(0,0,r,0,Math.PI*2); ctx.stroke(); });
ctx.fillStyle=col; ctx.font='bold 9px IBM Plex Mono';
ctx.textAlign='left'; ctx.textBaseline='bottom';
ctx.fillText('+', -39, -3);
_schTermDots(ctx,col,SCH_TERMS.buzzer);
}
function _schSymRGBLed(ctx, col) {
const T=SCH_TERMS.rgb_led;
ctx.strokeStyle=col; ctx.lineWidth=1.8; ctx.lineCap='round';
T.forEach(t=>{ ctx.beginPath(); ctx.moveTo(t.x,t.y); ctx.lineTo(Math.sign(t.x)*16,t.y); ctx.stroke(); });
ctx.beginPath(); ctx.rect(-16,-28,32,56); ctx.stroke();
ctx.fillStyle=col+'18'; ctx.fill();
ctx.font='bold 8px IBM Plex Mono'; ctx.textAlign='center'; ctx.textBaseline='middle';
ctx.fillStyle='#dc2626'; ctx.fillText('R',0,-20);
ctx.fillStyle='#16a34a'; ctx.fillText('G',0,0);
ctx.fillStyle='#2563eb'; ctx.fillText('B',0,20);
_schTermDots(ctx,col,T);
}
// Generic DIP IC / module box symbol
// leftPins[]: labels top-to-bottom on left side, rightPins[]: on right side
function _schSymIC(ctx, col, label, leftPins, rightPins) {
const NL=leftPins.length, NR=rightPins.length;
const N=Math.max(NL,NR,1), fy=-Math.floor(N/2)*20;
const BW=NR>0?60:40;
const bT=fy-10, bB=fy+(N-1)*20+10;
ctx.fillStyle=col+'18'; ctx.strokeStyle=col; ctx.lineWidth=1.5;
ctx.beginPath(); ctx.rect(-BW,bT,BW*2,bB-bT); ctx.fill(); ctx.stroke();
ctx.fillStyle=col; ctx.font='bold 8px IBM Plex Mono,monospace';
ctx.textAlign='center'; ctx.textBaseline='top';
ctx.fillText(label,0,bT+2);
ctx.font='7px IBM Plex Mono,monospace'; ctx.lineWidth=1.5; ctx.lineCap='round';
for(let i=0;i<NL;i++){
const y=fy+i*20;
ctx.strokeStyle=col; ctx.beginPath(); ctx.moveTo(-BW-20,y); ctx.lineTo(-BW,y); ctx.stroke();
ctx.fillStyle=col; ctx.textAlign='left'; ctx.textBaseline='middle';
ctx.fillText(leftPins[i],-BW+2,y);
ctx.beginPath(); ctx.arc(-BW-20,y,3,0,Math.PI*2); ctx.fill();
}
for(let i=0;i<NR;i++){
const y=fy+i*20;
ctx.strokeStyle=col; ctx.beginPath(); ctx.moveTo(BW,y); ctx.lineTo(BW+20,y); ctx.stroke();
ctx.fillStyle=col; ctx.textAlign='right'; ctx.textBaseline='middle';
ctx.fillText(rightPins[i],BW-2,y);
ctx.beginPath(); ctx.arc(BW+20,y,3,0,Math.PI*2); ctx.fill();
}
}
function _schSymTFTLCD(ctx, col, label, srcCanvas) {
const NL=5, NR=3, N=NL, fy=-Math.floor(N/2)*20;
const bT=fy-10, bB=fy+(N-1)*20+10, BW=60;
// 외부 프레임 (roundRect 폴백)
ctx.fillStyle=col+'28'; ctx.strokeStyle=col; ctx.lineWidth=2;
ctx.beginPath();
if (ctx.roundRect) ctx.roundRect(-BW,bT,BW*2,bB-bT,4);
else ctx.rect(-BW,bT,BW*2,bB-bT);
ctx.fill(); ctx.stroke();
// 내부 스크린
const sx=-BW+6, sy=bT+14, sw=BW*2-12, sh=bB-bT-20;
ctx.fillStyle='#000c14'; ctx.beginPath();
if (ctx.roundRect) ctx.roundRect(sx,sy,sw,sh,2);
else ctx.rect(sx,sy,sw,sh);
ctx.fill();
// TFT 프레임버퍼를 부품 안에 렌더링 (TFT 활성화되어 있을 때만)
const _tftLive = (tft && tft.active) || !!_esp32TftCtx2d;
if (_tftLive && srcCanvas && srcCanvas.width > 10 && srcCanvas.height > 10) {
ctx.save();
ctx.beginPath();
if (ctx.roundRect) ctx.roundRect(sx,sy,sw,sh,2);
else ctx.rect(sx,sy,sw,sh);
ctx.clip();
ctx.drawImage(srcCanvas, sx, sy, sw, sh);
ctx.restore();
} else {
// 내용 없을 때: 라벨 표시
ctx.fillStyle='#67e8f9'; ctx.font='bold 8px IBM Plex Mono,monospace';
ctx.textAlign='center'; ctx.textBaseline='middle';
ctx.fillText(label, 0, (bT+bB)/2);
}
// 좌측 핀 5개 (VCC GND CS RST D/C) — icT(5,3) terms[0..4]와 일치
const LP=['VCC','GND','CS','RST','D/C'];
ctx.font='7px IBM Plex Mono,monospace'; ctx.lineWidth=1.5;
for(let i=0;i<NL;i++){
const y=fy+i*20;
ctx.strokeStyle=col; ctx.beginPath(); ctx.moveTo(-BW-20,y); ctx.lineTo(-BW,y); ctx.stroke();
ctx.fillStyle=col; ctx.textAlign='left'; ctx.textBaseline='middle';
ctx.fillText(LP[i],-BW+3,y);
ctx.beginPath(); ctx.arc(-BW-20,y,3,0,Math.PI*2); ctx.fill();
}
// 우측 핀 3개 (MOSI SCK LED) — icT(5,3) terms[5..7]와 일치 (ryOff 없이 fy 기준)
const RP=['MOSI','SCK','LED'];
for(let i=0;i<NR;i++){
const y=fy+i*20;
ctx.strokeStyle=col; ctx.beginPath(); ctx.moveTo(BW,y); ctx.lineTo(BW+20,y); ctx.stroke();
ctx.fillStyle=col; ctx.textAlign='right'; ctx.textBaseline='middle';
ctx.fillText(RP[i],BW-3,y);
ctx.beginPath(); ctx.arc(BW+20,y,3,0,Math.PI*2); ctx.fill();
}
}
// ── SCH Event Handlers ───────────────────────────────────────────────────────
function schOnMouseMove(e) {
SCH.mousePt = schCanvasXY(e);
if (SCH.dragComp) {
SCH.dragComp.comp.x = SCH.mousePt.x + SCH.dragComp.ox;
SCH.dragComp.comp.y = SCH.mousePt.y + SCH.dragComp.oy;
}
// 영역 선택 박스 드래그
if (SCH.tool === 'select' && SCH.dragBox) {
SCH.dragBox.x2 = SCH.mousePt.x;
SCH.dragBox.y2 = SCH.mousePt.y;
}
if (SCH.dragComp || SCH.wireStart || SCH.dragBox || !['wire','select'].includes(SCH.tool)) schDraw();
}
function schOnMouseDown(e) {
if (e.button !== 0) return;
const pt = schCanvasXY(e);
SCH.mousePt = pt;
if (SCH.tool === 'select') {
const comp = schHitComp(pt.x, pt.y);
if (comp) {
// 컴포넌트 클릭 → 선택/드래그
SCH.selId = comp.id;
SCH.dragComp = {comp, ox:comp.x-pt.x, oy:comp.y-pt.y};
SCH.selection = [{type:'comp', id:comp.id}];
// 버튼 누름 → GPIO 입력 (UNO 전용)
if (comp.type === 'btn' && !_IS_ESP32 && !_IS_ESP8266 && emulatorReady) {
const gpio = _schLiveMap.btn.get(comp);
if (gpio !== undefined) _schBtnSetGpio(gpio, 0);
}
} else {
const wire = schHitWire(pt.x, pt.y);
if (wire) {
SCH.selId = wire.id;
SCH.selection = [{type:'wire', id:wire.id}];
} else {
// 빈 공간 클릭 → 영역 선택 시작
SCH.selId = null;
SCH.selection = [];
SCH.dragBox = {x1:pt.x, y1:pt.y, x2:pt.x, y2:pt.y};
}
}
schDraw(); return;
}
if (SCH.tool === 'wire') {
if (!SCH.wireStart) {
SCH.wireStart = {x:pt.x, y:pt.y};
} else {
const {x:sx,y:sy} = SCH.wireStart;
if (sx !== pt.x || sy !== pt.y) {
schSaveUndo();
if (sx !== pt.x) SCH.wires.push({id:SCH.nextId++, x1:sx,y1:sy, x2:pt.x,y2:sy});
if (sy !== pt.y) SCH.wires.push({id:SCH.nextId++, x1:pt.x,y1:sy, x2:pt.x,y2:pt.y});
SCH.wireStart = {x:pt.x, y:pt.y}; // chain
schUpdateNetlist();
}
}
schDraw(); return;
}
// Place component
const value = schAskValue(SCH.tool);
if (value === null) { schDraw(); return; }
schSaveUndo();
// VCC/GND 자동 배치: 처음 부품(전원 심볼 제외) 배치 시 캔버스 상단에 추가
if (!['vcc','gnd','wire','select'].includes(SCH.tool)) {
const hasVcc = SCH.components.some(c => c.type === 'vcc');
const hasGnd = SCH.components.some(c => c.type === 'gnd');
const G = SCH.GRID;
const snap = v => Math.round(v / G) * G;
if (!hasVcc) SCH.components.push({id:SCH.nextId++, type:'vcc', x:snap(pt.x + G*6), y:snap(pt.y - G*8), rot:0, flipH:false, value:''});
if (!hasGnd) SCH.components.push({id:SCH.nextId++, type:'gnd', x:snap(pt.x + G*6), y:snap(pt.y + G*8), rot:0, flipH:false, value:''});
}
SCH.components.push({id:SCH.nextId++, type:SCH.tool, x:pt.x, y:pt.y, rot:SCH.ghostRot, flipH:SCH.ghostFlipH, flipV:SCH.ghostFlipV, value});
schUpdateNetlist(); schDraw();
}
function schOnMouseUp() {
// 버튼 해제
if (_schBtnPressed >= 0) { _schBtnSetGpio(_schBtnPressed, 1); }
if (SCH.dragComp) {
SCH.dragComp = null;
schUpdateNetlist(); schDraw();
}
// 영역 선택 완료
if (SCH.dragBox) {
const {x1,y1,x2,y2} = SCH.dragBox;
const minX=Math.min(x1,x2), maxX=Math.max(x1,x2);
const minY=Math.min(y1,y2), maxY=Math.max(y1,y2);
SCH.selection = [];
// 컴포넌트 선택
for (const c of SCH.components) {
if (c.x>=minX && c.x<=maxX && c.y>=minY && c.y<=maxY) {
SCH.selection.push({type:'comp', id:c.id});
}
}
// 와이어 선택 (두 끝점이 모두 박스 안에 있으면)
for (const w of SCH.wires) {
if (w.x1>=minX && w.x1<=maxX && w.y1>=minY && w.y1<=maxY &&
w.x2>=minX && w.x2<=maxX && w.y2>=minY && w.y2<=maxY) {
SCH.selection.push({type:'wire', id:w.id});
}
}
SCH.selId = SCH.selection.length>0 ? SCH.selection[0].id : null;
SCH.dragBox = null;
schDraw();
}
}
function schOnDblClick(e) {
const pt = schCanvasXY(e);
const comp = schHitComp(pt.x, pt.y);
if (!comp) return;
const nv = prompt(`${comp.type} 값 변경:`, comp.value||'');
if (nv !== null) { comp.value = nv; schDraw(); }
}
function schOnContextMenu(e) {
e.preventDefault();
if (SCH.wireStart) { SCH.wireStart=null; schDraw(); return; }
const pt = schCanvasXY(e);
const comp = schHitComp(pt.x, pt.y);
if (comp) { schShowEditDialog(comp, e.clientX, e.clientY); return; }
const wire = schHitWire(pt.x, pt.y);
if (wire) {
schSaveUndo(); SCH.wires=SCH.wires.filter(w=>w.id!==wire.id);
schUpdateNetlist(); schDraw();
}
}
function schShowEditDialog(comp, cx, cy) {
let dlg = document.getElementById('sch-edit-dlg');
if (!dlg) {
dlg = document.createElement('div');
dlg.id = 'sch-edit-dlg';
dlg.style.cssText = 'position:fixed;z-index:9999;background:var(--bg2,#1e293b);border:1px solid var(--bdr,#334155);border-radius:8px;padding:14px;min-width:230px;box-shadow:0 4px 20px rgba(0,0,0,0.4);font-size:13px;color:var(--text,#e2e8f0);display:none';
document.body.appendChild(dlg);
}
const isBoardType = comp.type.startsWith('board_');
const typeLabel = comp.type.replace('_',' ').toUpperCase();
const boardOpts = isBoardType ? `
<label style="font-size:11px;color:var(--muted,#94a3b8)">보드 종류</label>
<select id="sch-edit-boardtype" style="width:100%;box-sizing:border-box;margin-top:3px;margin-bottom:8px;background:var(--bg,#0f172a);border:1px solid var(--bdr,#334155);color:var(--text,#e2e8f0);padding:5px 8px;border-radius:4px;font-size:13px">
<option value="board_esp32" ${comp.type==='board_esp32'?'selected':''}>ESP32 DevKit</option>
<option value="board_esp8266" ${comp.type==='board_esp8266'?'selected':''}>ESP8266 (NodeMCU)</option>
<option value="board_uno" ${comp.type==='board_uno'?'selected':''}>Arduino UNO R3</option>
<option value="board_nano" ${comp.type==='board_nano'?'selected':''}>Arduino Nano</option>
<option value="board_mega" ${comp.type==='board_mega'?'selected':''}>Arduino Mega 2560</option>
</select>` : '';
const valLabel = isBoardType ? '' : `
<label style="font-size:11px;color:var(--muted,#94a3b8)">값 (저항:1k, LED:red, 핀:D13 등)</label>
<input id="sch-edit-val" value="${(comp.value||'').replace(/"/g,'&quot;')}"
style="width:100%;box-sizing:border-box;margin-top:3px;margin-bottom:8px;
background:var(--bg,#0f172a);border:1px solid var(--bdr,#334155);
color:var(--text,#e2e8f0);padding:5px 8px;border-radius:4px;font-size:13px">`;
dlg.innerHTML = `
<div style="font-weight:600;margin-bottom:10px;color:var(--text)">${typeLabel} 편집</div>
${boardOpts}${valLabel}
<div style="display:flex;gap:5px;margin-bottom:8px">
<button id="sch-edit-rot" style="flex:1;background:var(--bdr,#334155);color:var(--text,#e2e8f0);border:none;padding:5px 0;border-radius:4px;cursor:pointer;font-size:13px" title="회전 90° (R)">↻ 회전</button>
<button id="sch-edit-fh" style="flex:1;background:var(--bdr,#334155);color:var(--text,#e2e8f0);border:none;padding:5px 0;border-radius:4px;cursor:pointer;font-size:13px" title="좌우 반전 (F)">↔ 좌우</button>
<button id="sch-edit-fv" style="flex:1;background:var(--bdr,#334155);color:var(--text,#e2e8f0);border:none;padding:5px 0;border-radius:4px;cursor:pointer;font-size:13px" title="상하 반전 (G)">↕ 상하</button>
</div>
<div style="display:flex;gap:6px;justify-content:flex-end">
<button id="sch-edit-del" style="background:#dc2626;color:#fff;border:none;padding:5px 12px;border-radius:4px;cursor:pointer;font-size:12px">삭제</button>
<button id="sch-edit-cancel" style="background:var(--bdr,#334155);color:var(--text,#e2e8f0);border:none;padding:5px 12px;border-radius:4px;cursor:pointer;font-size:12px">취소</button>
<button id="sch-edit-ok" style="background:#2563eb;color:#fff;border:none;padding:5px 12px;border-radius:4px;cursor:pointer;font-size:12px">확인</button>
</div>`;
const vw=window.innerWidth, vh=window.innerHeight;
let l=cx+8, t=cy+8;
if (l+250>vw) l=cx-258; if (t+160>vh) t=cy-168;
dlg.style.left=l+'px'; dlg.style.top=t+'px'; dlg.style.display='block';
const inp=document.getElementById('sch-edit-val');
const boardSel=document.getElementById('sch-edit-boardtype');
if (inp) { inp.focus(); inp.select(); }
else if (boardSel) boardSel.focus();
const close=()=>{ dlg.style.display='none'; };
document.getElementById('sch-edit-ok').onclick=()=>{
schSaveUndo();
if (boardSel) {
const newType = boardSel.value;
if (newType !== comp.type) {
// Remove wires connected to old board pins that don't exist on new board
comp.type = newType;
}
}
if (inp) comp.value=inp.value;
schUpdateNetlist(); schDraw(); close();
};
document.getElementById('sch-edit-del').onclick=()=>{ schSaveUndo(); SCH.components=SCH.components.filter(c=>c.id!==comp.id); schUpdateNetlist(); schDraw(); close(); };
document.getElementById('sch-edit-cancel').onclick=close;
document.getElementById('sch-edit-rot').onclick=()=>{ comp.rot=((comp.rot||0)+90)%360; schUpdateNetlist(); schDraw(); };
document.getElementById('sch-edit-fh').onclick=()=>{ comp.flipH=!comp.flipH; schUpdateNetlist(); schDraw(); };
document.getElementById('sch-edit-fv').onclick=()=>{ comp.flipV=!comp.flipV; schUpdateNetlist(); schDraw(); };
if (inp) inp.onkeydown=ev=>{ if(ev.key==='Enter') document.getElementById('sch-edit-ok').click(); if(ev.key==='Escape') close(); };
setTimeout(()=>{
const oc=ev=>{ if(!dlg.contains(ev.target)){close(); document.removeEventListener('mousedown',oc);} };
document.addEventListener('mousedown',oc);
},0);
}
function schOnKeyDown(e) {
const popup = document.getElementById('sch-panel');
if (!popup||!popup.classList.contains('open')) return;
const active = document.querySelector('.bb-content.active');
if (!active||active.id!=='bb-content-board') return;
// 입력 필드 포커스 중에는 단축키 비활성 (Ctrl+A 등이 편집 다이얼로그에 간섭 방지)
const tgt = e.target;
if (tgt && (tgt.tagName==='INPUT'||tgt.tagName==='TEXTAREA'||tgt.isContentEditable)) return;
if ((e.key==='r'||e.key==='R') && !e.ctrlKey) {
SCH.ghostRot = ((SCH.ghostRot||0)+90)%360;
if (SCH.selId) {
const c=SCH.components.find(c=>c.id===SCH.selId);
if (c) { c.rot=((c.rot||0)+90)%360; schUpdateNetlist(); }
}
schDraw();
}
if ((e.key==='f'||e.key==='F') && !e.ctrlKey) {
SCH.ghostFlipH = !SCH.ghostFlipH;
if (SCH.selId) {
const c=SCH.components.find(c=>c.id===SCH.selId);
if (c) { c.flipH = !c.flipH; schUpdateNetlist(); }
}
schDraw();
}
if ((e.key==='g'||e.key==='G') && !e.ctrlKey) {
SCH.ghostFlipV = !SCH.ghostFlipV;
if (SCH.selId) {
const c=SCH.components.find(c=>c.id===SCH.selId);
if (c) { c.flipV = !c.flipV; schUpdateNetlist(); }
}
schDraw();
}
if (e.key==='Escape') { SCH.wireStart=null; SCH.selId=null; SCH.selection=[]; schDraw(); }
if ((e.key==='Delete'||e.key==='Backspace') && SCH.selection.length>0) {
schSaveUndo();
const ids = new Set(SCH.selection.map(s=>s.id).filter(id => id != null));
SCH.components = SCH.components.filter(c=>!ids.has(c.id));
SCH.wires = SCH.wires.filter(w=>w.id != null && !ids.has(w.id));
SCH.selId=null; SCH.selection=[]; schUpdateNetlist(); schDraw();
}
if ((e.ctrlKey||e.metaKey) && (e.key==='z'||e.key==='Z')) { e.preventDefault(); window.schUndo(); }
if ((e.ctrlKey||e.metaKey) && (e.key==='c'||e.key==='C')) { e.preventDefault(); window.schCopy(); }
if ((e.ctrlKey||e.metaKey) && (e.key==='v'||e.key==='V')) { e.preventDefault(); window.schPaste(); }
if ((e.ctrlKey||e.metaKey) && (e.key==='a'||e.key==='A')) { e.preventDefault(); window.schSelectAll(); }
if (!e.ctrlKey && !e.metaKey && (e.key==='w'||e.key==='W')) window.schSetTool('wire');
if (!e.ctrlKey && !e.metaKey && (e.key==='v'||e.key==='V')) window.schSetTool('select');
}
function schAskValue(type) {
const defs={
res:'1000',cap:'100n',led:'red',diode:'1N4148',zener:'5.1V',bjt:'NPN',mosfet:'N-MOS',vcc:'+5V',ard:'D13',
pot:'10k',ldr:'LDR',ntc:'10k',buzzer:'passive',sw_toggle:'SW',sw520:'SW',
seg7:'CC',ic_555:'NE555',ic_741:'LM741',ic_7400:'74LS00',ic_shift:'74HC595',ic_l293:'L293D',
dht:'DHT11',hcsr:'HC-SR04',pir:'PIR',ir:'IR',hc05:'HC-05',nrf:'nRF24L01',esp01:'ESP-01',lm7805:'LM7805',
tft_lcd:'ILI9341',
board_esp32:'ESP32-WROOM', board_esp8266:'NodeMCU',
board_uno:'UNO-R3', board_nano:'Nano-v3', board_mega:'Mega-2560',
servo:'SG90', relay:'5V-Relay', ds18b20:'DS18B20', mpu6050:'MPU-6050', l298n:'L298N', bme280:'BME280',
};
const msgs={
res:'저항값 (Ω, 예: 220, 1k, 10k)',cap:'커패시터 (예: 100n, 10u, 22p)',led:'LED 색 (red/green/blue/yellow)',
diode:'다이오드 모델',zener:'제너 전압 (예: 5.1V)',bjt:'트랜지스터 타입 (NPN/PNP)',
mosfet:'MOSFET 타입 (N-MOS/P-MOS)',vcc:'전압 레이블',ard:'Arduino 핀 (예: D13, A0, 5V)',
pot:'가변저항 값 (예: 10k, 100k)',ldr:'광센서 메모',ntc:'서미스터 값 (예: 10k)',
buzzer:'부저 타입 (passive/active)',sw_toggle:'스위치 레이블',
seg7:'타입 (CC=공통캐소드 / CA=공통애노드)',ic_555:'IC 모델 (NE555/LM555)',
ic_741:'IC 모델 (LM741/UA741)',ic_7400:'IC 모델 (74LS00/74HC00)',
ic_shift:'IC 모델 (74HC595)',ic_l293:'IC 모델 (L293D)',
dht:'센서 모델 (DHT11/DHT22)',hcsr:'모듈 레이블',hc05:'모듈 타입 (HC-05/HC-06)',
nrf:'모듈 레이블',esp01:'모듈 레이블',lm7805:'레귤레이터 모델',
tft_lcd:'LCD 컨트롤러 (ILI9341 / ST7735 / ST7789)',
board_esp32:'보드 모델명 (선택)', board_esp8266:'보드 모델명 (선택)',
board_uno:'보드 모델명 (선택)', board_nano:'보드 모델명 (선택)', board_mega:'보드 모델명 (선택)',
servo:'서보 모델 (SG90/MG996R)', relay:'릴레이 타입', ds18b20:'센서 레이블',
mpu6050:'모듈 레이블', l298n:'드라이버 레이블', bme280:'센서 레이블',
};
const noPrompt=['gnd','btn','ir','pir','sw520','board_esp32','board_esp8266',
'board_uno','board_nano','board_mega','servo','relay','ds18b20','mpu6050','l298n','bme280'];
if (noPrompt.includes(type)) return defs[type]||'';
const v=prompt(msgs[type]||type, defs[type]||'');
return v; // null = cancelled
}
function schSaveUndo() {
SCH.undoStack.push({comps:JSON.stringify(SCH.components), wires:JSON.stringify(SCH.wires)});
if (SCH.undoStack.length>60) SCH.undoStack.shift();
}
window.schSetTool = (t) => {
SCH.tool=t; SCH.wireStart=null;
document.querySelectorAll('.sch-tool').forEach(b=>b.classList.remove('active'));
const el=document.getElementById('stool-'+t);
if (el) el.classList.add('active');
const hints={
select:'클릭: 선택 | 드래그: 이동 | Del: 삭제 | R: 회전 | F: 좌우반전 | 더블클릭: 값 편집',
wire:'클릭→클릭으로 와이어 | Esc: 취소 | 우클릭: 삭제 | 연속 배선 가능',
res:'클릭: 저항 배치 | R: 회전 | F: 반전 | 우클릭: 삭제',
cap:'클릭: 커패시터 배치 | R: 회전 | F: 반전',
led:'클릭: LED 배치 (애노드→캐소드 방향) | R: 회전 | F: 반전',
diode:'클릭: 다이오드 배치 | R: 회전 | F: 반전',
bjt:'클릭: BJT 트랜지스터 배치 | R: 회전 | F: 반전',
mosfet:'클릭: MOSFET 배치 | R: 회전 | F: 반전',
vcc:'클릭: +5V 전원 심볼 배치',
gnd:'클릭: GND 접지 심볼 배치',
ard:'클릭: Arduino 핀 연결 심볼 배치',
btn:'클릭: 푸시버튼 배치 | R: 회전',
pot:'클릭: 가변저항 배치 | 상단=Wiper, 좌우=양끝 | R: 회전',
sw_toggle:'클릭: 토글 스위치 배치 | R: 회전',
ldr:'클릭: 조도 센서(LDR) 배치 | R: 회전',
ntc:'클릭: NTC 서미스터 배치 | R: 회전',
buzzer:'클릭: 부저 배치 | +단자=왼쪽 | R: 회전',
rgb_led:'클릭: RGB LED 배치 | R=좌상, G=좌하, B=우상, COM=우하',
sw520:'클릭: 기울기 센서(SW-520D) 배치 | 2핀 | R: 회전',
seg7:'클릭: 7-세그 디스플레이 배치 | a-dp 좌측, e-COM 우측',
ic_555:'클릭: NE555 타이머 IC 배치 | 4+4핀 DIP-8',
ic_741:'클릭: LM741 Op-Amp 배치 | 4+4핀 DIP-8',
ic_7400:'클릭: 74LS00 로직 게이트 배치 | 7+7핀 DIP-14',
ic_shift:'클릭: 74HC595 시프트레지스터 배치 | 8+8핀 DIP-16',
ic_l293:'클릭: L293D 모터드라이버 배치 | 8+8핀 DIP-16',
dht:'클릭: DHT11/22 센서 배치 | VCC/DAT/GND 3핀',
hcsr:'클릭: HC-SR04 초음파 배치 | VCC/TRG/ECH/GND 4핀',
pir:'클릭: PIR 동작 센서 배치 | VCC/OUT/GND 3핀',
ir:'클릭: IR 적외선 쌍 배치 | L+/L-=송신, R+/R-=수신',
hc05:'클릭: HC-05 블루투스 배치 | VCC/GND/TX/RX 4핀',
nrf:'클릭: nRF24L01 무선 배치 | 8핀 SPI 모듈',
esp01:'클릭: ESP-01 WiFi 배치 | GND/IO2/IO0/RX 좌, TX/CH/RST/VCC 우',
lm7805:'클릭: LM7805 전압 레귤레이터 배치 | IN/GND 좌, OUT 우',
board_esp32:'클릭: ESP32 DevKit 배치 | 30핀 (D2~D39, RX/TX, EN, 3V3, GND, VIN)',
board_esp8266:'클릭: ESP8266 개발보드 배치 | NodeMCU / D1 Mini / ESP-01',
board_uno:'클릭: Arduino UNO R3 배치 | 25핀 (D0~D13, A0~A5, 5V, 3V3, GND, VIN)',
board_nano:'클릭: Arduino Nano 배치 | 28핀 (D0~D13, A0~A7, 5V, 3V3, GND, VIN, AREF)',
board_mega:'클릭: Arduino Mega 2560 배치 | 32핀 (D0~D13, D18/D19, A0~A10, 5V, 3V3, GND, VIN)',
};
const hint=document.getElementById('sch-hint');
if (hint) hint.textContent=hints[t]||'클릭으로 배치';
// Open popup + switch tab
const bbPopup=document.getElementById('sch-panel');
if (bbPopup && !bbPopup.classList.contains('open')) bbPopup.classList.add('open');
window.bbTab('board');
schDraw();
};
// parts list compatibility
window.bbSetTool = (t) => window.schSetTool(t);
window.schRotateSelected = () => {
SCH.ghostRot = ((SCH.ghostRot||0)+90)%360;
if (SCH.selId) {
const c = SCH.components.find(c => c.id === SCH.selId);
if (c) { c.rot = ((c.rot||0)+90)%360; schUpdateNetlist(); }
}
schDraw();
};
window.schFlipHSelected = () => {
SCH.ghostFlipH = !SCH.ghostFlipH;
if (SCH.selId) {
const c = SCH.components.find(c => c.id === SCH.selId);
if (c) { c.flipH = !c.flipH; schUpdateNetlist(); }
}
schDraw();
};
window.schFlipVSelected = () => {
SCH.ghostFlipV = !SCH.ghostFlipV;
if (SCH.selId) {
const c = SCH.components.find(c => c.id === SCH.selId);
if (c) { c.flipV = !c.flipV; schUpdateNetlist(); }
}
schDraw();
};
window.schUndo = () => {
if (!SCH.undoStack.length) return;
const s=SCH.undoStack.pop();
SCH.components=JSON.parse(s.comps); SCH.wires=JSON.parse(s.wires);
SCH.selId=null; SCH.wireStart=null;
schUpdateNetlist(); schDraw();
};
window.schClear = () => {
if (!confirm('회로도를 초기화하시겠습니까?')) return;
schSaveUndo(); SCH.components=[]; SCH.wires=[]; SCH.selId=null; SCH.wireStart=null;
schUpdateNetlist(); schDraw();
};
// ── 회로도 복사/붙여넣기 ───────────────────────────────────────────────────────
window.schSelectAll = () => {
SCH.selection = [
...SCH.components.map(c => ({type:'comp', id:c.id})),
...SCH.wires.map(w => ({type:'wire', id:w.id}))
];
SCH.selId = SCH.selection.length>0 ? SCH.selection[0].id : null;
schDraw();
};
window.schCopy = () => {
if (SCH.selection.length === 0) {
appendSerial('📋 선택된 항목 없음\n', 'sys');
return;
}
const ids = new Set(SCH.selection.map(s=>s.id));
const comps = SCH.components.filter(c => ids.has(c.id));
const wires = SCH.wires.filter(w => ids.has(w.id));
// 복사 기준점 (선택된 항목들의 최소 좌표)
const minX = Math.min(...comps.map(c=>c.x), ...wires.flatMap(w=>[w.x1,w.x2]));
const minY = Math.min(...comps.map(c=>c.y), ...wires.flatMap(w=>[w.y1,w.y2]));
SCH.clipboard = {
components: comps.map(c => ({...c, dx:c.x-minX, dy:c.y-minY})),
wires: wires.map(w => ({...w, dx1:w.x1-minX, dy1:w.y1-minY, dx2:w.x2-minX, dy2:w.y2-minY})),
baseX: minX, baseY: minY
};
appendSerial(`📋 복사: 부품 ${comps.length}개, 와이어 ${wires.length}개\n`, 'sys');
};
window.schPaste = () => {
if (!SCH.clipboard) {
appendSerial('📋 복사된 항목 없음\n', 'sys');
return;
}
schSaveUndo();
// 마우스 위치로 붙여넣기 (오프셋 계산)
const pasteX = SCH.mousePt.x;
const pasteY = SCH.mousePt.y;
const newIds = new Map(); // oldId -> newId
// 컴포넌트 붙여넣기
for (const c of SCH.clipboard.components) {
const newComp = {
id: SCH.nextId++,
type: c.type,
x: pasteX + c.dx,
y: pasteY + c.dy,
rot: c.rot,
flipH: c.flipH || false,
value: c.value
};
newIds.set(c.id, newComp.id);
SCH.components.push(newComp);
}
// 와이어 붙여넣기
for (const w of SCH.clipboard.wires) {
SCH.wires.push({
id: SCH.nextId++,
x1: pasteX + w.dx1,
y1: pasteY + w.dy1,
x2: pasteX + w.dx2,
y2: pasteY + w.dy2
});
}
SCH.selection = [];
schUpdateNetlist();
schDraw();
appendSerial(`📋 붙여넣기 완료\n`, 'sys');
};
// ── 회로도 저장/불러오기 (클라이언트 로컬 File System Access API) ─────────────
// IndexedDB에서 localDirHandle 가져오기
async function schGetDirHandle() {
try {
const handle = await new Promise((resolve, reject) => {
const req = indexedDB.open('code_editor_v1', 1);
req.onupgradeneeded = e => e.target.result.createObjectStore('handles');
req.onsuccess = e => {
const r = e.target.result.transaction('handles', 'readonly').objectStore('handles').get('dir');
r.onsuccess = () => resolve(r.result || null);
r.onerror = () => reject(new Error('IndexedDB 읽기 오류'));
};
req.onerror = () => reject(new Error('IndexedDB 열기 오류'));
});
if (!handle) return null;
// 권한 확인/요청
const perm = await handle.queryPermission({ mode: 'readwrite' });
if (perm === 'granted') return handle;
// 권한이 없으면 요청
const reqPerm = await handle.requestPermission({ mode: 'readwrite' });
if (reqPerm === 'granted') return handle;
return null;
} catch (e) {
console.error('[sch] dirHandle 오류:', e);
return null;
}
}
// 프로젝트 이름 가져오기
function schGetProjectName() {
// 1. URL 파라미터 (?project=my-project)
const urlParams = new URLSearchParams(window.location.search);
const projectParam = urlParams.get('project');
if (projectParam) return projectParam;
// 2. sessionStorage (코드 에디터에서 연 경우)
const project = sessionStorage.getItem('arduino_project');
if (project) return project;
return null;
}
// 프로젝트 디렉토리 핸들 가져오기
async function schGetProjectDir() {
const dirHandle = await schGetDirHandle();
if (!dirHandle) return null;
const projectName = schGetProjectName();
if (!projectName) return null;
try {
return await dirHandle.getDirectoryHandle(projectName, { create: true });
} catch (e) {
console.error('[sch] 프로젝트 디렉토리 오류:', e);
return null;
}
}
// 저장된 파일 목록 가져오기
async function schGetFileList() {
const projectDir = await schGetProjectDir();
if (!projectDir) return [];
try {
const files = [];
for await (const [name, handle] of projectDir.entries()) {
if (name.endsWith('.json') && handle.kind === 'file') {
try {
const file = await handle.getFile();
files.push({ name, size: file.size, mtime: file.lastModified ? new Date(file.lastModified).toISOString() : '' });
} catch (e) { /* skip unreadable */ }
}
}
files.sort((a, b) => (b.mtime || '').localeCompare(a.mtime || ''));
return files;
} catch (e) {
console.error('[sch] 파일 목록 오류:', e);
return [];
}
}
window.schSaveLocal = async () => {
const projectDir = await schGetProjectDir();
if (!projectDir) {
appendSerial('💾 오류: 코드 에디터에서 프로젝트 폴더를 먼저 열어주세요\n', 'error');
return;
}
if (SCH.components.length === 0 && SCH.wires.length === 0) {
appendSerial('💾 저장할 회로도가 없습니다\n', 'sys');
return;
}
const defaultName = new Date().toISOString().slice(0,19).replace(/[:T]/g,'-');
const name = prompt('저장할 이름을 입력하세요 (.json 자동 추가):', defaultName);
if (!name || name.trim() === '') return;
const fileName = name.endsWith('.json') ? name : name + '.json';
try {
const data = { components: SCH.components, wires: SCH.wires, savedAt: new Date().toISOString() };
const fileHandle = await projectDir.getFileHandle(fileName, { create: true });
const writable = await fileHandle.createWritable();
await writable.write(JSON.stringify(data, null, 2));
await writable.close();
const projectName = schGetProjectName() || projectDir.name;
appendSerial(`💾 저장 완료: ${fileName} (프로젝트: ${projectName})\n`, 'sys');
} catch (e) {
appendSerial('💾 오류: ' + e.message + '\n', 'error');
}
};
// ── 파일 목록 모달 ─────────────────────────────────────────────────────────────
function schShowFileModal({ title, files, onSelect, onDelete }) {
let modal = document.getElementById('sch-file-modal');
if (!modal) {
modal = document.createElement('div');
modal.id = 'sch-file-modal';
modal.style.cssText = 'position:fixed;inset:0;z-index:10000;background:rgba(0,0,0,0.35);display:flex;align-items:center;justify-content:center';
document.body.appendChild(modal);
}
modal.innerHTML = `
<div style="background:#ffffff;border:1px solid #d1d5db;border-radius:10px;
padding:18px;min-width:340px;max-width:480px;width:90vw;max-height:70vh;
display:flex;flex-direction:column;box-shadow:0 8px 32px rgba(0,0,0,0.18)">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
<span style="font-weight:600;font-size:14px;color:#1f2937">${title}</span>
<button id="sch-fm-close" style="background:none;border:none;color:#6b7280;cursor:pointer;font-size:18px;line-height:1">✕</button>
</div>
<div id="sch-fm-list" style="overflow-y:auto;flex:1;display:flex;flex-direction:column;gap:4px">
${files.length === 0
? '<div style="color:#6b7280;font-size:12px;padding:8px">저장된 파일이 없습니다</div>'
: files.map((f,i) => `
<div class="sch-fm-item" data-i="${i}"
style="display:flex;align-items:center;padding:7px 10px;border-radius:6px;cursor:pointer;
border:1px solid transparent;gap:8px;transition:background 0.1s"
onmouseover="this.style.background='#f3f4f6';this.style.borderColor='#e5e7eb'"
onmouseout="this.style.background='';this.style.borderColor='transparent'"
>
<span style="flex:1;font-size:13px;color:#111827">${f.name}</span>
<span style="font-size:10px;color:#9ca3af;white-space:nowrap">
${f.mtime ? new Date(f.mtime).toLocaleString('ko-KR',{month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}) : ''}
</span>
<span style="font-size:10px;color:#9ca3af">${f.size > 1024 ? (f.size/1024).toFixed(1)+'K' : f.size+'B'}</span>
${onDelete ? `<button class="sch-fm-del" data-i="${i}"
style="background:none;border:none;color:#ef4444;cursor:pointer;font-size:14px;padding:0 2px;flex-shrink:0"
title="삭제">🗑</button>` : ''}
</div>`).join('')}
</div>
</div>`;
modal.style.display = 'flex';
const close = () => { modal.style.display = 'none'; };
document.getElementById('sch-fm-close').onclick = close;
modal.addEventListener('click', e => { if (e.target === modal) close(); }, {once:true});
// Select
modal.querySelectorAll('.sch-fm-item').forEach(el => {
el.addEventListener('click', e => {
if (e.target.closest('.sch-fm-del')) return;
close(); onSelect(files[+el.dataset.i]);
});
});
// Delete buttons
if (onDelete) modal.querySelectorAll('.sch-fm-del').forEach(btn => {
btn.addEventListener('click', async e => {
e.stopPropagation();
const f = files[+btn.dataset.i];
if (!confirm(`${f.name} 을 삭제하시겠습니까?`)) return;
await onDelete(f);
close();
});
});
}
window.schLoadLocal = async () => {
const projectDir = await schGetProjectDir();
if (!projectDir) { appendSerial('📂 오류: 코드 에디터에서 프로젝트 폴더를 먼저 열어주세요\n', 'error'); return; }
const files = await schGetFileList();
schShowFileModal({
title: '📂 회로도 불러오기',
files,
onSelect: async (file) => {
try {
const fh = await projectDir.getFileHandle(file.name);
const f = await fh.getFile();
const data = JSON.parse(await f.text());
schSaveUndo();
SCH.components = data.components || [];
SCH.wires = data.wires || [];
SCH.nextId = Math.max(...[...SCH.components, ...SCH.wires].map(x=>x.id||0), 0) + 1;
SCH.selId = null; SCH.wireStart = null;
schUpdateNetlist(); schDraw();
appendSerial(`📂 불러옴: ${file.name} (부품:${SCH.components.length}, 와이어:${SCH.wires.length})\n`, 'sys');
} catch(e) { appendSerial('📂 오류: '+e.message+'\n', 'error'); }
},
onDelete: async (file) => {
try { await projectDir.removeEntry(file.name); appendSerial(`🗑 삭제: ${file.name}\n`, 'sys'); }
catch(e) { appendSerial('🗑 오류: '+e.message+'\n', 'error'); }
},
});
};
window.schDeleteLocal = async () => {
const projectDir = await schGetProjectDir();
if (!projectDir) { appendSerial('🗑 오류: 코드 에디터에서 프로젝트 폴더를 먼저 열어주세요\n', 'error'); return; }
const files = await schGetFileList();
schShowFileModal({
title: '🗑 회로도 파일 삭제',
files,
onSelect: () => {},
onDelete: async (file) => {
try { await projectDir.removeEntry(file.name); appendSerial(`🗑 삭제: ${file.name}\n`, 'sys'); }
catch(e) { appendSerial('🗑 오류: '+e.message+'\n', 'error'); }
},
});
};
window.schExport = () => {
if (SCH.components.length === 0 && SCH.wires.length === 0) {
appendSerial('⬇ 저장할 회로도가 없습니다\n', 'sys');
return;
}
// 기본 파일명 제안 (회로도 이름 또는 날짜)
const defaultName = 'arduino-schematic-' + new Date().toISOString().slice(0,19).replace(/[:T]/g,'-');
const fileName = prompt('파일 이름을 입력하세요 (.json 확장자는 자동 추가):', defaultName);
if (!fileName || fileName.trim() === '') return;
const finalName = fileName.endsWith('.json') ? fileName : fileName + '.json';
const data = { components: SCH.components, wires: SCH.wires, exportedAt: new Date().toISOString() };
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
// File System Access API 지원 시 → 위치 선택 다이얼로그
if (window.showSaveFilePicker) {
(async () => {
try {
const handle = await window.showSaveFilePicker({
suggestedName: finalName,
types: [{ description: 'JSON 파일', accept: { 'application/json': ['.json'] } }]
});
const writable = await handle.createWritable();
await writable.write(blob);
await writable.close();
appendSerial(`⬇ 저장 완료: ${handle.name}\n`, 'sys');
} catch (err) {
if (err.name !== 'AbortError') {
appendSerial('⬇ 저장 오류: ' + err.message + '\n', 'error');
}
}
})();
} else {
// 구형 브라우저 → 일반 다운로드
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = finalName;
a.click();
URL.revokeObjectURL(url);
appendSerial(`⬇ 다운로드: ${finalName}\n`, 'sys');
}
};
window.schImport = () => {
const inp = document.createElement('input');
inp.type = 'file';
inp.accept = '.json,application/json';
inp.onchange = (e) => {
const f = e.target.files[0];
if (!f) return;
const r = new FileReader();
r.onload = () => {
try {
const data = JSON.parse(r.result);
if (!data.components || !data.wires) throw new Error('유효하지 않은 파일');
schSaveUndo();
SCH.components = data.components;
SCH.wires = data.wires;
SCH.selId = null; SCH.wireStart = null;
schUpdateNetlist(); schDraw();
appendSerial(`⬆ 회로도 JSON 가져옴 (부품:${data.components.length}, 와이어:${data.wires.length})\n`, 'sys');
} catch(err) {
appendSerial('⬆ JSON 오류: ' + err.message + '\n', 'error');
}
};
r.readAsText(f);
};
inp.click();
};
// ── 프로젝트에 circuit.json 자동 저장 ──────────────────────────────────────────
window.schSaveProject = async () => {
const projectDir = await schGetProjectDir();
if (!projectDir) {
appendSerial('💾 오류: 코드 에디터에서 프로젝트 폴더를 먼저 열어주세요\n', 'error');
return;
}
try {
const data = { components: SCH.components, wires: SCH.wires, savedAt: new Date().toISOString() };
const fileHandle = await projectDir.getFileHandle('circuit.json', { create: true });
const writable = await fileHandle.createWritable();
await writable.write(JSON.stringify(data, null, 2));
await writable.close();
appendSerial(`💾 circuit.json 저장 완료 (부품:${SCH.components.length}, 와이어:${SCH.wires.length})\n`, 'sys');
} catch (e) {
appendSerial('💾 오류: ' + e.message + '\n', 'error');
}
};
// ── 프로젝트의 .ino 스케치 파일 목록 불러오기 ───────────────────────────────────
window.schLoadSketchFile = async () => {
const projectDir = await schGetProjectDir();
if (!projectDir) {
appendSerial('📝 오류: 코드 에디터에서 프로젝트 폴더를 먼저 열어주세요\n', 'error');
return;
}
const files = [];
try {
for await (const [name, handle] of projectDir.entries()) {
if (handle.kind === 'file' && (name.endsWith('.ino') || name.endsWith('.cpp') || name.endsWith('.h'))) {
const f = await handle.getFile();
files.push({ name, size: f.size, mtime: f.lastModified ? new Date(f.lastModified).toISOString() : '' });
}
}
files.sort((a,b)=>(b.mtime||'').localeCompare(a.mtime||''));
} catch(e) { appendSerial('📝 파일 목록 오류: '+e.message+'\n', 'error'); return; }
if (files.length === 0) { appendSerial('📝 프로젝트에 .ino 파일이 없습니다\n', 'sys'); return; }
schShowFileModal({
title: '📝 스케치 파일 불러오기',
files,
onSelect: async (file) => {
try {
const fh = await projectDir.getFileHandle(file.name);
const f = await fh.getFile();
const code = await f.text();
const fn = document.getElementById('code-editor-filename');
_setEditorCode(code);
if (fn) fn.textContent = file.name;
document.getElementById('filename').textContent = file.name;
// 코드 에디터 패널 열기
const panel = document.getElementById('code-editor-panel');
if (panel && panel.style.display === 'none') toggleCodeEditor();
appendSerial(`📝 스케치 불러옴: ${file.name}\n`, 'sys');
} catch(e) { appendSerial('📝 오류: '+e.message+'\n', 'error'); }
},
});
};
// ── 스케치 파일 저장 (코드 에디터 → 프로젝트 파일) ──────────────────────────────
window.schSaveSketchFile = async () => {
const projectDir = await schGetProjectDir();
if (!projectDir) {
appendSerial('💾 오류: 코드 에디터에서 프로젝트 폴더를 먼저 열어주세요\n', 'error');
return;
}
const fn = document.getElementById('code-editor-filename');
const code = _getEditorCode();
const fileName = fn?.textContent?.trim() || 'sketch.ino';
if (!code.trim()) { appendSerial('💾 저장할 코드가 없습니다\n', 'sys'); return; }
try {
const fileHandle = await projectDir.getFileHandle(fileName, { create: true });
const writable = await fileHandle.createWritable();
await writable.write(code);
await writable.close();
appendSerial(`💾 ${fileName} 저장 완료\n`, 'sys');
} catch (e) {
appendSerial('💾 오류: ' + e.message + '\n', 'error');
}
};
// ── 부팅 시 프로젝트의 circuit.json 자동 로드 ──────────────────────────────────
async function schAutoLoadProject() {
const projectDir = await schGetProjectDir();
if (!projectDir) return;
try {
const fh = await projectDir.getFileHandle('circuit.json');
const f = await fh.getFile();
const data = JSON.parse(await f.text());
if (!data.components || !data.wires) return;
SCH.components = data.components;
SCH.wires = data.wires;
SCH.nextId = Math.max(...[...SCH.components, ...SCH.wires].map(x=>x.id||0), 0) + 1;
SCH.selId = null; SCH.wireStart = null;
schUpdateNetlist();
appendSerial(`📂 circuit.json 자동 로드 (부품:${SCH.components.length}, 와이어:${SCH.wires.length})\n`, 'sys');
} catch(e) {
// circuit.json 없으면 무시
}
}
window.toggleBB = () => {
const el=document.getElementById('sch-panel');
el.classList.toggle('open');
const btn=document.getElementById('btn-bb');
btn.classList.toggle('active', el.classList.contains('open'));
if (el.classList.contains('open')) { schUpdateNetlist(); requestAnimationFrame(schDraw); }
};
window.toggleSchPanel = window.toggleBB;
window.bbTab = (tab) => {
document.querySelectorAll('.bb-content').forEach(c=>c.classList.remove('active'));
document.querySelectorAll('.bb-tab').forEach(b=>b.classList.remove('active'));
document.getElementById('bb-content-'+tab)?.classList.add('active');
document.getElementById('bb-tab-btn-'+tab)?.classList.add('active');
if (tab==='board') schDraw();
if (tab==='spice') schUpdateNetlist();
};
// ── Pin voltage (Arduino AVR or ESP32 QEMU) ───────────────────────────────────
function bbGetPinV(pinStr) {
const s = (pinStr || '').toUpperCase().trim();
if (_IS_ESP32) {
if (s === '3V3' || s === '3.3V' || s === 'VCC') return 3.3;
if (s === '5V' || s === 'VIN') return 5;
if (s === 'GND' || s === '0V') return 0;
const md = s.match(/^(?:D|GPIO)?(\d+)$/);
if (md) { const p = parseInt(md[1]); return (_esp32PinStates[p] || 0) * 3.3; }
return 0;
}
if (!cpu||!portB||!portC||!portD) return 0;
if (s==='5V'||s==='VCC') return 5;
if (s==='3.3V') return 3.3;
if (s==='GND'||s==='0V') return 0;
const md=s.match(/^D(\d+)$/);
if (md) {
const n=parseInt(md[1]);
if (n<8) return pinVoltage(portD, n)?5:0;
if (n<14) return pinVoltage(portB, n-8)?5:0;
}
const ma=s.match(/^A(\d+)$/);
if (ma) return pinVoltage(portC, parseInt(ma[1]))?5:0;
return 0;
}
// ── DHT Sensor Detection ─────────────────────────────────────────────────────────
function detectDHTConnections() {
const results = [];
if (SCH.components.length === 0) return results;
// Union-Find (same logic as schToNetlist)
const parent = new Map();
function find(k) { if (!parent.has(k)) parent.set(k,k); if (parent.get(k)===k) return k; const r=find(parent.get(k)); parent.set(k,r); return r; }
function union(a,b) { a=find(a); b=find(b); if(a!==b) parent.set(b,a); }
const pk=(x,y)=>`${x},${y}`;
for (const c of SCH.components) for (const t of schTermPositions(c)) find(pk(t.x,t.y));
for (const w of SCH.wires) { find(pk(w.x1,w.y1)); find(pk(w.x2,w.y2)); union(pk(w.x1,w.y1),pk(w.x2,w.y2)); }
for (const w of SCH.wires) for (const c of SCH.components) for (const t of schTermPositions(c)) {
if ((t.x===w.x1&&t.y===w.y1)||(t.x===w.x2&&t.y===w.y2)||ptOnSegStrict(t.x,t.y,w.x1,w.y1,w.x2,w.y2))
union(pk(t.x,t.y), pk(w.x1,w.y1));
}
// GND/VCC marking
const GND_KEY='__GND__'; parent.set(GND_KEY,GND_KEY);
for (const c of SCH.components) if (c.type==='gnd') { const t=schTermPositions(c)[0]; parent.set(find(pk(t.x,t.y)),GND_KEY); }
// Helper: avr8js gpio number → {port, pinIndex, pinName}
const _avrGpioToPort = (gpio) => {
if (gpio >= 0 && gpio < 8) return { port: portD, pinIndex: gpio, pinName: 'D'+gpio };
if (gpio >= 8 && gpio < 14) return { port: portB, pinIndex: gpio-8, pinName: 'D'+gpio };
if (gpio >= 14 && gpio < 20) return { port: portC, pinIndex: gpio-14, pinName: 'A'+(gpio-14) };
return null;
};
// Helper: board component terminal index → gpio pin number
const _brdPins = { board_uno: _BOARD_UNO_PINS, board_nano: _BOARD_NANO_PINS, board_mega: _BOARD_MEGA_PINS };
const _brdVccIdx = new Set([0,1,2]); // VIN / 5V / 3V3
const _brdGndIdx = new Set([3]); // GND
// Find DHT → Arduino connections
for (const comp of SCH.components) {
if (comp.type !== 'dht') continue;
const terms = schTermPositions(comp);
if (terms.length < 3) continue;
// DHT terminals: VCC(0), DAT(1), GND(2)
const datKey = find(pk(terms[1].x, terms[1].y));
const vccKey = find(pk(terms[0].x, terms[0].y));
const gndKey = find(pk(terms[2].x, terms[2].y));
let datGpio = -1, vccOk = false, gndOk = false;
for (const ard of SCH.components) {
const isBrd = ard.type in _brdPins;
if (ard.type !== 'ard' && !isBrd) continue;
const at = schTermPositions(ard);
if (isBrd) {
const pins = _brdPins[ard.type];
for (let i = 0; i < at.length; i++) {
const aKey = find(pk(at[i].x, at[i].y));
if (aKey === datKey) { const g = pins[i] ?? -1; if (g >= 0) datGpio = g; }
if (aKey === vccKey && _brdVccIdx.has(i)) vccOk = true;
if (aKey === gndKey && _brdGndIdx.has(i)) gndOk = true;
}
} else {
if (!at.length) continue;
const aKey = find(pk(at[0].x, at[0].y));
const v = (ard.value||'').toUpperCase().trim();
if (aKey === datKey) {
const md = v.match(/^D?(\d+)$/), ma = v.match(/^A(\d+)$/);
if (md) datGpio = parseInt(md[1]);
else if (ma) datGpio = parseInt(ma[1]) + 14;
}
if (aKey === vccKey && (v==='5V'||v==='VCC')) vccOk = true;
if (aKey === gndKey && v==='GND') gndOk = true;
}
}
// Standalone VCC/GND symbols
for (const c of SCH.components) {
if (c.type==='vcc') { const t=schTermPositions(c)[0]; if (find(pk(t.x,t.y))===vccKey) vccOk=true; }
if (c.type==='gnd') { const t=schTermPositions(c)[0]; if (find(pk(t.x,t.y))===gndKey) gndOk=true; }
}
if (datGpio < 0) continue;
const p = _avrGpioToPort(datGpio);
if (!p) continue;
const model = (comp.value||'DHT11').toUpperCase().includes('DHT22') ? 'DHT22' : 'DHT11';
results.push({ comp, port: p.port, pinIndex: p.pinIndex, pinName: p.pinName, model, vccOk, gndOk });
}
return results;
}
// ── Schematic → SPICE Netlist ─────────────────────────────────────────────────
function schToNetlist() {
const parent=new Map();
function find(k) {
if (!parent.has(k)) parent.set(k,k);
if (parent.get(k)===k) return k;
const r=find(parent.get(k)); parent.set(k,r); return r;
}
function union(a,b) { a=find(a); b=find(b); if(a!==b) parent.set(b,a); }
const pk=(x,y)=>`${x},${y}`;
// Register all terminal + wire points
for (const c of SCH.components) for (const t of schTermPositions(c)) find(pk(t.x,t.y));
for (const w of SCH.wires) { find(pk(w.x1,w.y1)); find(pk(w.x2,w.y2)); union(pk(w.x1,w.y1),pk(w.x2,w.y2)); }
// Union terminals on wires
for (const w of SCH.wires) for (const c of SCH.components) for (const t of schTermPositions(c)) {
if ((t.x===w.x1&&t.y===w.y1)||(t.x===w.x2&&t.y===w.y2)||ptOnSegStrict(t.x,t.y,w.x1,w.y1,w.x2,w.y2))
union(pk(t.x,t.y), pk(w.x1,w.y1));
}
// Mark GND
const GND_KEY='__GND__'; parent.set(GND_KEY,GND_KEY);
for (const c of SCH.components) if (c.type==='gnd') {
const t=schTermPositions(c)[0], r=find(pk(t.x,t.y));
parent.set(r,GND_KEY);
}
// Net name helper
const netNames=new Map(); let nn=1;
function getNet(x,y) {
let r=find(pk(x,y)); while(parent.has(r)&&parent.get(r)!==r) r=find(r);
if (r===GND_KEY||find(GND_KEY)===r) return '0';
if (!netNames.has(r)) netNames.set(r,`N${nn++}`);
return netNames.get(r);
}
const lines=['* Arduino Schematic — SPICE Netlist',`* Components: ${SCH.components.length} Wires: ${SCH.wires.length}`,''];
let ri=1,vi=1,di=1,ci=1,qi=1;
const hasNPN=SCH.components.some(c=>c.type==='bjt'&&!(c.value||'').toUpperCase().includes('PNP'));
const hasPNP=SCH.components.some(c=>c.type==='bjt'&&(c.value||'').toUpperCase().includes('PNP'));
for (const c of SCH.components) {
const tp=schTermPositions(c), nets=tp.map(t=>getNet(t.x,t.y));
switch(c.type) {
case 'res': lines.push(`R${ri++} ${nets[0]} ${nets[1]} ${parseFloat(c.value)||1000}`); break;
case 'cap': {
const vs=c.value||'100n';
const vn=vs.endsWith('u')?parseFloat(vs)*1e-6:vs.endsWith('n')?parseFloat(vs)*1e-9:vs.endsWith('p')?parseFloat(vs)*1e-12:parseFloat(vs)||100e-9;
lines.push(`C${ci++} ${nets[0]} ${nets[1]} ${vn.toExponential(3)}`); break;
}
case 'led': lines.push(`D${di++} ${nets[0]} ${nets[1]} LED_R`); break;
case 'diode': lines.push(`D${di++} ${nets[0]} ${nets[1]} DIODE`); break;
case 'zener': {
const zv=parseFloat(c.value)||5.1;
lines.push(`D${di++} ${nets[0]} ${nets[1]} ZENER_${Math.round(zv*10)}`);
lines.push(`.model ZENER_${Math.round(zv*10)} D(Is=1e-14 VJ=${zv} N=1.8 Rs=5)`); break;
}
case 'bjt': {
const t=(c.value||'NPN').toUpperCase().includes('PNP')?'PNP':'NPN';
lines.push(`Q${qi++} ${nets[1]} ${nets[0]} ${nets[2]} ${t} ; B=${nets[0]} C=${nets[1]} E=${nets[2]}`); break;
}
case 'vcc': lines.push(`V${vi++} ${nets[0]} 0 ${parseFloat(c.value)||5} ; VCC`); break;
case 'ard': {
const v=bbGetPinV(c.value||'');
lines.push(`V${vi++} ${nets[0]} 0 ${v.toFixed(2)} ; Arduino ${c.value}`); break;
}
}
}
lines.push('');
lines.push('.model LED_R D(Is=4.2e-21 N=1.8 VJ=1.90 Rs=5) ; Red LED ~2.0V@20mA');
lines.push('.model DIODE D(Is=1e-14 N=1 VJ=0.6 Rs=2)');
if (hasNPN) lines.push('.model NPN NPN(IS=14.34e-15 BF=200 BR=6 NF=1 NR=1)');
if (hasPNP) lines.push('.model PNP PNP(IS=14.34e-15 BF=200 BR=6 NF=1 NR=1)');
lines.push(''); lines.push('.op'); lines.push('.end');
return lines.join('\n');
}
// ── ESP32 DHT sensor detection ────────────────────────────────────────────────
function detectDHTConnectionsEsp32() {
const results = [];
if (SCH.components.length === 0) return results;
const parent = new Map();
function find(k) { if (!parent.has(k)) parent.set(k,k); if (parent.get(k)===k) return k; const r=find(parent.get(k)); parent.set(k,r); return r; }
function union(a,b) { a=find(a); b=find(b); if(a!==b) parent.set(b,a); }
const pk=(x,y)=>`${x},${y}`;
for (const c of SCH.components) for (const t of schTermPositions(c)) find(pk(t.x,t.y));
for (const w of SCH.wires) { find(pk(w.x1,w.y1)); find(pk(w.x2,w.y2)); union(pk(w.x1,w.y1),pk(w.x2,w.y2)); }
for (const w of SCH.wires) for (const c of SCH.components) for (const t of schTermPositions(c)) {
if ((t.x===w.x1&&t.y===w.y1)||(t.x===w.x2&&t.y===w.y2)||ptOnSegStrict(t.x,t.y,w.x1,w.y1,w.x2,w.y2))
union(pk(t.x,t.y), pk(w.x1,w.y1));
}
for (const comp of SCH.components) {
if (comp.type !== 'dht') continue;
const terms = schTermPositions(comp);
if (terms.length < 2) continue;
const datKey = pk(terms[1].x, terms[1].y);
let gpio = -1;
for (const ard of SCH.components) {
if (ard.type !== 'ard') continue;
const at = schTermPositions(ard);
if (!at.length) continue;
if (find(pk(at[0].x,at[0].y)) === find(datKey)) {
const v = (ard.value||'').toUpperCase().trim();
const gm = v.match(/^(?:GPIO|D)?(\d+)$/);
if (gm) gpio = parseInt(gm[1]);
}
}
if (gpio < 0) continue;
const model = (comp.value||'DHT11').toUpperCase().includes('DHT22') ? 'DHT22' : 'DHT11';
results.push({ comp, gpio, model });
}
return results;
}
function setupDHTSensorsEsp32() {
const detected = detectDHTConnectionsEsp32();
// Merge: keep DINI-detected pins (from sketch dht.begin()), add circuit-detected pins
const existingPins = new Set(_esp32DhtPins.map(p => p.gpio));
_esp32DhtPins = [..._esp32DhtPins]; // keep DINI pins
for (const d of detected) {
if (!existingPins.has(d.gpio)) _esp32DhtPins.push(d);
}
const section = document.getElementById('dht-section');
if (!section) return;
if (_esp32DhtPins.length === 0) { section.style.display = 'none'; return; }
section.style.display = '';
const dot = document.getElementById('dht-dot');
const label = document.getElementById('dht-label');
if (dot) dot.className = 'dht-dot on';
if (label) label.textContent = (_esp32DhtPins[0]?.model || 'DHT22') + ' 온습도 (GPIO' + _esp32DhtPins[0].gpio + ')';
const tempInput = document.getElementById('dht-temp');
if (tempInput && _esp32DhtPins[0]?.model === 'DHT11') { tempInput.min = '0'; tempInput.max = '50'; }
// Don't auto-send DHT values on setup — QEMU stdin write during boot causes crash
// Values will be sent when user changes the slider or on DINI event
}
function detectDHTConnectionsEsp8266() {
const results = [];
if (!SCH.components.some(c => c.type === 'dht')) return results;
const { find, pk } = _schBuildNets();
for (const comp of SCH.components) {
if (comp.type !== 'dht') continue;
const gpio = _schGpioFromComp(comp, 1, find, pk); // terminal 1 = DATA
if (gpio < 0) continue;
const model = (comp.value||'DHT11').toUpperCase().includes('DHT22') ? 'DHT22' : 'DHT11';
results.push({ comp, gpio, model });
}
return results;
}
function setupDHTSensorsEsp8266() {
const detected = detectDHTConnectionsEsp8266();
const existingPins = new Set(_esp8266DhtPins.map(p => p.gpio));
for (const d of detected) {
if (!existingPins.has(d.gpio)) _esp8266DhtPins.push({ gpio: d.gpio, model: d.model });
}
const section = document.getElementById('dht-section');
if (!section) return;
if (_esp8266DhtPins.length === 0) return; // 이미 표시된 경우 숨기지 않음
section.style.display = '';
const dot = document.getElementById('dht-dot');
const label = document.getElementById('dht-label');
if (dot) dot.className = 'dht-dot on';
if (label) label.textContent = (_esp8266DhtPins[0]?.model || 'DHT22') + ' 온습도 (GPIO' + _esp8266DhtPins[0].gpio + ')';
const tempInput = document.getElementById('dht-temp');
if (tempInput && _esp8266DhtPins[0]?.model === 'DHT11') { tempInput.min = '0'; tempInput.max = '50'; }
}
function _esp32SendDHTValues() {
if (!_esp32SessionId || !_esp32DhtPins.length) return;
const t = parseFloat(document.getElementById('dht-temp').value);
const h = parseFloat(document.getElementById('dht-humid').value);
for (const conn of _esp32DhtPins) {
_esp32FlashPin(conn.gpio);
fetch('/api/arduino/qemu/' + _esp32SessionId + '/dht', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pin: conn.gpio, temp: t, humid: h }),
}).catch(() => {});
}
}
// GPIO lookup for board components (terminal index → GPIO, -1 = power/GND/ctrl)
const _BOARD_ESP32_GPIOS = [13,12,14,27,26,25,33,32,35,34,39,36,-1,-1,-1, 15,2,4,16,17,5,18,19,21,3,1,22,23,-1,-1];
// ESP8266 GPIO mapping is now variant-aware — see _ESP8266_VARIANTS
// UNO: left(0-10)=VIN,5V,3V3,GND,RST,A0-A5 right(11-24)=D13-D0
const _BOARD_UNO_PINS = [-1,-1,-1,-1,-1, 14,15,16,17,18,19, 13,12,11,10,9,8,7,6,5,4,3,2,1,0];
// Nano: left(0-13)=VIN,5V,3V3,GND,RST,AREF,A0-A7 right(14-27)=D13-D0
const _BOARD_NANO_PINS = [-1,-1,-1,-1,-1,-1, 14,15,16,17,18,19,20,21, 13,12,11,10,9,8,7,6,5,4,3,2,1,0];
// Mega: left(0-15)=VIN,5V,3V3,GND,RST,A0-A10 right(16-31)=D13-D0,D18,D19
const _BOARD_MEGA_PINS = [-1,-1,-1,-1,-1, 54,55,56,57,58,59,60,61,62,63,64, 13,12,11,10,9,8,7,6,5,4,3,2,1,0,18,19];
function _schBuildNets() {
const parent = new Map();
function find(k) { if (!parent.has(k)) parent.set(k,k); if (parent.get(k)===k) return k; const r=find(parent.get(k)); parent.set(k,r); return r; }
function union(a,b) { a=find(a); b=find(b); if(a!==b) parent.set(b,a); }
const pk=(x,y)=>`${x},${y}`;
for (const c of SCH.components) for (const t of schTermPositions(c)) find(pk(t.x,t.y));
for (const w of SCH.wires) { find(pk(w.x1,w.y1)); find(pk(w.x2,w.y2)); union(pk(w.x1,w.y1),pk(w.x2,w.y2)); }
for (const w of SCH.wires) for (const c of SCH.components) for (const t of schTermPositions(c))
if ((t.x===w.x1&&t.y===w.y1)||(t.x===w.x2&&t.y===w.y2)||ptOnSegStrict(t.x,t.y,w.x1,w.y1,w.x2,w.y2))
union(pk(t.x,t.y), pk(w.x1,w.y1));
return { find, pk };
}
function _schGpioFromComp(comp, termIdx, find, pk) {
const terms = schTermPositions(comp);
if (termIdx >= terms.length) return -1;
const key = find(pk(terms[termIdx].x, terms[termIdx].y));
// Check ard components
for (const c of SCH.components) {
if (c.type !== 'ard' && c.type !== 'board_esp32' && c.type !== 'board_esp8266'
&& c.type !== 'board_uno' && c.type !== 'board_nano' && c.type !== 'board_mega') continue;
const ct = schTermPositions(c);
for (let i = 0; i < ct.length; i++) {
if (find(pk(ct[i].x, ct[i].y)) !== key) continue;
if (c.type === 'ard') {
const m = (c.value||'').toUpperCase().match(/^(?:GPIO|D)?(\d+)$/);
if (m) return parseInt(m[1]);
} else if (c.type === 'board_esp32') {
const g = _BOARD_ESP32_GPIOS[i]; if (g >= 0) return g;
} else if (c.type === 'board_esp8266') {
// Use variant-specific GPIO array
const v = _ESP8266_VARIANTS[c.variant || 'nodemcu'] || _ESP8266_VARIANTS.nodemcu;
const gpios = [...v.leftPins, ...v.rightPins].map(p => p === null ? -1 : p);
const g = gpios[i]; if (g >= 0) return g;
} else if (c.type === 'board_uno') {
const g = _BOARD_UNO_PINS[i]; if (g >= 0) return g;
} else if (c.type === 'board_nano') {
const g = _BOARD_NANO_PINS[i]; if (g >= 0) return g;
} else if (c.type === 'board_mega') {
const g = _BOARD_MEGA_PINS[i]; if (g >= 0) return g;
}
}
}
return -1;
}
function detectTFTConnectionsEsp32() {
const results = [];
if (!SCH.components.some(c => c.type === 'tft_lcd')) return results;
const { find, pk } = _schBuildNets();
for (const comp of SCH.components) {
if (comp.type !== 'tft_lcd') continue;
// tft_lcd terminals: [0]VCC [1]GND [2]CS [3]RST [4]D/C [5]MOSI [6]SCK [7]LED
const cs = _schGpioFromComp(comp, 2, find, pk);
const rst = _schGpioFromComp(comp, 3, find, pk);
const dc = _schGpioFromComp(comp, 4, find, pk);
const mosi = _schGpioFromComp(comp, 5, find, pk);
const sck = _schGpioFromComp(comp, 6, find, pk);
results.push({ comp, cs, rst, dc, mosi, sck, label: (comp.value||'ILI9341').toUpperCase() });
}
return results;
}
function setupTFTSectionEsp32() {
const detected = detectTFTConnectionsEsp32();
const sec = document.getElementById('tft-section');
if (!sec) return;
if (!detected.length) return; // don't hide — may be active from sketch
sec.style.display = '';
const dot = document.getElementById('tft-dot');
const lbl = document.getElementById('tft-label');
if (dot) dot.classList.add('on');
if (lbl) {
const d = detected[0];
const parts = [d.label];
if (d.cs >= 0) parts.push('CS:' + d.cs);
if (d.dc >= 0) parts.push('DC:' + d.dc);
if (d.sck >= 0) parts.push('SCK:' + d.sck);
if (d.mosi >= 0) parts.push('MOSI:' + d.mosi);
lbl.textContent = parts.join(' · ');
}
// canvas 초기화 (240×320 기본, 검은 화면) — _esp32TftCtx2d가 없으면 항상 설정
const c = document.getElementById('tft-canvas');
if (c && !_esp32TftCtx2d) {
c.width = _esp32TftW || 240; c.height = _esp32TftH || 320;
const ctx = c.getContext('2d');
ctx.fillStyle = '#000'; ctx.fillRect(0, 0, c.width, c.height);
_esp32TftCtx2d = ctx;
}
}
// ── Code → Schematic Generation ─────────────────────────────────────────────
// Component terminal-index map: pin name → SCH_TERMS index
const _COMP_PIN_TERMS = {
led: { anode: 0, cathode: 1 },
diode: { anode: 0, cathode: 1 },
btn: { a: 0, b: 1 },
buzzer: { pos: 0, neg: 1 },
ldr: { a: 0, b: 1 },
ntc: { a: 0, b: 1 },
dht: { vcc: 0, data: 1, gnd: 2 },
hcsr: { vcc: 0, trig: 1, echo: 2, gnd: 3 },
pir: { vcc: 0, out: 1, gnd: 2 },
ir: { vcc: 0, gnd: 1, data: 2 },
hc05: { vcc: 0, gnd: 1, tx: 2, rx: 3 },
rgb_led: { r: 0, g: 1, b: 2, gnd: 3 },
tft_lcd: { vcc: 0, gnd: 1, cs: 2, rst: 3, dc: 4, mosi: 5, sck: 6, led_pin: 7 },
servo: { vcc: 0, gnd: 1, sig: 2 },
relay: { vcc: 0, gnd: 1, in: 2 },
ds18b20: { vcc: 0, dq: 1, gnd: 2 },
mpu6050: { vcc: 0, gnd: 1, scl: 2, sda: 3, int: 4 },
bme280: { vcc: 0, gnd: 1, scl: 2, sda: 3 },
l298n: { vcc: 0, gnd: 1, ena: 2, enb: 3, in1: 4, in2: 5, in3: 6, in4: 7 },
};
// Static code parser: extracts component types and GPIO pins
function _schStaticParse(code) {
// Resolve #define NAME VALUE
const defines = {};
for (const m of code.matchAll(/#define\s+(\w+)\s+(\d+)/g)) defines[m[1]] = parseInt(m[2]);
const rpin = s => { const n = parseInt(s); return isNaN(n) ? (defines[s] ?? -1) : n; };
const components = [];
const found = new Set();
const add = (type, value, pins) => { if (!found.has(type)) { found.add(type); components.push({ type, value: value||'', pins: pins||{} }); } };
const inc = [...code.matchAll(/#include\s*[<"]([\w./]+)[>"]/g)].map(m => m[1]);
const hasInc = (...names) => names.some(n => inc.includes(n));
// TFT
if (hasInc('TFT_eSPI.h','Adafruit_ILI9341.h','Adafruit_ST7789.h','Adafruit_ST7735.h','Adafruit_GFX.h')) {
const pins = {};
// SPI pins often in #define
for (const [k,v] of [['TFT_CS','cs'],['TFT_DC','dc'],['TFT_RST','rst'],['TFT_MOSI','mosi'],['TFT_SCLK','sck']]) {
if (defines[k] !== undefined) pins[v] = defines[k];
}
// Adafruit_ILI9341 tft(CS, DC, MOSI, CLK, RST, ...)
const m = code.match(/Adafruit_(?:ILI9341|ST7789|ST7735)\s+\w+\s*\(\s*(\w+)\s*,\s*(\w+)/);
if (m) { pins.cs = rpin(m[1]); pins.dc = rpin(m[2]); }
const model = hasInc('Adafruit_ILI9341.h') ? 'ILI9341' : hasInc('Adafruit_ST7789.h') ? 'ST7789' : hasInc('Adafruit_ST7735.h') ? 'ST7735' : 'ILI9341';
add('tft_lcd', model, pins);
}
// DHT
if (hasInc('DHT.h','DHT_U.h','DHT_Unified.h')) {
const m = code.match(/DHT(?:_Unified)?\s+\w+\s*\(\s*(\w+)\s*,\s*(\w+)/);
add('dht', m ? m[2].replace(/^DHT/,'') : '', m ? { data: rpin(m[1]) } : {});
}
// HC-SR04
if (hasInc('NewPing.h','HCSR04.h')) {
const m = code.match(/NewPing\s+\w+\s*\(\s*(\w+)\s*,\s*(\w+)/);
add('hcsr', 'HC-SR04', m ? { trig: rpin(m[1]), echo: rpin(m[2]) } : {});
}
// Bluetooth HC-05/06
if (hasInc('SoftwareSerial.h')) {
const m = code.match(/SoftwareSerial\s+\w+\s*\(\s*(\w+)\s*,\s*(\w+)/);
add('hc05', 'HC-05', m ? { tx: rpin(m[1]), rx: rpin(m[2]) } : {});
}
// PIR (often just digitalRead pattern)
const pirM = code.match(/(?:#define\s+PIR_PIN\s+(\d+)|digitalRead\s*\(\s*(\w+)\s*\).*pir|pir.*digitalRead\s*\(\s*(\w+)\s*\))/i);
if (pirM) add('pir', '', { out: rpin(pirM[1]||pirM[2]||pirM[3]) });
// Buzzer: tone(PIN, ...) calls
const buzzM = code.match(/tone\s*\(\s*(\w+)\s*,/);
if (buzzM) add('buzzer', '', { pos: rpin(buzzM[1]) });
// RGB LED: three digitalWrite/analogWrite for R,G,B pins
const rgbDef = ['RED_PIN','GREEN_PIN','BLUE_PIN','R_PIN','G_PIN','B_PIN'];
if (rgbDef.some(d => defines[d] !== undefined)) {
add('rgb_led', '', {
r: defines['RED_PIN'] ?? defines['R_PIN'] ?? -1,
g: defines['GREEN_PIN'] ?? defines['G_PIN'] ?? -1,
b: defines['BLUE_PIN'] ?? defines['B_PIN'] ?? -1,
});
}
// LED: pinMode(X, OUTPUT) pins not already claimed
const usedPins = new Set(components.flatMap(c => Object.values(c.pins).filter(v => v >= 0)));
for (const m of code.matchAll(/pinMode\s*\(\s*(\w+)\s*,\s*OUTPUT\s*\)/g)) {
const pin = rpin(m[1]);
if (pin >= 0 && !usedPins.has(pin)) { add('led', '', { anode: pin }); usedPins.add(pin); }
}
return components;
}
// AI parser: supplement static parse with model analysis
async function _schAiParse(code, staticResult) {
const token = sessionStorage.getItem('smallclaw_token') || localStorage.getItem('smallclaw_token') || '';
const staticStr = staticResult.map(c => `${c.type}(${JSON.stringify(c.pins)})`).join(', ') || '없음';
const prompt = `Arduino 스케치를 분석해서 전자 부품과 GPIO 핀 연결을 JSON으로만 출력해줘. 코드 설명 없이 JSON 한 줄만.
지원 type: dht, tft_lcd, led, hcsr, pir, ir, buzzer, rgb_led, ldr, ntc, btn, hc05
이미 감지된 부품: ${staticStr}
출력 예시:
{"components":[{"type":"dht","value":"DHT22","pins":{"data":4}},{"type":"tft_lcd","value":"ILI9341","pins":{"cs":5,"dc":4,"rst":22,"mosi":23,"sck":18}}]}
코드:
\`\`\`cpp
${code.slice(0, 3000)}
\`\`\``;
let full = '';
try {
const resp = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: 'Bearer ' + token } : {}) },
body: JSON.stringify({ message: prompt, sessionId: 'emu_sch_gen', direct: true }),
});
if (!resp.body) return [];
const reader = resp.body.getReader(), dec = new TextDecoder();
for (;;) {
const { done, value } = await reader.read();
if (done) break;
for (const line of dec.decode(value).split('\n')) {
if (!line.startsWith('data: ')) continue;
try { const d = JSON.parse(line.slice(6)); if (d.text) full += d.text; } catch {}
}
}
} catch { return []; }
const jm = full.match(/\{[\s\S]*"components"[\s\S]*?\}/);
if (!jm) return [];
try { return JSON.parse(jm[0]).components || []; } catch { return []; }
}
// Auto-layout and apply generated component list to schematic
function _schApplyGenerated(boardType, components) {
if (!components.length) return;
if (SCH.components.length && !confirm(`회로도에 ${components.length}개 부품을 배치합니다. 기존 회로도를 지울까요?`)) return;
SCH.components = [];
SCH.wires = [];
const GRID = 20;
const BOARD_X = 200, BOARD_Y = 320;
const COMP_X = 560;
const snap = v => Math.round(v / GRID) * GRID;
// ── Build board GPIO array and labels (variant-aware for ESP8266) ──────────
let boardGpios, allLabels;
const espVariant = boardType === 'board_esp8266' ? (_esp8266Variant || 'nodemcu') : null;
if (espVariant) {
// Use variant-specific data for ESP8266
const v = _ESP8266_VARIANTS[espVariant] || _ESP8266_VARIANTS.nodemcu;
boardGpios = [...v.leftPins, ...v.rightPins].map(p => p === null ? -1 : p);
allLabels = [...v.leftLabels, ...v.rightLabels];
// Update terminal layout to match variant pin count
SCH_TERMS.board_esp8266 = _icTermOffsets(v.leftPins.length, v.rightPins.length);
} else {
const _boardGpioMap = {
board_esp32: _BOARD_ESP32_GPIOS,
board_uno: _BOARD_UNO_PINS, board_nano: _BOARD_NANO_PINS, board_mega: _BOARD_MEGA_PINS,
};
boardGpios = _boardGpioMap[boardType] || _BOARD_ESP32_GPIOS;
const leftLabelsMap = {
board_esp32: ['D13','D12','D14','D27','D26','D25','D33','D32','D35','D34','D39','D36','EN','GND','VIN'],
board_uno: ['VIN','5V','3V3','GND','RST','A0','A1','A2','A3','A4','A5'],
board_nano: ['VIN','5V','3V3','GND','RST','AREF','A0','A1','A2','A3','A4','A5','A6','A7'],
board_mega: ['VIN','5V','3V3','GND','RST','A0','A1','A2','A3','A4','A5','A6','A7','A8','A9','A10'],
};
const rightLabelsMap = {
board_esp32: ['D15','D2','D4','D16','D17','D5','D18','D19','D21','RX','TX','D22','D23','GND','3V3'],
board_uno: ['D13','D12','D11','D10','D9','D8','D7','D6','D5','D4','D3','D2','D1','D0'],
board_nano: ['D13','D12','D11','D10','D9','D8','D7','D6','D5','D4','D3','D2','D1','D0'],
board_mega: ['D13','D12','D11','D10','D9','D8','D7','D6','D5','D4','D3','D2','D1','D0','D18','D19'],
};
allLabels = [...(leftLabelsMap[boardType]||[]), ...(rightLabelsMap[boardType]||[])];
}
const boardComp = { type: boardType, x: BOARD_X, y: BOARD_Y, r: 0, value: '', variant: espVariant || undefined };
SCH.components.push(boardComp);
const boardTerms = schTermPositions(boardComp);
// Board bounding box (pins extend beyond the IC body)
const boardLeftX = Math.min(...boardTerms.map(t => t.x)) - 10;
const boardRightX = Math.max(...boardTerms.map(t => t.x)) + 10;
const boardTopY = Math.min(...boardTerms.map(t => t.y)) - 20;
const boardBotY = Math.max(...boardTerms.map(t => t.y)) + 20;
// Find board power terminal positions (GPIO = -1)
const powerNets = {};
for (let i = 0; i < boardGpios.length && i < allLabels.length; i++) {
if (boardGpios[i] !== -1) continue;
const lbl = allLabels[i].toUpperCase();
if (lbl.includes('3V3') || lbl.includes('VIN') || lbl === '5V' || lbl === 'VCC') {
if (!powerNets['VCC']) powerNets['VCC'] = boardTerms[i];
} else if (lbl.includes('GND')) {
if (!powerNets['GND']) powerNets['GND'] = boardTerms[i];
}
}
// ── Place components with generous vertical spacing ────────────────────────
let compY = 140;
const placedComps = [];
for (const comp of components) {
const termMap = _COMP_PIN_TERMS[comp.type];
if (!termMap) continue;
const placed = { type: comp.type, x: COMP_X, y: compY, r: 0, value: comp.value || '' };
SCH.components.push(placed);
const compTerms = schTermPositions(placed);
placedComps.push({ comp, placed, compTerms, termMap, compY });
compY += 240;
}
// ── Routing ────────────────────────────────────────────────────────────────
// Every wire gets its OWN vertical column — no sharing. Left-side pins get
// a departure column on the left, then route above/below board to their
// routing column in the gap. Power (VCC/GND) uses dedicated left columns.
//
// Layout:
// [dep cols] [PWR] [BOARD] ... [route cols] ... [COMPONENTS]
// ↑ ↑ ↑ ↑ ↑
// left pins VCC/GND board one per comp pins
// each own columns left/right wire, 60px
// 30px apart pins apart
const PWR_VCC_X = snap(boardLeftX - GRID * 2); // VCC vertical column
const PWR_GND_X = snap(boardLeftX - GRID * 1); // GND vertical column (just inside VCC)
const DEPART_START_X = snap(boardLeftX - GRID * 3); // start of departure columns (left of VCC)
const ROUTE_START_X = snap(boardRightX + GRID * 2);
let nextRouteCol = ROUTE_START_X;
let nextDepartCol = DEPART_START_X;
const colSpacing = GRID * 3; // 60px between routing columns
const depSpacing = GRID * 1.5; // 30px between departure columns
// Stagger Y offsets for above/below routing to avoid horizontal overlap
let aboveYIdx = 0, belowYIdx = 0;
const routeAboveBase = snap(boardTopY - GRID * 2);
const routeBelowBase = snap(boardBotY + GRID * 2);
const routeYSpacing = GRID * 1.5; // 30px stagger per wire
function nextAboveY() { return snap(routeAboveBase - aboveYIdx++ * routeYSpacing); }
function nextBelowY() { return snap(routeBelowBase + belowYIdx++ * routeYSpacing); }
for (let ci = 0; ci < placedComps.length; ci++) {
const { comp, placed, compTerms, termMap } = placedComps[ci];
// ── GPIO wires ──────────────────────────────────────────────────────────
for (const [pinName, gpio] of Object.entries(comp.pins || {})) {
if (gpio < 0) continue;
const compTIdx = termMap[pinName];
if (compTIdx === undefined || compTIdx >= compTerms.length) continue;
const boardTIdx = boardGpios.indexOf(gpio);
if (boardTIdx < 0) continue;
const bt = boardTerms[boardTIdx], ct = compTerms[compTIdx];
if (!bt || !ct) continue;
const isLeftPin = bt.x < BOARD_X;
const mx = snap(nextRouteCol);
nextRouteCol += colSpacing;
if (isLeftPin) {
// Left pin: each wire gets its own departure column and route Y
const depX = snap(nextDepartCol);
nextDepartCol -= depSpacing; // move left for next wire
const routeY = bt.y <= BOARD_Y ? nextAboveY() : nextBelowY();
// 1: board pin → horizontal left to departure column
SCH.wires.push({ x1: bt.x, y1: bt.y, x2: depX, y2: bt.y });
// 2: vertical up/down on departure column to routeY
if (bt.y !== routeY)
SCH.wires.push({ x1: depX, y1: bt.y, x2: depX, y2: routeY });
// 3: horizontal across (above/below board) to routing column
SCH.wires.push({ x1: depX, y1: routeY, x2: mx, y2: routeY });
// 4: vertical down/up to component pin row
if (routeY !== ct.y)
SCH.wires.push({ x1: mx, y1: routeY, x2: mx, y2: ct.y });
// 5: horizontal to component pin
SCH.wires.push({ x1: mx, y1: ct.y, x2: ct.x, y2: ct.y });
} else {
// Right pin: direct Manhattan routing through the gap
if (bt.y === ct.y) {
SCH.wires.push({ x1: bt.x, y1: bt.y, x2: ct.x, y2: ct.y });
} else {
SCH.wires.push({ x1: bt.x, y1: bt.y, x2: mx, y2: bt.y });
SCH.wires.push({ x1: mx, y1: bt.y, x2: mx, y2: ct.y });
SCH.wires.push({ x1: mx, y1: ct.y, x2: ct.x, y2: ct.y });
}
}
}
// ── Power connections (VCC/GND) ────────────────────────────────────────
const powerPins = {};
if (termMap.vcc !== undefined) powerPins.vcc = termMap.vcc;
if (termMap.gnd !== undefined) powerPins.gnd = termMap.gnd;
for (const [net, tIdx] of Object.entries(powerPins)) {
if (tIdx === undefined || tIdx >= compTerms.length) continue;
const ct = compTerms[tIdx];
const pt = powerNets[net === 'vcc' ? 'VCC' : 'GND'];
if (!pt || !ct) continue;
const isLeft = pt.x < BOARD_X;
if (isLeft) {
// Left-side power: use dedicated power column, stagger Y
const pwrColX = net === 'vcc' ? PWR_VCC_X : PWR_GND_X;
const routeY = ct.y <= BOARD_Y ? nextAboveY() : nextBelowY();
const mx = snap(nextRouteCol);
nextRouteCol += colSpacing;
// 1: board power pin → left to power column
SCH.wires.push({ x1: pt.x, y1: pt.y, x2: pwrColX, y2: pt.y });
// 2: vertical on power column to routeY
if (pt.y !== routeY)
SCH.wires.push({ x1: pwrColX, y1: pt.y, x2: pwrColX, y2: routeY });
// 3: horizontal across (above/below board) to routing column
SCH.wires.push({ x1: pwrColX, y1: routeY, x2: mx, y2: routeY });
// 4: vertical to component pin row
if (routeY !== ct.y)
SCH.wires.push({ x1: mx, y1: routeY, x2: mx, y2: ct.y });
// 5: horizontal to component pin
SCH.wires.push({ x1: mx, y1: ct.y, x2: ct.x, y2: ct.y });
} else {
// Right-side power pin: same as right-side GPIO
const mx = snap(nextRouteCol);
nextRouteCol += colSpacing;
if (pt.y === ct.y) {
SCH.wires.push({ x1: pt.x, y1: pt.y, x2: ct.x, y2: ct.y });
} else {
SCH.wires.push({ x1: pt.x, y1: pt.y, x2: mx, y2: pt.y });
SCH.wires.push({ x1: mx, y1: pt.y, x2: mx, y2: ct.y });
SCH.wires.push({ x1: mx, y1: ct.y, x2: ct.x, y2: ct.y });
}
}
}
}
// Assign IDs to all auto-generated wires (required for selection/deletion)
for (const w of SCH.wires) {
if (!w.id) w.id = SCH.nextId++;
}
// ── 전원 심벌 항상 추가 (보드 전원 단자에 연결) ────────────────────────────
if (powerNets.VCC) {
const symY = snap(40);
SCH.components.push({ type: 'vcc', x: PWR_VCC_X, y: symY, r: 0, value: '+5V' });
SCH.wires.push({ x1: PWR_VCC_X, y1: symY, x2: PWR_VCC_X, y2: powerNets.VCC.y });
SCH.wires.push({ x1: PWR_VCC_X, y1: powerNets.VCC.y, x2: powerNets.VCC.x, y2: powerNets.VCC.y });
} else {
SCH.components.push({ type: 'vcc', x: COMP_X + 140, y: 60, r: 0, value: '+5V' });
}
if (powerNets.GND) {
const lastCompY = placedComps.length ? placedComps[placedComps.length - 1].compY : 300;
const symY = snap(Math.max(routeBelowBase + GRID * 4, lastCompY + 200));
SCH.components.push({ type: 'gnd', x: PWR_GND_X, y: symY, r: 0, value: '' });
SCH.wires.push({ x1: PWR_GND_X, y1: symY, x2: PWR_GND_X, y2: powerNets.GND.y });
SCH.wires.push({ x1: PWR_GND_X, y1: powerNets.GND.y, x2: powerNets.GND.x, y2: powerNets.GND.y });
} else {
const lastY = placedComps.length ? placedComps[placedComps.length - 1].compY : 300;
SCH.components.push({ type: 'gnd', x: COMP_X + 140, y: lastY + 200, r: 0, value: '' });
}
schUpdateNetlist();
schDraw();
if (typeof bbTab === 'function') bbTab('board');
}
// Entry point: parse code (static+AI) → build schematic
window.schGenFromCode = async function schGenFromCode() {
const code = _getEditorCode() || (_IS_ESP32 ? _esp32SketchCode : _esp8266SketchCode);
if (!code || !code.trim()) { alert('스케치 코드가 없습니다. 먼저 코드를 에디터에 붙여넣거나 컴파일하세요.'); return; }
const boardType = _IS_ESP32 ? 'board_esp32' : 'board_esp8266';
const btn = document.getElementById('sch-gen-btn');
if (btn) { btn.textContent = '⏳...'; btn.disabled = true; }
try {
const staticResult = _schStaticParse(code);
const aiResult = await _schAiParse(code, staticResult);
// Merge: AI fills in pins that static missed
const merged = [...staticResult];
for (const aiComp of aiResult) {
const ex = merged.find(c => c.type === aiComp.type);
if (ex) { for (const [k, v] of Object.entries(aiComp.pins || {})) if (!(k in ex.pins)) ex.pins[k] = v; }
else merged.push(aiComp);
}
const valid = merged.filter(c => SCH_TERMS[c.type]);
if (!valid.length) { alert('인식된 부품이 없습니다. 코드에 라이브러리 include나 pinMode 호출이 있는지 확인하세요.'); return; }
_schApplyGenerated(boardType, valid);
} finally {
if (btn) { btn.textContent = '⚡ 코드→회로'; btn.disabled = false; }
}
}
// ── 회로도 ↔ 보드 라이브 연동 (UNO) ──────────────────────────────────────────
let _schLiveMap = { led: new Map(), btn: new Map() };
let _schBtnPressed = -1;
function _schLedLitColor(v) {
const m={red:'#ff5555',green:'#44ff66',blue:'#5599ff',yellow:'#ffee44',white:'#ffffff',orange:'#ffaa44'};
return m[(v||'').toLowerCase()] || '#ffee44';
}
function _schBtnSetGpio(gpio, value) {
_schBtnPressed = value === 0 ? gpio : -1;
const port = gpio < 8 ? portD : gpio < 14 ? portB : null;
const pin = gpio < 8 ? gpio : gpio < 14 ? gpio - 8 : -1;
if (port && pin >= 0) try { port.setPin(pin, value); } catch(e) {}
}
// GPIO 탐색: direct + 1-hop through passive (res/cap/diode)
function _schGpioFromNet(netKey, find, pk) {
for (const c of SCH.components) {
if (c.type !== 'ard' && !c.type.startsWith('board_')) continue;
const ct = schTermPositions(c);
for (let i = 0; i < ct.length; i++) {
if (find(pk(ct[i].x, ct[i].y)) !== netKey) continue;
if (c.type === 'ard') {
const m = (c.value||'').toUpperCase().match(/^(?:GPIO|D)?(\d+)$/);
if (m) return parseInt(m[1]);
} else if (c.type === 'board_esp32') {
const g = _BOARD_ESP32_GPIOS[i]; if (g >= 0) return g;
} else if (c.type === 'board_uno') {
const g = _BOARD_UNO_PINS[i]; if (g >= 0) return g;
} else if (c.type === 'board_nano') {
const g = _BOARD_NANO_PINS[i]; if (g >= 0) return g;
} else if (c.type === 'board_mega') {
const g = _BOARD_MEGA_PINS[i]; if (g >= 0) return g;
}
}
}
// 1-hop: passive 부품(저항/캐패시터) 건너 탐색
for (const c of SCH.components) {
if (c.type !== 'res' && c.type !== 'cap') continue;
const ct = schTermPositions(c);
if (ct.length < 2) continue;
const n0 = find(pk(ct[0].x, ct[0].y));
const n1 = find(pk(ct[1].x, ct[1].y));
const otherNet = n0 === netKey ? n1 : n1 === netKey ? n0 : null;
if (!otherNet) continue;
const g = _schGpioFromNet_direct(otherNet, find, pk);
if (g >= 0) return g;
}
return -1;
}
function _schGpioFromNet_direct(netKey, find, pk) {
for (const c of SCH.components) {
if (c.type !== 'ard' && !c.type.startsWith('board_')) continue;
const ct = schTermPositions(c);
for (let i = 0; i < ct.length; i++) {
if (find(pk(ct[i].x, ct[i].y)) !== netKey) continue;
if (c.type === 'ard') {
const m = (c.value||'').toUpperCase().match(/^(?:GPIO|D)?(\d+)$/);
if (m) return parseInt(m[1]);
} else if (c.type === 'board_esp32') {
const g = _BOARD_ESP32_GPIOS[i]; if (g >= 0) return g;
} else if (c.type === 'board_uno') {
const g = _BOARD_UNO_PINS[i]; if (g >= 0) return g;
} else if (c.type === 'board_nano') {
const g = _BOARD_NANO_PINS[i]; if (g >= 0) return g;
} else if (c.type === 'board_mega') {
const g = _BOARD_MEGA_PINS[i]; if (g >= 0) return g;
}
}
}
return -1;
}
function _buildSchLiveMap() {
_schLiveMap.led.clear();
_schLiveMap.btn.clear();
if (!SCH.components.length) return;
const { find, pk } = _schBuildNets();
for (const comp of SCH.components) {
if (comp.type === 'led') {
// 양극(term[0]) 에서 GPIO 탐색 (저항 경유 포함)
const terms = schTermPositions(comp);
const anodeNet = terms[0] ? find(pk(terms[0].x, terms[0].y)) : null;
const gpio = anodeNet ? _schGpioFromNet(anodeNet, find, pk) : -1;
if (gpio >= 0) _schLiveMap.led.set(comp, gpio);
}
if (comp.type === 'btn' || comp.type === 'sw_toggle') {
let gpio = -1;
const terms = schTermPositions(comp);
for (let ti = 0; ti < terms.length && gpio < 0; ti++) {
const net = find(pk(terms[ti].x, terms[ti].y));
gpio = _schGpioFromNet(net, find, pk);
}
if (gpio >= 0) _schLiveMap.btn.set(comp, gpio);
}
}
}
// ── AVR TFT 회로도 감지: CS/DC GPIO 추출 → SPI 핀 동적 업데이트 ─────────────────
function _setupTFTFromSchematicAVR() {
const tftComps = SCH.components.filter(c => c.type === 'tft_lcd');
const sec = document.getElementById('tft-section');
if (!tftComps.length) return;
if (sec) sec.style.display = '';
const dot = document.getElementById('tft-dot');
const lbl = document.getElementById('tft-label');
if (dot) dot.classList.add('on');
// GPIO 탐색
const { find, pk } = _schBuildNets();
const comp = tftComps[0];
// tft_lcd terminals: [0]VCC [1]GND [2]CS [3]RST [4]DC [5]MOSI [6]SCK [7]LED
const csGpio = _schGpioFromComp(comp, 2, find, pk);
const dcGpio = _schGpioFromComp(comp, 4, find, pk);
const model = (comp.value || 'ILI9341').toUpperCase().split(':')[0];
if (csGpio >= 0) _tftCsGpio = csGpio;
if (dcGpio >= 0) _tftDcGpio = dcGpio;
if (lbl) {
const parts = [model];
if (csGpio >= 0) parts.push('CS:D' + csGpio);
if (dcGpio >= 0) parts.push('DC:D' + dcGpio);
lbl.textContent = parts.join(' · ');
}
tft.active = true; // 회로도 결선만으로 TFT 섹션 활성화
_tftShowSection();
}
function schUpdateNetlist() {
const ta=document.getElementById('spice-netlist');
if (ta) ta.value=schToNetlist();
if (_IS_ESP32) { setupDHTSensorsEsp32(); setupTFTSectionEsp32(); _buildSchLiveMap(); }
else if (_IS_ESP8266) { setupDHTSensorsEsp8266(); setupTFTSectionEsp32(); _buildSchLiveMap(); }
else {
_setupTFTFromSchematicAVR();
if (emulatorReady) { setupDHTSensors(); _buildSchLiveMap(); }
}
}
// ── ngspice Runner ─────────────────────────────────────────────────────────────
// (JS MNA solver removed — server-side ngspice via /api/spice/run)
window.bbRunSpice = async () => {
const ta = document.getElementById('spice-netlist');
const res = document.getElementById('spice-results');
const st = document.getElementById('spice-status');
const analysis = document.getElementById('spice-analysis')?.value || '.op';
st.textContent = '실행 중…'; st.style.color = 'var(--yellow)';
res.innerHTML = '';
try {
const r = await fetch('/api/spice/run', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ netlist: ta.value, analysis }),
});
const data = await r.json();
if (!r.ok || data.error) throw new Error(data.error || 'ngspice 오류');
let html = '<div class="spice-section">노드 전압 (DC)</div>';
const vEntries = Object.entries(data.voltages || {}).sort(([a],[b]) => a.localeCompare(b));
if (vEntries.length === 0) html += '<div style="color:var(--muted);font-size:11px;padding:4px 8px">결과 없음 (노드 이름을 확인하세요)</div>';
for (const [n, v] of vEntries) {
const vs = (Math.abs(v) < 1e-9 ? '0.0000' : v.toFixed(4)) + ' V';
html += `<div class="spice-node"><span class="spice-node-name">${n}</span><span class="spice-node-val">${vs}</span></div>`;
}
const iEntries = Object.entries(data.currents || {}).sort(([a],[b]) => a.localeCompare(b));
if (iEntries.length) {
html += '<div class="spice-section" style="margin-top:10px">전원 전류</div>';
for (const [n, v] of iEntries) {
const vs = (Math.abs(v) < 1e-12 ? '0.000' : (v * 1000).toFixed(3)) + ' mA';
html += `<div class="spice-node"><span class="spice-node-name">${n}</span><span class="spice-node-val">${vs}</span></div>`;
}
}
res.innerHTML = html;
st.textContent = `완료 ✓ (ngspice ${vEntries.length}노드)`; st.style.color = 'var(--green)';
} catch(e) {
st.textContent = '오류: ' + e.message; st.style.color = 'var(--red)';
res.innerHTML = `<pre style="font-size:10px;color:var(--red);white-space:pre-wrap">${e.message}</pre>`;
}
};
// placeholder to suppress old reference errors
function spiceGaussian(A, b) {
const n = A.length;
const M = A.map((row,i) => [...row, b[i]]);
for (let c=0; c<n; c++) {
let mx=c;
for (let r=c+1; r<n; r++) if (Math.abs(M[r][c])>Math.abs(M[mx][c])) mx=r;
[M[c],M[mx]]=[M[mx],M[c]];
if (Math.abs(M[c][c])<1e-15) return null;
for (let r=c+1; r<n; r++) {
const f=M[r][c]/M[c][c];
for (let k=c; k<=n; k++) M[r][k]-=f*M[c][k];
}
}
const x=new Array(n).fill(0);
for (let i=n-1; i>=0; i--) {
x[i]=M[i][n];
for (let j=i+1; j<n; j++) x[i]-=M[i][j]*x[j];
x[i]/=M[i][i];
}
return x;
}
function spiceSolve(netlist) {
const nodes = new Map(); // name→idx (1-based; 0=gnd)
let N = 0;
const getN = name => {
if (name==='0'||name.toLowerCase()==='gnd') return 0;
if (!nodes.has(name)) nodes.set(name, ++N);
return nodes.get(name);
};
const lines = netlist.split('\n').map(l=>l.trim()).filter(l=>l&&!l.startsWith('*')&&!l.startsWith('.'));
const elems = [];
for (const line of lines) {
const p=line.split(/\s+/);
if (p.length<4) continue;
const name=p[0].toUpperCase();
if (name[0]==='R') elems.push({t:'R', n1:p[1],n2:p[2], val:parseFloat(p[3])||1e-3});
else if (name[0]==='C') elems.push({t:'C', n1:p[1],n2:p[2], val:parseFloat(p[3])||1e-9});
else if (name[0]==='V') elems.push({t:'V', n1:p[1],n2:p[2], val:parseFloat(p[3])||0});
else if (name[0]==='D') {
const mLine=netlist.match(new RegExp('\\.model\\s+'+p[3]+'\\s+D\\([^)]*VJ=([0-9.e+-]+)','i'));
const vj=mLine?parseFloat(mLine[1]):0.7;
const nLine=netlist.match(new RegExp('\\.model\\s+'+p[3]+'\\s+D\\([^)]*N=([0-9.]+)','i'));
const nn=nLine?parseFloat(nLine[1]):1.8;
const isLine=netlist.match(new RegExp('\\.model\\s+'+p[3]+'\\s+D\\([^)]*Is=([0-9.e+-]+)','i'));
const is=isLine?parseFloat(isLine[1]):1e-9;
elems.push({t:'D', n1:p[1],n2:p[2], vj, nn, is, name:p[0]});
} else if (name[0]==='Q') {
const modelName=p[4]||'';
const esc=modelName.replace(/[.*+?^${}()|[\]\\]/g,'\\$&');
const mLine=netlist.match(new RegExp('\\.model\\s+'+esc+'\\s+(NPN|PNP)(?:\\s*\\(([^)]*)\\)|\\b)','i'));
const type=mLine?mLine[1].toUpperCase():'NPN';
const modelBody=mLine?mLine[2]:'';
const getParam=(key,def)=>{const m=modelBody.match(new RegExp(key+'=([0-9.eE+-]+)','i'));return m?parseFloat(m[1]):def;};
elems.push({t:'Q', nC:p[1],nB:p[2],nE:p[3], name:p[0], polarity:type==='PNP'?-1:1, Is:getParam('IS',14.34e-15), BF:getParam('BF',200), BR:getParam('BR',6), NF:getParam('NF',1), NR:getParam('NR',1)});
}
}
if (!elems.length) return {voltages:{}, diodes:[]};
// Pre-pass node registration
for (const e of elems) {
if (e.t==='Q') {
getN(e.nC); getN(e.nB); getN(e.nE);
} else {
getN(e.n1); getN(e.n2);
}
}
// Newton-Raphson (max 60 iter)
const diodes = elems.filter(e=>e.t==='D');
const bjts = elems.filter(e=>e.t==='Q');
const Vd = Object.fromEntries(diodes.map(d=>[d.name, d.vj]));
const Vbe = Object.fromEntries(bjts.map(q=>[q.name, 0.65]));
const Vbc = Object.fromEntries(bjts.map(q=>[q.name, -0.2]));
const VT = 0.02585;
const GMIN_BJT = 1e-12;
function limitJunctionVoltage(vd, vt, IS) {
const vcrit = vt * Math.log(vt / (Math.sqrt(2) * IS));
if (vd > vcrit) return vcrit + vt * Math.log(1 + (vd - vcrit) / vt);
return Math.max(vd, -40 * vt);
}
let voltages = null;
for (let iter=0; iter<60; iter++) {
const sz=N; if (!sz) break;
const G=Array.from({length:sz},()=>new Array(sz).fill(0));
const I=new Array(sz).fill(0);
const stamp=(n1,n2,g)=>{
if (n1>0){G[n1-1][n1-1]+=g; if(n2>0)G[n1-1][n2-1]-=g;}
if (n2>0){G[n2-1][n2-1]+=g; if(n1>0)G[n2-1][n1-1]-=g;}
};
const injectI=(n,i)=>{ if(n>0)I[n-1]+=i; };
for (const e of elems) {
if (e.t==='Q') {
const {nC,nB,nE,name,polarity,Is,BF,BR,NF,NR}=e;
const idxC=getN(nC), idxB=getN(nB), idxE=getN(nE);
const vbe_raw=Vbe[name]||0.65;
const vbc_raw=Vbc[name]||-0.2;
const vtF=(NF||1)*VT, vtR=(NR||1)*VT;
const vBE=limitJunctionVoltage(vbe_raw, vtF, Is);
const vBC=limitJunctionVoltage(vbc_raw, vtR, Is);
const expBE=Math.exp(vBE/vtF);
const expBC=Math.exp(vBC/vtR);
const IF=Is*(expBE-1);
const IR=Is*(expBC-1);
const IC=IF-IR*(1+1/BR);
const IB=IF/BF+IR/BR;
const gF=(Is/vtF)*expBE+GMIN_BJT;
const gR=(Is/vtR)*expBC+GMIN_BJT;
const gm_f=gF;
const gm_r=gR*(1+1/BR);
const go_be=gF/BF;
const go_bc=gR/BR;
const IC_eq=IC-gm_f*vBE+gm_r*vBC;
const IB_eq=IB-go_be*vBE-go_bc*vBC;
const IE_eq=-(IC_eq+IB_eq);
const pol=polarity||1;
const addG=(ni,mi,g)=>{if(ni>0&&mi>0)G[ni-1][mi-1]+=g;};
if(idxC>0){addG(idxC,idxC,gm_r);addG(idxC,idxB,gm_f-gm_r);addG(idxC,idxE,-gm_f);injectI(idxC,-pol*IC_eq);}
if(idxB>0){addG(idxB,idxC,-go_bc);addG(idxB,idxB,go_be+go_bc);addG(idxB,idxE,-go_be);injectI(idxB,-pol*IB_eq);}
if(idxE>0){addG(idxE,idxC,-gm_r+go_bc);addG(idxE,idxB,-(gm_f-gm_r+go_be+go_bc));addG(idxE,idxE,gm_f+go_be);injectI(idxE,-pol*IE_eq);}
continue;
}
const n1=getN(e.n1), n2=getN(e.n2);
if (e.t==='R') stamp(n1,n2, 1/Math.max(e.val,1e-9));
else if (e.t==='V') {
// Stamp voltage source via large admittance (companion model)
const Gv=1e8;
stamp(n1,n2,Gv);
injectI(n1, e.val*Gv); injectI(n2, -e.val*Gv);
} else if (e.t==='D') {
const vdg=Vd[e.name]||e.vj;
const Is=e.is||1e-9;
const Id=Is*(Math.exp(vdg/(e.nn*VT))-1);
const Gd=Is*Math.exp(vdg/(e.nn*VT))/(e.nn*VT);
const Gdc=Math.max(Gd,1e-15);
const Ieq=Id-Gdc*vdg;
stamp(n1,n2,Gdc);
injectI(n1,-Ieq); injectI(n2,Ieq);
}
}
const x=spiceGaussian(G,I);
if (!x) break;
const newV={};
newV['0']=0;
for (const [nm,idx] of nodes) newV[nm]=x[idx-1]||0;
voltages=newV;
let conv=true;
for (const d of diodes) {
const nv=(newV[d.n1]||0)-(newV[d.n2]||0);
if (Math.abs(nv-Vd[d.name])>1e-5) conv=false;
Vd[d.name]=Math.max(-5, Math.min(nv, 10));
}
for (const q of bjts) {
const vbn=(newV[q.nB]||0), vcn=(newV[q.nC]||0), ven=(newV[q.nE]||0);
const nvbe=q.polarity*(vbn-ven), nvbc=q.polarity*(vbn-vcn);
if (Math.abs(nvbe-Vbe[q.name])>1e-4 || Math.abs(nvbc-Vbc[q.name])>1e-4) conv=false;
Vbe[q.name]=Vbe[q.name]+0.7*(nvbe-Vbe[q.name]);
Vbc[q.name]=Vbc[q.name]+0.7*(nvbc-Vbc[q.name]);
}
if (conv) break;
}
return {voltages: voltages||{'0':0}, diodes: diodes.map(d=>({name:d.name, vd:Vd[d.name]||0})), bjts: bjts.map(q=>({name:q.name, vbe:Vbe[q.name]||0, vbc:Vbc[q.name]||0}))};
}
window.bbExportNetlist = () => {
const txt=document.getElementById('spice-netlist').value;
const a=document.createElement('a');
a.href=URL.createObjectURL(new Blob([txt],{type:'text/plain'}));
a.download='circuit.cir'; a.click();
URL.revokeObjectURL(a.href);
};
// Drag resize handle for inline sch-panel
(()=>{
const handle = document.getElementById('sch-drag');
const panel = document.getElementById('sch-panel');
if (!handle || !panel) return;
let drag=false, startX=0, startW=0;
handle.addEventListener('mousedown', e=>{
drag=true; startX=e.clientX; startW=panel.offsetWidth;
panel.style.flex='none';
handle.classList.add('active');
e.preventDefault();
});
document.addEventListener('mousemove', e=>{
if (!drag) return;
const dx = startX - e.clientX; // dragging left expands panel
panel.style.width = Math.max(200, Math.min(1000, startW+dx))+'px';
schDraw();
});
document.addEventListener('mouseup', ()=>{
if (drag) { drag=false; handle.classList.remove('active'); }
});
})();
// ── Code editor panel resize (serial ↔ code divider) ──────────────────────────
(()=>{
const handle = document.getElementById('code-drag');
const serial = document.querySelector('.serial-panel');
const code = document.getElementById('code-editor-panel');
if (!handle || !serial || !code) return;
let drag=false, startX=0, startSW=0, startCW=0;
handle.addEventListener('mousedown', e=>{
drag=true; startX=e.clientX; startSW=serial.offsetWidth; startCW=code.offsetWidth;
serial.style.flex='none'; code.style.flex='none';
handle.classList.add('active');
e.preventDefault();
});
document.addEventListener('mousemove', e=>{
if (!drag) return;
const dx = e.clientX - startX;
const sw = Math.max(120, startSW + dx);
const cw = Math.max(120, startCW - dx);
serial.style.width = sw+'px';
code.style.width = cw+'px';
});
document.addEventListener('mouseup', ()=>{
if (drag) { drag=false; handle.classList.remove('active'); }
});
})();
const _schCv = document.getElementById('sch-canvas');
_schCv.addEventListener('mousemove', schOnMouseMove);
_schCv.addEventListener('mousedown', schOnMouseDown);
_schCv.addEventListener('mouseup', schOnMouseUp);
_schCv.addEventListener('dblclick', schOnDblClick);
_schCv.addEventListener('contextmenu', schOnContextMenu);
_schCv.addEventListener('wheel', e => {
e.preventDefault();
const r=_schCv.getBoundingClientRect();
const mx=e.clientX-r.left, my=e.clientY-r.top;
const factor=e.deltaY<0?1.15:0.87;
const ns=Math.max(0.15,Math.min(6, SCH.scale*factor));
SCH.panX=mx-(mx-SCH.panX)*(ns/SCH.scale);
SCH.panY=my-(my-SCH.panY)*(ns/SCH.scale);
SCH.scale=ns;
schDraw();
}, {passive:false});
document.addEventListener('keydown', schOnKeyDown);
// 패널 크기 변경 시 캔버스 자동 리드로우
new ResizeObserver(()=>{ if(document.getElementById('sch-panel')?.classList.contains('open')) schDraw(); })
.observe(document.getElementById('sch-canvas-wrap'));
// ── 에디터 헬퍼 (CodeMirror 래퍼) ───────────────────────────────────────────────
function _getEditorCode() {
if (window._cmSketch) return window._cmSketch.getValue();
return document.getElementById('code-editor-textarea')?.value || _esp32SketchCode || '';
}
function _setEditorCode(code) {
_esp32SketchCode = code;
if (window._cmSketch) { window._cmSketch.setValue(code); return; }
const ta = document.getElementById('code-editor-textarea');
if (ta) ta.value = code;
}
// ── CodeMirror 스케치 에디터 초기화 ──────────────────────────────────────────────
function initCodeMirrorSketch() {
const wrap = document.getElementById('cm-sketch-wrap');
if (!wrap || !window.CodeMirror) return;
window._cmSketch = CodeMirror(wrap, {
mode: 'text/x-c++src',
lineNumbers: true,
tabSize: 2,
indentWithTabs: false,
electricChars: true,
matchBrackets: true,
extraKeys: { Tab: cm => cm.replaceSelection(' ') },
value: _esp32SketchCode || '',
});
window._cmSketch.setSize('100%', '100%');
window._cmSketch.on('change', () => {
if (_esp32SyncBusy) return;
const code = window._cmSketch.getValue();
_esp32SketchCode = code;
_esp32CodeChannel.postMessage({ type: 'code', code });
});
}
// ── 속도 슬라이더 ────────────────────────────────────────────────────────────────
window.setSpeedSlider = (val) => {
const v = parseInt(val);
const mult = v <= 0 ? 1 : v >= 100 ? 1000 : Math.round(Math.pow(10, v / 33.33));
ticksPerFrame = Math.round(160000 * mult);
const lbl = document.getElementById('speed-val-label');
if (lbl) lbl.textContent = mult >= 1000 ? '최대' : mult + 'x';
};
window.setSpeed = (val) => { ticksPerFrame = Math.round(160000 * parseInt(val)); };
// ── 회로→코드 AI 생성 ────────────────────────────────────────────────────────────
window.schGenCodeFromCircuit = async function schGenCodeFromCircuit() {
if (!SCH.components.length) {
appendSerial('🤖 회로도에 부품이 없습니다. 먼저 회로를 그려주세요.\n', 'sys');
return;
}
const btn = document.getElementById('sch-gen-code-btn');
if (btn) { btn.textContent = '⏳...'; btn.disabled = true; }
const boardType = _IS_ESP32 ? 'ESP32 DevKit' : _IS_ESP8266 ? 'ESP8266 NodeMCU' : 'Arduino UNO';
const compSummary = SCH.components
.filter(c => c.type !== 'wire')
.map(c => {
const pins = Object.entries(c.pins || {}).map(([k, v]) => `${k}=${v}`).join(', ');
return `- ${c.type}${c.value ? '(' + c.value + ')' : ''}${pins ? ': ' + pins : ''}`;
}).join('\n');
const prompt = `다음 ${boardType} 회로도를 보고 Arduino 스케치(.ino)를 작성해줘.
회로 부품:
${compSummary}
요구사항:
- 필요한 라이브러리 #include 포함
- 핀 번호는 회로도 그대로 사용
- setup()과 loop() 함수 완성
- 각 부품 동작 주석 포함
- 코드만 출력 (설명 없이 \`\`\`cpp 블록으로)`;
const token = sessionStorage.getItem('smallclaw_token') || localStorage.getItem('smallclaw_token') || '';
let rawText = '';
try {
const resp = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: 'Bearer ' + token } : {}) },
body: JSON.stringify({ message: prompt, sessionId: 'emu_code_gen', direct: true }),
});
if (!resp.body) throw new Error('응답 없음');
const reader = resp.body.getReader(), dec = new TextDecoder();
for (;;) {
const { done, value } = await reader.read();
if (done) break;
for (const line of dec.decode(value).split('\n')) {
if (!line.startsWith('data: ')) continue;
try { const d = JSON.parse(line.slice(6)); if (d.text) rawText += d.text; } catch {}
}
}
const match = rawText.match(/```(?:cpp|c\+\+|arduino|ino|c)?\n?([\s\S]*?)```/);
const finalCode = (match ? match[1] : rawText).trim();
if (!finalCode) { appendSerial('🤖 코드 생성 실패: 응답에서 코드를 찾을 수 없습니다\n', 'error'); return; }
_setEditorCode(finalCode);
_esp32CodeChannel.postMessage({ type: 'code', code: finalCode });
const panel = document.getElementById('code-editor-panel');
if (panel && panel.style.display === 'none') toggleCodeEditor();
appendSerial(`🤖 회로→코드 생성 완료 (${finalCode.split('\n').length}줄)\n`, 'sys');
} catch (e) {
appendSerial('🤖 오류: ' + e.message + '\n', 'error');
} finally {
if (btn) { btn.textContent = '🤖 회로→코드'; btn.disabled = false; }
}
};
// ── 초기화 ────────────────────────────────────────────────────────────────────────
initCodeMirrorSketch();
if (avr8jsReady) boot();
schAutoLoadProject();
// ── Code Editor & Compile ──────────────────────────────────────────────────────
let defaultSketch = `#include <DHT.h>
#define DHTPIN 2
#define DHTTYPE DHT11
DHT dht(DHTPIN, DHTTYPE);
void setup() {
Serial.begin(9600);
dht.begin();
}
void loop() {
delay(2000);
float h = dht.readHumidity();
float t = dht.readTemperature();
if (isnan(h) || isnan(t)) {
Serial.println("Failed to read from DHT sensor!");
return;
}
Serial.print("Humidity: ");
Serial.print(h);
Serial.print("% Temperature: ");
Serial.print(t);
Serial.println("°C");
}`;
window.toggleCodeEditor = function toggleCodeEditor() {
const panel = document.getElementById('code-editor-panel');
const dragHandle = document.getElementById('code-drag');
const btn = document.getElementById('btn-code');
const main = document.querySelector('.main');
const visible = panel.style.display !== 'none';
panel.style.display = visible ? 'none' : 'flex';
if (dragHandle) dragHandle.style.display = visible ? 'none' : '';
btn.classList.toggle('active', !visible);
if (main) main.classList.toggle('editor-open', !visible);
if (!visible) {
if (!_getEditorCode().trim()) _setEditorCode(_esp32SketchCode || defaultSketch.trim());
setTimeout(() => { if (window._cmSketch) window._cmSketch.refresh(); }, 50);
}
}
window.compileAndRun = async function compileAndRun() {
const code = _getEditorCode();
if (!code.trim()) return;
_esp32SketchCode = code;
const btn = document.getElementById('btn-compile');
const statusEl = document.getElementById('code-editor-status');
btn.disabled = true;
btn.textContent = '⏳ 컴파일 중…';
statusEl.textContent = '컴파일 중…';
statusEl.style.color = 'var(--muted)';
// ESP32 mode: use qemu/start (with mock injection)
if (_IS_ESP32) {
try {
// Stop existing QEMU session first
if (_esp32SessionId) {
try { await fetch('/api/arduino/qemu/' + _esp32SessionId, { method:'DELETE', credentials:'include' }); } catch {}
if (_esp32EvtSource) { _esp32EvtSource.close(); _esp32EvtSource = null; }
_esp32SessionId = null;
}
const r = await fetch('/api/arduino/qemu/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ code, fqbn: 'esp32:esp32:esp32' }),
});
const data = await r.json();
if (data.error) {
statusEl.textContent = '❌ 컴파일 오류';
statusEl.style.color = 'var(--red)';
const errLines = data.error.split('\n').filter(l => l.includes('error:') || l.includes(':')).slice(0, 5);
showOverlayError('컴파일 오류\n\n' + errLines.join('\n'));
return;
}
if (data.autoInstalled?.length) {
statusEl.textContent = '📦 라이브러리 설치: ' + data.autoInstalled.join(', ');
} else {
statusEl.textContent = '✅ ESP32 실행 중…';
}
statusEl.style.color = 'var(--green)';
initEsp32Mode(data.id, document.getElementById('code-editor-filename').textContent, 'esp32:esp32:esp32');
document.getElementById('filename').textContent = document.getElementById('code-editor-filename').textContent;
} catch (e) {
statusEl.textContent = '❌ 서버 오류: ' + e.message;
statusEl.style.color = 'var(--red)';
} finally {
btn.disabled = false;
btn.textContent = '▶ 컴파일';
}
return;
}
// UNO mode
try {
const r = await fetch('/api/arduino/compile', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ code, board: 'uno' }),
});
const data = await r.json();
if (data.error) {
statusEl.textContent = '❌ 컴파일 오류';
statusEl.style.color = 'var(--red)';
const errLines = data.error.split('\n').filter(l => l.includes('error:') || l.includes(':')).slice(0, 5);
showOverlayError('컴파일 오류\n\n' + errLines.join('\n'));
return;
}
if (data.autoInstalled?.length) {
statusEl.textContent = '📦 라이브러리 설치: ' + data.autoInstalled.join(', ');
} else {
statusEl.textContent = '✅ 컴파일 성공 — ' + (data.hex.match(/:/g)||[]).length + ' lines';
}
statusEl.style.color = 'var(--green)';
initEmulator(data.hex);
document.getElementById('filename').textContent = document.getElementById('code-editor-filename').textContent;
} catch (e) {
statusEl.textContent = '❌ 서버 오류: ' + e.message;
statusEl.style.color = 'var(--red)';
} finally {
btn.disabled = false;
btn.textContent = '▶ 컴파일';
}
}
// Ctrl+Enter to compile
document.addEventListener('keydown', e => {
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault();
compileAndRun();
}
});
</script>
</body>
</html>