- 스킬앱 HTML 내장화: accountant/lawyer/investor/weather/music/mind/dental-agent 각 앱이 SKILL_CONTENT를 HTML에 직접 내장 → .smallclaw/skills/ SKILL.md 파일 제거 - 새 게이트웨이 라우트: routes-dental, routes-music, routes-mcp, routes-voice - skillContext 서버 지원: /api/chat에서 skill 컨텍스트를 callerCtx로 주입 - 세션 삭제 API: DELETE /api/admin/sessions/:username/:id - Telegram defaultUsername: 어드민 계정 자동 추론 - Ollama /api/ollama/models에 vision 지원 여부 플래그 추가 - DB 워크플로 개선: workflow_images.py 대규모 리팩터링 - 아두이노 에뮬레이터 추가 개선 (arduino-emulator.html +400줄) - 우즈베크어 학습 콘텐츠 및 MIDI 파일 추가 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
7557 lines
360 KiB
HTML
7557 lines
360 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="ko">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>Arduino Emulator</title>
|
||
<style>
|
||
@import url('https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=Noto+Sans+KR:wght@400;500;600&display=swap');
|
||
|
||
:root {
|
||
--bg:#f0f4f8; --panel:#ffffff; --board-bg:#f7fafc;
|
||
--accent:#2563eb; --green:#16a34a; --red:#dc2626; --yellow:#d97706; --muted:#64748b; --orange:#ea580c;
|
||
--bdr:#cbd5e1; --bdr-in:#e2e8f0; --bdr2:#94a3b8;
|
||
--ctrl:#e8eef5;
|
||
--board-tint:none;
|
||
--text:#1e293b; --text2:#475569;
|
||
}
|
||
[data-theme="cosmic"] {
|
||
--bg:#10101e; --panel:#1e1e38; --board-bg:#181830;
|
||
--accent:#5b9cf8; --green:#34d058; --red:#f87171; --yellow:#fcd34d; --muted:#9098b4; --orange:#fb923c;
|
||
--bdr:#3a3a60; --bdr-in:#2c2c52; --bdr2:#4a4a72;
|
||
--ctrl:#252548;
|
||
--board-tint:none;
|
||
--text:#e5e7eb; --text2:#9ca3af;
|
||
}
|
||
[data-theme="matrix"] {
|
||
--bg:#061006; --panel:#0e200e; --board-bg:#0b1a0b;
|
||
--accent:#4ade80; --green:#86efac; --red:#f87171; --yellow:#fde68a; --muted:#6ab06a; --orange:#86efac;
|
||
--bdr:#246024; --bdr-in:#184818; --bdr2:#2e762e;
|
||
--ctrl:#102810;
|
||
--board-tint:sepia(1) hue-rotate(85deg) saturate(2.5);
|
||
--text:#d1fae5; --text2:#6ab06a;
|
||
}
|
||
[data-theme="amber"] {
|
||
--bg:#fffbeb; --panel:#fef3c7; --board-bg:#fefce8;
|
||
--accent:#b45309; --green:#15803d; --red:#dc2626; --yellow:#d97706; --muted:#92400e; --orange:#c2410c;
|
||
--bdr:#d97706; --bdr-in:#fde68a; --bdr2:#b45309;
|
||
--ctrl:#fde68a;
|
||
--board-tint:none;
|
||
--text:#451a03; --text2:#92400e;
|
||
}
|
||
[data-theme="ice"] {
|
||
--bg:#f0f9ff; --panel:#e0f2fe; --board-bg:#f0f9ff;
|
||
--accent:#0369a1; --green:#0891b2; --red:#dc2626; --yellow:#d97706; --muted:#0284c7; --orange:#0284c7;
|
||
--bdr:#7dd3fc; --bdr-in:#bae6fd; --bdr2:#38bdf8;
|
||
--ctrl:#e0f2fe;
|
||
--board-tint:none;
|
||
--text:#0c4a6e; --text2:#0369a1;
|
||
}
|
||
* { margin:0; padding:0; box-sizing:border-box; }
|
||
body { background:var(--bg); color:var(--text); font-family:'IBM Plex Mono','Noto Sans KR',monospace; height:100vh; display:flex; flex-direction:column; overflow:hidden; }
|
||
|
||
/* Header */
|
||
.header { display:flex; align-items:center; gap:12px; padding:10px 16px; background:var(--panel); border-bottom:1px solid var(--bdr); flex-shrink:0; }
|
||
.header h1 { font-size:14px; font-weight:600; color:var(--text); }
|
||
.header .file { font-size:12px; color:var(--accent); }
|
||
.status { font-size:11px; padding:2px 8px; border-radius:4px; font-weight:600; }
|
||
.status.loading { background:#1e3a5f; color:#60a5fa; }
|
||
.status.ready { background:#1a3a2a; color:var(--green); }
|
||
.status.running { background:#3a3a1a; color:var(--yellow); animation: pulse 1.5s infinite; }
|
||
.status.error { background:#3a1a1a; color:var(--red); }
|
||
.status.stopped { background:#2a2a2a; color:var(--muted); }
|
||
@keyframes pulse { 0%,100% { opacity:1 } 50% { opacity:.6 } }
|
||
.toolbar { display:flex; gap:8px; margin-left:auto; align-items:center; }
|
||
.toolbar button { background:var(--ctrl); border:1px solid var(--bdr2); color:var(--text); border-radius:6px; padding:6px 14px; font-size:12px; cursor:pointer; font-family:inherit; transition:all .15s; }
|
||
.toolbar button:hover { filter:brightness(0.92); }
|
||
.toolbar button:disabled { opacity:.4; cursor:not-allowed; }
|
||
/* 보드 SVG 테마 틴트 */
|
||
.board-svg svg { filter:var(--board-tint); transition:filter .4s; }
|
||
/* SVG 보드 인터랙션 */
|
||
#power-switch { cursor:pointer; }
|
||
#power-switch:hover #pw-bg { stroke:#5a5a9a; }
|
||
#reset-btn { cursor:pointer; }
|
||
#reset-btn:hover circle { stroke:#8888cc; fill:#3a3a7a; }
|
||
/* 테마 닷 */
|
||
.theme-picker { display:flex; gap:4px; align-items:center; margin-left:4px; }
|
||
.tdot { width:13px; height:13px; border-radius:50%; background:var(--c,#888); border:2px solid transparent; cursor:pointer; padding:0; flex-shrink:0; transition:transform .12s, border-color .12s; }
|
||
.tdot:hover { transform:scale(1.3); }
|
||
.tdot.active { border-color:#fff; }
|
||
.speed-label { font-size:11px; color:var(--muted); }
|
||
select { background:var(--ctrl); border:1px solid var(--bdr2); color:var(--text); border-radius:6px; padding:5px 10px; font-size:12px; font-family:inherit; cursor:pointer; }
|
||
|
||
/* Main layout */
|
||
.main { flex:1; display:flex; gap:0; overflow:hidden; }
|
||
|
||
/* Editor open: board | serial | drag | code — serial & code share space */
|
||
.main.editor-open {
|
||
display: flex;
|
||
}
|
||
.main.editor-open .board-panel { flex:none; }
|
||
.main.editor-open .serial-panel { flex:1; min-width:120px; }
|
||
.main.editor-open #code-drag { flex-shrink:0; display:block; }
|
||
.main.editor-open #code-editor-panel { flex:1; min-width:120px; }
|
||
/* When BOTH editor and sch-panel are open: three equal columns */
|
||
.main.editor-open .sch-panel.open { flex:1; min-width:120px; }
|
||
|
||
/* Board panel — resizable */
|
||
.board-panel { width:220px; min-width:140px; max-width:480px; flex-shrink:0; background:var(--board-bg); border-right:1px solid var(--bdr); display:flex; flex-direction:column; overflow-y:auto; overflow-x:hidden; resize:horizontal; }
|
||
.board-title { padding:10px 14px; font-size:11px; color:var(--muted); font-weight:600; letter-spacing:.08em; border-bottom:1px solid var(--bdr-in); flex-shrink:0; }
|
||
.board-svg { padding:10px 14px; flex-shrink:0; }
|
||
.board-svg svg { width:100%; }
|
||
|
||
/* LCD display */
|
||
.lcd-section { padding:10px 14px; flex-shrink:0; border-bottom:1px solid var(--bdr-in); }
|
||
.lcd-section-title { font-size:10px; color:var(--muted); margin-bottom:8px; font-weight:600; letter-spacing:.08em; display:flex; align-items:center; gap:6px; }
|
||
.lcd-wrap { background:#1a2a0a; border:1px solid #2a4a1a; border-radius:5px; padding:8px 10px; display:inline-block; }
|
||
.lcd-row { display:flex; gap:2px; margin-bottom:2px; }
|
||
.lcd-row:last-child { margin-bottom:0; }
|
||
.lcd-cell { width:9px; height:14px; background:#0d1a05; border-radius:1px; display:flex; align-items:center; justify-content:center; font-size:9px; color:#6aff20; line-height:1; font-family:'IBM Plex Mono',monospace; }
|
||
.lcd-cell.lit { background:#1a3a08; }
|
||
.lcd-off .lcd-cell { color:#0d1a05; background:#0d1a05; }
|
||
.lcd-off .lcd-wrap { filter:brightness(.4); }
|
||
|
||
/* SSD1306 OLED */
|
||
.oled-section { padding:10px 14px; flex-shrink:0; border-bottom:1px solid var(--bdr-in); }
|
||
.oled-section-title { font-size:10px; color:var(--muted); margin-bottom:6px; font-weight:600; letter-spacing:.08em; display:flex; align-items:center; gap:6px; }
|
||
.oled-dot { width:6px; height:6px; border-radius:50%; background:#333; flex-shrink:0; }
|
||
.oled-dot.on { background:#60a5fa; box-shadow:0 0 4px #60a5fa; }
|
||
.oled-wrap { background:#000; border:2px solid #222; border-radius:4px; padding:4px; position:relative; }
|
||
#oled-canvas { display:block; max-width:100%; height:auto; image-rendering:pixelated; }
|
||
.oled-placeholder { position:absolute; inset:4px; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:4px; pointer-events:none; }
|
||
.oled-placeholder span { font-size:10px; color:#333; letter-spacing:.05em; }
|
||
.oled-placeholder .oled-ph-grid { width:100%; height:2px; background:repeating-linear-gradient(90deg,#1a1a1a 0,#1a1a1a 4px,transparent 4px,transparent 8px); }
|
||
|
||
/* TFT LCD display */
|
||
.tft-section { padding:10px 14px; flex-shrink:0; border-bottom:1px solid var(--bdr-in); }
|
||
.tft-section-title { font-size:10px; color:var(--muted); margin-bottom:6px; font-weight:600; letter-spacing:.08em; display:flex; align-items:center; gap:6px; }
|
||
.tft-dot { width:6px; height:6px; border-radius:50%; background:#333; flex-shrink:0; }
|
||
.tft-dot.on { background:#22c55e; box-shadow:0 0 4px #22c55e; }
|
||
.tft-wrap { background:#000; border:2px solid #222; border-radius:4px; padding:4px; overflow:hidden; }
|
||
#tft-canvas { display:block; width:100%; height:auto; image-rendering:pixelated; }
|
||
|
||
/* DHT sensor controls */
|
||
.dht-section { padding:10px 14px; flex-shrink:0; border-bottom:1px solid var(--bdr-in); }
|
||
.dht-section-title { font-size:10px; color:var(--muted); margin-bottom:6px; font-weight:600; letter-spacing:.08em; display:flex; align-items:center; gap:6px; }
|
||
.dht-dot { width:6px; height:6px; border-radius:50%; background:#333; flex-shrink:0; }
|
||
.dht-dot.on { background:#0891b2; box-shadow:0 0 4px #0891b2; }
|
||
.dht-controls { display:flex; flex-direction:column; gap:4px; }
|
||
.dht-row { display:flex; align-items:center; gap:6px; font-size:11px; }
|
||
.dht-param { width:28px; color:var(--muted); flex-shrink:0; }
|
||
.dht-row input[type=range] { flex:1; height:4px; accent-color:var(--accent); }
|
||
.dht-val { width:42px; text-align:right; color:var(--accent); font-weight:600; font-size:10px; }
|
||
|
||
/* Servo display */
|
||
.servo-row { display:flex; align-items:center; gap:8px; font-size:11px; margin-bottom:4px; }
|
||
.servo-pin-label { width:36px; color:var(--muted); flex-shrink:0; }
|
||
.servo-arc-canvas { flex-shrink:0; }
|
||
.servo-angle-val { width:38px; text-align:right; color:#dc2626; font-weight:600; font-size:10px; }
|
||
|
||
/* Sensor/actuator panels (IMU, BME280, DS18B20, Relay, Motor) */
|
||
.sensor-section { padding:10px 12px; border-bottom:1px solid var(--bdr-in); display:none; }
|
||
.sensor-section-title { display:flex; align-items:center; gap:6px; font-size:11px; font-weight:600; color:#e5e7eb; margin-bottom:8px; }
|
||
.sensor-dot { width:8px; height:8px; border-radius:50%; }
|
||
.sensor-row { display:flex; align-items:center; gap:8px; font-size:11px; margin-bottom:5px; }
|
||
.sensor-param { width:48px; color:var(--muted); flex-shrink:0; }
|
||
.sensor-row input[type=range] { flex:1; height:4px; accent-color:var(--accent); }
|
||
.sensor-val { width:48px; text-align:right; color:var(--accent); font-weight:600; font-size:10px; }
|
||
.sensor-info { font-size:10px; color:var(--muted); margin-top:4px; }
|
||
.relay-grid { display:grid; grid-template-columns:repeat(2, 1fr); gap:6px; }
|
||
.relay-row { display:flex; align-items:center; gap:6px; font-size:11px; }
|
||
|
||
/* WiFi status panel */
|
||
.wifi-section { padding:10px 12px; border-bottom:1px solid var(--bdr-in); display:none; }
|
||
.wifi-section-title { display:flex; align-items:center; gap:6px; font-size:11px; font-weight:600; color:#e5e7eb; margin-bottom:8px; }
|
||
.wifi-dot { width:8px; height:8px; border-radius:50%; background:#333; }
|
||
.wifi-dot.on { background:#22c55e; box-shadow:0 0 4px #22c55e; }
|
||
.wifi-row { display:flex; align-items:center; gap:6px; font-size:11px; margin-bottom:4px; }
|
||
.wifi-label { width:46px; color:var(--muted); flex-shrink:0; }
|
||
.wifi-val { flex:1; text-align:right; color:var(--text); }
|
||
.wifi-btn { flex:1; padding:4px 6px; border:1px solid var(--bdr2); background:var(--panel); color:var(--text); border-radius:4px; cursor:pointer; font-size:10px; }
|
||
.wifi-btn.on { background:#2563eb; border-color:#2563eb; color:#fff; }
|
||
.wifi-input { flex:1; background:var(--panel); border:1px solid var(--bdr2); color:var(--text); border-radius:4px; padding:4px 6px; font-size:10px; font-family:inherit; }
|
||
.relay-btn { flex:1; padding:4px 6px; border:1px solid var(--bdr2); background:var(--panel); color:var(--text); border-radius:4px; cursor:pointer; font-size:10px; }
|
||
.relay-btn.on { background:#dc2626; border-color:#dc2626; color:#fff; }
|
||
.motor-channel { border:1px solid var(--bdr2); border-radius:6px; padding:6px; margin-bottom:6px; }
|
||
.motor-label { font-size:10px; color:var(--muted); margin-bottom:4px; }
|
||
.motor-dir-row { display:flex; gap:4px; margin-top:4px; }
|
||
.motor-dir-btn { flex:1; padding:3px 4px; border:1px solid var(--bdr2); background:var(--panel); color:var(--text); border-radius:4px; cursor:pointer; font-size:9px; }
|
||
.motor-dir-btn.active { background:#2563eb; border-color:#2563eb; color:#fff; }
|
||
|
||
/* Pin grid (shared by popup) */
|
||
.pins-header { font-size:10px; color:var(--muted); margin-bottom:6px; font-weight:600; letter-spacing:.08em; }
|
||
.pin-row { display:flex; align-items:center; gap:8px; padding:2px 0; font-size:11px; }
|
||
.pin-label { width:32px; color:var(--muted); flex-shrink:0; }
|
||
.pin-led { width:10px; height:10px; border-radius:50%; background:#2a2a3a; border:1px solid #3a3a5a; flex-shrink:0; transition:background .08s, box-shadow .08s; }
|
||
.pin-led.on { background:var(--green); box-shadow:0 0 5px var(--green); border-color:var(--green); }
|
||
.pin-led.pwm { background:var(--yellow); box-shadow:0 0 5px var(--yellow); border-color:var(--yellow); }
|
||
.pin-led.in { background:var(--accent); box-shadow:0 0 5px var(--accent); border-color:var(--accent); }
|
||
.pin-val { color:#9ca3af; font-size:10px; }
|
||
.pin-sep { height:1px; background:#1e1e36; margin:4px 0; }
|
||
|
||
/* Pin popup floating window */
|
||
.pin-popup { position:fixed; top:58px; right:18px; width:210px; max-height:72vh; background:var(--panel); border:1px solid var(--bdr2); border-radius:8px; display:none; flex-direction:column; z-index:50; box-shadow:0 6px 24px rgba(0,0,0,.5); }
|
||
.pin-popup.open { display:flex; }
|
||
.pin-popup-hdr { padding:7px 12px; font-size:11px; font-weight:600; color:#e5e7eb; background:var(--ctrl); border-bottom:1px solid var(--bdr); cursor:move; display:flex; align-items:center; user-select:none; flex-shrink:0; border-radius:8px 8px 0 0; }
|
||
.pin-popup-hdr button { margin-left:auto; background:none; border:none; color:var(--muted); cursor:pointer; font-size:14px; line-height:1; padding:0 2px; }
|
||
.pin-popup-hdr button:hover { color:#e5e7eb; }
|
||
.pin-popup-body { padding:10px 12px; overflow-y:auto; flex:1; }
|
||
|
||
/* Serial monitor */
|
||
.serial-panel { flex:1; display:flex; flex-direction:column; }
|
||
.serial-header { padding:10px 16px; font-size:11px; color:var(--muted); font-weight:600; letter-spacing:.08em; border-bottom:1px solid var(--bdr-in); display:flex; align-items:center; gap:12px; flex-shrink:0; }
|
||
.serial-header .baud { font-size:11px; color:#60a5fa; }
|
||
.serial-clear { margin-left:auto; background:none; border:none; color:var(--muted); cursor:pointer; font-size:11px; font-family:inherit; }
|
||
.serial-clear:hover { color:#e5e7eb; }
|
||
.serial-output { flex:1; overflow-y:auto; padding:12px 16px; font-size:13px; line-height:1.7; white-space:pre-wrap; word-break:break-all; }
|
||
.serial-output .line { color:var(--text); }
|
||
.serial-output .sys { color:var(--muted); font-style:italic; }
|
||
.serial-output .error { color:var(--red); }
|
||
.serial-input-row { display:flex; gap:8px; padding:10px 16px; border-top:1px solid var(--bdr-in); flex-shrink:0; }
|
||
.serial-input { flex:1; background:var(--panel); border:1px solid var(--bdr2); color:var(--text); border-radius:6px; padding:7px 12px; font-family:inherit; font-size:13px; }
|
||
.serial-input:focus { outline:none; border-color:var(--accent); }
|
||
.serial-send { background:#1a3a2a; border:1px solid #2d5a3a; color:var(--green); border-radius:6px; padding:7px 14px; font-family:inherit; font-size:12px; cursor:pointer; }
|
||
.serial-send:hover { background:#224a3a; }
|
||
|
||
/* Footer */
|
||
.footer { padding:7px 16px; border-top:1px solid var(--bdr); font-size:11px; color:var(--muted); background:var(--panel); flex-shrink:0; display:flex; justify-content:space-between; }
|
||
.footer .cycles { color:#60a5fa; }
|
||
|
||
/* Compile overlay */
|
||
.overlay { position:fixed; inset:0; background:rgba(0,0,0,.7); display:flex; align-items:center; justify-content:center; z-index:100; }
|
||
.overlay-box { background:var(--panel); border:1px solid var(--bdr2); border-radius:10px; padding:28px 36px; text-align:center; min-width:300px; }
|
||
.overlay-box h2 { font-size:15px; margin-bottom:12px; }
|
||
.overlay-box p { font-size:12px; color:var(--muted); margin-bottom:18px; white-space:pre-wrap; text-align:left; max-height:200px; overflow-y:auto; }
|
||
.overlay-box button { background:#1a3a2a; border:1px solid #2d5a3a; color:var(--green); border-radius:6px; padding:8px 20px; font-family:inherit; font-size:13px; cursor:pointer; }
|
||
.spinner { width:28px; height:28px; border:3px solid #3a3a5a; border-top-color:var(--accent); border-radius:50%; animation:spin .8s linear infinite; margin:0 auto 16px; }
|
||
@keyframes spin { to { transform:rotate(360deg) } }
|
||
|
||
/* ── Inline schematic panel ─────────────────────────────────────────────────── */
|
||
.bb-popup { display:none; } /* legacy stub, panel moved inline */
|
||
.sch-panel { flex:1; min-width:200px; display:none; flex-direction:column; border-left:1px solid var(--bdr); background:var(--panel); overflow:hidden; }
|
||
.sch-panel.open { display:flex; }
|
||
.sch-drag { width:5px; flex-shrink:0; cursor:col-resize; background:var(--bdr); transition:background .15s; }
|
||
.sch-drag:hover, .sch-drag.active { background:var(--accent); }
|
||
.bb-hdr { padding:6px 10px; font-size:11px; font-weight:600; color:var(--text); background:var(--ctrl); border-bottom:1px solid var(--bdr); display:flex; align-items:center; gap:6px; user-select:none; flex-shrink:0; }
|
||
.bb-hdr .bb-close { display:none; }
|
||
.sch-file-btn { background:var(--ctrl); border:1px solid var(--bdr2); color:var(--text); border-radius:4px; padding:3px 8px; font-size:10px; cursor:pointer; font-family:inherit; }
|
||
.sch-file-btn:hover { filter:brightness(.88); }
|
||
.bb-tabs-row { display:flex; gap:2px; margin-left:8px; }
|
||
.bb-tab { background:none; border:none; color:var(--muted); cursor:pointer; font-size:11px; padding:3px 10px; border-radius:4px; font-family:inherit; }
|
||
.bb-tab:hover { color:var(--text); }
|
||
.bb-tab.active { background:var(--bdr); color:var(--text); }
|
||
.bb-body { flex:1; display:flex; overflow:hidden; }
|
||
.bb-content { display:none; flex:1; flex-direction:column; overflow:hidden; }
|
||
.bb-content.active { display:flex; }
|
||
/* Schematic tab */
|
||
.sch-toolbar { display:flex; gap:4px; padding:7px 10px; flex-shrink:0; border-bottom:1px solid var(--bdr-in); flex-wrap:wrap; align-items:center; }
|
||
.sch-tool { background:var(--ctrl); border:1px solid var(--bdr2); color:var(--text); border-radius:5px; padding:4px 9px; font-size:11px; cursor:pointer; font-family:inherit; transition:all .12s; }
|
||
.sch-tool:hover { filter:brightness(0.92); box-shadow:0 1px 4px rgba(0,0,0,.12); }
|
||
.sch-tool.active { background:var(--accent); color:#fff; border-color:var(--accent); }
|
||
.sch-canvas-wrap { flex:1; overflow:hidden; background:#fff; position:relative; }
|
||
#sch-canvas { cursor:crosshair; display:block; }
|
||
.sch-hint { font-size:10px; color:var(--muted); padding:2px 6px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:260px; }
|
||
/* SPICE tab */
|
||
.spice-ctrl { padding:8px 10px; display:flex; gap:8px; align-items:center; flex-shrink:0; border-bottom:1px solid var(--bdr-in); }
|
||
.spice-ctrl button { background:var(--ctrl); border:1px solid var(--bdr2); color:var(--text); border-radius:5px; padding:4px 10px; font-size:11px; cursor:pointer; font-family:inherit; }
|
||
.spice-ctrl button:hover { filter:brightness(0.92); }
|
||
.spice-split { flex:1; display:flex; overflow:hidden; }
|
||
.spice-netlist { flex:1; background:#f8fafc; border:none; color:#1e40af; font-family:'IBM Plex Mono',monospace; font-size:11px; padding:10px; resize:none; outline:none; border-right:1px solid var(--bdr-in); }
|
||
.spice-results { width:270px; flex-shrink:0; overflow-y:auto; padding:10px; font-size:11px; }
|
||
.spice-node { display:flex; justify-content:space-between; padding:3px 0; border-bottom:1px solid var(--bdr-in); }
|
||
.spice-node-name { color:var(--muted); font-size:10px; }
|
||
.spice-node-val { color:var(--accent); font-weight:600; }
|
||
.spice-section { font-size:10px; font-weight:600; color:var(--accent); margin:10px 0 4px; letter-spacing:.05em; }
|
||
/* Parts list tab */
|
||
.parts-list { flex:1; overflow-y:auto; padding:8px; display:flex; flex-direction:column; gap:4px; }
|
||
.parts-cat { font-size:10px; font-weight:700; color:var(--accent); letter-spacing:.08em; padding:8px 4px 4px; border-bottom:1px solid var(--bdr-in); margin-bottom:2px; }
|
||
.parts-item { display:flex; align-items:center; gap:8px; padding:5px 8px; border-radius:5px; cursor:pointer; border:1px solid transparent; transition:all .12s; }
|
||
.parts-item:hover { background:var(--ctrl); border-color:var(--bdr2); }
|
||
.parts-item.selected { background:var(--accent); color:#fff; border-color:var(--accent); }
|
||
.parts-item.selected .parts-desc { color:rgba(255,255,255,.8); }
|
||
.parts-icon { font-size:18px; width:28px; text-align:center; flex-shrink:0; }
|
||
.parts-info { flex:1; min-width:0; }
|
||
.parts-name { font-size:11px; font-weight:600; color:var(--text); }
|
||
.parts-item.selected .parts-name { color:#fff; }
|
||
.parts-desc { font-size:10px; color:var(--muted); margin-top:1px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||
.parts-shortcut { font-size:9px; background:var(--bdr-in); color:var(--muted); border-radius:3px; padding:1px 4px; flex-shrink:0; }
|
||
|
||
/* Code editor panel */
|
||
.code-editor-panel { display:flex; flex-direction:column; background:var(--panel); border-left:1px solid var(--bdr2); flex:1; min-width:200px; }
|
||
.code-editor-header { display:flex; align-items:center; gap:8px; padding:6px 12px; background:var(--ctrl); border-bottom:1px solid var(--bdr); font-size:12px; font-weight:600; color:var(--text2); flex-shrink:0; }
|
||
.code-editor-filename { font-family:'IBM Plex Mono',monospace; font-size:11px; color:var(--accent); flex:1; min-width:0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||
.code-editor-close { background:none; border:none; color:var(--muted); font-size:16px; cursor:pointer; padding:2px 6px; border-radius:4px; }
|
||
.code-editor-close:hover { background:var(--bdr-in); color:var(--text); }
|
||
.code-editor-textarea { flex:1; width:100%; resize:none; font-family:'IBM Plex Mono',monospace; font-size:12px; line-height:1.5; padding:10px 12px; border:none; outline:none; background:var(--bg); color:var(--text); tab-size:4; -moz-tab-size:4; }
|
||
.code-editor-textarea:focus { background:color-mix(in srgb, var(--bg) 97%, var(--accent)); }
|
||
.code-editor-status { padding:3px 12px; font-size:10px; color:var(--muted); background:var(--ctrl); border-top:1px solid var(--bdr); flex-shrink:0; }
|
||
#btn-code.active { background:var(--accent); color:#fff; border-color:var(--accent); }
|
||
#btn-compile { background:#1a3a2a; color:var(--green); border:1px solid #2d5a3a; }
|
||
#btn-compile:hover { background:#245a3a; }
|
||
#btn-compile:disabled { opacity:.5; cursor:wait; }
|
||
#btn-stop { background:#3a1a1a; color:#ef4444; border:1px solid #5a2d2d; }
|
||
#btn-stop:hover:not(:disabled) { background:#4a2020; }
|
||
#btn-reset { background:#1a2a3a; color:#67e8f9; border:1px solid #1a4a6a; }
|
||
#btn-reset:hover:not(:disabled) { background:#1f3a4a; }
|
||
|
||
|
||
/* CodeMirror 스케치 에디터 */
|
||
.CodeMirror { height:100% !important; font-family:'IBM Plex Mono',monospace !important; font-size:12px !important; line-height:1.5 !important; border:none !important; background:var(--bg) !important; color:var(--text) !important; }
|
||
.CodeMirror-scroll { height:100% !important; }
|
||
.CodeMirror-gutters { background:var(--board-bg) !important; border-right:1px solid var(--bdr-in) !important; }
|
||
.CodeMirror-linenumber { color:var(--muted) !important; padding:0 6px !important; }
|
||
.CodeMirror-cursor { border-left-color:var(--accent) !important; }
|
||
.CodeMirror-selected { background:rgba(37,99,235,.15) !important; }
|
||
.cm-s-default .cm-keyword { color:var(--accent) !important; }
|
||
.cm-s-default .cm-string { color:var(--green) !important; }
|
||
.cm-s-default .cm-number { color:var(--orange) !important; }
|
||
.cm-s-default .cm-comment { color:var(--muted) !important; font-style:italic !important; }
|
||
.cm-s-default .cm-def { color:var(--yellow) !important; }
|
||
/* 속도 슬라이더 */
|
||
#speed-range { width:70px; height:4px; accent-color:var(--accent); vertical-align:middle; cursor:pointer; }
|
||
</style>
|
||
<link rel="stylesheet" href="/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 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="컴파일 & 실행 (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="브레드보드 & 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 === ' ' ? ' ' : 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();
|
||
}
|
||
|
||
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 {
|
||
// 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,'"')}"
|
||
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();
|
||
}
|
||
});
|
||
</script>
|
||
</body>
|
||
</html>
|