feat: 코드앱에서 ESP 보드 무선(OTA) 업로드
인버터 옆처럼 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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') ||
|
||||
|
||||
@@ -602,10 +602,19 @@
|
||||
<button id="cup-tbtn-opts" onclick="cupTab('opts')" style="background:none;border:none;color:#8a7050;cursor:pointer;font-size:12px;font-family:'IBM Plex Mono',monospace;padding:0 8px;height:100%;border-bottom:2px solid transparent;white-space:nowrap;flex-shrink:0">옵션</button>
|
||||
<button id="cup-tbtn-mon" onclick="cupTab('mon')" style="background:none;border:none;color:#8a7050;cursor:pointer;font-size:12px;font-family:'IBM Plex Mono',monospace;padding:0 8px;height:100%;border-bottom:2px solid transparent;white-space:nowrap;flex-shrink:0">시리얼</button>
|
||||
<button onclick="cupSelectPort()" style="background:#3a2818;border:1px solid #6a4820;color:#e89040;border-radius:5px;padding:3px 10px;font-size:12px;cursor:pointer;font-family:'IBM Plex Mono',monospace;flex-shrink:0;white-space:nowrap">🔌 포트</button>
|
||||
<button id="cup-btn-ota" onclick="cupToggleOta()" title="WiFi로 무선 업로드 (ESP8266/ESP32, 보드에 OTA 코드가 이미 올라가 있어야 함)" style="background:#3a2818;border:1px solid #6a4820;color:#e89040;border-radius:5px;padding:3px 10px;font-size:12px;cursor:pointer;font-family:'IBM Plex Mono',monospace;flex-shrink:0;white-space:nowrap">📡 OTA</button>
|
||||
<button id="cup-btn-download" onclick="cupDoDownload()" disabled style="background:#3a2818;border:1px solid #4a3820;color:#907050;border-radius:5px;padding:3px 9px;font-size:12px;cursor:pointer;font-family:'IBM Plex Mono',monospace;flex-shrink:0">⬇</button>
|
||||
<button id="cup-btn-upload" onclick="cupDoUpload()" disabled style="background:color-mix(in srgb,#e89040 20%,#2a1e10);border:1px solid #e89040;color:#e89040;border-radius:5px;padding:3px 10px;font-size:12px;cursor:pointer;font-family:'IBM Plex Mono',monospace;font-weight:700;flex-shrink:0;white-space:nowrap">⚡ 업로드</button>
|
||||
<button onclick="toggleUploadPanel()" style="background:none;border:none;color:#8a6840;cursor:pointer;font-size:16px;padding:0 6px;line-height:1;flex-shrink:0">×</button>
|
||||
</div>
|
||||
<!-- OTA 대상 입력줄 — 📡 OTA 버튼으로 접었다 폈다 -->
|
||||
<div id="cup-ota-bar" style="display:none;align-items:center;gap:6px;padding:5px 8px;background:#241a0e;border-bottom:1px solid #5a3820;flex-shrink:0;flex-wrap:wrap">
|
||||
<span style="font-size:11px;color:#c8a060;font-family:'IBM Plex Mono',monospace;flex-shrink:0">보드 IP</span>
|
||||
<input id="cup-ota-ip" type="text" placeholder="192.168.0.42" style="width:130px;background:#1a1409;border:1px solid #5a3820;color:#e8c090;border-radius:4px;padding:3px 7px;font-size:12px;font-family:'IBM Plex Mono',monospace" />
|
||||
<span style="font-size:11px;color:#c8a060;font-family:'IBM Plex Mono',monospace;flex-shrink:0">암호</span>
|
||||
<input id="cup-ota-pw" type="password" placeholder="OTA_PASSWORD" style="width:130px;background:#1a1409;border:1px solid #5a3820;color:#e8c090;border-radius:4px;padding:3px 7px;font-size:12px;font-family:'IBM Plex Mono',monospace" />
|
||||
<span style="font-size:11px;color:#8a7050;line-height:1.5">첫 업로드는 USB로 — OTA 코드가 보드에 들어가야 무선이 열립니다</span>
|
||||
</div>
|
||||
<progress id="cup-progress" value="0" max="100" style="display:none;width:100%;height:4px;border:none;border-radius:0;flex-shrink:0;accent-color:#e89040"></progress>
|
||||
<!-- OUTPUT tab -->
|
||||
<div id="cup-tab-log" style="flex:1;overflow-y:auto;padding:10px 14px;font-size:13px;line-height:1.8;white-space:pre-wrap;word-break:break-all;font-family:'IBM Plex Mono',monospace"></div>
|
||||
|
||||
@@ -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());
|
||||
|
||||
Reference in New Issue
Block a user