Files
homeclaw/web-ui/arduino-emulator.html
T
2026-06-30 18:04:04 +09:00

7982 lines
380 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-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; cursor:zoom-in; }
#oled-canvas { display:block; max-width:100%; height:auto; image-rendering:pixelated; cursor:zoom-in; }
/* 확대 오버레이 */
#canvas-zoom-overlay {
display:none; position:fixed; inset:0; z-index:9999;
background:rgba(0,0,0,.85); align-items:center; justify-content:center;
cursor:zoom-out;
}
#canvas-zoom-overlay.open { display:flex; }
#canvas-zoom-img {
image-rendering:pixelated;
border:2px solid #333; border-radius:4px;
box-shadow:0 8px 40px rgba(0,0,0,.8);
}
#canvas-zoom-hint {
position:fixed; bottom:24px; left:50%; transform:translateX(-50%);
font-size:11px; color:#555; pointer-events:none;
}
/* 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; }
/* ── mob-tab (inline since styles.css not loaded) ── */
.mob-tab{flex:1;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:3px;border:none;background:transparent;color:var(--muted);font-family:inherit;font-size:10px;font-weight:700;letter-spacing:.04em;cursor:pointer;padding:6px 4px;border-top:2px solid transparent;transition:color .15s,border-color .15s;-webkit-tap-highlight-color:transparent;}
.mob-tab.active{color:var(--accent);border-top-color:var(--accent);}
/* ── Mobile ── */
@media(max-width:1024px){
.header{flex-wrap:wrap;padding:7px 10px;gap:5px;}
.header h1{font-size:13px;}
.toolbar{gap:4px;}
.board-panel{
position:fixed;top:0;left:0;bottom:52px;z-index:500;
width:84vw;max-width:300px;
transform:translateX(-100%);transition:transform .25s ease;
box-shadow:4px 0 24px rgba(0,0,0,.5);
resize:none;
}
.board-panel.mob-open{transform:translateX(0);}
#mob-backdrop{display:none;position:fixed;inset:0 0 52px 0;background:rgba(0,0,0,.55);z-index:499;}
#mob-backdrop.show{display:block;}
#mob-tabbar{display:flex !important;position:fixed;bottom:0;left:0;right:0;z-index:600;background:var(--panel);border-top:1px solid var(--bdr);height:52px;}
.main{padding-bottom:52px;}
input,select{font-size:16px !important;}
}
@media(min-width:1025px){
#mob-backdrop{display:none !important;}
#mob-tabbar{display:none !important;}
}
</style>
<link rel="stylesheet" href="/vendor/codemirror/lib/codemirror.css">
<script src="/vendor/codemirror/lib/codemirror.js"></script>
<script src="/vendor/codemirror/mode/clike/clike.js"></script>
</head>
<body>
<div id="mob-backdrop" onclick="mobCloseBoard()"></div>
<div id="mob-tabbar" style="display:none">
<button class="mob-tab" id="mobtab-board" onclick="mobToggleBoard()"><span style="font-size:18px">🔌</span>보드</button>
<button class="mob-tab active" id="mobtab-serial" onclick="mobCloseBoard()"><span style="font-size:18px">📟</span>시리얼</button>
</div>
<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>
<span id="rp2040-led" title="GPIO25 (내장 LED)" style="display:none;width:12px;height:12px;border-radius:50%;background:#1a2e1a;border:1px solid #2d4a2d;margin:0 2px;flex-shrink:0"></span>
<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>
<!-- ADC / Analog Input controls (AVR analogRead) -->
<div class="dht-section" id="adc-section" style="display:none">
<div class="dht-section-title">
<span class="dht-dot" id="adc-dot" style="background:#8b5cf6"></span>
<span>아날로그 입력 (A0–A5)</span>
</div>
<div id="adc-controls"></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>
<!-- Scan network mock config -->
<div style="margin-top:8px;border-top:1px solid var(--bdr-in);padding-top:8px">
<div style="font-size:10px;color:var(--muted);font-weight:600;letter-spacing:.06em;margin-bottom:5px">SCAN 목록 (scanNetworks 모의)</div>
<div id="wifi-scan-list" style="display:flex;flex-direction:column;gap:3px;margin-bottom:5px"></div>
<div style="display:flex;gap:4px;margin-bottom:4px">
<button class="wifi-btn" onclick="_wifiScanAddNet()" style="flex:none;padding:2px 8px;font-size:10px">+ 추가</button>
<button class="wifi-btn" onclick="_wifiScanApply()" style="flex:none;padding:2px 8px;font-size:10px;background:var(--accent);border-color:var(--accent);color:#fff">적용</button>
</div>
</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>
<span id="sch-log" style="font-size:11px;color:#e8c860;white-space:nowrap"></span>
<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,
AVRADC, adcConfig, AVREEPROM, EEPROMMemoryBackend, eepromConfig;
try {
const mod = await import('./vendor/avr8js/index.js');
let avrInstruction;
({ CPU, AVRIOPort, AVRUSART, AVRTimer, AVRTWI,
portAConfig, portBConfig, portCConfig, portDConfig,
timer0Config, timer1Config, timer2Config, twiConfig,
AVRADC, adcConfig, AVREEPROM, EEPROMMemoryBackend, eepromConfig,
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());
}
// RP2040 SPI → TFT LCD 연결 (SPI0/SPI1 onTransmit 훅)
// schematic에서 CS/DC GPIO를 감지한 후 GPIO 리스너도 추가 (setupTFTFromSchematicRP2040에서)
function _setupRP2040SPI(sim) {
for (const spi of sim.rp2040.spi) {
spi.onTransmit = (value) => {
if (!tft.cs) tftSPIByte(value);
spi.completeTransmit(0);
};
}
}
// RP2040 스케치 코드 정적 분석 → TFT CS/DC 핀 자동 감지
function _rp2040AutoDetectTft(sim) {
const code = (typeof _esp32SketchCode === 'string') ? _esp32SketchCode : '';
if (!code) return;
// #define 매크로 값 수집
const defs = {};
for (const m of code.matchAll(/#define\s+(\w+)\s+(\d+)/g)) defs[m[1]] = parseInt(m[2]);
const rpin = s => { const n = parseInt(s); return isNaN(n) ? (defs[s] ?? -1) : n; };
let csGpio = -1, dcGpio = -1;
// 패턴 1: #define TFT_CS / TFT_DC (TFT_eSPI, Adafruit 공통)
for (const [k, v] of Object.entries(defs)) {
const ku = k.toUpperCase();
if ((ku.includes('TFT') || ku.includes('LCD') || ku.includes('DISP')) && ku.includes('CS') && csGpio < 0) csGpio = v;
if ((ku.includes('TFT') || ku.includes('LCD') || ku.includes('DISP')) && (ku.includes('DC') || ku.includes('RS')) && dcGpio < 0) dcGpio = v;
}
// 패턴 2: Adafruit_ST7735/ST7789/ILI9341 생성자 첫 두 인수 → CS, DC
if (csGpio < 0 || dcGpio < 0) {
const m = code.match(/Adafruit_(?:ST7735|ST7789|ILI9341)\s+\w+\s*\(\s*(\w+)\s*,\s*(\w+)/);
if (m) { if (csGpio < 0) csGpio = rpin(m[1]); if (dcGpio < 0) dcGpio = rpin(m[2]); }
}
if (csGpio < 0 || dcGpio < 0) return; // 감지 실패
// GPIO 리스너 및 TFT 섹션 활성화
const readPinHigh = g => { const p = sim.rp2040.gpio[g]; return p ? p.value === 1 : true; };
tft.cs = readPinHigh(csGpio);
tft.dc = readPinHigh(dcGpio);
if (sim.rp2040.gpio[csGpio]) sim.rp2040.gpio[csGpio].addListener(state => {
const nc = !!(state & 1);
if (!tft.cs && nc) { tft.inRamWr = false; tft.pendingNeed = 0; tft.pendingArgs = []; }
tft.cs = nc;
});
if (sim.rp2040.gpio[dcGpio]) sim.rp2040.gpio[dcGpio].addListener(state => { tft.dc = !!(state & 1); });
_rp2040TftListenersAdded = true;
const lbl = document.getElementById('tft-label');
if (lbl) lbl.textContent = `TFT · CS:GP${csGpio} DC:GP${dcGpio}`;
tft.active = true;
_tftShowSection();
console.log(`[RP2040] TFT 자동감지: CS=GP${csGpio}, DC=GP${dcGpio}`);
}
// RP2040 회로도 기반 TFT CS/DC GPIO 리스너 등록 (schUpdateNetlist에서 호출)
let _rp2040TftListenersAdded = false;
function _setupTFTFromSchematicRP2040() {
if (!_rp2040Sim) return;
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');
const { find, pk } = _schBuildNets();
const comp = tftComps[0];
const csGpio = _schGpioFromComp(comp, 2, find, pk);
const dcGpio = _schGpioFromComp(comp, 4, find, pk);
const model = (comp.value || 'ST7735').toUpperCase().split(':')[0];
if (lbl) {
const parts = [model];
if (csGpio >= 0) parts.push('CS:GP' + csGpio);
if (dcGpio >= 0) parts.push('DC:GP' + dcGpio);
lbl.textContent = parts.join(' · ');
}
// GPIO 리스너 등록 + 현재 핀 상태 즉시 반영
// (리스너는 변경 시에만 발화하므로 초기 상태는 직접 읽어야 함)
const readPinHigh = (gpioN) => {
if (gpioN < 0 || gpioN > 29) return true;
const g = _rp2040Sim.rp2040.gpio[gpioN];
return g ? g.value === 1 : true; // GPIOPinState.High = 1
};
const addGpioListener = (gpioN, onChange) => {
if (gpioN < 0 || gpioN > 29) return;
const g = _rp2040Sim.rp2040.gpio[gpioN];
if (g) g.addListener(onChange);
};
_rp2040TftListenersAdded = false;
if (csGpio >= 0) {
_tftCsGpio = csGpio;
tft.cs = readPinHigh(csGpio); // 현재 상태 즉시 반영
addGpioListener(csGpio, (state) => {
const newCs = !!(state & 1);
if (!tft.cs && newCs) { tft.inRamWr = false; tft.pendingNeed = 0; tft.pendingArgs = []; }
tft.cs = newCs;
});
}
if (dcGpio >= 0) {
_tftDcGpio = dcGpio;
tft.dc = readPinHigh(dcGpio); // 현재 상태 즉시 반영
addGpioListener(dcGpio, (state) => { tft.dc = !!(state & 1); });
}
_rp2040TftListenersAdded = true;
tft.active = true;
_tftShowSection();
}
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();
_wifiScanRender();
}
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(()=>{});
}
}
// WiFi scan mock management
let _wifiScanNets = [
{ ssid: 'HomeNetwork', rssi: -45, channel: 1 },
{ ssid: 'Office_5G', rssi: -55, channel: 6 },
{ ssid: 'Neighbor_WiFi', rssi: -65, channel: 11 }
];
function _wifiScanRender() {
const list = document.getElementById('wifi-scan-list');
if (!list) return;
list.innerHTML = '';
_wifiScanNets.forEach((net, idx) => {
const row = document.createElement('div');
row.style.cssText = 'display:flex;align-items:center;gap:3px;font-size:10px';
row.innerHTML =
`<input value="${net.ssid}" placeholder="SSID" style="flex:2;min-width:0;background:var(--panel);border:1px solid var(--bdr2);color:var(--text);border-radius:3px;padding:2px 4px;font-size:10px;font-family:inherit" oninput="_wifiScanNets[${idx}].ssid=this.value">` +
`<input type="number" value="${net.rssi}" min="-100" max="-1" style="width:42px;background:var(--panel);border:1px solid var(--bdr2);color:var(--text);border-radius:3px;padding:2px 4px;font-size:10px;font-family:inherit" oninput="_wifiScanNets[${idx}].rssi=+this.value" title="RSSI (dBm)">` +
`<input type="number" value="${net.channel}" min="1" max="14" style="width:30px;background:var(--panel);border:1px solid var(--bdr2);color:var(--text);border-radius:3px;padding:2px 4px;font-size:10px;font-family:inherit" oninput="_wifiScanNets[${idx}].channel=+this.value" title="Ch">` +
`<button onclick="_wifiScanRemove(${idx})" style="flex:none;padding:1px 5px;border:1px solid var(--bdr2);background:var(--panel);color:var(--muted);border-radius:3px;cursor:pointer;font-size:10px">✕</button>`;
list.appendChild(row);
});
}
function _wifiScanAddNet() {
_wifiScanNets.push({ ssid: 'NewNetwork', rssi: -70, channel: 1 });
_wifiScanRender();
}
function _wifiScanRemove(idx) {
_wifiScanNets.splice(idx, 1);
_wifiScanRender();
}
function _wifiScanApply() {
if (!_IS_ESP32 || !_esp32SessionId) return;
fetch('/api/arduino/qemu/' + _esp32SessionId + '/scan', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, credentials: 'include',
body: JSON.stringify({ networks: _wifiScanNets })
}).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 _esp32Fqbn = 'esp32:esp32:esp32';
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 _IS_RP2040 = false;
let _rp2040Sim = null;
let _rp2040Running = false;
let _rp2040RafId = null;
let _rp2040UartBuf = '';
let _rp2040PinStates = {};
let _rp2040UartDec = null;
let _rp2040BootromU32 = null;
let _rp2040Uf2Bytes = null;
let _rp2040Fname = 'sketch.ino';
let _rp2040BootTimeoutId = null;
let _origConsoleWarn = null;
const _RP2040_WARN_FILTER = /Write to invalid SIO address|Read from invalid SIO address|2sio\.js/i;
const _PICO_LEFT_PINS = [null,null,null,null,null,null,28,null,27,26,null,22,null,21,20,19,18,null,17,16];
function _silenceRp2040jsWarnings() {
if (_origConsoleWarn) return;
_origConsoleWarn = console.warn;
console.warn = function(...args) {
const msg = args.join(' ');
if (_RP2040_WARN_FILTER.test(msg)) return;
return _origConsoleWarn.apply(this, args);
};
}
function _restoreConsoleWarn() {
if (_origConsoleWarn) { console.warn = _origConsoleWarn; _origConsoleWarn = null; }
}
const _PICO_RIGHT_PINS = [0,1,null,2,3,4,5,null,6,7,8,9,null,10,11,12,13,null,14,15];
function _updateRp2040Board() {
const on25 = !!_rp2040PinStates[25];
const toolbar = document.getElementById('rp2040-led');
if (toolbar) toolbar.style.background = on25 ? '#4ade80' : '#1a2e1a';
const svgLed = document.getElementById('pico-led');
if (svgLed) svgLed.setAttribute('fill', on25 ? '#22c55e' : '#0a1f0a');
_PICO_LEFT_PINS.forEach((g, i) => {
if (g === null) return;
const el = document.getElementById('rp-lp-' + i);
if (!el) return;
const st = _rp2040PinStates[g];
el.setAttribute('fill', st === undefined ? '#555' : st ? '#4ade80' : '#2a3a2a');
el.setAttribute('stroke', st ? '#86efac' : '#aaa');
});
_PICO_RIGHT_PINS.forEach((g, i) => {
if (g === null) return;
const el = document.getElementById('rp-rp-' + i);
if (!el) return;
const st = _rp2040PinStates[g];
el.setAttribute('fill', st === undefined ? '#555' : st ? '#4ade80' : '#2a3a2a');
el.setAttribute('stroke', st ? '#86efac' : '#aaa');
});
}
let _avrBoardType = 'board_uno';
let _esp8266SessionId = null;
let _esp8266EvtSource = null;
let _esp8266PinStates = {};
let _esp8266PinModes = {};
let _esp8266Fqbn = 'esp8266:esp8266:nodemcuv2';
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;
_esp8266Fqbn = fqbn || 'esp8266:esp8266:nodemcuv2';
_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 = '';
};
_adcShowSection();
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 (fqbn) _esp32Fqbn = fqbn;
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');
_adcShowSection();
}
// ── STM32 Renode mode ─────────────────────────────────────────────────────────
let _renodeSessionId = null;
let _renodeEvtSource = null;
// Arduino 번호 → STM32 핀 이름 (variant_PILL_F103Cx 실제 순서)
// D0=PB9 … D6=PB3, D7=PA15, D8=PA12 … D17=PC13 … D19=PC15, D20=PA0 …
const _STM32_ARDUINO_PINS = [
'PB9','PB8','PB7','PB6','PB5','PB4','PB3', // D0-D6
'PA15','PA12','PA11','PA10','PA9','PA8', // D7-D12
'PB15','PB14','PB13','PB12', // D13-D16
'PC13','PC14','PC15', // D17-D19
'PA0','PA1','PA2','PA3','PA4','PA5','PA6','PA7', // D20-D27
'PB0','PB1','PB10','PB11','PB2','PA13','PA14', // D28-D34
];
// HIGH/LOW/1/0/0x1/0x0 → 0 or 1
// Arduino: #define HIGH 0x1 이므로 _RN_S(HIGH) → "0x1" 로 전달됨
function _stm32PinVal(v) {
if (v === 'HIGH' || v === 'true') return 1;
if (v === 'LOW' || v === 'false') return 0;
const n = parseInt(v, 16);
return (!isNaN(n) && n !== 0) ? 1 : 0;
}
function _stm32UpdatePin(pinName, val) {
const resolved = _stm32PinResolve(pinName);
const on = _stm32PinVal(val);
if (resolved === 'PC13' || resolved === 'LED_BUILTIN') {
// PC13 active LOW — 핀 서클 + 온보드 LED 둘 다 업데이트
const lit = on === 0;
const fill = lit ? '#ffcc00' : '#1a1a0a';
const flt = lit ? 'drop-shadow(0 0 5px #ffcc00)' : 'none';
['stm32-pc13', 'stm32-led-pc13'].forEach(id => {
const el = document.getElementById(id);
if (el) { el.style.fill = fill; el.style.filter = flt; }
});
} else {
const id = 'stm32-' + resolved.toLowerCase();
const el = document.getElementById(id);
if (!el) return;
el.style.fill = on ? '#22cc55' : '#0d2a0d';
el.style.filter = on ? 'drop-shadow(0 0 3px #22cc55)' : 'none';
}
}
// Arduino 핀 번호/PinName 숫자 → STM32 포트·핀 이름
function _stm32PinResolve(s) {
if (!s) return '?';
// 이미 이름 형식("PA0", "PC13" 등)이면 그대로 반환
if (/^P[A-Z]\d/.test(s) || s === 'LED_BUILTIN') return s;
const num = parseInt(s, 0); // "0xC0"→192, "192"→192, "45"→45
if (isNaN(num)) return s;
// Digital pin 0-31 (STMduino BLUEPILL 순서)
if (num >= 0 && num < _STM32_ARDUINO_PINS.length) return _STM32_ARDUINO_PINS[num];
// Analog pin (PNUM_ANALOG_BASE=0xC0): PA0-PA7, PB0, PB1
const _AP = ['PA0','PA1','PA2','PA3','PA4','PA5','PA6','PA7','PB0','PB1'];
if ((num & 0xC0) === 0xC0 && (num - 0xC0) < _AP.length) return _AP[num - 0xC0];
// PinName 인코딩: 상위 니블=포트, 하위 니블=핀 (PA_0=0x00, PC_13=0x2D)
if (num < 0xC0) {
const port = (num >> 4) & 0xF, pin = num & 0xF;
if (port < 12) return 'P' + String.fromCharCode(65 + port) + pin;
}
return 'D' + num;
}
let _renodeGpioBuf = '';
let _stm32LcdInited = false;
function _stm32ParseGpio(raw) {
_renodeGpioBuf += raw;
let out = '';
let i = 0;
while (i <= _renodeGpioBuf.length) {
const s = _renodeGpioBuf.indexOf('\x02', i);
if (s < 0) { out += _renodeGpioBuf.slice(i); _renodeGpioBuf = ''; break; }
out += _renodeGpioBuf.slice(i, s);
const e = _renodeGpioBuf.indexOf('\x03', s + 1);
if (e < 0) { _renodeGpioBuf = _renodeGpioBuf.slice(s); break; }
const pkt = _renodeGpioBuf.slice(s + 1, e);
_renodeGpioBuf = _renodeGpioBuf.slice(e + 1);
i = 0;
const parts = pkt.split(':');
if (parts[0] === 'P' && parts.length >= 3)
_stm32UpdatePin(_stm32PinResolve(parts[1]), parts[2]);
else if (parts[0] === 'L' && parts.length >= 3)
_stm32LcdDraw(parts[1], parts.slice(2).join(':'));
}
return out;
}
function _stm32LcdDraw(op, data) {
if (op === 'I') {
const [w, h] = data.split(',').map(Number);
_esp32TftW = w || 240; _esp32TftH = h || 320; _esp32TftCtx2d = null;
_esp32TftDraw('I:' + _esp32TftW + ',' + _esp32TftH);
_stm32LcdInited = true;
return;
}
if (op === 'R') { _esp32TftDraw('R:' + parseInt(data)); return; }
if (!_stm32LcdInited) {
_stm32LcdInited = true;
_esp32TftW = 240; _esp32TftH = 320; _esp32TftCtx2d = null;
_esp32TftDraw('I:240,320');
}
const n = data.split(',').map(Number);
switch (op) {
case 'r': _esp32TftDraw('B:' + n.join(',')); break; // fillRect x,y,w,h,c
case 'h': _esp32TftDraw('B:' + [n[0],n[1],n[2],1,n[3]].join(',')); break; // hline → fillRect h=1
case 'v': _esp32TftDraw('B:' + [n[0],n[1],1,n[2],n[3]].join(',')); break; // vline → fillRect w=1
case 'p': _esp32TftDraw('P:' + n.join(',')); break; // pixel x,y,c
case 'l': _esp32TftDraw('L:' + n.join(',')); break; // line x0,y0,x1,y1,c
case 'c': _esp32TftDraw('C:' + [n[0],n[1],n[2],n[4],n[3]].join(',')); break; // circle cx,cy,r,fill,c → C:cx,cy,r,c,fill
case 't': _esp32TftDraw('T:' + n.join(',')); break; // char x,y,fg,bg,size,charcode
}
}
function _buildBluePillSVG(isF4) {
const leftPins = [
['GND','#666'],['GND','#666'],['3V3','#c0c090'],['NRST','#b0b090'],
['PA0','#0d2a0d'],['PA1','#0d2a0d'],['PA2','#0d2a0d'],['PA3','#0d2a0d'],
['PA4','#0d2a0d'],['PA5','#0d2a0d'],['PA6','#0d2a0d'],['PA7','#0d2a0d'],
['PB0','#0d2a0d'],['PB1','#0d2a0d'],['PB10','#0d2a0d'],['PB11','#0d2a0d'],
['PB12','#0d2a0d'],['PB13','#0d2a0d'],['PB14','#0d2a0d'],['PB15','#0d2a0d'],
];
const rightPins = [
['GND','#666'],['GND','#666'],['3V3','#c0c090'],['PA8','#0d2a0d'],
['PA9','#0d2a0d','TX'],['PA10','#0d2a0d','RX'],['PA11','#0d2a0d'],['PA12','#0d2a0d'],
['PA15','#0d2a0d'],['PB3','#0d2a0d'],['PB4','#0d2a0d'],['PB5','#0d2a0d'],
['PB6','#0d2a0d'],['PB7','#0d2a0d'],['PB8','#0d2a0d'],['PB9','#0d2a0d'],
['PC13','#1a1a0a'],['PC14','#0d2a0d'],['PC15','#0d2a0d'],['5V','#c09090'],
];
// sp=핀 간격, y0=시작 y, cx_l/r=좌우 서클 x
const sp = 9, y0 = 18, cx_l = 5, cx_r = 95;
let lp = '', rp = '';
leftPins.forEach(([name, fill], i) => {
const y = y0 + i * sp;
const isGpio = name.startsWith('P');
const id = isGpio ? ` id="stm32-${name.toLowerCase()}"` : '';
const sc = isGpio ? '#1a5a1a' : '#555';
lp += `<circle${id} cx="${cx_l}" cy="${y}" r="3" fill="${fill}" stroke="${sc}" stroke-width="0.7"/>`;
lp += `<text x="${cx_l+6}" y="${y+1.5}" fill="#6aadee" font-size="4" font-family="monospace" font-weight="600">${name}</text>`;
});
rightPins.forEach(([name, fill, tag], i) => {
const y = y0 + i * sp;
const isGpio = name.startsWith('P');
const id = isGpio ? ` id="stm32-${name.toLowerCase()}"` : '';
const sc = isGpio ? '#1a5a1a' : '#555';
rp += `<circle${id} cx="${cx_r}" cy="${y}" r="3" fill="${fill}" stroke="${sc}" stroke-width="0.7"/>`;
const lbl = tag ? `${name}<tspan fill="#ff9944" font-size="3"> ${tag}</tspan>` : name;
rp += `<text x="${cx_r-6}" y="${y+1.5}" text-anchor="end" fill="#6aadee" font-size="4" font-family="monospace" font-weight="600">${lbl}</text>`;
});
const chipLines = isF4 ? ['STM32F4','F411CE'] : ['STM32F1','F103C8T6'];
const h = y0 + leftPins.length * sp + 16; // 보드 높이 자동
const w = 100;
const chipY = Math.round(h * 0.28), chipH = Math.round(h * 0.42);
const ledY = Math.round(chipY * 0.55);
return `<svg viewBox="0 0 ${w} ${h}" xmlns="http://www.w3.org/2000/svg">
<rect x="1" y="1" width="${w-2}" height="${h-2}" rx="4" fill="#152060" stroke="#2a40aa" stroke-width="2"/>
<rect x="1.5" y="12" width="7" height="${h-24}" rx="2" fill="#0d1428" stroke="#2a4080" stroke-width="0.6"/>
<rect x="${w-8.5}" y="12" width="7" height="${h-24}" rx="2" fill="#0d1428" stroke="#2a4080" stroke-width="0.6"/>
${lp}${rp}
<rect x="20" y="${chipY}" width="60" height="${chipH}" rx="3" fill="#1a1a1a" stroke="#888" stroke-width="1"/>
<ellipse cx="50" cy="${chipY}" rx="4" ry="2" fill="#333"/>
<text x="50" y="${chipY + chipH*0.45}" text-anchor="middle" fill="#ccc" font-size="6.5" font-family="monospace" font-weight="bold">${chipLines[0]}</text>
<text x="50" y="${chipY + chipH*0.65}" text-anchor="middle" fill="#aaa" font-size="5" font-family="monospace">${chipLines[1]}</text>
<circle id="stm32-pwr-led" cx="62" cy="${ledY}" r="5" fill="#0d2a0d" stroke="#1a5a1a" stroke-width="1"/>
<text x="62" y="${ledY-7}" text-anchor="middle" fill="#5a9a5a" font-size="4" font-family="monospace" font-weight="bold">PWR</text>
<circle id="stm32-led-pc13" cx="38" cy="${ledY}" r="5" fill="#1a1a0a" stroke="#4a4a1a" stroke-width="1"/>
<text x="38" y="${ledY-7}" text-anchor="middle" fill="#8a8a40" font-size="4" font-family="monospace" font-weight="bold">LED</text>
<g style="cursor:pointer" onclick="document.dispatchEvent(new CustomEvent('renode-reset'))">
<circle cx="80" cy="${ledY}" r="5" fill="#cc2020" stroke="#ff5050" stroke-width="1"/>
<text x="80" y="${ledY+1.5}" text-anchor="middle" fill="#ffbbbb" font-size="3.5" font-family="monospace" font-weight="bold">RST</text>
</g>
<rect x="35" y="${h-12}" width="30" height="10" rx="2" fill="#888" stroke="#bbb" stroke-width="0.6"/>
<rect x="38" y="${h-10}" width="24" height="6" rx="1" fill="#444"/>
<text x="50" y="${h-14}" text-anchor="middle" fill="#6aadee" font-size="3.5" font-family="monospace">Micro-USB</text>
</svg>`;
}
function initSTM32RenodeMode(sessionId, fname, fqbn) {
_renodeSessionId = sessionId;
_renodeGpioBuf = '';
_stm32LcdInited = false;
_esp32TftCtx2d = null;
const _tftSec = document.getElementById('tft-section');
if (_tftSec) _tftSec.style.display = 'none';
const _tftDot = document.getElementById('tft-dot');
if (_tftDot) _tftDot.classList.remove('on');
document.querySelector('.header h1').textContent = '🖥️ STM32 Emulator (Renode)';
document.getElementById('filename').textContent = fname;
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;';
const isF4 = fqbn.includes('GenF4') || fqbn.includes('f4');
badge.textContent = (isF4 ? 'STM32F4' : 'STM32F1') + ' · Renode';
badge.style.background = '#1a0a3a'; badge.style.color = '#c084fc'; badge.style.borderColor = '#7c3aed';
document.getElementById('filename').after(badge);
// 보드 SVG를 Blue Pill로 교체
const boardSvgEl = document.querySelector('.board-svg');
const boardTitleEl = document.querySelector('.board-title');
if (boardSvgEl) { boardSvgEl.innerHTML = _buildBluePillSVG(isF4); boardSvgEl.style.display = ''; }
if (boardTitleEl) { boardTitleEl.textContent = isF4 ? 'Black Pill · STM32F4' : 'Blue Pill · STM32F1'; boardTitleEl.style.display = ''; }
// PWR LED 켜기
setTimeout(() => {
const pwr = document.getElementById('stm32-pwr-led');
if (pwr) { pwr.style.fill = '#22cc44'; pwr.style.filter = 'drop-shadow(0 0 3px #22cc44)'; }
}, 500);
document.getElementById('btn-stop')?.style && (document.getElementById('btn-stop').style.display = 'none');
document.getElementById('btn-reset')?.style && (document.getElementById('btn-reset').style.display = 'none');
document.getElementById('renode-stop-btn')?.remove();
const toolbar = document.querySelector('.toolbar-right') || document.querySelector('.toolbar');
if (toolbar) {
const stopBtn = document.createElement('button');
stopBtn.id = 'renode-stop-btn'; stopBtn.textContent = '■ 정지';
stopBtn.style.cssText = 'font-size:12px;padding:3px 8px;background:#3a1010;border:1px solid #cc4444;color:#ff8888;border-radius:4px;cursor:pointer';
stopBtn.onclick = () => {
fetch('/api/arduino/renode/' + _renodeSessionId, { method: 'DELETE', credentials: 'include', keepalive: true }).catch(() => {});
if (_renodeEvtSource) { _renodeEvtSource.close(); _renodeEvtSource = null; }
setStatus('off', '정지됨');
const pwr = document.getElementById('stm32-pwr-led');
if (pwr) { pwr.style.fill = '#0d2a0d'; pwr.style.filter = 'none'; }
};
toolbar.append(stopBtn);
}
const serialInput = document.getElementById('serial-input');
const serialSend = document.querySelector('.serial-send');
if (serialInput) serialInput.disabled = false;
if (serialSend) serialSend.removeAttribute('disabled');
const doSend = () => {
const text = serialInput?.value || '';
if (!text) return;
fetch('/api/arduino/renode/' + _renodeSessionId + '/input', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text: text + '\n' }),
}).catch(() => {});
if (serialInput) serialInput.value = '';
};
serialInput?.addEventListener('keydown', e => { if (e.key === 'Enter') doSend(); });
serialSend?.addEventListener('click', doSend);
setStatus('loading', 'Renode 기동 중...');
_renodeEvtSource = new EventSource('/api/arduino/renode/' + sessionId + '/output', { withCredentials: true });
_renodeEvtSource.onopen = () => setStatus('running', '실행 중');
_renodeEvtSource.onerror = () => setStatus('error', '연결 오류');
// TX 핀(PA9) 깜빡임 — 시리얼 데이터 수신 시
let _txTimer = null;
function _blinkTx() {
const el = document.getElementById('stm32-pa9');
if (!el) return;
el.style.fill = '#ff8800'; el.style.filter = 'drop-shadow(0 0 2px #ff8800)';
clearTimeout(_txTimer);
_txTimer = setTimeout(() => { el.style.fill = '#0d2a0d'; el.style.filter = 'none'; }, 80);
}
// RX 핀(PA10) 깜빡임 — 사용자 입력 전송 시
let _rxTimer = null;
function _blinkRx() {
const el = document.getElementById('stm32-pa10');
if (!el) return;
el.style.fill = '#00aaff'; el.style.filter = 'drop-shadow(0 0 2px #00aaff)';
clearTimeout(_rxTimer);
_rxTimer = setTimeout(() => { el.style.fill = '#0d2a0d'; el.style.filter = 'none'; }, 80);
}
// doSend에 RX 깜빡임 추가
const _origDoSend = doSend;
const doSendWithRx = () => { _blinkRx(); _origDoSend(); };
serialInput?.removeEventListener('keydown', e => { if (e.key === 'Enter') doSend(); });
serialSend?.removeEventListener('click', doSend);
serialInput?.addEventListener('keydown', e => { if (e.key === 'Enter') doSendWithRx(); });
serialSend?.addEventListener('click', doSendWithRx);
_renodeEvtSource.onmessage = e => {
try {
const d = JSON.parse(e.data);
if (d.serial) {
const visible = _stm32ParseGpio(d.serial);
if (visible) { appendSerial(visible); _blinkTx(); }
}
if (d.exit !== undefined) {
setStatus('off', '종료');
const pwr = document.getElementById('stm32-pwr-led');
if (pwr) { pwr.style.fill = '#0d2a0d'; pwr.style.filter = 'none'; }
}
} catch {}
};
window.addEventListener('beforeunload', () => {
fetch('/api/arduino/renode/' + sessionId, { method: 'DELETE', credentials: 'include', keepalive: true }).catch(() => {});
});
}
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;
let avrAdc = null, avrEeprom = null;
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) return;
if (_IS_RP2040 && _rp2040Sim) {
for (const ch of inp.value + '\n') _rp2040Sim.rp2040.uart[0].feedByte(ch.charCodeAt(0));
inp.value = ''; return;
}
if (!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;
// PWM 핀 설정 (Arduino UNO): {pin: {TCCRA주소, OCR주소, COM비트시프트, 비트수}}
const _PWM = {
3: { tccrA: 0xB0, ocr: 0xB4, sh: 4, b: 8 }, // Timer2B → D3
5: { tccrA: 0x44, ocr: 0x48, sh: 4, b: 8 }, // Timer0B → D5
6: { tccrA: 0x44, ocr: 0x47, sh: 6, b: 8 }, // Timer0A → D6
9: { tccrA: 0x80, ocr: 0x88, sh: 6, b: 16 }, // Timer1A → D9
10: { tccrA: 0x80, ocr: 0x8A, sh: 4, b: 16 }, // Timer1B → D10
11: { tccrA: 0xB0, ocr: 0xB3, sh: 6, b: 8 }, // Timer2A → D11
};
function _pwmDuty(pin) {
const p = _PWM[pin]; if (!p || !cpu) return null;
const com = (cpu.data[p.tccrA] >> p.sh) & 0x3;
if (!com) return null; // COM=0 → 일반 GPIO (PWM 아님)
const ocr = p.b === 16
? (cpu.data[p.ocr] | (cpu.data[p.ocr + 1] << 8))
: cpu.data[p.ocr];
const max = p.b === 16 ? 65535 : 255;
return Math.round(ocr / max * 100);
}
// 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);
const duty = _pwmDuty(i);
if (duty !== null) {
if (led) led.className = 'pin-led pwm';
if (lbl) lbl.textContent = duty + '%';
} else {
if (led) led.className = 'pin-led' + (val ? ' on' : '');
if (lbl) lbl.textContent = val ? '1' : '0';
}
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);
const duty = _pwmDuty(pinNum);
if (duty !== null) {
if (led) led.className = 'pin-led pwm';
if (lbl) lbl.textContent = duty + '%';
} else {
if (led) led.className = 'pin-led' + (val ? ' on' : '');
if (lbl) lbl.textContent = val ? '1' : '0';
}
document.getElementById('board-d' + pinNum + '-led')?.setAttribute('fill', val ? '#22c55e' : '#0d2a0d');
if (i === 5) {
document.getElementById('board-led-13')?.setAttribute('fill', val ? '#22c55e' : '#1a1a1a');
}
}
// Analog pins A0-A5 (Port C) — ADC 값 표시
for (let i = 0; i < 6; i++) {
const val = pinVoltage(portC, i);
const led = document.getElementById('apin-' + i);
const lbl = document.getElementById('apinv-' + i);
// ADC 채널 값 읽기 (analogRead 중이 아닐 때는 설정된 channelValues 표시)
const adcV = avrAdc ? (avrAdc.channelValues[i] || 0) : 0;
if (led) led.className = 'pin-led' + (val ? ' in' : '');
if (lbl) lbl.textContent = avrAdc ? adcV.toFixed(2) + 'V' : (val ? '1' : '0');
document.getElementById('board-a' + i + '-led')?.setAttribute('fill', val ? '#3b82f6' : '#0d2a0d');
}
// 회로도 pot/ldr → ADC 채널 동기화
if (avrAdc && _schLiveMap.analog && _schLiveMap.analog.size > 0) {
for (const [comp, ch] of _schLiveMap.analog) {
const v = ((comp.value != null ? comp.value : 0.5)) * 5.0;
if (Math.abs((avrAdc.channelValues[ch] || 0) - v) > 0.01) {
avrAdc.channelValues[ch] = v;
const slider = document.getElementById('adc-ch' + ch);
const lbl = document.getElementById('adc-ch' + ch + '-val');
if (slider) slider.value = Math.round(v * 100);
if (lbl) lbl.textContent = v.toFixed(2) + 'V';
}
}
}
// 회로도 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);
// ADC: analogRead() 지원 (슬라이더 값은 리셋 후에도 유지)
if (AVRADC && adcConfig) {
const prevValues = avrAdc ? [...avrAdc.channelValues] : null;
avrAdc = new AVRADC(cpu, adcConfig);
for (let _i = 0; _i < 6; _i++) {
const _s = document.getElementById('adc-ch' + _i);
avrAdc.channelValues[_i] = _s ? parseInt(_s.value) / 100
: (prevValues ? (prevValues[_i] || 0) : 0);
}
_adcShowSection();
}
// EEPROM: EEPROM.read()/write() 지원 (컴파일 시 초기화, 리셋 시 유지)
if (AVREEPROM && EEPROMMemoryBackend && eepromConfig) {
avrEeprom = new AVREEPROM(cpu, new EEPROMMemoryBackend(1024), eepromConfig);
}
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(); // 메시지 자동 출력
};
// ── RP2040 emulation ─────────────────────────────────────────────────────────
const _PICO_BOARD_SVG = `<svg id="board-svg" viewBox="0 0 90 215" xmlns="http://www.w3.org/2000/svg">
<!-- PCB -->
<rect x="1" y="1" width="88" height="213" rx="5" fill="#1a5c2a" stroke="#3a9a4a" stroke-width="1.2"/>
<!-- USB Micro-B -->
<rect x="31" y="0" width="28" height="10" rx="2" fill="#aaa" stroke="#ccc" stroke-width=".5"/>
<rect x="34" y="1.5" width="22" height="6" rx="1" fill="#666"/>
<text x="45" y="7.5" text-anchor="middle" fill="#eee" font-size="3.5" font-family="monospace">USB</text>
<!-- RP2040 chip -->
<rect x="22" y="78" width="46" height="44" rx="3" fill="#111" stroke="#555" stroke-width=".8"/>
<circle cx="24" cy="80" r="1.5" fill="#333"/>
<text x="45" y="97" text-anchor="middle" fill="#ccc" font-size="7" font-family="monospace" font-weight="bold">RP2040</text>
<text x="45" y="107" text-anchor="middle" fill="#888" font-size="3.5" font-family="monospace">Raspberry Pi</text>
<!-- Flash chip -->
<rect x="28" y="118" width="22" height="12" rx="1.5" fill="#222" stroke="#555" stroke-width=".6"/>
<text x="39" y="126" text-anchor="middle" fill="#777" font-size="3" font-family="monospace">FLASH</text>
<!-- Built-in LED GPIO25 -->
<circle id="pico-led" cx="76" cy="25" r="3.5" fill="#0a1f0a" stroke="#2a6a2a" stroke-width=".8"/>
<text x="76" y="20" text-anchor="middle" fill="#6aaa6a" font-size="3" font-family="monospace">LED</text>
<!-- Power LED indicator -->
<circle id="board-pwr-led" cx="14" cy="25" r="2.5" fill="#0d2a0d" stroke="#1a5a1a" stroke-width=".6"/>
<text x="14" y="20" text-anchor="middle" fill="#6aaa6a" font-size="3" font-family="monospace">PWR</text>
<!-- BOOTSEL button -->
<rect x="50" y="148" width="14" height="7" rx="1.5" fill="#2a2a2a" stroke="#666" stroke-width=".5"/>
<text x="57" y="153.5" text-anchor="middle" fill="#aaa" font-size="3" font-family="monospace">BOOT</text>
<!-- Power button -->
<g id="power-switch" onclick="togglePower()" style="cursor:pointer">
<circle id="pw-bg" cx="20" cy="197" r="7.5" fill="#1a0808" stroke="#803030" stroke-width="1.2"/>
<circle cx="20" cy="197" r="5.5" fill="none" id="pw-ring" stroke="#602020" stroke-width=".8"/>
<path id="pw-arc" d="M 16.8 194.5 A 4 4 0 1 0 23.2 194.5" stroke="#c0404088" stroke-width="1.6" fill="none" stroke-linecap="round"/>
<line id="pw-line" x1="20" y1="192.5" x2="20" y2="196.5" stroke="#c0404088" stroke-width="1.6" stroke-linecap="round"/>
<circle cx="20" cy="197" r="7.5" fill="transparent" stroke="transparent"/>
</g>
<!-- Reset button -->
<g id="reset-btn" onclick="resetEmulator()" style="cursor:pointer">
<rect x="57" y="190" width="20" height="11" rx="2.5" fill="#1a2060" stroke="#3050b0" stroke-width=".8"/>
<text x="67" y="197.5" text-anchor="middle" fill="#88aaff" font-size="3.5" font-family="monospace">RESET</text>
<rect x="57" y="190" width="20" height="11" rx="2.5" fill="transparent" stroke="transparent"/>
</g>
<!-- Left pins (20) -->
${Array.from({length:20},(_,i)=>{
const y=18+i*9;
const labels=['VBUS','VSYS','GND','3V3','3V3_EN','ADC_REF','GP28','AGND','GP27','GP26','RUN','GP22','GND','GP21','GP20','GP19','GP18','GND','GP17','GP16'];
const gpNums=[null,null,null,null,null,null,28,null,27,26,null,22,null,21,20,19,18,null,17,16];
const isGnd=labels[i].includes('GND')||labels[i]==='AGND';
const is3v=labels[i].includes('3V3')||labels[i]==='VBUS'||labels[i]==='VSYS';
const col=isGnd?'#888':is3v?'#f80':'#5cf';
const gp=gpNums[i];
const id=gp!==null?` id="rp-lp-${i}"`:'';
return `<rect${id} x="1" y="${y}" width="6" height="5" rx="1" fill="#555" stroke="#aaa" stroke-width=".4"/>
<text x="9" y="${y+4}" fill="${col}" font-size="3" font-family="monospace">${labels[i]}</text>`;
}).join('')}
<!-- Right pins (20) -->
${Array.from({length:20},(_,i)=>{
const y=18+i*9;
const labels=['GP0','GP1','GND','GP2','GP3','GP4','GP5','GND','GP6','GP7','GP8','GP9','GND','GP10','GP11','GP12','GP13','GND','GP14','GP15'];
const gpNums=[0,1,null,2,3,4,5,null,6,7,8,9,null,10,11,12,13,null,14,15];
const isGnd=labels[i].includes('GND');
const col=isGnd?'#888':'#5cf';
const gp=gpNums[i];
const id=gp!==null?` id="rp-rp-${i}"`:'';
return `<rect${id} x="83" y="${y}" width="6" height="5" rx="1" fill="#555" stroke="#aaa" stroke-width=".4"/>
<text x="81" y="${y+4}" text-anchor="end" fill="${col}" font-size="3" font-family="monospace">${labels[i]}</text>`;
}).join('')}
</svg>`;
function _rp2040Start() {
if (!_rp2040Sim) return;
// 이미 실행 중이면 무시 (정지/시작 토글용)
if (_rp2040Running) return;
// 이전 스케줄러가 남아 있을 수 있으므로 먼저 정리
_rp2040Stop();
_rp2040Running = true;
_updatePowerSwitch();
const NS = 8; // 125 MHz → 8 ns/cycle
const rp2040 = _rp2040Sim.rp2040;
const clock = _rp2040Sim.clock;
const FLASH_BASE = 0x10000000, SRAM_BASE = 0x20000000;
// Uint16 뷰: Thumb 명령어(2바이트 정렬) 패턴 감지용
const flashU16 = new Uint16Array(rp2040.flash.buffer);
// Uint32 뷰: 4바이트 단위 bulk 복사/제로필 용
const flashU32 = new Uint32Array(rp2040.flash.buffer);
const sramU32 = new Uint32Array(rp2040.sram.buffer);
// SRAM 코드 fast-path 감지용 Uint16 뷰
const sramU16 = new Uint16Array(rp2040.sram.buffer);
// 범용 초기화 루프 fast-path 함수
// 패턴: STMIA Rn!,{r0} / CMP Rn,Rm / BNE or BCC back
function _tryFastLoop(pc, memU16, base, isFlash) {
const hOff = (pc - base) >>> 1;
const op0 = memU16[hOff], op1 = memU16[hOff+1], op2 = memU16[hOff+2];
// Fast-path A: LDMIA r1!,{r0}(c901) / STMIA r2!,{r0}(c201) / CMP r2,r3(429a) / BCC(d3fb)
if (isFlash && op0 === 0xc901 && op1 === 0xc201 && memU16[hOff+2] === 0x429a && memU16[hOff+3] === 0xd3fb) {
const r1 = rp2040.core.registers[1] >>> 0;
const r2 = rp2040.core.registers[2] >>> 0;
const r3 = rp2040.core.registers[3] >>> 0;
if (r2 < r3 && (r1 & 3) === 0 && (r2 & 3) === 0 &&
r1 >= FLASH_BASE && r1 < FLASH_BASE + rp2040.flash.length &&
r2 >= SRAM_BASE && r2 < SRAM_BASE + rp2040.sram.length) {
const cnt = (r3 - r2) >>> 2;
sramU32.set(flashU32.subarray((r1-FLASH_BASE)>>>2, ((r1-FLASH_BASE)>>>2)+cnt), (r2-SRAM_BASE)>>>2);
rp2040.core.registers[1] = r1 + cnt * 4;
rp2040.core.registers[2] = r3;
rp2040.core.PC = pc + 8;
clock.tick(NS * 2 * cnt * 4);
return true;
}
}
// Fast-path B: STMIA r1!,{r0}(c101) / CMP r1,r2(4291) / BNE(d1fc) — .bss 제로 루프
if (op0 === 0xc101 && op1 === 0x4291 && op2 === 0xd1fc) {
const r0 = rp2040.core.registers[0] >>> 0;
const r1 = rp2040.core.registers[1] >>> 0;
const r2 = rp2040.core.registers[2] >>> 0;
if (r1 < r2 && (r1 & 3) === 0 && (r2 & 3) === 0 &&
r1 >= SRAM_BASE && r2 <= SRAM_BASE + rp2040.sram.length) {
const cnt = (r2 - r1) >>> 2;
sramU32.fill(r0, (r1-SRAM_BASE)>>>2, ((r1-SRAM_BASE)>>>2)+cnt);
rp2040.core.registers[1] = r2;
rp2040.core.PC = pc + 6;
clock.tick(NS * 2 * cnt * 3);
return true;
}
}
return false;
}
// Stuck detection: same PC for too long usually means a fault/loop
let _lastPc = rp2040.core.PC, _stuckCycles = 0, _reportedStuck = false;
function tick() {
if (!_rp2040Running) return;
const _booted = !!(_rp2040Sim && _rp2040Sim._booted);
// 부팅 중: 10ms 버스트 후 즉시 재스케줄 (RAF 16ms 상한 없이 최대 처리량)
// 부팅 후: RAF로 전환 (UI 동기화)
const BURST = _booted ? 20 : 10; // ms
const deadline = performance.now() + BURST;
let checkN = 0;
try {
while (true) {
if (rp2040.core.waiting) {
const dt = clock.nanosToNextAlarm;
clock.tick(dt > 0 ? dt : NS);
} else {
const pc = rp2040.core.PC & ~1;
if (pc === _lastPc) {
if (++_stuckCycles > 50000 && !_reportedStuck) {
_reportedStuck = true;
appendSerial(`\n[경고: PC ${pc.toString(16)}에서 멈춤 — HardFault/idle loop 의심]\n`, 'error');
}
} else {
_lastPc = pc; _stuckCycles = 0; _reportedStuck = false;
}
// Flash 범위: fast-path 검사
if (pc >= FLASH_BASE && pc < FLASH_BASE + rp2040.flash.length - 8) {
if (_tryFastLoop(pc, flashU16, FLASH_BASE, true)) {
if (++checkN >= 500 && performance.now() >= deadline) break;
continue;
}
}
// SRAM 범위: fast-path 검사 (.bss 패턴만)
else if (pc >= SRAM_BASE && pc < SRAM_BASE + rp2040.sram.length - 6) {
if (_tryFastLoop(pc, sramU16, SRAM_BASE, false)) {
if (++checkN >= 500 && performance.now() >= deadline) break;
continue;
}
}
rp2040.step();
clock.tick(NS * 2);
}
if (++checkN >= 500 && performance.now() >= deadline) break;
}
} catch(e) { _rp2040Running = false; appendSerial('\n[실행 오류: ' + e.message + ']\n', 'error'); return; }
// RAF 프레임마다 디스플레이 플러시 (TFT/OLED dirty flag 기반 — 변화 없으면 no-op)
tftFlushCanvas();
oledFlushCanvas();
// Heartbeat: 부팅 중에는 1초마다, 부팅 후에는 3초마다 PC/SP/LR/waiting 출력
if (_rp2040Sim && _rp2040Sim._booted) {
_rp2040RafId = requestAnimationFrame(tick);
} else {
// 부팅 중: setTimeout(0)으로 최대한 빠르게 재스케줄
_rp2040RafId = setTimeout(tick, 0);
}
}
_rp2040RafId = setTimeout(tick, 0);
}
function _rp2040Stop() {
_rp2040Running = false;
if (_rp2040RafId) {
cancelAnimationFrame(_rp2040RafId);
clearTimeout(_rp2040RafId);
_rp2040RafId = null;
}
if (_rp2040BootTimeoutId) {
clearTimeout(_rp2040BootTimeoutId);
_rp2040BootTimeoutId = null;
}
}
function _rp2040MarkBooted() {
if (_rp2040Sim && !_rp2040Sim._booted) {
_rp2040Sim._booted = true;
if (_rp2040BootTimeoutId) { clearTimeout(_rp2040BootTimeoutId); _rp2040BootTimeoutId = null; }
console.log('[RP2040] 부팅 완료');
}
}
function _rp2040ScheduleBootTimeout(ms = 8000) {
if (_rp2040BootTimeoutId) clearTimeout(_rp2040BootTimeoutId);
_rp2040BootTimeoutId = setTimeout(() => {
_rp2040BootTimeoutId = null;
if (_rp2040Sim && !_rp2040Sim._booted) {
_rp2040Sim._booted = true;
console.log('[RP2040] 부팅 완료 (타임아웃)');
}
}, ms);
}
function _loadUF2(data, rp2040) {
const FLASH_START = 0x10000000;
for (let i = 0; i + 512 <= data.length; i += 512) {
const v = new DataView(data.buffer, data.byteOffset + i, 512);
if (v.getUint32(0, true) !== 0x0A324655 || v.getUint32(4, true) !== 0x9E5D5157) continue;
const addr = v.getUint32(12, true);
const size = v.getUint32(16, true);
const off = addr - FLASH_START;
if (off < 0 || off + size > rp2040.flash.length) continue;
rp2040.flash.set(new Uint8Array(data.buffer, data.byteOffset + i + 32, size), off);
}
}
async function initRP2040Mode(uf2B64, fname) {
_IS_RP2040 = true;
document.getElementById('filename').textContent = fname || 'sketch.ino';
document.querySelector('.board-title').textContent = 'RASPBERRY PI PICO · RP2040';
document.querySelector('.board-svg').innerHTML = _PICO_BOARD_SVG;
const footerEl = document.getElementById('footer-info');
if (footerEl) footerEl.textContent = 'rp2040js · RP2040 @ 125 MHz';
document.getElementById('btn-compile').style.display = 'none';
// rp2040js의 SIO 미구현 주소 경고 콘솔 범람 방지
_silenceRp2040jsWarnings();
let Simulator, USBCDC;
try {
({ Simulator, USBCDC } = await import('./vendor/rp2040js/index.js'));
window._rp2040Modules = { Simulator, USBCDC };
} catch(e) { showOverlayError('rp2040js 로드 실패\n' + e.message); return; }
const uf2Bytes = Uint8Array.from(atob(uf2B64), c => c.charCodeAt(0));
_rp2040Uf2Bytes = uf2Bytes;
_rp2040Fname = fname || 'sketch.ino';
const sim = new Simulator();
_rp2040Sim = sim;
// 1) RP2040 공식 bootrom 로드 (helper 함수 table을 위해 필수)
try {
const br = await fetch('/api/arduino/rp2040-bootrom', { credentials: 'include' });
if (!br.ok) throw new Error('HTTP ' + br.status);
const brData = await br.json();
const bootrom = Uint8Array.from(atob(brData.bin), c => c.charCodeAt(0));
if (bootrom.length !== 16 * 1024) throw new Error('bootrom size mismatch: ' + bootrom.length);
const bootromU32 = new Uint32Array(bootrom.buffer, bootrom.byteOffset, 4096);
_rp2040BootromU32 = bootromU32;
sim.rp2040.loadBootrom(bootromU32);
console.log(`[RP2040] bootrom 로드 완료: ${bootrom.length} bytes`);
} catch(e) {
appendSerial('[RP2040 bootrom 로드 실패: ' + e.message + ']\n', 'error');
// bootrom 없이도 계속 시도 (helper 호출 시 멈출 수 있음)
}
// 2) UF2 → flash 로드
_loadUF2(uf2Bytes, sim.rp2040);
// 3) 앱 벡터 테이블 찾기
// Arduino-Pico UF2: boot2(0x10000000) 뒤에 임시 vector table(0x10000100)이 있고,
// 실제 .text 섹션 시작인 0x10003000에 진짜 vector table이 위치함.
// 보드/링커 설정에 따라 0x10003000 또는 0x10000100 중 하나를 사용.
const fv = new DataView(sim.rp2040.flash.buffer);
const readVTOR = (base) => ({ sp: fv.getUint32(base, true), pc: fv.getUint32(base + 4, true), base });
const candidates = [readVTOR(0x3000), readVTOR(0x100)].filter(v => v.sp && v.pc && (v.pc & ~1) >= 0x10000000);
const appVTOR = candidates[0];
if (!appVTOR) {
showOverlayError('UF2 로드 실패: 앱 벡터 테이블을 찾을 수 없습니다.\nrp2040:rp2040 코어(arduino-pico)를 사용하세요.');
return;
}
// 3) 부팅을 위해 VTOR를 찾은 앱 vector table로 설정.
// bootrom을 덮어쓰지 않으므로 bootrom helper 함수들은 그대로 사용 가능.
// reset()이 실행되면 core.PC = flash[VTOR+4], core.SP = flash[VTOR].
sim.rp2040.core.VTOR = 0x10000000 + appVTOR.base;
// 4) CPU 리셋 (VTOR → flash 앱 vector 테이블에서 SP/PC 로드)
sim.rp2040.core.reset();
// rp2040js 로거 억제: SEV, 미구현 명령어·주변장치 로그가 콘솔에 범람하는 것을 방지
{
const noop = () => {};
for (const logger of [sim.rp2040.logger, sim.rp2040.core?.logger]) {
if (!logger) continue;
for (const lvl of ['debug', 'info', 'log', 'warn']) {
if (typeof logger[lvl] === 'function') logger[lvl] = noop;
}
}
}
// rp2040js 패치: exception(HardFault 등) 발생 시 실행을 멈추고 메시지를 출력.
// (주의: 이전 wide-Thumb 명령어 PC 패치는 BL/BLX 등 정상 32비트 명령어를 망가뜨려 제거함)
{
const core = sim.rp2040.core;
const _origException = core.exceptionEntry.bind(core);
core.exceptionEntry = function(exceptionNumber) {
if (exceptionNumber === 3) { // HardFault만 중지 (IRQ/SysTick/PendSV는 정상 실행)
_rp2040Running = false;
_origException(exceptionNumber);
appendSerial(`\n[HardFault (LR=${this.LR.toString(16)}, PC=${this.PC.toString(16)}) — 실행 중지]\n`, 'error');
} else {
_origException(exceptionNumber);
}
};
}
// UART0 → Serial1 (UTF-8 스트리밍 디코딩)
_rp2040UartDec = new TextDecoder('utf-8', { fatal: false });
sim.rp2040.uart[0].onByte = (value) => {
_rp2040MarkBooted();
const decoded = _rp2040UartDec.decode(new Uint8Array([value]), { stream: true });
if (decoded) appendSerial(decoded);
};
// USB CDC → Serial (default Arduino-Pico Serial)
try {
const cdc = new USBCDC(sim.rp2040.usbCtrl);
window._rp2040Cdc = cdc;
const dec = new TextDecoder();
cdc.onSerialData = (buf) => {
_rp2040MarkBooted();
appendSerial(dec.decode(buf));
};
cdc.onDeviceConnected = () => console.log('[RP2040] USB CDC 연결됨');
console.log('[RP2040] USB CDC 초기화 완료');
} catch(e) {
appendSerial('[USB CDC 초기화 실패: ' + e.message + ']\n', 'error');
}
// 모든 GPIO 핀 리스너 등록 (GP0-GP22, GP26-GP28, GP25=LED)
const _allPicoGpios = [...new Set([..._PICO_LEFT_PINS, ..._PICO_RIGHT_PINS, 25].filter(g => g !== null))];
for (const g of _allPicoGpios) {
if (!sim.rp2040.gpio[g]) continue;
sim.rp2040.gpio[g].addListener((state) => {
_rp2040PinStates[g] = state;
_updateRp2040Board();
});
}
// TFT/LCD/OLED 상태 초기화 (framebuf=null 상태에서 tftSPIByte 호출 시 crash 방지)
lcdReset(); oledReset(); tftReset();
// SPI0/SPI1 → TFT LCD 연결 (schematic에서 CS/DC GPIO 감지 후 리스너 추가됨)
_rp2040TftListenersAdded = false;
_setupRP2040SPI(sim);
// 스케치 코드 정적 분석으로 TFT CS/DC 핀 자동 감지 (회로도 없이도 동작)
_rp2040AutoDetectTft(sim);
// I2C0 / I2C1 → LCD + OLED 연결 (AVR setupTWI와 동일 로직)
for (const i2c of sim.rp2040.i2c) {
let _slave = null;
i2c.onStart = () => { i2c.completeStart(); };
i2c.onStop = () => { _slave = null; i2c.completeStop(); };
i2c.onConnect = (addr, write) => {
if (LCD_ADDRS.includes(addr)) { _slave = 'lcd'; i2c.completeConnect(true); return; }
if (OLED_ADDRS.includes(addr)) { _slave = 'oled'; oledOnI2CStart(); i2c.completeConnect(true); return; }
_slave = null; i2c.completeConnect(false);
};
i2c.onWriteByte = (byte) => {
if (_slave === 'lcd') lcdOnI2CByte(byte);
if (_slave === 'oled') oledOnI2CByte(byte);
i2c.completeWrite(true);
};
i2c.onReadByte = () => { i2c.completeRead(0xFF); };
}
document.getElementById('overlay').style.display = 'none';
const ledEl2 = document.getElementById('rp2040-led');
if (ledEl2) ledEl2.style.display = 'inline-block';
document.getElementById('btn-stop').disabled = false;
const resetBtn = document.getElementById('btn-reset');
if (resetBtn) { resetBtn.disabled = false; }
console.log(`[RP2040] 에뮬레이터 시작 — VTOR=${sim.rp2040.core.VTOR.toString(16)} SP=${sim.rp2040.core.SP.toString(16)} PC=${sim.rp2040.core.PC.toString(16)}`);
_rp2040Start();
_rp2040ScheduleBootTimeout(8000);
setStatus('running', '실행 중');
}
window.resetEmulator = () => {
if (_IS_RP2040) {
if (!_rp2040Sim || !_rp2040Uf2Bytes) return;
_rp2040Stop();
_rp2040UartBuf = '';
_rp2040PinStates = {};
_updateRp2040Board();
_updatePowerSwitch();
appendSerial('\n--- 리셋 ---\n', 'sys');
// 주변 장치(clock/timer/uart 등) 상태가 남아 있으면 멈추므로 Simulator를 새로 만듦.
// (RP2040.reset()은 flash를 지우지만, Simulator 재생성 + UF2 재로드는 flash를 보존)
const { Simulator } = window._rp2040Modules || {};
_rp2040Sim = Simulator ? new Simulator() : null;
const sim = _rp2040Sim;
if (!sim) { appendSerial('[리셋 실패: Simulator 클래스 없음]\n', 'error'); return; }
if (_rp2040BootromU32) {
try { sim.rp2040.loadBootrom(_rp2040BootromU32); } catch(_) {}
}
_loadUF2(_rp2040Uf2Bytes, sim.rp2040);
// VTOR 설정 후 core.reset()으로 SP/PC 로드 (initRP2040Mode와 동일한 방식)
const fv2 = new DataView(sim.rp2040.flash.buffer);
const readEntry = (base) => ({ sp: fv2.getUint32(base, true), pc: fv2.getUint32(base + 4, true), base });
const cands = [readEntry(0x3000), readEntry(0x100)].filter(v => v.sp > 0x20000000 && (v.pc & ~1) >= 0x10000000);
const entry = cands[0] || readEntry(0x3000);
sim.rp2040.core.VTOR = 0x10000000 + entry.base;
sim.rp2040.core.reset();
// rp2040js 로거 억제 (info/debug/warn 등 콘솔 범람 방지)
{
const noop = () => {};
for (const logger of [sim.rp2040.logger, sim.rp2040.core?.logger]) {
if (!logger) continue;
for (const lvl of ['debug', 'info', 'log', 'warn']) {
if (typeof logger[lvl] === 'function') logger[lvl] = noop;
}
}
}
// 예외 패치 재설정
{
const core = sim.rp2040.core;
const _origException = core.exceptionEntry.bind(core);
core.exceptionEntry = function(exceptionNumber) {
if (exceptionNumber === 3) {
_rp2040Running = false;
_origException(exceptionNumber);
appendSerial(`\n[HardFault (LR=${this.LR.toString(16)}, PC=${this.PC.toString(16)}) — 실행 중지]\n`, 'error');
} else {
_origException(exceptionNumber);
}
};
}
// UART / USB CDC 재연결
_rp2040UartDec = new TextDecoder('utf-8', { fatal: false });
sim.rp2040.uart[0].onByte = (value) => {
_rp2040MarkBooted();
const decoded = _rp2040UartDec.decode(new Uint8Array([value]), { stream: true });
if (decoded) appendSerial(decoded);
};
try {
let USBCDC;
// eslint-disable-next-line no-undef
({ USBCDC } = window._rp2040Modules || {});
if (USBCDC) {
const cdc = new USBCDC(sim.rp2040.usbCtrl);
window._rp2040Cdc = cdc;
const dec = new TextDecoder();
cdc.onSerialData = (buf) => {
_rp2040MarkBooted();
appendSerial(dec.decode(buf));
};
cdc.onDeviceConnected = () => console.log('[RP2040] USB CDC 연결됨');
console.log('[RP2040] USB CDC 초기화 완료');
}
} catch(_) {}
// GPIO 리스너 재등록
const _allPicoGpios = [...new Set([..._PICO_LEFT_PINS, ..._PICO_RIGHT_PINS, 25].filter(g => g !== null))];
for (const g of _allPicoGpios) {
if (!sim.rp2040.gpio[g]) continue;
sim.rp2040.gpio[g].addListener((state) => {
_rp2040PinStates[g] = state;
_updateRp2040Board();
});
}
_rp2040Start();
_rp2040ScheduleBootTimeout(8000);
setStatus('running', '실행 중');
return;
}
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 = _IS_RP2040 ? _rp2040Running : 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 (_IS_RP2040 && _rp2040Sim) {
if (_rp2040Running) { _rp2040Stop(); setStatus('stopped', '정지'); }
else { _rp2040Start(); setStatus('running', '실행 중'); }
_updatePowerSwitch();
return;
}
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',
rpipico: 'Raspberry Pi Pico · RP2040', rpipicow: 'Raspberry Pi Pico W · RP2040',
pico: 'Arduino Nano RP2040 Connect',
};
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 if (qemuSession.startsWith('renode_')) {
const savedSketch = localStorage.getItem('stm32_sketch');
if (savedSketch) { localStorage.removeItem('stm32_sketch'); }
initSTM32RenodeMode(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;
}
// ── RP2040 browser mode ──────────────────────────────────────────────────────
const uf2B64 = sessionStorage.getItem('arduino_uf2');
if (uf2B64) {
sessionStorage.removeItem('arduino_uf2');
const rp2040Sketch = sessionStorage.getItem('arduino_sketch');
sessionStorage.removeItem('arduino_sketch');
if (rp2040Sketch) _esp32SketchCode = rp2040Sketch;
const rp2040Fname = sessionStorage.getItem('arduino_filename') || 'sketch.ino';
sessionStorage.removeItem('arduino_filename');
initRP2040Mode(uf2B64, rp2040Fname);
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로 대체)
const _avrBoardKey = (_fqbnBoard || (board || 'uno').split(':').pop()).toLowerCase();
_avrBoardType = _avrBoardKey.includes('mega') ? 'board_mega' : _avrBoardKey.includes('nano') ? 'board_nano' : 'board_uno';
if (!_fqbnBoard) {
const _avrName = _BOARD_NAMES[_avrBoardKey] || _BOARD_NAMES.uno;
const boardTitleEl = document.querySelector('.board-title');
if (boardTitleEl && _avrBoardKey !== 'uno') boardTitleEl.textContent = _avrName;
}
document.getElementById('filename').textContent = fname;
if (hexDirect) { if (sketch) _esp32SketchCode = sketch; 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 (SCH.components.length && !confirm(components.length ? `회로도에 ${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' : _IS_ESP8266 ? 'board_esp8266' : _avrBoardType;
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]);
_schApplyGenerated(boardType, valid);
if (!valid.length) {
const log = document.getElementById('sch-log');
if (log) { log.textContent = '인식된 부품 없음 — 보드만 표시됩니다.'; log.style.color='#e8c860'; }
}
} 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;
}
// ── ADC (analogRead) 슬라이더 UI ─────────────────────────────────────────────
function _adcShowSection() {
const sec = document.getElementById('adc-section');
if (!sec) return;
sec.style.display = '';
const dot = document.getElementById('adc-dot');
if (dot) dot.style.background = '#8b5cf6';
_adcBuildControls();
}
// ESP32 A0-A5 GPIO 핀 번호
const _ESP32_ADC_PINS = [36, 39, 34, 35, 32, 33];
function _adcBuildControls() {
const wrap = document.getElementById('adc-controls');
if (!wrap) return;
wrap.innerHTML = ''; // 보드 전환 시 재빌드
const maxSlider = _IS_ESP32 || _IS_ESP8266 ? 330 : 500; // 3.3V vs 5V
const maxLabel = _IS_ESP32 || _IS_ESP8266 ? '3.3' : '5.0';
const count = _IS_ESP8266 ? 1 : 6;
for (let i = 0; i < count; i++) {
const row = document.createElement('div');
row.className = 'dht-row';
const pinLabel = _IS_ESP32
? `A${i}<small style="color:#6b7280;margin-left:2px">GPIO${_ESP32_ADC_PINS[i]}</small>`
: `A${i}`;
row.innerHTML =
`<span class="dht-param">${pinLabel}</span>` +
`<input type="range" id="adc-ch${i}" min="0" max="${maxSlider}" value="0" oninput="window.updateADCValue(${i})">` +
`<span class="dht-val" id="adc-ch${i}-val">0V</span>`;
wrap.appendChild(row);
}
const hint = document.createElement('div');
hint.style.cssText = 'font-size:10px;color:#6b7280;padding:2px 4px;';
hint.textContent = `0 ~ ${maxLabel}V`;
wrap.appendChild(hint);
}
window.updateADCValue = function(ch) {
const slider = document.getElementById('adc-ch' + ch);
if (!slider) return;
const v = parseInt(slider.value) / 100;
const disp = v.toFixed(2) + 'V';
document.getElementById('adc-ch' + ch + '-val').textContent = disp;
if (_IS_ESP32 && _esp32SessionId) {
fetch('/api/arduino/qemu/' + _esp32SessionId + '/adc', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pin: _ESP32_ADC_PINS[ch], voltage: v }),
}).catch(() => {});
} else if (_IS_ESP8266 && _esp8266SessionId) {
fetch('/api/arduino/esp8266/' + _esp8266SessionId + '/adc', {
method: 'POST', credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ voltage: v }),
}).catch(() => {});
} else if (avrAdc) {
avrAdc.channelValues[ch] = v;
}
};
function _adcSyncSliders() {
if (!avrAdc) return;
_adcShowSection();
for (let i = 0; i < 6; i++) {
const slider = document.getElementById('adc-ch' + i);
const val = document.getElementById('adc-ch' + i + '-val');
if (slider) slider.value = Math.round((avrAdc.channelValues[i] || 0) * 100);
if (val) val.textContent = ((avrAdc.channelValues[i] || 0)).toFixed(2) + 'V';
}
}
// 회로도 pot/ldr → ADC 채널 연결 (A0~A5에 연결된 pot/ldr을 _schLiveMap.analog에 기록)
function _buildAdcSchMap() {
if (!_schLiveMap.analog) _schLiveMap.analog = new Map();
_schLiveMap.analog.clear();
if (!SCH.components.length) return;
const { find, pk } = _schBuildNets();
for (const comp of SCH.components) {
if (comp.type !== 'pot' && comp.type !== 'ldr' && comp.type !== 'ntc') continue;
const terms = schTermPositions(comp);
// pot: terms[0]=wiper, terms[1]=left, terms[2]=right
// ldr/ntc: terms[0], terms[1]
const wiperTerm = terms[0];
if (!wiperTerm) continue;
const net = find(pk(wiperTerm.x, wiperTerm.y));
const gpio = _schGpioFromNet(net, find, pk);
// analogRead는 A0=gpio14, A1=15, ..., A5=19
if (gpio >= 14 && gpio <= 19) {
_schLiveMap.analog.set(comp, gpio - 14); // ch 0-5
}
}
}
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);
}
}
_buildAdcSchMap();
}
// ── 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 if (_IS_RP2040) { _setupTFTFromSchematicRP2040(); _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: _esp32Fqbn }),
});
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, _esp32Fqbn);
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;
}
// ESP8266 mode
if (_IS_ESP8266) {
try {
if (_esp8266SessionId) {
try { await fetch('/api/arduino/esp8266/' + _esp8266SessionId, { method:'DELETE', credentials:'include' }); } catch {}
if (_esp8266EvtSource) { _esp8266EvtSource.close(); _esp8266EvtSource = null; }
_esp8266SessionId = null;
}
const r = await fetch('/api/arduino/esp8266/start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ code, fqbn: _esp8266Fqbn }),
});
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;
}
statusEl.textContent = '✅ ESP8266 실행 중…';
statusEl.style.color = 'var(--green)';
_esp8266SketchCode = code;
initEsp8266Mode(data.id, document.getElementById('code-editor-filename').textContent, _esp8266Fqbn);
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;
}
// AVR (UNO/Nano/Mega) mode
try {
const _boardStr = _avrBoardType === 'board_nano' ? 'nano' : _avrBoardType === 'board_mega' ? 'mega' : 'uno';
const r = await fetch('/api/arduino/compile', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ code, board: _boardStr }),
});
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();
}
});
// ── Mobile ───────────────────────────────────────────────────────────────────
function mobCloseBoard(){
document.querySelector('.board-panel').classList.remove('mob-open');
document.getElementById('mob-backdrop').classList.remove('show');
document.getElementById('mobtab-board').classList.remove('active');
document.getElementById('mobtab-serial').classList.add('active');
}
function mobToggleBoard(){
if(window.innerWidth>1024)return;
const open=document.querySelector('.board-panel').classList.contains('mob-open');
if(open){mobCloseBoard();}
else{
document.querySelector('.board-panel').classList.add('mob-open');
document.getElementById('mob-backdrop').classList.add('show');
document.getElementById('mobtab-board').classList.add('active');
document.getElementById('mobtab-serial').classList.remove('active');
}
}
// ── 캔버스 확대 오버레이 ──────────────────────────────────────────────────────
(function() {
const overlay = document.createElement('div');
overlay.id = 'canvas-zoom-overlay';
const zoomCanvas = document.createElement('canvas');
zoomCanvas.id = 'canvas-zoom-img';
overlay.appendChild(zoomCanvas);
const hint = document.createElement('div');
hint.id = 'canvas-zoom-hint';
hint.textContent = '클릭 또는 ESC로 닫기';
overlay.appendChild(hint);
document.body.appendChild(overlay);
let _src = null, _rafId = null;
function _loop() {
if (!_src || !overlay.classList.contains('open')) return;
const ctx = zoomCanvas.getContext('2d');
ctx.imageSmoothingEnabled = false;
ctx.drawImage(_src, 0, 0, zoomCanvas.width, zoomCanvas.height);
_rafId = requestAnimationFrame(_loop);
}
function openZoom(canvas) {
_src = canvas;
// 화면의 90% 이내에서 최대 정수 배율
const scale = Math.min(1.5,
window.innerWidth * 0.9 / canvas.width,
window.innerHeight * 0.9 / canvas.height
);
zoomCanvas.width = canvas.width * scale;
zoomCanvas.height = canvas.height * scale;
overlay.classList.add('open');
cancelAnimationFrame(_rafId);
_loop();
}
function closeZoom() {
overlay.classList.remove('open');
cancelAnimationFrame(_rafId);
_src = null;
}
overlay.addEventListener('click', closeZoom);
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeZoom(); });
['tft-canvas', 'oled-canvas'].forEach(id => {
const c = document.getElementById(id);
if (c) c.addEventListener('click', e => { e.stopPropagation(); openZoom(c); });
});
window._canvasZoomOpen = openZoom;
})();
</script>
</body>
</html>