diff --git a/tools/app.js b/tools/app.js index 8a3a857..7742275 100644 --- a/tools/app.js +++ b/tools/app.js @@ -15,6 +15,96 @@ let isEsp32Rebooting = false; let rebootStartTime = 0; // ==================== 刷写进度 ==================== var activePhase = null; // 'esp32Ota' | 'gd32Upload' | 'gd32Relay' | null +var currentOtaTarget = null; // 'esp32' | 'gd32' | null — 当前 OTA 目标,决定显示哪套阶段 +// 切换 OTA 阶段面板显示 +function switchStageFlow(target) { + currentOtaTarget = target; + var esp32El = document.getElementById('esp32Stages'); + var gd32El = document.getElementById('gd32Stages'); + if (target === 'esp32') { + if (esp32El) esp32El.style.display = ''; + if (gd32El) gd32El.style.display = 'none'; + } else { + if (esp32El) esp32El.style.display = 'none'; + if (gd32El) gd32El.style.display = ''; + } + // 重置 ESP32 阶段节点 + ['es1','es2','es3','es4','es5','es6'].forEach(function(id) { + var el = document.getElementById(id); + if (el) el.className = 'stage-node'; + }); + ['esd1','esd2','esd3','esd4','esd5','esd6'].forEach(function(id) { + var el = document.getElementById(id); + if (el) el.textContent = ''; + }); + // 重置 GD32 阶段节点 + ['s1','s2','s3','s4','s5','s6'].forEach(function(id) { + var el = document.getElementById(id); + if (el) el.className = 'stage-node'; + }); + ['sd1','sd2','sd3','sd4','sd5','sd6'].forEach(function(id) { + var el = document.getElementById(id); + if (el) el.textContent = ''; + }); +} +// ESP32 OTA 阶段状态: 0=待机, 1=上传中, 2=OTA写入, 3=SHA-256校验, 4=应用固件, 5=重启中, 6=启动完成 +var esp32OtaPhase = 0; +var esp32PhaseLabels = ['', '上传中', '写入中', '校验中', '应用中', '重启中', '已完成']; +function updateEsp32StagesFromPhase(phase) { + esp32OtaPhase = phase; + var ids = ['es1', 'es2', 'es3', 'es4', 'es5', 'es6']; + var detailIds = ['esd1', 'esd2', 'esd3', 'esd4', 'esd5', 'esd6']; + var all = ids.map(function(id) { return document.getElementById(id); }); + var details = detailIds.map(function(id) { return document.getElementById(id); }); + all.forEach(function (s) { if (s) s.className = 'stage-node'; }); + details.forEach(function (d) { if (d) d.textContent = ''; }); + var statusBar = document.getElementById('stageStatusBar'); + var statusText = document.getElementById('stageStatusText'); + if (phase <= 0) { + if (statusBar) statusBar.className = 'stage-status-bar'; + if (statusText) statusText.textContent = '待机'; + return; + } + for (var i = 0; i < phase - 1 && i < 6; i++) { + if (all[i]) all[i].className = 'stage-node done'; + } + if (phase >= 1 && phase <= 6) { + if (all[phase - 1]) all[phase - 1].className = 'stage-node live'; + if (details[phase - 1]) details[phase - 1].textContent = esp32PhaseLabels[phase] || ''; + } + if (statusBar && statusText) { + if (phase >= 6) { + statusBar.className = 'stage-status-bar ok'; + statusText.textContent = 'ESP32 刷写完成'; + } else { + statusBar.className = 'stage-status-bar active'; + statusText.textContent = (esp32PhaseLabels[phase] || '进行中') + ' (' + phase + '/6)'; + } + } +} +function updateEsp32StagesError() { + var ids = ['es1', 'es2', 'es3', 'es4', 'es5', 'es6']; + var all = ids.map(function(id) { return document.getElementById(id); }); + all.forEach(function (s) { if (s) s.className = 'stage-node'; }); + var statusBar = document.getElementById('stageStatusBar'); + var statusText = document.getElementById('stageStatusText'); + if (statusBar) statusBar.className = 'stage-status-bar err'; + if (statusText) statusText.textContent = '校验/应用失败'; +} + +function showVerifyActions(type) { + // type: 'apply' | 'retry' | 'none' + var actions = document.getElementById('stageVerifyActions'); + if (!actions) return; + if (type === 'none') { actions.style.display = 'none'; return; } + actions.style.display = 'block'; + var applyBtn = document.getElementById('applyEsp32Btn'); + var retryBtn = document.getElementById('retryEsp32Btn'); + var cancelBtn = document.getElementById('cancelEsp32Btn'); + if (applyBtn) applyBtn.style.display = (type === 'apply') ? 'inline-block' : 'none'; + if (retryBtn) retryBtn.style.display = (type === 'retry') ? 'inline-block' : 'none'; + if (cancelBtn) cancelBtn.style.display = (type === 'retry') ? 'inline-block' : 'none'; +} function setActivePhase(phase) { activePhase = phase; @@ -29,18 +119,16 @@ function setActivePhase(phase) { var fill = document.getElementById('progressFill'); if (fill) fill.classList.remove('done'); } - function markPhaseDone(phase) { var stepMap = { esp32Ota: 'ppEsp32', gd32Upload: 'ppGd32Up', gd32Relay: 'ppGd32Relay' }; var el = document.getElementById(stepMap[phase]); if (el) { el.classList.remove('active'); el.classList.add('done'); } // All done? turn progress bar green - if (phase === 'gd32Relay') { + if (phase === 'gd32Relay' || phase === 'esp32Ota') { var fill = document.getElementById('progressFill'); if (fill) fill.classList.add('done'); } } - function setProgress(pct, text) { var fillEl = document.getElementById('progressFill'); var txtEl = document.getElementById('progressTxt'); @@ -49,10 +137,11 @@ function setProgress(pct, text) { if (txtEl) txtEl.textContent = text; if (pctEl) pctEl.textContent = pct + '%'; } - function resetProgress() { setProgress(0, '等待操作...'); activePhase = null; + esp32OtaPhase = 0; + switchStageFlow(null); ['ppEsp32', 'ppGd32Up', 'ppGd32Relay'].forEach(function(id) { var el = document.getElementById(id); if (el) el.classList.remove('active', 'done'); @@ -60,14 +149,12 @@ function resetProgress() { var fill = document.getElementById('progressFill'); if (fill) fill.classList.remove('done'); } - // ==================== 工具函数 ==================== function formatBytes(bytes) { if (bytes < 1024) return bytes + ' B'; if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; return (bytes / (1024 * 1024)).toFixed(2) + ' MB'; } - function formatTime(date) { var h = String(date.getHours()).padStart(2, '0'); var m = String(date.getMinutes()).padStart(2, '0'); @@ -75,7 +162,6 @@ function formatTime(date) { var ms = String(date.getMilliseconds()).padStart(3, '0'); return h + ':' + m + ':' + s + '.' + ms; } - function formatUptime(sec) { if (!Number.isFinite(sec) || sec < 0) return '--'; const d = Math.floor(sec / 86400); @@ -87,13 +173,11 @@ function formatUptime(sec) { if (m > 0) return m + 'm ' + s + 's'; return s + 's'; } - function formatHttpErrorDetail(text) { var detail = (text || '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim(); if (detail.length > 180) detail = detail.slice(0, 177) + '...'; return detail; } - function showToast(message, type) { type = type || 'success'; const toast = document.getElementById('toast'); @@ -104,32 +188,25 @@ function showToast(message, type) { msg.textContent = message; setTimeout(function () { toast.classList.remove('show'); }, 3000); } - function getEspIp() { return document.getElementById('espIp').value.trim() || '192.168.31.127'; } - function getBaseUrl() { return 'http://' + getEspIp(); } - // ==================== 日志系统 (带去重 + 自动滚动) ==================== function makeLogHash(level, tag, message) { return level + '|' + tag + '|' + message; } - function addLog(level, tag, message) { var scroll = document.getElementById('logScroll'); var time = formatTime(new Date()); var hash = makeLogHash(level, tag, message); if (hash === lastLogHash) return; // 去重 lastLogHash = hash; - var entry = { time: time, level: level, tag: tag, message: message }; logEntries.push(entry); - if (logFilter !== 'all' && level !== logFilter) return; - var div = document.createElement('div'); div.className = 'log-line'; div.innerHTML = @@ -138,18 +215,15 @@ function addLog(level, tag, message) { '[' + tag + ']' + '' + message + ''; scroll.appendChild(div); - if (autoScroll) scroll.scrollTop = scroll.scrollHeight; document.getElementById('logCount').textContent = logEntries.length; } - function clearLogs() { document.getElementById('logScroll').innerHTML = ''; logEntries = []; lastLogHash = null; document.getElementById('logCount').textContent = '0'; } - function setLogFilter(level, btn) { document.querySelectorAll('.log-filter').forEach(function (b) { b.classList.remove('active'); }); btn.classList.add('active'); @@ -170,27 +244,23 @@ function setLogFilter(level, btn) { }); if (autoScroll) scroll.scrollTop = scroll.scrollHeight; } - function toggleAutoScroll() { autoScroll = !autoScroll; var btn = document.getElementById('autoScrollBtn'); btn.textContent = autoScroll ? '\uD83D\uDCCC 自动滚动' : '\u23F8\uFE0F 暂停滚动'; } - function updateUploadButtonLabel() { var esp32Btn = document.getElementById('uploadEsp32Btn'); var gd32Btn = document.getElementById('uploadGd32Btn'); if (esp32Btn) esp32Btn.disabled = !esp32File || isUploading; if (gd32Btn) gd32Btn.disabled = !gd32File || isUploading; } - function setHelper(id, text, type) { var el = document.getElementById(id); if (!el) return; el.className = 'helper-note ' + (type || ''); el.innerHTML = text; } - function updateCanHint() { var dlc = parseInt(document.getElementById('canDlc').value) || 0; var dataHex = document.getElementById('canData').value.trim().replace(/\s/g, ''); @@ -213,14 +283,20 @@ function updateCanHint() { } setHelper('canHint', 'HEX 数据合法,当前长度 ' + bytes + ' 字节,与 DLC 一致。', 'ok'); } - function resetStageFlow() { - ['s1', 's2', 's3', 's4'].forEach(function (id) { + ['s1', 's2', 's3', 's4', 's5', 's6'].forEach(function (id) { var el = document.getElementById(id); if (el) el.className = 'stage-node'; }); + ['sd1', 'sd2', 'sd3', 'sd4', 'sd5', 'sd6'].forEach(function (id) { + var el = document.getElementById(id); + if (el) el.textContent = ''; + }); + var statusBar = document.getElementById('stageStatusBar'); + if (statusBar) statusBar.className = 'stage-status-bar'; + var statusText = document.getElementById('stageStatusText'); + if (statusText) statusText.textContent = '待机'; } - function applyDisconnectedRuntimeState() { document.getElementById('pillIP').textContent = '--'; document.getElementById('pillRSSI').textContent = '--'; @@ -233,43 +309,35 @@ function applyDisconnectedRuntimeState() { resetProgress(); resetStageFlow(); } - function applyRebootingRuntimeState() { var pc = document.getElementById('pillConn'); var pct = document.getElementById('pillConnTxt'); pc.className = 'pill processing'; var elapsed = Math.round((Date.now() - rebootStartTime) / 1000); pct.textContent = '重启中 ' + elapsed + 's'; - var cs = document.getElementById('connStatus'); var cst = document.getElementById('connStatusTxt'); cs.className = 'conn-indicator warn'; cst.textContent = 'ESP32 正在重启...'; - var bc = document.getElementById('bConn'); bc.textContent = '重启中'; bc.className = 'v warn'; - document.getElementById('pillRelayTxt').textContent = '重启中'; document.getElementById('pillRelay').className = 'pill processing'; } - function applyUploadingRuntimeState() { var pc = document.getElementById('pillConn'); var pct = document.getElementById('pillConnTxt'); pc.className = 'pill processing'; pct.textContent = '上传中'; - var cs = document.getElementById('connStatus'); var cst = document.getElementById('connStatusTxt'); cs.className = 'conn-indicator warn'; cst.textContent = '固件上传中,设备忙...'; - var bc = document.getElementById('bConn'); bc.textContent = '上传中'; bc.className = 'v warn'; } - // ==================== 连接管理 ==================== function updateConnectionUi(connected, text) { // header pill @@ -277,19 +345,16 @@ function updateConnectionUi(connected, text) { var pct = document.getElementById('pillConnTxt'); pc.className = connected ? 'pill online' : 'pill offline'; pct.textContent = text || (connected ? '已连接' : '未连接'); - // connection card indicator var cs = document.getElementById('connStatus'); var cst = document.getElementById('connStatusTxt'); cs.className = connected ? 'conn-indicator ok' : 'conn-indicator err'; cst.textContent = connected ? (text || '已连接') : '未连接'; - // primary badge var bc = document.getElementById('bConn'); bc.textContent = connected ? '在线' : '离线'; bc.className = 'v ' + (connected ? 'ok' : 'err'); } - function updateHealthUi(healthCode, freeHeap) { var healthNames = ['正常', '警告', '危险', '关机']; var health = healthCode !== undefined ? (healthNames[healthCode] || '未知') : '未知'; @@ -301,70 +366,93 @@ function updateHealthUi(healthCode, freeHeap) { document.getElementById('mHeap').textContent = formatBytes(freeHeap); } } - // ==================== OTA 阶段驱动 (真实状态) ==================== +// Relay state → 6-stage mapping: +// Stage 1 (认证握手): rs 1-6 握手/挑战/会话密钥/认证 +// Stage 2 (传输协商): rs 7-8 固件大小协商 +// Stage 3 (数据转发): rs 9-13 数据包收发/ACK/重发 +// Stage 4 (传输完成): rs 14-15 数据接收完毕 +// Stage 5 (OTA刷写): rs 16-17 OTA 结束/完成 +// Stage 6 (校验验证): rs 18 APP 校验 +// Error: rs 255 +var relayStateDetailMap = { + 0: '待机', + 1: '等待握手', 2: '握手已发', 3: '挑战已发', + 4: '收到握手响应', 5: '会话密钥已发', 6: '认证完成', + 7: '大小已发', 8: '大小确认', + 9: '接收中', 10: '转发中', 11: '数据包已发', 12: 'ACK正常', 13: '等待重发', + 14: '接收完成', 15: '传输完成', + 16: '结束已发', 17: 'OTA完成', + 18: 'APP验证', + 255: '错误' +}; function updateStagesFromRelayState(rs) { - var s1 = document.getElementById('s1'); - var s2 = document.getElementById('s2'); - var s3 = document.getElementById('s3'); - var s4 = document.getElementById('s4'); - var all = [s1, s2, s3, s4]; - + var ids = ['s1', 's2', 's3', 's4', 's5', 's6']; + var detailIds = ['sd1', 'sd2', 'sd3', 'sd4', 'sd5', 'sd6']; + var all = ids.map(function(id) { return document.getElementById(id); }); + var details = detailIds.map(function(id) { return document.getElementById(id); }); // reset all - all.forEach(function (s) { s.className = 'stage-node'; }); - - if (rs === undefined || rs === null) return; - + all.forEach(function (s) { if (s) s.className = 'stage-node'; }); + details.forEach(function (d) { if (d) d.textContent = ''; }); + // status bar + var statusBar = document.getElementById('stageStatusBar'); + var statusText = document.getElementById('stageStatusText'); + if (rs === undefined || rs === null) { + if (statusBar) statusBar.className = 'stage-status-bar'; + if (statusText) statusText.textContent = '待机'; + return; + } if (rs === 255) { - s1.className = 'stage-node err'; - s2.className = 'stage-node err'; - s3.className = 'stage-node err'; - s4.className = 'stage-node err'; + all.forEach(function (s) { if (s) s.className = 'stage-node err'; }); + if (statusBar) statusBar.className = 'stage-status-bar err'; + if (statusText) statusText.textContent = '错误'; return; } - var activeStage = 0; if (rs >= 1 && rs <= 6) activeStage = 1; else if (rs >= 7 && rs <= 8) activeStage = 2; - else if (rs >= 9 && rs <= 15) activeStage = 3; - else if (rs >= 16 && rs <= 18) activeStage = 4; - + else if (rs >= 9 && rs <= 13) activeStage = 3; + else if (rs >= 14 && rs <= 15) activeStage = 4; + else if (rs >= 16 && rs <= 17) activeStage = 5; + else if (rs >= 18 && rs <= 18) activeStage = 6; for (var i = 0; i < activeStage - 1; i++) { - all[i].className = 'stage-node done'; + if (all[i]) all[i].className = 'stage-node done'; + } + if (activeStage >= 1 && activeStage <= 6) { + if (all[activeStage - 1]) all[activeStage - 1].className = 'stage-node live'; + } + // Show detail text for the active stage + var detailText = relayStateDetailMap[rs] || ''; + if (activeStage >= 1 && activeStage <= 6 && details[activeStage - 1]) { + details[activeStage - 1].textContent = detailText; } - if (activeStage >= 1 && activeStage <= 4) { - all[activeStage - 1].className = 'stage-node live'; + // Update status bar + if (statusBar && statusText) { + if (rs === 0) { + statusBar.className = 'stage-status-bar'; + statusText.textContent = '待机'; + } else { + statusBar.className = 'stage-status-bar active'; + statusText.textContent = (relayStateDetailMap[rs] || '未知') + ' (' + rs + ')'; + } } } - function updateSystemStatusUi(data) { if (!data) return; latestSystemStatus = data; - var wifi = data.wifi || {}; var ota = data.ota || {}; var healthCode = data.health; - - var relayStates = { - 0: '待机', 1: '等待握手', 2: '握手已发', 3: '挑战已发', - 4: '收到握手响应', 5: '会话密钥已发', 6: '认证完成', - 7: '大小已发', 8: '大小确认', 9: '接收中', 10: '转发中', - 11: '数据包已发', 12: 'ACK正常', 13: '等待重发', - 14: '接收完成', 15: '传输完成', 16: '结束已发', 17: 'OTA完成', 18: 'APP验证', 255: '错误' - }; - + // Use global relayStateDetailMap defined in updateStagesFromRelayState section var connected = !!wifi.connected; var connText = connected ? (wifi.mode || 'STA') + ' 已连接' : (wifi.mode || 'STA') + ' 未连接'; - updateConnectionUi(connected, connText); updateHealthUi(healthCode, data.free_heap); - // version if (data.version) { document.getElementById('pillVer').textContent = 'v' + data.version; document.getElementById('pillVer').title = data.build_time ? ('编译: ' + data.build_time) : ''; document.getElementById('mVersion').textContent = 'v' + data.version + (data.build_time ? ' (' + data.build_time + ')' : ''); - // GD32 版本 var mGd32App = document.getElementById('mGd32App'); var mGd32Boot = document.getElementById('mGd32Boot'); @@ -387,28 +475,23 @@ function updateSystemStatusUi(data) { } } } - // temperature document.getElementById('mTemp').textContent = Number.isFinite(data.temperature) ? data.temperature.toFixed(1) + '\u00B0C' : '--'; - // uptime var uptimeEl = document.getElementById('pillUptime'); if (uptimeEl) uptimeEl.textContent = '\u23F1 ' + formatUptime(data.uptime_sec); - // wifi / signal document.getElementById('mWifi').textContent = wifi.connected ? (wifi.ip || '已连接') : '未连接'; document.getElementById('pillIP').textContent = wifi.ip || '--'; var rssi = (wifi.rssi !== undefined && wifi.rssi !== -127) ? wifi.rssi + ' dBm' : '--'; document.getElementById('mRSSI').textContent = rssi; document.getElementById('pillRSSI').textContent = rssi; - // heap document.getElementById('mHeap').textContent = data.free_heap !== undefined ? formatBytes(data.free_heap) : '--'; document.getElementById('mMinHeap').textContent = data.min_free_heap !== undefined ? formatBytes(data.min_free_heap) : '--'; - // ota cache var otaStored = ota.stored_size || 0; var mOtaCache = document.getElementById('mOtaCache'); @@ -421,17 +504,21 @@ function updateSystemStatusUi(data) { mOtaCache.className = 'v'; } } - - // relay state + // relay state - skip GD32 relay UI when ESP32 OTA is active + if (currentOtaTarget === 'esp32' && esp32OtaPhase > 0) { + document.getElementById('pillRelayTxt').textContent = esp32OtaPhase >= 6 ? 'ESP32 OTA 完成' : 'ESP32 OTA'; + var pr = document.getElementById('pillRelay'); + if (pr) pr.className = esp32OtaPhase >= 6 ? 'pill online' : 'pill processing'; + return; + } var rs = ota.relay_state; - var relayText = relayStates[rs] || '待机'; + var relayText = relayStateDetailMap[rs] || '待机'; document.getElementById('pillRelayTxt').textContent = relayText; var pr = document.getElementById('pillRelay'); if (rs === 255) pr.className = 'pill offline'; else if (rs > 0 && rs < 17) pr.className = 'pill processing'; else if (rs === 17 || rs === 18) pr.className = 'pill online'; else pr.className = 'pill idle'; - // OTA cache progress (GD32 upload to ESP32 flash) var total = ota.total_size || 0; var stored = ota.stored_size || 0; @@ -439,27 +526,46 @@ function updateSystemStatusUi(data) { var bp = document.getElementById('bOTAProgress'); bp.textContent = storageProgress + '%'; bp.className = 'v ' + (storageProgress === 100 ? 'ok' : storageProgress > 0 ? 'warn' : ''); - // GD32 relay forwarding progress var relaySeq = ota.relay_seq || 0; var relayTotal = ota.relay_total_packets || 0; var relayProgress = relayTotal > 0 ? Math.min(100, Math.round(relaySeq * 100 / relayTotal)) : 0; - var gd32AppAlive = ota.gd32_app_alive; - if (rs > 0 && rs < 17) { - // Relay is active — show relay progress + if (rs > 0 && rs <= 8) { + // Authentication or size negotiation phase + setActivePhase('gd32Relay'); + if (rs <= 6) { + setProgress(0, '认证中: ' + (relayStateDetailMap[rs] || '')); + } else { + setProgress(0, '协商固件大小...'); + } + } else if (rs >= 9 && rs <= 13) { + // Active data forwarding setActivePhase('gd32Relay'); setProgress(relayProgress, '已转发 ' + relaySeq + ' / ' + relayTotal + ' 包'); + } else if (rs >= 14 && rs <= 15) { + // Transfer complete + setActivePhase('gd32Relay'); + setProgress(100, '数据转发完成 (' + relayTotal + ' 包)'); + } else if (rs === 16) { + // OTA end sent + setActivePhase('gd32Relay'); + setProgress(100, 'OTA 刷写中...'); } else if (rs === 17) { - // Relay done, waiting for APP verify + // OTA complete setActivePhase('gd32Relay'); - setProgress(100, '转发完成,等待 APP 启动...'); + setProgress(100, 'OTA 完成,等待 APP 启动...'); } else if (rs === 18) { // APP verify phase setActivePhase('gd32Relay'); if (gd32AppAlive) { markPhaseDone('gd32Relay'); setProgress(100, 'GD32 刷写完成'); + // Mark status bar as success + var sb = document.getElementById('stageStatusBar'); + var st = document.getElementById('stageStatusText'); + if (sb) sb.className = 'stage-status-bar ok'; + if (st) st.textContent = 'GD32 刷写完成'; } else { setProgress(100, '转发完成,验证 APP 存活...'); } @@ -471,46 +577,60 @@ function updateSystemStatusUi(data) { // Relay finished and reset, but APP is alive → truly done markPhaseDone('gd32Relay'); setProgress(100, 'GD32 刷写完成'); + var sb2 = document.getElementById('stageStatusBar'); + var st2 = document.getElementById('stageStatusText'); + if (sb2) sb2.className = 'stage-status-bar ok'; + if (st2) st2.textContent = 'GD32 刷写完成'; } - // stage flow from real state - updateStagesFromRelayState(rs); + if (currentOtaTarget !== 'esp32') { updateStagesFromRelayState(rs); } } - async function testConnection() { - if (!latestSystemStatus) { - addLog('W', '连接', '暂无同步数据,请等待 1 秒自动同步'); - showToast('暂无同步数据,请稍等 1 秒', 'warning'); - return; + // 如果在降速模式,先恢复正常轮询 + if (pollSlowMode) { + addLog('I', '连接', '正在重新连接...'); + restorePolling(); + } + // 立即发一次请求检测 + try { + var resp = await fetch(getBaseUrl() + '/system/status', { + signal: AbortSignal.timeout(3000) + }); + if (resp.ok) { + var data = await resp.json(); + latestSystemStatus = data; + updateSystemStatusUi(data); + var wifi = data.wifi || {}; + addLog('I', '连接', wifi.connected + ? '已连接:' + (wifi.mode || 'STA') + ' ' + (wifi.ip || '') + : '未连接:' + (wifi.mode || 'STA')); + showToast(wifi.connected ? 'ESP32 当前在线' : 'ESP32 当前未连接', + wifi.connected ? 'success' : 'warning'); + } else { + addLog('E', '连接', 'HTTP ' + resp.status); + showToast('设备响应异常: HTTP ' + resp.status, 'error'); + } + } catch (e) { + addLog('E', '连接', '连接失败: ' + e.message); + // 连接失败时自动诊断 + var diag = await diagnoseNetwork(); + addLog('W', '诊断', diag); + showToast('无法连接: ' + diag, 'error'); } - var wifi = latestSystemStatus.wifi || {}; - var connected = !!wifi.connected; - updateSystemStatusUi(latestSystemStatus); - addLog('I', '连接', connected - ? '当前已连接:' + (wifi.mode || 'STA') + ' ' + (wifi.ip || '') - : '当前未连接:' + (wifi.mode || 'STA')); - showToast(connected ? 'ESP32 当前在线' : 'ESP32 当前未连接', - connected ? 'success' : 'warning'); } - // ==================== CAN 帧发送 ==================== async function sendCanFrame() { var ch = parseInt(document.getElementById('canChannel').value); var idStr = document.getElementById('canId').value.trim(); var dlc = parseInt(document.getElementById('canDlc').value) || 8; var dataStr = document.getElementById('canData').value.trim(); - var extId = 0; if (idStr.toLowerCase().indexOf('0x') === 0) extId = parseInt(idStr, 16); else { extId = parseInt(idStr, 10); if (isNaN(extId)) extId = parseInt(idStr, 16); } - if (isNaN(extId)) { showToast('ID 格式错误', 'error'); return; } - var dataHex = dataStr.replace(/\s/g, ''); if (dataHex.length % 2 !== 0) { showToast('数据长度必须为偶数', 'error'); return; } - addLog('I', 'CAN', '发送 CH' + ch + ' ID=0x' + extId.toString(16).toUpperCase() + ' DLC=' + dlc); - try { var resp = await fetch(getBaseUrl() + '/can/send', { method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -534,7 +654,6 @@ async function sendCanFrame() { showToast('发送失败', 'error'); } } - function clearCanFields() { document.getElementById('canId').value = '0x18DA00F1'; document.getElementById('canDlc').value = '8'; @@ -542,13 +661,11 @@ function clearCanFields() { document.getElementById('canResp').style.display = 'none'; updateCanHint(); } - // ==================== SPI 发送 ==================== function updateSpiHint() { var typeHex = document.getElementById('spiType').value.trim(); var dataHex = document.getElementById('spiData').value.trim().replace(/\s/g, ''); var hint = document.getElementById('spiHint'); - if (!typeHex || !/^[0-9a-fA-F]{1,2}$/.test(typeHex)) { hint.className = 'helper-note err'; hint.innerHTML = 'type 需填 1 字节 hex(如 01, B0)。'; @@ -571,17 +688,14 @@ function updateSpiHint() { (bytes > 0 ? ',payload: ' + bytes + 'B' : ',无 payload') + ',线上 ' + totalOnWire + 'B(SOF+TYPE+LEN+PAYLOAD+CRC)'; } - async function sendSpiFrame() { var typeHex = document.getElementById('spiType').value.trim(); var dataStr = document.getElementById('spiData').value.trim(); var timeout = parseInt(document.getElementById('spiTimeout').value) || 1000; - if (!typeHex || !/^[0-9a-fA-F]{1,2}$/.test(typeHex)) { showToast('type 需填 1 字节 hex', 'error'); return; } - var dataHex = dataStr.replace(/\s/g, ''); if (dataHex && !/^[0-9a-fA-F]*$/.test(dataHex)) { showToast('数据包含非 HEX 字符', 'error'); @@ -591,14 +705,10 @@ async function sendSpiFrame() { showToast('HEX 数据长度必须为偶数', 'error'); return; } - var txBytes = dataHex ? dataHex.length / 2 : 0; - addLog('I', 'SPI', 'type=0x' + typeHex.toUpperCase() + (txBytes > 0 ? ', payload=' + dataHex.toUpperCase() : ', 无负载')); - var body = { type: typeHex, data: dataHex.toUpperCase(), timeout_ms: timeout }; - try { var resp = await fetch(getBaseUrl() + '/spi/send', { method: 'POST', @@ -607,7 +717,6 @@ async function sendSpiFrame() { signal: AbortSignal.timeout(timeout + 2000) }); var result = await resp.json(); - if (result.status === 'ok') { var recvHex = result.recv || ''; var respTypeName = {'80': 'ACK', '81': 'WAIT', '82': 'ERROR'}; @@ -634,7 +743,6 @@ async function sendSpiFrame() { showToast('SPI 发送失败: ' + e.message, 'error'); } } - function clearSpiFields() { document.getElementById('spiType').value = '01'; document.getElementById('spiData').value = ''; @@ -642,13 +750,11 @@ function clearSpiFields() { document.getElementById('spiResp').style.display = 'none'; updateSpiHint(); } - // ==================== 文件处理 ==================== function handleFileSelect(event, chip) { var file = event.target.files[0]; if (file) processFile(file, chip); } - // 通过 File System Access API 选择文件(支持热重载) async function pickFileViaFSAPI(chip) { if (!window.showOpenFilePicker) { @@ -679,7 +785,6 @@ async function pickFileViaFSAPI(chip) { } return false; } - function setupDropZone(zoneId, chip) { var zone = document.getElementById(zoneId); if (!zone) return; @@ -697,15 +802,11 @@ function setupDropZone(zoneId, chip) { } setupDropZone('uploadZoneEsp32', 'esp32'); setupDropZone('uploadZoneGd32', 'gd32'); - document.getElementById('canData').addEventListener('input', updateCanHint); document.getElementById('canDlc').addEventListener('input', updateCanHint); - document.getElementById('spiType').addEventListener('input', updateSpiHint); document.getElementById('spiData').addEventListener('input', updateSpiHint); - // ==================== Intel HEX → BIN 转换 ==================== - /** * 将 Intel HEX 字符串解析为原始 BIN 字节数组。 * @@ -720,17 +821,14 @@ function parseIntelHex(hexText) { var segments = []; // { offset, data } 列表 var minAddr = 0xFFFFFFFF; var maxAddr = 0; - var lines = hexText.split(/\r?\n/); for (var i = 0; i < lines.length; i++) { var line = lines[i].trim(); if (!line || line[0] !== ':') continue; - // 解析行: :LLAAAATT[DD...]CC var byteCount = parseInt(line.substr(1, 2), 16); var addr16 = parseInt(line.substr(3, 4), 16); var recType = parseInt(line.substr(7, 2), 16); - // 校验和验证 var sum = 0; for (var j = 1; j < line.length; j += 2) { @@ -739,7 +837,6 @@ function parseIntelHex(hexText) { if ((sum & 0xFF) !== 0) { throw new Error('HEX 行 ' + (i + 1) + ' 校验和错误: ' + line); } - if (recType === 0x01) { // EOF break; @@ -763,11 +860,9 @@ function parseIntelHex(hexText) { } // 其他记录类型 (03, 05 等) 忽略 } - if (segments.length === 0) { throw new Error('HEX 文件中没有有效数据记录'); } - // 合并为连续 bin,间隙填 0xFF var totalSize = maxAddr - minAddr; var bin = new Uint8Array(totalSize); @@ -778,10 +873,8 @@ function parseIntelHex(hexText) { var off = seg.offset - minAddr; bin.set(seg.data, off); } - return { data: bin, baseAddr: minAddr, endAddr: maxAddr }; } - /** * 检测文件内容是否为 Intel HEX 格式。 * 看前几个非空行是否以 ':' 开头。 @@ -795,13 +888,10 @@ function isIntelHexText(text) { } return false; } - // ==================== 文件处理 ==================== - function processFile(file, chip) { var suffix = chip === 'esp32' ? 'Esp32' : 'Gd32'; var isHexByName = file.name.toLowerCase().endsWith('.hex'); - if (chip === 'gd32' && isHexByName) { // GD32 固件: .hex 文件自动转换为 .bin,然后替换文件对象 var reader = new FileReader(); @@ -825,12 +915,10 @@ function processFile(file, chip) { var binBlob = new Blob([result.data], { type: 'application/octet-stream' }); var binFile = new File([binBlob], binName, { type: 'application/octet-stream' }); gd32File = binFile; - var baseHex = '0x' + result.baseAddr.toString(16).toUpperCase().padStart(8, '0'); addLog('I', '文件', 'HEX → BIN 转换完成: ' + file.name + ' → ' + binName); addLog('I', '文件', '基地址: ' + baseHex + ', 数据大小: ' + formatBytes(result.data.length)); showToast('HEX 已自动转换为 BIN', 'success'); - document.getElementById('fileInfo' + suffix).classList.add('show'); document.getElementById('fileName' + suffix).textContent = binName + ' (由 ' + file.name + ' 转换)'; document.getElementById('fileType' + suffix).textContent = 'Binary (HEX转换)'; @@ -847,7 +935,6 @@ function processFile(file, chip) { reader.readAsText(file); return; // 异步,回调里设置 gd32File } - // .bin 文件或 ESP32 文件: 直接使用 if (chip === 'esp32') esp32File = file; else gd32File = file; @@ -859,23 +946,41 @@ function processFile(file, chip) { updateUploadButtonLabel(); addLog('I', '文件', chip.toUpperCase() + ' 已选择: ' + file.name + ' (' + formatBytes(file.size) + ')'); showToast(chip.toUpperCase() + ' 文件已选择', 'success'); + // ESP32 文件: 计算并显示 SHA-256 + if (chip === 'esp32') { + var shaElem = document.getElementById('fileSha256' + suffix); + if (shaElem) { + shaElem.textContent = '计算中...'; + file.arrayBuffer().then(function(buf) { + return crypto.subtle.digest('SHA-256', buf); + }).then(function(hashBuffer) { + var hashArray = Array.from(new Uint8Array(hashBuffer)); + var hashHex = hashArray.map(function(b) { return b.toString(16).padStart(2, '0'); }).join(''); + shaElem.textContent = hashHex.toUpperCase(); + // 存储到全局变量供上传时使用 + window._esp32FileSha256 = hashHex.toUpperCase(); + addLog('I', '文件', 'ESP32 固件 SHA-256: ' + hashHex.toUpperCase()); + }).catch(function(err) { + shaElem.textContent = '计算失败: ' + err.message; + }); + } + } } - function clearFile(chip) { var suffix = chip === 'esp32' ? 'Esp32' : 'Gd32'; - if (chip === 'esp32') { esp32File = null; esp32FileHandle = null; } + if (chip === 'esp32') { esp32File = null; esp32FileHandle = null; window._esp32FileSha256 = null; } else { gd32File = null; gd32FileHandle = null; } document.getElementById('fileInput' + suffix).value = ''; document.getElementById('fileInfo' + suffix).classList.remove('show'); + var shaElem = document.getElementById('fileSha256' + suffix); + if (shaElem) shaElem.textContent = '-'; updateUploadButtonLabel(); addLog('I', '文件', chip.toUpperCase() + ' 文件已清除'); } - // ==================== 上传固件 ==================== async function startUpload(chip) { var file = chip === 'esp32' ? esp32File : gd32File; if (!file || isUploading) return; - // 每次上传前尝试重新从本地读取文件 var handle = chip === 'esp32' ? esp32FileHandle : gd32FileHandle; if (handle) { @@ -911,21 +1016,43 @@ async function startUpload(chip) { return; } } - isUploading = true; var uploadBtn = document.getElementById(chip === 'esp32' ? 'uploadEsp32Btn' : 'uploadGd32Btn'); var origText = uploadBtn.textContent; uploadBtn.disabled = true; uploadBtn.textContent = '上传中...'; + switchStageFlow(chip); setActivePhase(chip === 'esp32' ? 'esp32Ota' : 'gd32Upload'); + if (chip === 'esp32') updateEsp32StagesFromPhase(1); + // Reset verification panel + var verifyInfo = document.getElementById('stageVerifyInfo'); + if (verifyInfo) verifyInfo.style.display = 'none'; + var sviFront = document.getElementById('sviFrontSha'); + var sviDevice = document.getElementById('sviDeviceSha'); + var sviResult = document.getElementById('sviResult'); + if (sviFront) sviFront.textContent = '-'; + if (sviDevice) sviDevice.textContent = '-'; + if (sviResult) { sviResult.textContent = '-'; sviResult.className = 'svi-val'; } addLog('I', '上传', '开始上传: ' + file.name + ' \u2192 ' + chip.toUpperCase()); showToast('开始上传固件到 ' + chip.toUpperCase(), 'success'); - try { var arrayBuffer = await file.arrayBuffer(); + // 刷写时重新计算 SHA-256 + if (chip === 'esp32') { + try { + var hashBuffer = await crypto.subtle.digest('SHA-256', arrayBuffer); + var hashArray = Array.from(new Uint8Array(hashBuffer)); + var hashHex = hashArray.map(function(b) { return b.toString(16).padStart(2, '0'); }).join(''); + window._esp32FileSha256 = hashHex.toUpperCase(); + var shaElem = document.getElementById('fileSha256Esp32'); + if (shaElem) shaElem.textContent = window._esp32FileSha256; + addLog('I', '文件', '刷写前重新计算 SHA-256: ' + window._esp32FileSha256); + } catch (e) { + addLog('W', '文件', 'SHA-256 重新计算失败: ' + e.message); + } + } var fileSize = file.size; var body, url = getBaseUrl() + '/ota/upload'; - if (chip === 'esp32') { var headerObj = { type: 'esp32', total_size: fileSize }; var payload = arrayBuffer; @@ -958,7 +1085,6 @@ async function startUpload(chip) { document.getElementById('bOTAProgress').textContent = pct + '%'; } }); - xhr.addEventListener('load', function () { isUploading = false; uploadBtn.disabled = false; @@ -970,22 +1096,19 @@ async function startUpload(chip) { setProgress(100, '上传完成'); if (chip === 'esp32') { markPhaseDone('esp32Ota'); - var countdown = 3; - addLog('I', 'OTA', countdown + ' 秒后自动调用 POST /ota/update 完成升级并重启...'); - var timer = setInterval(function () { - countdown--; - if (countdown > 0) addLog('I', 'OTA', countdown + ' 秒后开始刷写...'); - else { - clearInterval(timer); - addLog('I', 'OTA', '开始刷写!'); - triggerEsp32Update(); - } - }, 1000); + updateEsp32StagesFromPhase(2); // OTA 写入阶段 + addLog('I', 'OTA', '固件上传完成,准备 SHA-256 校验...'); + verifyEsp32Firmware(); } else { + switchStageFlow('gd32'); markPhaseDone('gd32Upload'); setActivePhase('gd32Relay'); setProgress(0, '等待触发转发...'); } + } else if (xhr.status === 403) { + addLog('E', '上传', 'ESP32 OTA 被其他通道占用(BLE/WiFi 互斥),请稍后重试'); + showToast('ESP32 OTA 被其他通道占用,请稍后重试', 'error'); + setProgress(0, 'OTA 被其他通道占用'); } else { var detail = formatHttpErrorDetail(xhr.responseText); var message = '上传失败: HTTP ' + xhr.status + (detail ? ' - ' + detail : ''); @@ -993,7 +1116,6 @@ async function startUpload(chip) { showToast(detail ? ('上传失败: ' + detail) : '上传失败', 'error'); } }); - xhr.addEventListener('error', function () { isUploading = false; uploadBtn.disabled = false; @@ -1002,7 +1124,6 @@ async function startUpload(chip) { addLog('E', '上传', '上传过程发生错误'); showToast('上传出错', 'error'); }); - xhr.open('POST', url); xhr.send(body); } catch (e) { @@ -1014,28 +1135,118 @@ async function startUpload(chip) { updateUploadButtonLabel(); } } - // ==================== ESP32 OTA 完成升级 ==================== -async function triggerEsp32Update() { - addLog('I', 'OTA', '=== ESP32 OTA 完成,开始重启 ==='); - showToast('ESP32 正在重启...', 'success'); +async function verifyEsp32Firmware() { + updateEsp32StagesFromPhase(3); // 校验中 + var sha256 = window._esp32FileSha256; + if (sha256) { + addLog('I', 'OTA', '前端文件 SHA-256: ' + sha256); + var verifyInfo = document.getElementById('stageVerifyInfo'); + if (verifyInfo) verifyInfo.style.display = 'block'; + var frontSha = document.getElementById('sviFrontSha'); + if (frontSha) frontSha.textContent = sha256; + } + + showToast('ESP32 正在校验固件...', 'success'); try { + var requestBody = {}; + if (sha256) requestBody.sha256 = sha256; + requestBody.verify_only = true; + var resp = await fetch(getBaseUrl() + '/ota/update', { - method: 'POST', signal: AbortSignal.timeout(5000) + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(requestBody), + signal: AbortSignal.timeout(120000) + }); + + var data = await resp.json(); + + if (data.status === 'ok') { + addLog('I', 'OTA', '设备端 SHA-256: ' + (data.sha256 || '-')); + if (data.sha256) { + var deviceSha = document.getElementById('sviDeviceSha'); + if (deviceSha) deviceSha.textContent = data.sha256; + var resultEl = document.getElementById('sviResult'); + if (sha256 && sha256.toLowerCase() === data.sha256.toLowerCase()) { + addLog('I', 'OTA', 'SHA-256 验证通过!固件完整性确认,自动应用...'); + showToast('SHA-256 验证通过,自动应用中...', 'success'); + if (resultEl) { resultEl.textContent = '验证通过'; resultEl.className = 'svi-val ok'; } + showVerifyActions('none'); + applyEsp32Firmware(); + } + } + } else { + var detail = data.message || (data.device ? '不匹配' : '未知错误'); + addLog('E', 'OTA', '校验失败: ' + detail); + showToast('校验失败: ' + detail, 'error'); + var resultEl = document.getElementById('sviResult'); + if (resultEl) { resultEl.textContent = '❌ ' + detail; resultEl.className = 'svi-val err'; } + updateEsp32StagesError(); + showVerifyActions('retry'); + } + } catch (e) { + addLog('E', 'OTA', '校验请求失败: ' + e.message); + showToast('校验请求失败', 'error'); + updateEsp32StagesError(); + showVerifyActions('retry'); + } +} + +async function retryEsp32Upload() { + addLog('I', 'OTA', '重新上传固件...'); + showVerifyActions('none'); + var verifyInfo = document.getElementById('stageVerifyInfo'); + if (verifyInfo) verifyInfo.style.display = 'none'; + var sviFront = document.getElementById('sviFrontSha'); + var sviDevice = document.getElementById('sviDeviceSha'); + var sviResult = document.getElementById('sviResult'); + if (sviFront) sviFront.textContent = '-'; + if (sviDevice) sviDevice.textContent = '-'; + if (sviResult) { sviResult.textContent = '-'; sviResult.className = 'svi-val'; } + startUpload('esp32'); +} + +async function cancelEsp32Ota() { + addLog('I', 'OTA', '已取消 OTA 刷写'); + showToast('已取消 OTA', 'error'); + showVerifyActions('none'); + resetProgress(); +} + +async function applyEsp32Firmware() { + addLog('I', 'OTA', '确认应用固件并重启 ESP32...'); + showToast('正在应用固件并重启...', 'success'); + try { + var resp = await fetch(getBaseUrl() + '/ota/update', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + signal: AbortSignal.timeout(120000) }); + if (resp.ok) { var data = await resp.json(); - addLog('I', 'OTA', '升级完成: ' + data.message); - showToast('ESP32 重启中...', 'success'); + addLog('I', 'OTA', '升级完成: ' + (data.message || '')); + updateEsp32StagesFromPhase(4); // 应用固件 isEsp32Rebooting = true; + updateEsp32StagesFromPhase(5); // 重启中 rebootStartTime = Date.now(); + } else { + var detail = '应用失败: HTTP ' + resp.status; + try { var d = await resp.json(); detail += ' - ' + (d.message || ''); } catch (_) {} + addLog('E', 'OTA', detail); + showToast(detail, 'error'); } } catch (e) { - addLog('E', 'OTA', '升级失败: ' + e.message); - showToast('升级失败', 'error'); + addLog('W', 'OTA', '应用请求出现异常(设备可能已重启): ' + e.message); + isEsp32Rebooting = true; + updateEsp32StagesFromPhase(5); + rebootStartTime = Date.now(); } } + async function restartEsp32() { if (!confirm('确定要重启 ESP32 吗?')) return; addLog('I', '系统', '=== 请求重启 ESP32 ==='); @@ -1056,12 +1267,10 @@ async function restartEsp32() { showToast('重启失败', 'error'); } } - // ==================== 触发 OTA 中继 (真实状态驱动) ==================== async function triggerRelay() { addLog('I', 'OTA', '触发 GD32 OTA 中继...'); showToast('正在触发 OTA 中继', 'success'); - try { var resp = await fetch(getBaseUrl() + '/ota/relay', { method: 'POST', signal: AbortSignal.timeout(5000) @@ -1081,8 +1290,78 @@ async function triggerRelay() { showToast('触发失败', 'error'); } } - // ==================== 轮询状态 ==================== +var consecutivePollFailures = 0; +var statusTimerId = null; +var logsTimerId = null; +var pollSlowMode = false; // 降速模式(10s 一次) +var lastDiagTime = 0; // 上次诊断时间 +var diagResult = ''; // 最近一次诊断结果 +// 从设备 IP 推导网关 IP(取前三段 + .1) +function getGatewayIp() { + var parts = getEspIp().split('.'); + if (parts.length === 4) return parts[0] + '.' + parts[1] + '.' + parts[2] + '.1'; + return '192.168.31.1'; +} +// 网络诊断:区分浏览器离线 / 网关不通 / 仅设备不通 +async function diagnoseNetwork() { + lastDiagTime = Date.now(); + var result = []; + // 1) 浏览器是否在线 + if (navigator.onLine === false) { + result.push('浏览器无网络连接'); + diagResult = result.join(';'); + return diagResult; + } + // 2) 尝试触达网关(超时 2s) + var gwIp = getGatewayIp(); + var gwOk = false; + try { + await fetch('http://' + gwIp + '/', { mode: 'no-cors', signal: AbortSignal.timeout(2000) }); + gwOk = true; // no-cors 模式下不抛异常即视为可达 + } catch (_) {} + if (!gwOk) { + // 再试一次 favicon(有些路由器不响应 /) + try { + await fetch('http://' + gwIp + '/favicon.ico', { mode: 'no-cors', signal: AbortSignal.timeout(1500) }); + gwOk = true; + } catch (_) {} + } + if (!gwOk) { + result.push('网关 ' + gwIp + ' 不可达(本地网络不通)'); + } else { + result.push('网关 ' + gwIp + ' 可达'); + // 3) 网关通但设备不通 → 设备自身问题 + result.push('设备 ' + getEspIp() + ' 不响应(可能断电、重启中或 WiFi 断开)'); + } + diagResult = result.join(';'); + return diagResult; +} +function startPolling() { + consecutivePollFailures = 0; + pollSlowMode = false; + if (statusTimerId) clearInterval(statusTimerId); + if (logsTimerId) clearInterval(logsTimerId); + statusTimerId = setInterval(pollStatus, 1000); + logsTimerId = setInterval(pollLogs, 1000); +} +function slowDownPolling() { + pollSlowMode = true; + if (statusTimerId) clearInterval(statusTimerId); + if (logsTimerId) clearInterval(logsTimerId); + statusTimerId = setInterval(pollStatus, 10000); + logsTimerId = setInterval(pollLogs, 10000); +} +function restorePolling() { + if (pollSlowMode) { + pollSlowMode = false; + consecutivePollFailures = 0; + if (statusTimerId) clearInterval(statusTimerId); + if (logsTimerId) clearInterval(logsTimerId); + statusTimerId = setInterval(pollStatus, 1000); + logsTimerId = setInterval(pollLogs, 1000); + } +} async function pollStatus() { try { var resp = await fetch(getBaseUrl() + '/system/status', { @@ -1090,14 +1369,43 @@ async function pollStatus() { }); if (resp.ok) { var data = await resp.json(); + // 恢复:如果之前在降速模式,切回 1 秒轮询 + if (consecutivePollFailures > 0 || pollSlowMode) { + var wasSlow = pollSlowMode; + consecutivePollFailures = 0; + if (wasSlow) { + restorePolling(); + addLog('I', '系统', '设备已恢复在线,轮询恢复正常'); + showToast('设备已恢复在线', 'success'); + } + } if (isEsp32Rebooting) { isEsp32Rebooting = false; + updateEsp32StagesFromPhase(6); // 启动完成 + addLog('I', '系统', 'ESP32 已恢复在线'); + showToast('ESP32 已恢复在线', 'success'); + } else if (currentOtaTarget === 'esp32' && esp32OtaPhase >= 3 && esp32OtaPhase < 6) { + updateEsp32StagesFromPhase(6); addLog('I', '系统', 'ESP32 已恢复在线'); showToast('ESP32 已恢复在线', 'success'); } updateSystemStatusUi(data); } } catch (e) { + consecutivePollFailures++; + // 传输中屏蔽网关无响应的检测功能(上传期间设备忙,无法响应轮询是正常的) + if (consecutivePollFailures === 5 && !pollSlowMode && !isUploading) { + addLog('W', '诊断', '设备连续 5 次无响应,正在诊断网络...'); + var diag = await diagnoseNetwork(); + addLog('W', '诊断', diag); + updateConnectionUi(false, '未连接'); + applyDisconnectedRuntimeState(); + var cst = document.getElementById('connStatusTxt'); + if (cst) cst.textContent = '诊断中: ' + diag; + // 降速到 10 秒继续监测,等待设备恢复 + slowDownPolling(); + addLog('I', '系统', '已切换为慢速轮询(10s),持续监测设备恢复...'); + } if (isUploading) { applyUploadingRuntimeState(); } else if (isEsp32Rebooting) { @@ -1111,14 +1419,13 @@ async function pollStatus() { } else { applyRebootingRuntimeState(); } - } else { + } else if (!pollSlowMode) { updateConnectionUi(false, '未连接'); applyDisconnectedRuntimeState(); } } } -setInterval(pollStatus, 1000); - +startPolling(); // ==================== ESP32 实时日志拉取 (增量) ==================== var lastLogTimestamp = 0; async function pollLogs() { @@ -1129,7 +1436,6 @@ async function pollLogs() { if (!resp.ok) return; var logs = await resp.json(); if (!Array.isArray(logs) || logs.length === 0) return; - var levelMap = { E: 'E', W: 'W', I: 'I', D: 'D' }; logs.forEach(function (line) { var m = line.match(/^\[([EWID])\]\[([^\]]+)\]\s*(.*)$/); @@ -1141,13 +1447,20 @@ async function pollLogs() { }); } catch (e) { /* 静默 */ } } -setInterval(pollLogs, 1000); - setTimeout(function () { updateUploadButtonLabel(); updateCanHint(); addLog('I', '系统', '控制台初始化完成,等待固件文件...'); updateSpiHint(); + // IP 输入变化时自动恢复轮询 + var ipInput = document.getElementById('espIp'); + if (ipInput) { + ipInput.addEventListener('change', function () { + if (pollSlowMode) { + addLog('I', '系统', 'IP 已修改,重新启动轮询...'); + restorePolling(); + } + }); + } }, 500); -