From 435c8f4e9c42a459aa45bcf485f1edca0e71d76a Mon Sep 17 00:00:00 2001 From: kim Date: Sat, 15 Aug 2026 12:42:01 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EC=BD=94=EB=93=9C=EC=95=B1=EC=97=90?= =?UTF-8?q?=EC=84=9C=20ESP=20=EB=B3=B4=EB=93=9C=20=EB=AC=B4=EC=84=A0(OTA)?= =?UTF-8?q?=20=EC=97=85=EB=A1=9C=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 인버터 옆처럼 USB를 다시 꽂기 어려운 곳에 보드를 설치하면 시리얼 경로가 무용지물이 된다. 코드앱 업로드 패널은 지금까지 USB 전용이었다 — 플래싱을 브라우저에서 Web Serial(esptool-js/STK500/STM32 UART)로 직접 하는 구조라 무선 경로가 없었다. OTA는 브라우저가 할 수 없는 일이다. 페이지에서 보드로 espota 핸드셰이크를 열 방법이 없어서, 이 업로드 경로만 서버를 경유한다. - 백엔드: /api/arduino/upload의 port가 IP면 OTA로 분기 — 컴파일해서 .bin을 만들고 코어에 들어있는 espota.py로 밀어넣는다. espota.py는 arduino-cli가 아니라 ESP 코어에 딸려오고 코어를 올릴 때마다 버전 디렉토리가 바뀌므로 호출 시점에 최신 버전부터 찾는다. 부트로더/파티션 이미지는 걸러내고 애플리케이션 바이너리만 보낸다. ESP32는 3232, ESP8266은 8266 포트. - 암호는 exec가 아니라 execFile로 넘긴다 — 사용자가 넣는 값이라 셸에 닿으면 안 된다. - 프런트: 패널에 📡 OTA 토글과 보드 IP/암호 입력줄 추가. OTA 모드에서는 시리얼 포트를 안 골라도 업로드 버튼이 살아있어야 하므로 disabled 조건을 분리했다. 에러 힌트는 espota.py의 logging.error() 문구를 직접 읽고 맞췄다. 처음엔 "No response from device"를 기준으로 짐작해서 썼는데, 실제로 응답 없는 보드는 **"No Answer"**를 뱉어서 힌트가 한 번도 안 떴다(실측 확인). 안 뜨는 힌트는 없느니만 못하다. 인증 실패/무응답/Listen 실패를 각각 다른 안내로 매핑. 실측: 실제 rems_solar_esp8266.ino로 엔드포인트 호출 → 컴파일 성공, .bin 탐색, espota 실행, 응답 없는 IP에 대해 3단계 확인 순서 힌트까지 정상 출력. Co-Authored-By: Claude Opus 5 --- src/gateway/routes/routes-arduino.ts | 77 ++++++++++++++++++++++++++++ web-ui/html/code.html | 9 ++++ web-ui/js/code-editor/code.js | 67 ++++++++++++++++++++++++ 3 files changed, 153 insertions(+) diff --git a/src/gateway/routes/routes-arduino.ts b/src/gateway/routes/routes-arduino.ts index a0abb04..fceab6c 100644 --- a/src/gateway/routes/routes-arduino.ts +++ b/src/gateway/routes/routes-arduino.ts @@ -1733,6 +1733,34 @@ app.get('/api/arduino/ports', async (_req, res) => { } catch { res.json({ ports: [] }); } }); +/** Is this upload target an IP address (OTA) rather than a serial device? */ +function isNetworkTarget(port: any): boolean { + const s = String(port || '').trim(); + const m = s.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); + return !!m && m.slice(1).every(o => Number(o) <= 255); +} + +/** + * Locate espota.py inside the installed core for this FQBN. + * + * It ships with the ESP cores rather than with arduino-cli, and the version directory changes on + * every core update, so it is resolved at call time instead of being configured anywhere. + */ +function findEspotaTool(fqbn: string): string | null { + const fs = require('fs'), path = require('path'); + const [vendor, arch] = String(fqbn).split(':'); + if (!vendor || !arch) return null; + const base = path.join(require('os').homedir(), '.arduino15', 'packages', vendor, 'hardware', arch); + try { + // Newest core version first, so an upgrade doesn't leave us on a stale copy. + for (const ver of fs.readdirSync(base).sort().reverse()) { + const p = path.join(base, ver, 'tools', 'espota.py'); + if (fs.existsSync(p)) return p; + } + } catch {} + return null; +} + // ── Arduino: compile + upload ───────────────────────────────────────────────── app.post('/api/arduino/upload', async (req, res) => { const { code, fqbn: rawFqbn, port } = req.body || {}; @@ -1772,6 +1800,55 @@ app.post('/api/arduino/upload', async (req, res) => { await execQ(`"${cliPath}" core install "${coreId}"`, 300000); } + // ── OTA: the "port" is an IP, not a serial device ──────────────────────── + // Once a board is installed where its USB port is (behind an inverter, in a ceiling), the + // serial path stops being usable and the only way to reflash is over WiFi. The ESP cores + // ship espota.py for exactly this, so OTA is: compile to a .bin, then push the .bin. + if (isNetworkTarget(port)) { + const otaPassword = String((req.body || {}).otaPassword || ''); + const espota = findEspotaTool(fqbn); + if (!espota) { + return res.json({ error: `이 보드(${fqbn})의 코어에서 espota.py를 찾지 못했습니다. OTA는 ESP8266/ESP32 계열만 지원합니다.` }); + } + const outDir = tmpDir + '/out'; + require('fs').mkdirSync(outDir, { recursive: true }); + + const c = await execQ(`"${cliPath}" compile --fqbn ${fqbn} --output-dir "${outDir}" ${sketchDir}`, 300000); + const cOut = (c.stdout + '\n' + c.stderr).trim(); + if (c.err) return res.json({ error: '컴파일 실패\n' + cOut }); + + // Skip bootloader/partition images — the OTA payload is the application binary. + const bin = (require('fs').readdirSync(outDir) as string[]) + .filter(f => f.endsWith('.bin') && !/bootloader|partitions|boot_app/i.test(f)) + .sort((a, b) => b.length - a.length)[0]; + if (!bin) return res.json({ error: '컴파일은 됐으나 .bin을 찾지 못했습니다\n' + cOut }); + + const otaPort = fqbn.startsWith('esp32:') ? '3232' : '8266'; + const args = [espota, '-i', String(port), '-p', otaPort, '-f', outDir + '/' + bin, '-r']; + if (otaPassword) args.push('--auth=' + otaPassword); + + // execFile, not exec: the password is user-supplied and must never reach a shell. + const r = await new Promise<{ out: string; err: any }>(resolve => + require('child_process').execFile('python3', args, { timeout: 180000, maxBuffer: 8 * 1024 * 1024 }, + (err: any, stdout: string, stderr: string) => resolve({ out: ((stdout || '') + '\n' + (stderr || '')).trim(), err }))); + + if (!r.err || /Result: OK/i.test(r.out)) { + return res.json({ success: true, output: `📡 OTA 업로드 성공 (${port}:${otaPort})\n${r.out}` }); + } + // Match espota.py's actual wording — checked against the tool's own logging.error() calls, + // because a hint that never fires is worse than none (verified 2026-08-15: an unreachable + // board reports the bare string "No Answer", which an earlier guess at this regex missed). + const hint = + /No Answer to our Authentication|Authentication Failed|Bad Answer/i.test(r.out) + ? '\n\n힌트: 스케치의 OTA_PASSWORD와 입력한 암호가 다릅니다.' + : /No Answer|No response from device|Host .* Not Found/i.test(r.out) + ? `\n\n힌트: ${port}가 응답하지 않습니다. 확인 순서 —\n 1) 보드가 켜져 있고 그 IP로 WiFi에 붙어 있는지\n 2) OTA 코드가 들어간 펌웨어가 이미 올라가 있는지 (첫 업로드는 반드시 USB)\n 3) IP가 바뀌지 않았는지 (DHCP)` + : /Listen Failed/i.test(r.out) + ? '\n\n힌트: 서버가 응답 포트를 열지 못했습니다. 잠시 후 다시 시도하세요.' + : ''; + return res.json({ error: (r.out || 'OTA 업로드 실패') + hint }); + } + const isFlashError = (s: string) => s.includes('Invalid head of packet') || s.includes('verify flash chip connection') || s.includes('serial noise') || s.includes('does not respond') || diff --git a/web-ui/html/code.html b/web-ui/html/code.html index 8486787..6422923 100644 --- a/web-ui/html/code.html +++ b/web-ui/html/code.html @@ -602,10 +602,19 @@ + + +
diff --git a/web-ui/js/code-editor/code.js b/web-ui/js/code-editor/code.js index a120e8b..bd4495a 100644 --- a/web-ui/js/code-editor/code.js +++ b/web-ui/js/code-editor/code.js @@ -6541,8 +6541,75 @@ async function cupDoDownload() { cupLog(`다운로드: ${a.download}`, 'ok'); _cupSetStatus('done', '다운로드됨'); } +/** + * Toggle the OTA target bar. + * + * OTA cannot run in the browser the way the serial paths do — esptool-js talks over Web Serial, + * but pushing firmware to a board over WiFi is an espota handshake to the device, which a page + * has no way to open. So this one upload path goes through the server. + */ +function cupToggleOta() { + const bar = document.getElementById('cup-ota-bar'); + const on = bar.style.display === 'none' || !bar.style.display; + bar.style.display = on ? 'flex' : 'none'; + const btn = document.getElementById('cup-btn-ota'); + btn.style.background = on ? '#e89040' : '#3a2818'; + btn.style.color = on ? '#241a0e' : '#e89040'; + // Serial upload needs a chosen port; OTA needs an IP instead, so the button must not stay + // disabled just because no serial port was picked. + if (on) document.getElementById('cup-btn-upload').disabled = false; + else document.getElementById('cup-btn-upload').disabled = !_cupPort; + cupLog(on ? '📡 OTA 모드 — 보드 IP와 암호를 넣고 ⚡ 업로드' : '📡 OTA 모드 해제', 'info'); +} + +function _cupOtaTarget() { + const bar = document.getElementById('cup-ota-bar'); + if (!bar || bar.style.display === 'none' || !bar.style.display) return null; + const ip = (document.getElementById('cup-ota-ip').value || '').trim(); + return ip ? { ip, password: document.getElementById('cup-ota-pw').value || '' } : null; +} + +/** Compile + push over WiFi, both on the server (see /api/arduino/upload). */ +async function _cupUploadOTA(target) { + _cupBusy = true; + document.getElementById('cup-btn-upload').disabled = true; + document.getElementById('cup-btn-download').disabled = true; + document.getElementById('cup-tab-log').innerHTML = ''; + // espota reports progress on the server's stdout, which we only see when it finishes, so the + // bar is indeterminate rather than faked. + _cupSetProgress(null); + _cupSetStatus('uploading', 'OTA 업로드 중...'); + cupLog(`📡 OTA 업로드 [${_cupBoardName}] → ${target.ip}`, 'prog'); + cupLog('서버에서 컴파일 후 무선 전송합니다. 30초 이상 걸릴 수 있습니다.', 'info'); + try { + const r = await fetch('/api/arduino/upload', { + method: 'POST', credentials: 'include', + headers: { 'Content-Type': 'application/json', ...authHeaders() }, + body: JSON.stringify({ + code: _cupCode, fqbn: _cupGetEffectiveFqbn(), + port: target.ip, otaPassword: target.password, + }), + }); + const data = await r.json(); + if (data.error) { cupLog('❌ ' + data.error, 'err'); _cupSetStatus('error', 'OTA 실패'); } + else { cupLog(data.output || '완료', 'ok'); _cupSetStatus('done', '완료 ✓'); } + } catch (e) { + cupLog('❌ 오류: ' + e.message, 'err'); _cupSetStatus('error', 'OTA 실패'); + } finally { + _cupSetProgress(null); + _cupBusy = false; + document.getElementById('cup-btn-upload').disabled = false; + document.getElementById('cup-btn-download').disabled = false; + } +} + async function cupDoUpload() { if (_cupBusy) return; + const ota = _cupOtaTarget(); + if (ota) { cupTab('log'); await _cupUploadOTA(ota); return; } + if (document.getElementById('cup-ota-bar')?.style.display === 'flex') { + cupLog('보드 IP를 입력하세요 (예: 192.168.0.42)', 'err'); return; + } if (!_cupPort) { cupLog('포트를 먼저 선택하세요 (🔌 포트 버튼)', 'err'); return; } cupTab('log'); const method = _cupGetUploadMethod(_cupGetEffectiveFqbn());