diff --git a/src/gateway/routes-arduino.ts b/src/gateway/routes-arduino.ts index 4907083..c52cf30 100644 --- a/src/gateway/routes-arduino.ts +++ b/src/gateway/routes-arduino.ts @@ -1953,6 +1953,24 @@ app.post('/api/arduino/renode/start', async (req, res) => { // SystemClock_Config() 제거 — HAL이 비트밴드(0x42xxxxxx)로 RCC에 접근하는데 // Renode가 비트밴드를 처리 못해서 무한 루프 → 호출 제거 (Renode는 클럭 설정 불필요) patchedCode = patchedCode.replace(/SystemClock_Config\s*\(\s*\)\s*;/g, '/* SystemClock_Config skipped for Renode */;'); + // GPIO 텔레메트리 — digitalWrite() 호출 시 STX "P:name:val" ETX 형식으로 핀 상태 전송 + // Serial.write()로 STX(0x02)/ETX(0x03) 바이트를 직접 전송 (print("\x02")보다 명확) + // _RN_PNAME: digitalPinToPinName()으로 런타임에 핀 이름 복원 (PA0=192 같은 숫자 매크로 대응) + const gpioHeader = [ + 'static char _rn_pbuf[8];', + 'static const char* _RN_PNAME(uint32_t p) {', + ' PinName pn = digitalPinToPinName(p);', + ' if (pn == NC) return "?";', + ' int port = ((uint32_t)pn >> 4) & 0xF;', + ' int pin = (uint32_t)pn & 0xF;', + ' _rn_pbuf[0]=\'P\'; _rn_pbuf[1]=\'A\'+port;', + ' if (pin>=10){_rn_pbuf[2]=\'1\';_rn_pbuf[3]=\'0\'+(pin-10);_rn_pbuf[4]=0;}', + ' else {_rn_pbuf[2]=\'0\'+pin;_rn_pbuf[3]=0;}', + ' return _rn_pbuf;', + '}', + '#define _RN_DW(p,v) do{uint32_t _rv=(uint32_t)(v);digitalWrite(p,_rv);Serial.write(2);Serial.print("P:");Serial.print(_RN_PNAME((uint32_t)(p)));Serial.print(":");Serial.print(_rv);Serial.write(3);}while(0)', + ].join('\n') + '\n'; + patchedCode = gpioHeader + patchedCode.replace(/\bdigitalWrite\s*\(/g, '_RN_DW('); require('fs').writeFileSync(sketchDir + '/sketch.ino', patchedCode); const execQ = (cmd: string, ms = 60000): Promise<{ stdout: string; stderr: string }> => diff --git a/src/gateway/server-v2.ts b/src/gateway/server-v2.ts index 63f4e91..eaede09 100644 --- a/src/gateway/server-v2.ts +++ b/src/gateway/server-v2.ts @@ -10102,7 +10102,7 @@ app.get('/api/status', async (_req, res) => { provider, currentModel: activeModel, workspace: (config as any).workspace?.path || '', - search: rawCfg.search?.google_api_key ? 'google' : (rawCfg.search?.tavily_api_key ? 'tavily' : 'none'), + search: rawCfg.search?.google_api_key ? 'google' : (rawCfg.search?.tavily_api_key ? 'tavily' : (rawCfg.search?.searxng_url ? 'searxng' : 'none')), orchestration: orchCfg ? { enabled: orchCfg.enabled, secondary: orchCfg.secondary, diff --git a/web-ui/app.js b/web-ui/app.js index 8ef399a..ff20be1 100644 --- a/web-ui/app.js +++ b/web-ui/app.js @@ -8925,8 +8925,11 @@ function mobCloseMenu() { const RIGHT_COLLAPSE_KEY = 'rightPanelCollapsed'; function applyRightPanelCollapsed(collapsed) { document.body.classList.toggle('right-collapsed', !!collapsed); - // Only override on desktop — narrow viewports use the mobile single-column layout. - const isDesktop = window.matchMedia('(min-width: 1025px)').matches; + // Only override on the true 3-column desktop layout. At ≤1200px the tablet/mobile + // media queries own the grid (sidebar hidden), so inline 3-column styles here would + // fight them — e.g. restoring a wide saved width and letting the context panel eat + // most of the screen. Clear inline styles below that width so CSS takes over. + const isDesktop = window.matchMedia('(min-width: 1201px)').matches; if (!isDesktop) { document.body.style.gridTemplateColumns = ''; return; } if (collapsed) { document.body.style.gridTemplateColumns = '280px 1fr 36px'; @@ -8949,6 +8952,13 @@ function toggleRightPanel() { applyRightPanelCollapsed(collapsed); })(); +// Re-evaluate on viewport change (e.g. tablet rotation crossing the 1200px boundary) +// so inline desktop grid styles are cleared/applied to match the active CSS layout. +window.addEventListener('resize', () => { + const stored = localStorage.getItem(RIGHT_COLLAPSE_KEY); + applyRightPanelCollapsed(stored === null ? true : stored === '1'); +}); + // ── Right-panel resize ────────────────────────────────────────────────────── (function initRightPanelResize() { const MIN_W = 280; @@ -8963,8 +8973,10 @@ function toggleRightPanel() { return Math.max(MIN_W, Math.min(Math.floor(window.innerWidth * MAX_W_RATIO), px)); } - // Restore saved width (only if not collapsed) - if (!document.body.classList.contains('right-collapsed')) { + // Restore saved width — desktop only. Below 1201px the tablet/mobile CSS owns the + // grid, so applying a saved (often wide) width here would override it. + const isDesktop = () => window.matchMedia('(min-width: 1201px)').matches; + if (isDesktop() && !document.body.classList.contains('right-collapsed')) { const saved = parseInt(localStorage.getItem(STORE_KEY), 10); if (saved && saved >= MIN_W) applyWidth(clamp(saved)); } diff --git a/web-ui/arduino-emulator.html b/web-ui/arduino-emulator.html index fbc7d34..391ffe7 100644 --- a/web-ui/arduino-emulator.html +++ b/web-ui/arduino-emulator.html @@ -3023,8 +3023,153 @@ function initEsp32Mode(sessionId, fname, fqbn) { let _renodeSessionId = null; let _renodeEvtSource = null; +// Arduino 번호 → STM32 핀 이름 (STM32duino BLUEPILL_F103C8 순서) +const _STM32_ARDUINO_PINS = [ + 'PA0','PA1','PA2','PA3','PA4','PA5','PA6','PA7', // D0-D7 + 'PB0','PB1','PB10','PB11','PB12','PB13','PB14','PB15', // D8-D15 + 'PA8','PA9','PA10','PA11','PA12','PA15', // D16-D21 + 'PB3','PB4','PB5','PB6','PB7','PB8','PB9', // D22-D28 + 'PC13','PC14','PC15', // D29-D31 +]; + +// HIGH/LOW/1/0/0x1/0x0 → 0 or 1 +// Arduino: #define HIGH 0x1 이므로 _RN_S(HIGH) → "0x1" 로 전달됨 +function _stm32PinVal(v) { + if (v === 'HIGH' || v === 'true') return 1; + if (v === 'LOW' || v === 'false') return 0; + const n = parseInt(v, 16); + return (!isNaN(n) && n !== 0) ? 1 : 0; +} + +function _stm32UpdatePin(pinName, val) { + const resolved = _stm32PinResolve(pinName); + const on = _stm32PinVal(val); + if (resolved === 'PC13' || resolved === 'LED_BUILTIN') { + // PC13 active LOW — 핀 서클 + 온보드 LED 둘 다 업데이트 + const lit = on === 0; + const fill = lit ? '#ffcc00' : '#1a1a0a'; + const flt = lit ? 'drop-shadow(0 0 5px #ffcc00)' : 'none'; + ['stm32-pc13', 'stm32-led-pc13'].forEach(id => { + const el = document.getElementById(id); + if (el) { el.style.fill = fill; el.style.filter = flt; } + }); + } else { + const id = 'stm32-' + resolved.toLowerCase(); + const el = document.getElementById(id); + if (!el) return; + el.style.fill = on ? '#22cc55' : '#0d2a0d'; + el.style.filter = on ? 'drop-shadow(0 0 3px #22cc55)' : 'none'; + } +} + +// Arduino 핀 번호/PinName 숫자 → STM32 포트·핀 이름 +function _stm32PinResolve(s) { + if (!s) return '?'; + // 이미 이름 형식("PA0", "PC13" 등)이면 그대로 반환 + if (/^P[A-Z]\d/.test(s) || s === 'LED_BUILTIN') return s; + const num = parseInt(s, 0); // "0xC0"→192, "192"→192, "45"→45 + if (isNaN(num)) return s; + // Digital pin 0-31 (STMduino BLUEPILL 순서) + if (num >= 0 && num < _STM32_ARDUINO_PINS.length) return _STM32_ARDUINO_PINS[num]; + // Analog pin (PNUM_ANALOG_BASE=0xC0): PA0-PA7, PB0, PB1 + const _AP = ['PA0','PA1','PA2','PA3','PA4','PA5','PA6','PA7','PB0','PB1']; + if ((num & 0xC0) === 0xC0 && (num - 0xC0) < _AP.length) return _AP[num - 0xC0]; + // PinName 인코딩: 상위 니블=포트, 하위 니블=핀 (PA_0=0x00, PC_13=0x2D) + if (num < 0xC0) { + const port = (num >> 4) & 0xF, pin = num & 0xF; + if (port < 12) return 'P' + String.fromCharCode(65 + port) + pin; + } + return 'D' + num; +} + +let _renodeGpioBuf = ''; +function _stm32ParseGpio(raw) { + // 텔레메트리: STX(0x02) "P:name:val" ETX(0x03) 패킷 추출, 나머지는 시리얼로 + _renodeGpioBuf += raw; + let out = ''; + let i = 0; + while (i <= _renodeGpioBuf.length) { + const s = _renodeGpioBuf.indexOf('\x02', i); + if (s < 0) { out += _renodeGpioBuf.slice(i); _renodeGpioBuf = ''; break; } + out += _renodeGpioBuf.slice(i, s); + const e = _renodeGpioBuf.indexOf('\x03', s + 1); + if (e < 0) { _renodeGpioBuf = _renodeGpioBuf.slice(s); break; } // 패킷 미완성 + const pkt = _renodeGpioBuf.slice(s + 1, e); // "P:PA0:1" + _renodeGpioBuf = _renodeGpioBuf.slice(e + 1); + i = 0; + const parts = pkt.split(':'); + if (parts[0] === 'P' && parts.length >= 3) + _stm32UpdatePin(_stm32PinResolve(parts[1]), parts[2]); + } + return out; +} + +function _buildBluePillSVG(isF4) { + const leftPins = [ + ['GND','#666'],['GND','#666'],['3V3','#c0c090'],['NRST','#b0b090'], + ['PA0','#0d2a0d'],['PA1','#0d2a0d'],['PA2','#0d2a0d'],['PA3','#0d2a0d'], + ['PA4','#0d2a0d'],['PA5','#0d2a0d'],['PA6','#0d2a0d'],['PA7','#0d2a0d'], + ['PB0','#0d2a0d'],['PB1','#0d2a0d'],['PB10','#0d2a0d'],['PB11','#0d2a0d'], + ['PB12','#0d2a0d'],['PB13','#0d2a0d'],['PB14','#0d2a0d'],['PB15','#0d2a0d'], + ]; + const rightPins = [ + ['GND','#666'],['GND','#666'],['3V3','#c0c090'],['PA8','#0d2a0d'], + ['PA9','#0d2a0d','TX'],['PA10','#0d2a0d','RX'],['PA11','#0d2a0d'],['PA12','#0d2a0d'], + ['PA15','#0d2a0d'],['PB3','#0d2a0d'],['PB4','#0d2a0d'],['PB5','#0d2a0d'], + ['PB6','#0d2a0d'],['PB7','#0d2a0d'],['PB8','#0d2a0d'],['PB9','#0d2a0d'], + ['PC13','#1a1a0a'],['PC14','#0d2a0d'],['PC15','#0d2a0d'],['5V','#c09090'], + ]; + // sp=핀 간격, y0=시작 y, cx_l/r=좌우 서클 x + const sp = 9, y0 = 18, cx_l = 5, cx_r = 95; + let lp = '', rp = ''; + leftPins.forEach(([name, fill], i) => { + const y = y0 + i * sp; + const isGpio = name.startsWith('P'); + const id = isGpio ? ` id="stm32-${name.toLowerCase()}"` : ''; + const sc = isGpio ? '#1a5a1a' : '#555'; + lp += ``; + lp += `${name}`; + }); + rightPins.forEach(([name, fill, tag], i) => { + const y = y0 + i * sp; + const isGpio = name.startsWith('P'); + const id = isGpio ? ` id="stm32-${name.toLowerCase()}"` : ''; + const sc = isGpio ? '#1a5a1a' : '#555'; + rp += ``; + const lbl = tag ? `${name} ${tag}` : name; + rp += `${lbl}`; + }); + const chipLines = isF4 ? ['STM32F4','F411CE'] : ['STM32F1','F103C8T6']; + const h = y0 + leftPins.length * sp + 16; // 보드 높이 자동 + const w = 100; + const chipY = Math.round(h * 0.28), chipH = Math.round(h * 0.42); + const ledY = Math.round(chipY * 0.55); + return ` + + + + ${lp}${rp} + + + ${chipLines[0]} + ${chipLines[1]} + + PWR + + LED + + + RST + + + + Micro-USB +`; +} + function initSTM32RenodeMode(sessionId, fname, fqbn) { _renodeSessionId = sessionId; + _renodeGpioBuf = ''; document.querySelector('.header h1').textContent = '🖥️ STM32 Emulator (Renode)'; document.getElementById('filename').textContent = fname; @@ -3032,13 +3177,23 @@ function initSTM32RenodeMode(sessionId, fname, fqbn) { const badge = document.createElement('span'); badge.id = 'chip-badge'; badge.style.cssText = 'display:inline-block;padding:2px 7px;border-radius:4px;font-size:10px;font-weight:600;border:1px solid;flex-shrink:0;'; - const family = fqbn.includes('GenF4') || fqbn.includes('f4') ? 'STM32F4' : 'STM32F1'; - badge.textContent = family + ' · Renode'; + const isF4 = fqbn.includes('GenF4') || fqbn.includes('f4'); + badge.textContent = (isF4 ? 'STM32F4' : 'STM32F1') + ' · Renode'; badge.style.background = '#1a0a3a'; badge.style.color = '#c084fc'; badge.style.borderColor = '#7c3aed'; document.getElementById('filename').after(badge); - document.querySelector('.board-svg')?.style && (document.querySelector('.board-svg').style.display = 'none'); - document.querySelector('.board-title')?.style && (document.querySelector('.board-title').style.display = 'none'); + // 보드 SVG를 Blue Pill로 교체 + const boardSvgEl = document.querySelector('.board-svg'); + const boardTitleEl = document.querySelector('.board-title'); + if (boardSvgEl) { boardSvgEl.innerHTML = _buildBluePillSVG(isF4); boardSvgEl.style.display = ''; } + if (boardTitleEl) { boardTitleEl.textContent = isF4 ? 'Black Pill · STM32F4' : 'Blue Pill · STM32F1'; boardTitleEl.style.display = ''; } + + // PWR LED 켜기 + setTimeout(() => { + const pwr = document.getElementById('stm32-pwr-led'); + if (pwr) { pwr.style.fill = '#22cc44'; pwr.style.filter = 'drop-shadow(0 0 3px #22cc44)'; } + }, 500); + document.getElementById('btn-stop')?.style && (document.getElementById('btn-stop').style.display = 'none'); document.getElementById('btn-reset')?.style && (document.getElementById('btn-reset').style.display = 'none'); @@ -3052,6 +3207,8 @@ function initSTM32RenodeMode(sessionId, fname, fqbn) { fetch('/api/arduino/renode/' + _renodeSessionId, { method: 'DELETE', credentials: 'include', keepalive: true }).catch(() => {}); if (_renodeEvtSource) { _renodeEvtSource.close(); _renodeEvtSource = null; } setStatus('off', '정지됨'); + const pwr = document.getElementById('stm32-pwr-led'); + if (pwr) { pwr.style.fill = '#0d2a0d'; pwr.style.filter = 'none'; } }; toolbar.append(stopBtn); } @@ -3077,11 +3234,44 @@ function initSTM32RenodeMode(sessionId, fname, fqbn) { _renodeEvtSource = new EventSource('/api/arduino/renode/' + sessionId + '/output', { withCredentials: true }); _renodeEvtSource.onopen = () => setStatus('running', '실행 중'); _renodeEvtSource.onerror = () => setStatus('error', '연결 오류'); + // TX 핀(PA9) 깜빡임 — 시리얼 데이터 수신 시 + let _txTimer = null; + function _blinkTx() { + const el = document.getElementById('stm32-pa9'); + if (!el) return; + el.style.fill = '#ff8800'; el.style.filter = 'drop-shadow(0 0 2px #ff8800)'; + clearTimeout(_txTimer); + _txTimer = setTimeout(() => { el.style.fill = '#0d2a0d'; el.style.filter = 'none'; }, 80); + } + // RX 핀(PA10) 깜빡임 — 사용자 입력 전송 시 + let _rxTimer = null; + function _blinkRx() { + const el = document.getElementById('stm32-pa10'); + if (!el) return; + el.style.fill = '#00aaff'; el.style.filter = 'drop-shadow(0 0 2px #00aaff)'; + clearTimeout(_rxTimer); + _rxTimer = setTimeout(() => { el.style.fill = '#0d2a0d'; el.style.filter = 'none'; }, 80); + } + // doSend에 RX 깜빡임 추가 + const _origDoSend = doSend; + const doSendWithRx = () => { _blinkRx(); _origDoSend(); }; + serialInput?.removeEventListener('keydown', e => { if (e.key === 'Enter') doSend(); }); + serialSend?.removeEventListener('click', doSend); + serialInput?.addEventListener('keydown', e => { if (e.key === 'Enter') doSendWithRx(); }); + serialSend?.addEventListener('click', doSendWithRx); + _renodeEvtSource.onmessage = e => { try { const d = JSON.parse(e.data); - if (d.serial) appendSerial(d.serial); - if (d.exit !== undefined) setStatus('off', '종료'); + if (d.serial) { + const visible = _stm32ParseGpio(d.serial); + if (visible) { appendSerial(visible); _blinkTx(); } + } + if (d.exit !== undefined) { + setStatus('off', '종료'); + const pwr = document.getElementById('stm32-pwr-led'); + if (pwr) { pwr.style.fill = '#0d2a0d'; pwr.style.filter = 'none'; } + } } catch {} }; window.addEventListener('beforeunload', () => { diff --git a/web-ui/code.js b/web-ui/code.js index f89bdc6..b81e4eb 100644 --- a/web-ui/code.js +++ b/web-ui/code.js @@ -2823,6 +2823,7 @@ async function _codeAiOneRound(messages, onToken) { tools: CLIENT_TOOL_DEFS, sessionId: codeAiSessionId, ...(codeAiModel ? { model: codeAiModel } : {}), + ...(_csbSelBoard?.fqbn ? { boardContext: `${_csbSelBoard.name} (${_csbSelBoard.fqbn})` } : {}), }), }); if (!resp.ok) throw new Error(`/api/code/round ${resp.status}: ${await resp.text()}`); diff --git a/web-ui/index.html b/web-ui/index.html index bdf865b..8d4f5d2 100644 --- a/web-ui/index.html +++ b/web-ui/index.html @@ -806,12 +806,12 @@
웹 검색