diff --git a/appjs_diff.txt b/appjs_diff.txt new file mode 100644 index 0000000..d883efa --- /dev/null +++ b/appjs_diff.txt @@ -0,0 +1,1314 @@ +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); + +- diff --git a/config/sdkconfig.defaults b/config/sdkconfig.defaults index 99cd2b3..8655193 100644 --- a/config/sdkconfig.defaults +++ b/config/sdkconfig.defaults @@ -53,6 +53,8 @@ CONFIG_LOG_MAXIMUM_LEVEL_VERBOSE=y # OTA CONFIG_ESP_OTA_IMG_VERIFICATION=y +CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE=y +CONFIG_APP_ROLLBACK_ENABLE=y # HTTP server CONFIG_HTTPD_MAX_REQ_HDR_LEN=1024 diff --git a/docs/CODE_REVIEW_REPORT_2026-06-13.html b/docs/CODE_REVIEW_REPORT_2026-06-13.html new file mode 100644 index 0000000..f55735d --- /dev/null +++ b/docs/CODE_REVIEW_REPORT_2026-06-13.html @@ -0,0 +1,970 @@ + + + + + +ESP32_COMM 高风险代码评审报告 + + + + +
+ + +
+

ESP32_COMM 高风险代码评审报告

+
多维度并行审查 + 对抗性验证 · 4维度 × 33项确认发现
+
+ 📋 项目: ESP32_COMM (ESP32-C3 OTA Relay) + 📅 日期: 2026-06-13 + 🔌 固件版本: v0.3.12 + 👥 审查Agent: 4 Review + 36 Verify + 1 Synthesize +
+
+ + +
+
+
33
+
确认发现
+
+
+
8
+
CRITICAL
+
+
+
18
+
HIGH
+
+
+
4
+
MEDIUM
+
+
+
33/36
+
验证通过率
+
+
+
2
+
已驳回(误报)
+
+
+ + +
+
📊 各维度发现分布
+
+
+
8
+
+
+
+
+
+
内存安全
+
+
+
10
+
+
+
+
+
+
并发/RTOS
+
+
+
9
+
+
+
+
+
+
OTA/固件
+
+
+
7
+
+
+
+
+
+
SPI/协议
+
+
+
+ + +
+
🚨 TOP 3 最危险 Bug
+ + +
+
+
+
Finding #1 — CRITICAL
+
SPI 总线无并发保护 — 多任务竞争导致协议损坏
+
+
+ CRITICAL + 并发竞态 +
+
+
+
+ 文件: + spi_master.h:73-142, ota_relay.cpp, wifi_ota.cpp + 维度: + 并发/RTOS + 验证: + ✔ HIGH置信度 — 源码确认 +
+

SpiMaster 是单例但没有任何互斥锁。多个 FreeRTOS 任务直接调用 SPI 方法:ota_relay 任务 (优先级 MAX-2)、wifi_ota 任务 (MAX-3)、health_monitor 任务 (MAX-4)。协议操作是多步非原子的(如 otaTransfer = 发送帧 + 轮询响应,CS在两次传输之间释放),高优先级任务可在协议序列中间抢占。

+
// spi_master.h — 无任何mutex成员 +class SpiMaster { +private: + spi_device_handle_t m_spiDevice = NULL; // ← 唯一成员,无锁 +};
+
+
⚠ 影响
+ 帧交错(A任务的请求配对B任务的响应)、GD32状态机混乱、固件传输数据损坏。生产环境高频CAN通信+OTA并发时必现。 +
+
+
🔧 修复建议
+ 在 SpiMaster 中添加 SemaphoreHandle_t m_mutex,所有公共方法入口加锁。对于多步协议操作(otaTransfer等),使用统一的锁粒度保护整个请求-响应序列。 +
+
+
+ + +
+
+
+
Finding #2 — CRITICAL
+
硬编码占位密钥 — 认证体系形同虚设
+
+
+ CRITICAL + 安全/OTA +
+
+
+
+ 文件: + app_config.h:107-115 + 维度: + OTA/固件完整性 + 验证: + ✔ HIGH置信度 — 源码确认 +
+

MASTER_KEY 使用 NIST AES 测试向量,SCRAMBLE_CONST 使用简单递增序列,GD32_CHIP_UID 使用 0x00-0x0B。注释标注"需填入实际密钥"但从未替换。

+
// ⚠️ 请填入实际密钥 ← 注释说"需填入"但从未填入 +inline constexpr uint8_t MASTER_KEY[] = { + 0x01, 0x23, 0x45, 0x67, 0x89, 0xAB, 0xCD, 0xEF, // ← NIST AES测试向量! + 0xFE, 0xDC, 0xBA, 0x98, 0x76, 0x54, 0x32, 0x10 +}; +inline constexpr uint8_t SCRAMBLE_CONST[] = { + 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77, 0x88, // ← 简单递增序列 + 0x99, 0xAA, 0xBB, 0xCC, 0xDD, 0xEE, 0xFF, 0x00 +}; +inline constexpr uint8_t GD32_CHIP_UID[] = { + 0x00, 0x01, 0x02, 0x03, 0x04, 0x05, // ← 0x00-0x0B + 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B +};
+
+
⚠ 影响
+ 攻击者可轻易伪造认证握手,向 GD32 注入恶意固件。GD32_CHIP_UID占位值意味着所有设备共享相同"身份"(如果运行时未正确覆盖)。 +
+
+
🔧 修复建议
+ 从安全存储(NVS/efuse)读取密钥;移除所有硬编码占位值;添加编译期断言确保非测试密钥;MASTER_KEY和SCRAMBLE_CONST应在首次设备配准时由安全随机数生成。 +
+
+
+ + +
+
+
+
Finding #3 — CRITICAL
+
Flash 写入无对齐检查 — 可能导致固件损坏
+
+
+ CRITICAL + 内存安全/OTA +
+
+
+
+ 文件: + ota_relay.cpp:308-320, wifi_ota.cpp:711/740, ble_ota.cpp:575 + 维度: + 内存安全 + OTA完整性 + 验证: + ✔ HIGH置信度 — 源码确认 +
+
esp_err_t OtaRelay::flashWriteChunk(uint32_t offset, const uint8_t *data, size_t len) +{ + // ... 无对齐检查 ... + return esp_partition_write(instance().m_gd32Partition, offset, data, len); // ← offset/len可能非4字节对齐 +}
+

两个调用路径产生非对齐值:(1) WiFi OTA: gd32_offset 从 httpd_req_recv 返回值累加,最后一个chunk是固件余数;(2) BLE OTA: data_len = recv_len - 11(7字节头+4字节CRC),不保证4字节对齐。esp_partition_write 要求 offset 和 size 均4字节对齐。

+
+
⚠ 影响
+ 非对齐写入导致 ESP_ERR_INVALID_ARG 或触发 read-modify-write 异常,GD32固件最后一个chunk损坏或丢失。 +
+
+
🔧 修复建议
+ flashWriteChunk 内部做4字节对齐对齐(不足部分 read-modify-write),或在上层确保只传对齐参数。 +
+
+
+
+ + +
+
🔴 其他 CRITICAL 级发现
+ +
+
+
+
Finding #4 — CRITICAL
+
BLE/WiFi 双通道 OTA 同时触发 — ESP32 OTA状态冲突
+
+
CRITICAL
+
+
+
+ 文件:wifi_ota.h:42-46, ble_ota.h + 验证:✔ HIGH置信度 +
+

BleOta 和 WifiOta 各自维护独立的 m_esp32OtaActive、m_esp32OtaHandle、m_esp32UpdatePartition,但底层 esp_ota_begin/write/end 操作的是同一个OTA分区。两个通道同时触发时:第二个 esp_ota_begin 会失败或覆盖句柄;esp_ota_write 可能交错写入同一分区;导致固件损坏+变砖。

+
🔧 修复建议
添加全局 OTA 互斥标志,同一时刻只允许一个OTA源(BLE或WiFi)。
+
+
+ +
+
+
+
Finding #5 — CRITICAL
+
SPI 协议帧仅用 XOR 伪校验 — 数据损坏不可检测
+
+
CRITICAL
+
+
+
+ 文件:spi_master.cpp:134-141 + 验证:✔ HIGH置信度 +
+
uint8_t SpiMaster::otaCrc8(const uint8_t *data, size_t len) { + uint8_t crc = 0; + for (size_t i = 0; i < len; ++i) + crc ^= data[i]; // ← 简单XOR,非CRC8 + return crc; +}
+

XOR校验无法检测:偶数个相同位置的位翻转、字节交换、全零段丢失。固件数据的静默损坏几乎不可检测。

+
🔧 修复建议
替换为 CRC-8/MAXIM 或 CRC-16/CCITT,与GD32团队协商。
+
+
+ +
+
+
+
Finding #6 — CRITICAL
+
SPI_OTA_SOF 与 SPI_APP_ENTER_BOOT_ACK_OK 值碰撞 (0xA5)
+
+
CRITICAL
+
+
+
+ 文件:spi_master.h:21,26 + 验证:✔ HIGH置信度 +
+
#define SPI_OTA_SOF (0xA5) // OTA帧起始标记 +#define SPI_APP_ENTER_BOOT_ACK_OK (0xA5) // APP确认进入Bootloader
+

同一值 0xA5 表示两种完全不同的语义。GD32在OTA窗口回复ACK时,ESP32可能将ACK字节误解析为OTA帧起始,导致协议状态机混乱。

+
🔧 修复建议
修改其中一个常量值,消除歧义。例如将 SPI_APP_ENTER_BOOT_ACK_OK 改为 0xB5。
+
+
+ +
+
+
+
Finding #7 — CRITICAL
+
OTA 会话无全局超时 — 可无限挂起
+
+
CRITICAL
+
+
+
+ 文件:ota_relay.cpp:76-91,562; ota_relay.h:59 + 验证:✔ HIGH置信度 +
+

waitUntilNotWait() 每次调用有 max_wait_rounds 上限,但整个 forwardFirmware() 流程没有全局超时。GD32 持续返回 WAIT 时,单个 SEND_SIZE阶段可挂起 ~78秒,完整流程可挂起数分钟。OTA_RESULT_ERR_USER_ABORT 已定义但从未使用。

+
🔧 修复建议
在 forwardFirmware() 入口记录开始时间,每个阶段检查全局超时(如5分钟),超时则中止并清理。
+
+
+ +
+
+
+
Finding #8 — CRITICAL
+
HTTP CORS 允许所有来源 — 任意网页可触发 OTA
+
+
CRITICAL
+
+
+
+ 文件:wifi_ota.cpp:58 + 验证:✔ HIGH置信度 +
+
httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
+

任何网页都可以向 ESP32 发起 OTA 请求。结合占位密钥问题,攻击链完整:恶意网页 → 上传伪造固件 → 通过占位密钥认证 → 烧录恶意GD32固件。

+
🔧 修复建议
限制 CORS 为本地网络来源,或移除 CORS头并使用同源策略。
+
+
+
+ + +
+
🟠 HIGH 级关键发现
+
+ 共 18 项 HIGH 级发现,点击展开详情 + +
+ + + + + + + + + + + + + + + + + + + + +
#严重度文件问题影响
9HIGHwifi_ota.cpp:121WiFi事件回调中调用 esp_wifi_connect() 重连在系统事件任务中调用可能死锁
10HIGHhealth_monitor.cpphealthState 静态变量无锁跨任务读写竞态导致健康判断错误
11HIGHota_relay.h:80-845个静态变量(relayState等)无锁跨任务访问协议状态不一致
12HIGHwifi_ota.h:33-47WifiOta 所有成员 public 且无锁HTTP回调与WiFi事件回调竞争
13HIGHble_ota.hBleOta 成员无锁,BLE GATT回调与主任务竞争BLE OTA 数据损坏
14HIGHdebug_log.cpp:296log() 栈分配 msg[512] + log_buf[600] = 1112字节低栈任务中栈溢出
15HIGHwifi_ota.cpp:1008spi_send_handler 栈分配 1728+ 字节HTTP server栈仅8KB,接近溢出
16HIGHota_relay.cpp:456sendNonceAndAuthenticate() nonce 仅用 esp_random() 一次认证会话密钥可预测
17HIGHota_relay.h:25ota_meta_t.checksum[32] 从未填充/验证固件完整性无保障
18HIGHwifi_ota.cpp:429-444ESP32 OTA 完成→重启,无回滚验证新固件若无法启动将变砖
19HIGHspi_master.cpp:258syncTransfer/recv 忽略 timeout_ms 参数SPI事务可能无限等待
20HIGHwifi_ota.cpp:599-629esp_ota_write 失败后 break,未调用 esp_ota_abortOTA分区处于不一致状态
21HIGHble_ota.cpp:439-444BLE OTA 帧预留4字节CRC但减去计算不匹配最后几个字节被误截断
22HIGHspi_master.hSPI仅4线,无硬件流控/就绪信号高速传输时GD32来不及处理
+
+ + +
+
🏗 架构级隐患
+ +
+

① 全局状态泛滥,无封装无同步

+

项目中有 5个静态全局变量 (relayState, relaySeq, relayTotalPackets, gd32AppAlive, gd32Version) 和 大量public成员 (WifiOta/BleOta所有状态),跨越3+个FreeRTOS任务和多个ISR/callback上下文访问,没有任何一个被mutex/atomic/critical section保护。这是系统性问题,不是个别bug。

+
+ +
+

② 单通道SPI + 多任务无仲裁

+

SpiMaster 单例是唯一的SPI通信通道,但没有任何仲裁机制。WiFi/BLE/健康监控/OTA中继四个功能模块各自直接调用,协议级操作(请求-响应对)不是原子的。需要从架构层面引入SPI总线仲裁器。

+
+ +
+

③ 安全架构不完整

+

认证握手存在但密钥硬编码→形同虚设;固件校验字段存在但未填充→形同虚设;CORS开放→任何人可触发OTA。安全功能"看起来有"但实际上不提供保护,这种"安全假象"比没有安全功能更危险,因为它给了虚假的信心。

+
+
+ + +
+
🛠 优先修复顺序
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
优先级修复项工作量紧急度进度
P0添加 SPI 总线互斥锁小极高 — 生产环境必崩
P0替换硬编码密钥为安全存储中极高 — 安全漏洞
P0Flash 写入对齐检查小极高 — 数据损坏
P1添加全局 OTA 超时+中止机制中高 — 设备挂死
P1ESP32 OTA 添加回滚验证中高 — 变砖风险
P1替换 XOR 为 CRC-8/16 校验小高 — 固件损坏
P1消除 0xA5 值碰撞极小高 — 协议混乱
P2添加 OTA 单源互斥 (BLE/WiFi)中中高
P2为所有跨任务状态添加锁/atomic大中高
P2修复 WiFi 事件回调中的重连调用小中
P3修复大栈分配问题小中
P3限制 CORS 来源小中
P3实现 ota_meta_t 校验填充与验证中中
+
+ + +
+
✅ 已驳回发现(误报)
+ + + + + + + + + + + + + + + + +
原始严重度问题描述驳回原因
CRITICALSPI DMA缓冲区栈分配导致数据损坏/硬FaultESP-IDF 6.0 的 spi_master 驱动内置 setup_dma_priv_buffer() 函数自动检测并对齐DMA缓冲区,栈分配仅导致性能损失,不引发数据损坏
HIGHTOCTOU: relayState 检查后 reset() 无锁虽然模式存在,但当前代码流程中检查和重置在同一线程同步执行,实际竞态窗口极小,风险被高估
+
+ + + + +
+ + + + + diff --git a/docs/test_ble_ota_fix.md b/docs/test_ble_ota_fix.md new file mode 100644 index 0000000..79bcbda --- /dev/null +++ b/docs/test_ble_ota_fix.md @@ -0,0 +1,316 @@ +# BLE OTA 刷写修复测试计划 + +> 对应修复:问题1(偶发丢包检测错误)+ 问题2(通道占用延迟) +> 涉及文件:`ble_ota.h` / `ble_ota.cpp` / `ble_ota.html` + +--- + +## 一、正常流程测试 + +### T-01 标准 ESP32 OTA 全流程(基线回归) + +| 项目 | 说明 | +|------|------| +| 固件大小 | 使用常规固件(~200KB–1MB) | +| 操作步骤 | BLE 连接 → 选择文件 → 一键刷写 | +| 预期结果 | 刷写完成,设备重启,日志无报错 | + +**验证要点:** +- 每个窗口 ACK 字节数与期望完全匹配 +- 日志中出现 `已写 X/2035 包` 进度连续递增 +- 日志出现 `[FINISH] ESP32 OTA 刷写完成,等待重启` + toast 成功提示 +- 设备重启后 `readVersion` 返回新版本号 + +### T-02 标准 GD32 OTA 中继全流程(回归验证) + +| 项目 | 说明 | +|------|------| +| 目标 | GD32(经 ESP32 中继 SPI 转发) | +| 操作步骤 | BLE 连接 → 选择 GD32 固件 → 一键刷写 | +| 预期结果 | ESP32 中继正常,GD32 收到完整固件 | + +**验证要点:** +- ESP32 端去重逻辑仅作用于 `BLE_OTA_CMD_ESP32_DATA`(0x01),不影响 GD32 通道(0x02) +- GD32 窗口 ACK 正常返回 +- 刷写完成后 CTRL 命令触发成功 + +### T-03 尾包非整窗口场景 + +| 项目 | 说明 | +|------|------| +| 条件 | `packetTotal % BLE_OTA_ACK_WINDOW != 0`,如固件 976752 字节 / 480 = 2035 包,最后一个窗口仅 19 包(seq 2016–2034) | +| 预期 | 最后一个窗口的 `endSeq = 2034`,ACK 正常返回 | + +**验证要点:** +- `expectedBytes = Math.min((endSeq+1)*480, totalSize)` 正确截断 +- 尾包 `data_len < BLE_OTA_CHUNK_PAYLOAD` 触发 `isLastPacket` +- 设备创建收尾任务,发送 `[FINISH] 即将执行 esp_ota_end()` + +### T-04 多窗口连续刷写稳定性 + +| 项目 | 说明 | +|------|------| +| 条件 | 固件 > 500KB(至少 16 个窗口) | +| 操作 | 连续执行 3 次完整刷写 | +| 预期 | 每次均成功,无丢包报错 | + +**验证要点:** +- `m_esp32ExpectedSeq` 在 `ble_esp32_ota_begin` 中正确重置为 0 +- 旧 OTA 会话被先中止(日志出现 `检测到未完成的 ESP32 OTA,会先中止旧会话`) +- 每次刷写的 `m_esp32Received` 从 0 开始累加 + +--- + +## 二、边界条件测试 + +### T-05 固件大小恰好为窗口整数倍 + +| 项目 | 说明 | +|------|------| +| 条件 | 构造固件大小 = N × 32 × 480,如 15360 字节(1 窗口)或 491520 字节(32 窗口) | +| 预期 | 无尾包,最后一个窗口 `endSeq = packetTotal - 1`,`expectedBytes = totalSize` | + +**验证要点:** +- 最后一个包 `data_len == BLE_OTA_CHUNK_PAYLOAD`,不触发 `isLastPacket` +- 完成条件走 `m_esp32Received >= total_size` 分支 + +### T-06 固件极小(单包/小于窗口) + +| 项目 | 说明 | +|------|------| +| 条件 | 固件 < 480 字节(仅 1 包)或 480–15360 字节(1 窗口内) | +| 预期 | 单包即触发尾包检测或窗口 ACK | + +**验证要点:** +- 单包场景:`seq=0` 同时满足 `(seq+1)%32==0` 否、但 `isLastPacket` 是 +- 设备发送 ACK,`m_esp32Received` 等于实际固件大小 +- `expectedBytes` 正确等于 `totalSize` + +### T-07 固件极大(接近分区上限) + +| 项目 | 说明 | +|------|------| +| 条件 | 固件大小接近 OTA 分区 size(如 ~1.5MB) | +| 预期 | 完整刷写成功,约 50+ 个窗口全部通过 | + +**验证要点:** +- `m_esp32ExpectedSeq` 跨窗口持续递增,不会溢出 `uint16_t`(最大值 65535) +- `ble_esp32_ota_begin` 中 `total_size > check_partition->size` 检查仍正确拒绝超大固件 + +### T-08 payloadSize 非默认值 + +| 项目 | 说明 | +|------|------| +| 条件 | 在页面修改 payload 大小为 256 / 400 / 498 | +| 预期 | 包总数自动调整,每窗口 ACK 字节校验正确 | + +**验证要点:** +- 设备端 `BLE_OTA_CHUNK_PAYLOAD = 480` 是固定值 +- 若 Web 端 payload < 480,设备端 `remaining_after_header < BLE_OTA_CHUNK_PAYLOAD + 4` 会走 `data_len = remaining - 4` 分支 +- 若 Web 端 payload > 480,设备端会按 480 解析,剩余数据被当作下一包处理(可能出错) +- **注意:** payload 大小必须与设备端 `BLE_OTA_CHUNK_PAYLOAD` 一致,此为已知约束 + +### T-09 BLE 断开重连后重新刷写 + +| 项目 | 说明 | +|------|------| +| 操作 | 刷写到一半 → 手动断开 BLE → 重新连接 → 再次刷写 | +| 预期 | 新刷写从 `ble_esp32_ota_begin` 重新开始 | + +**验证要点:** +- `m_esp32ExpectedSeq` 在 `ble_esp32_ota_begin` 中重置为 0 +- 旧 OTA 会话被中止,`m_esp32OtaActive` 被清除 +- 全局互斥 `esp32_ota_mutex_release` 被正确调用 + +--- + +## 三、故障注入测试 + +### T-10 BLE 链路层偶发丢包自动恢复(核心修复验证) + +| 项目 | 说明 | +|------|------| +| 注入方式 | 在 BLE 信号较弱的环境中刷写(距离远/有干扰),或临时将包间延迟移除后快速发送 | +| 预期 | 检测到丢包 → 自动重发窗口 → 恢复成功 | + +**验证要点(日志检查):** +- 出现 `丢包检测: seq=X 丢失Y字节(Z包),第1次重发整个窗口` 警告日志 +- 重发后 ACK 字节数匹配,继续下一窗口 +- 设备端日志出现 `ESP32 OTA 重复包,跳过: seq=X expected=Y` +- 最终刷写成功,无数据损坏 + +### T-11 单窗口连续 2 次丢包(重试用尽) + +| 项目 | 说明 | +|------|------| +| 注入方式 | 极端信号劣化,使一个窗口连续 3 次发送都有丢包 | +| 预期 | 2 次重试用尽后抛出错误 | + +**验证要点:** +- 日志出现第 1 次、第 2 次重发记录 +- 最终报错:`BLE 丢包检测: seq=X 期望Y字节 实际Z字节 丢失W字节(V包) 已重试2次` +- `uploadFirmware` 返回 `false`,toast 显示失败 +- `state.lastUploadStatus = 'failed'` +- 用户手动重新刷写可成功 + +### T-12 通道占用 → 立即失败(核心修复验证) + +| 项目 | 说明 | +|------|------| +| 注入方式 | 先通过 WiFi 通道启动 ESP32 OTA(占用互斥锁),然后通过 BLE 发起刷写 | +| 预期 | BLE 刷写在 < 1 秒内失败,而非等待 10 秒超时 | + +**验证要点(日志检查):** +- 设备端日志:`ESP32 OTA 被其他通道占用,拒绝 BLE OTA begin` +- Web 端日志按顺序出现: + 1. `[FINISH] ESP32 OTA 被其他通道占用,互斥拒绝` + 2. Toast 弹出 `ESP32 OTA 被其他通道占用,请稍后重试` + 3. `写入失败: 设备端 FINISH 错误: stage=0x83 value=0` + 4. `[AUTO] 一键刷写中止` +- **关键时序**:从 FINISH 通知到写入失败之间 < 500ms(此前需要 10 秒) +- 不应出现 `等待 ACK 超时` 日志 + +### T-13 通道占用后重试成功 + +| 项目 | 说明 | +|------|------| +| 操作 | T-12 失败后 → 等待 WiFi OTA 完成 → 重新 BLE 刷写 | +| 预期 | 互斥锁已释放,BLE 刷写成功 | + +**验证要点:** +- `esp32_ota_mutex_release` 在 WiFi OTA 完成后被调用 +- `g_esp32OtaOccupied` 被清除为 `false` +- BLE 刷写正常走完全流程 + +### T-14 esp_ota_end / set_boot_partition 失败快速中断 + +| 项目 | 说明 | +|------|------| +| 注入方式 | 使用损坏的固件文件(无有效 ESP32 镜像头),使 `esp_ota_end` 返回错误 | +| 预期 | 设备发送 `FINISH stage=0x81`,Web 端立即报错 | + +**验证要点:** +- Web 端收到 FINISH 0x81,toast `ESP32 OTA 刷写失败` +- `ackWaiter` 被 reject(如果此时有等待中的 ACK) +- `uploadFirmware` 快速返回 `false` +- 设备端 `esp_ota_abort` 被调用,互斥锁释放 + +### T-15 刷写过程中用户中止 + +| 项目 | 说明 | +|------|------| +| 操作 | 刷写进行中点击「中止」按钮 | +| 预期 | 当前窗口完成后抛出 `用户中止` 错误 | + +**验证要点:** +- `state.abortRequested = true` 在窗口循环开头被检测 +- `throw new Error('用户中止')` 被触发 +- `finally` 块清理 `ackWaiter`、设置 `state.uploading = false` +- 设备端 OTA 会话在下次刷写时被 `ble_esp32_ota_begin` 清理 + +### T-16 窗口末尾丢包恢复(最常见场景) + +| 项目 | 说明 | +|------|------| +| 场景 | 窗口 0(seq 0–31),其中 seq=31 在首次发送时丢失 | +| 预期 | 重发整个窗口后,seq=0–30 被跳过(重复包),seq=31 写入成功 | + +**验证要点:** +- 首次发送:seq=0–30 写入成功,`m_esp32ExpectedSeq = 31` +- seq=31 丢失,设备未收到,不发送 ACK(因为 ACK 触发条件在 seq=31) +- 主机 waitForAck 超时 → 触发窗口重发 +- 重发时 seq=0 到 seq=30:`seq < m_esp32ExpectedSeq(31)` → 重复包跳过 +- 重发时 seq=31:`seq == expectedSeq(31)` → 写入成功,`expectedSeq` 变为 32 +- 触发 ACK,`m_esp32Received = 32 × 480 = 15360`,匹配期望值 +- 设备日志出现 `ESP32 OTA 重复包,跳过: seq=X expected=Y` + +### T-17 窗口中间丢包恢复(严格顺序检测验证) + +| 项目 | 说明 | +|------|------| +| 场景 | 窗口 0(seq 0–31),其中 seq=15 在首次发送时丢失,seq=16–31 正常到达 | +| 预期 | 乱序包被拒绝,重发后缺失包被填补 | + +**验证要点:** +- 首次发送:seq=0–14 写入成功,`m_esp32ExpectedSeq = 15` +- seq=15 丢失 +- seq=16 到达:`seq(16) > expectedSeq(15)` → **乱序包跳过**,设备日志出现 `ESP32 OTA 乱序包,跳过: seq=16 expected=15 (前序包丢失)` +- seq=17–31 同样被跳过(乱序) +- seq=31 被跳过但仍触发 ACK(因 `(seq+1)%32==0`),ACK 回报 `m_esp32Received = 15 × 480 = 7200` +- 主机检测到 7200 < 15360,触发重发 +- 重发:seq=0–14 跳过(重复),seq=15 写入(`expectedSeq` 变为 16),seq=16–31 写入 +- 最终 `m_esp32Received = 15360`,匹配期望值 +- 最终刷写成功,固件数据完整 + +### T-18 窗口内多包丢失跨轮次恢复 + +| 项目 | 说明 | +|------|------| +| 场景 | 窗口内 seq=5 和 seq=20 同时丢失 | +| 预期 | 第 1 次重发填补 seq=5,第 2 次重发填补 seq=20 | + +**验证要点:** +- 首次:seq=0–4 写入,seq=5 丢失,seq=6–31 被拒绝(乱序),ACK 报 2400 字节 +- 第 1 次重发:seq=0–4 跳过,seq=5 写入,seq=6–19 写入,seq=20 丢失,seq=21–31 拒绝,ACK 报 9600 字节 +- 第 2 次重发:seq=0–19 跳过,seq=20 写入,seq=21–31 写入,ACK 报 15360 字节 +- 2 次重试内完成(`MAX_WINDOW_RETRIES = 2`),刷写成功 + +--- + +## 四、性能与回归测试 + +### T-19 包间延迟对总刷写时间的影响 + +| 项目 | 说明 | +|------|------| +| 测量 | 记录完整刷写耗时(修改前 vs 修改后) | +| 预期 | 每窗口增加 ~62ms(31 × 2ms),64 窗口约增加 ~4 秒,可接受 | + +**参考计算:** +- 2035 包 ÷ 32 包/窗口 ≈ 64 窗口 +- 每窗口 31 个间隔 × 2ms = 62ms +- 总额外延迟 ≈ 64 × 62ms ≈ 3.97 秒 +- 总刷写时间预计从 ~30 秒增加到 ~34 秒 + +### T-20 连续多次刷写无内存泄漏 + +| 项目 | 说明 | +|------|------| +| 操作 | 连续刷写 5 次(每次完整流程) | +| 预期 | 设备 free heap 无明显下降 | + +**验证要点:** +- `ble_esp32_ota_begin` 中旧会话清理完整(abort + mutex release + 状态重置) +- `m_esp32ExpectedSeq` 每次重置,不会累积 +- 收尾任务(`ble_esp32_finish_and_reboot_task`)正常删除 + +### T-21 刷写完成后版本验证 + +| 项目 | 说明 | +|------|------| +| 操作 | 刷写完成 → 设备重启 → 重新 BLE 连接 → 读取版本号 | +| 预期 | 版本号与新固件匹配 | + +**验证要点:** +- `esp_ota_set_boot_partition` 指向新分区 +- 设备从新分区启动 +- `readVersion` 返回 `version.h` 中定义的版本号 + +--- + +## 五、修改文件清单 + +| 文件 | 修改内容 | +|------|----------| +| `main/server/ble_ota.h` | 新增 `m_esp32ExpectedSeq` 字段,用于跟踪下一个期望的包序号 | +| `main/server/ble_ota.cpp` | `ble_esp32_ota_begin` 重置期望序号;DATA_IN 回调添加严格顺序检测(拒绝重复包 + 拒绝乱序包) | +| `tools/ble_ota.html` | 包间 2ms 延迟;整窗口重传重试(最多 2 次,50ms 重发前等待);FINISH 错误事件立即 reject ackWaiter | + +## 六、测试优先级 + +| 优先级 | 用例 | 说明 | +|--------|------|------| +| P0 | T-01, T-10, T-12, T-16, T-17 | 核心功能回归 + 两个修复的直接验证 + 丢包恢复场景 | +| P1 | T-03, T-09, T-13, T-15, T-18 | 边界场景 + 恢复路径 + 多包丢失 | +| P2 | T-05, T-06, T-07, T-11, T-14 | 极端边界 + 重试耗尽 | +| P3 | T-02, T-04, T-08, T-19, T-20, T-21 | 回归 + 性能 + 长期稳定性 | diff --git a/main/core/main.cpp b/main/core/main.cpp index 2c38bdf..75cce9a 100644 --- a/main/core/main.cpp +++ b/main/core/main.cpp @@ -11,6 +11,7 @@ #include "esp_system.h" #include "esp_netif.h" #include "esp_event.h" +#include "esp_ota_ops.h" #include "monitor/health_monitor.h" #include "relay/ota_relay.h" @@ -19,15 +20,25 @@ #include "server/ble_ota.h" #include "transport/protocol.h" #include "monitor/debug_log.h" +#include "app_config.h" +#include "ota_mutex.h" /** * @brief 程序主入口,初始化系统组件并创建各 FreeRTOS 任务。 */ +// 全局 ESP32 OTA 互斥变量(声明在 ota_mutex.h) +SemaphoreHandle_t g_esp32OtaMutex = NULL; +volatile bool g_esp32OtaOccupied = false; + extern "C" void app_main(void) { esp_chip_info_t chip_info; esp_chip_info(&chip_info); + // 创建 ESP32 OTA 全局互斥 Mutex + g_esp32OtaMutex = xSemaphoreCreateMutex(); + g_esp32OtaOccupied = false; + // 初始化网络接口 + 事件循环(必须最早) ESP_ERROR_CHECK(esp_netif_init()); ESP_ERROR_CHECK(esp_event_loop_create_default()); @@ -123,6 +134,16 @@ extern "C" void app_main(void) DEBUG_INFO("MAIN", "BLE OTA 任务已创建"); } + // ★ OTA 回滚保护:标记当前固件有效 + // 启用 CONFIG_APP_ROLLBACK_ENABLE 后,新固件首次启动必须调用此函数 + // 若未调用就崩溃/重启,bootloader 会自动回退到上一个有效分区 + esp_err_t ota_valid_err = esp_ota_mark_app_valid_cancel_rollback(); + if (ota_valid_err == ESP_OK) { + DEBUG_INFO("MAIN", "OTA 回滚保护: 当前固件已标记有效"); + } else { + DEBUG_WARN("MAIN", "OTA 回滚保护: 标记失败 (%s),可能非 OTA 启动", esp_err_to_name(ota_valid_err)); + } + DEBUG_INFO("MAIN", "========================================"); DEBUG_INFO("MAIN", "所有任务创建完成,OTA Relay 运行中"); DEBUG_INFO("MAIN", "调试日志输出: UDP(%s:%d) + UART0(GPIO21/TX, 115200)", diff --git a/main/include/app_config.h b/main/include/app_config.h index 7d6a054..06c1dcb 100644 --- a/main/include/app_config.h +++ b/main/include/app_config.h @@ -1,4 +1,4 @@ -/* +/* * app_config.h - 统一配置文件(C++ 版本) * ESP32-C3 OTA 中继项目 * @@ -7,6 +7,7 @@ */ #pragma once #include +#include "freertos/FreeRTOS.h" #include "version.h" // ============================================================ @@ -95,6 +96,7 @@ inline constexpr uint16_t BLE_OTA_DATA_OUT_UUID = 0x0003; inline constexpr uint16_t BLE_OTA_VERSION_UUID = 0x0004; inline constexpr uint8_t BLE_OTA_CTRL_TRIGGER_GD32 = 0xF7; inline constexpr uint8_t BLE_OTA_CTRL_TRIGGER_ESP32 = 0xF8; +inline constexpr uint8_t BLE_OTA_CTRL_VERIFY_SHA256 = 0xF9; // SHA-256哈希验证命令 inline constexpr uint8_t BLE_OTA_CMD_GD32_DATA = 0x00; inline constexpr uint8_t BLE_OTA_CMD_ESP32_DATA = 0x01; inline constexpr size_t BLE_OTA_CHUNK_PAYLOAD = 480; diff --git a/main/include/ota_mutex.h b/main/include/ota_mutex.h new file mode 100644 index 0000000..92d8ad2 --- /dev/null +++ b/main/include/ota_mutex.h @@ -0,0 +1,36 @@ +/* + * ota_mutex.h - ESP32 OTA 全局互斥(BLE / WiFi 通道共享) + * + * 同一时刻只允许一个 OTA 源(BLE 或 WiFi)操作 ESP32 OTA 分区。 + * 变量定义 + Mutex 创建在 main.cpp app_main()。 + */ +#pragma once +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" +#include + +extern SemaphoreHandle_t g_esp32OtaMutex; +extern volatile bool g_esp32OtaOccupied; + +// 获取全局 OTA 占用标志(带超时),成功返回 true +// 内部: Take Mutex → 检查标志 → 若空闲则置 true → Give Mutex +static inline bool esp32_ota_mutex_acquire(uint32_t timeout_ms) { + if (!g_esp32OtaMutex) return false; + if (xSemaphoreTake(g_esp32OtaMutex, pdMS_TO_TICKS(timeout_ms)) != pdTRUE) return false; + if (g_esp32OtaOccupied) { + xSemaphoreGive(g_esp32OtaMutex); + return false; + } + g_esp32OtaOccupied = true; + xSemaphoreGive(g_esp32OtaMutex); + return true; +} + +// 释放全局 OTA 占用标志 +// 内部: Take Mutex → 清除标志 → Give Mutex +static inline void esp32_ota_mutex_release(void) { + if (!g_esp32OtaMutex) return; + xSemaphoreTake(g_esp32OtaMutex, portMAX_DELAY); + g_esp32OtaOccupied = false; + xSemaphoreGive(g_esp32OtaMutex); +} diff --git a/main/include/version.h b/main/include/version.h index b0d545d..e07dfc6 100644 --- a/main/include/version.h +++ b/main/include/version.h @@ -8,7 +8,7 @@ #define FW_VERSION_STRING "0.3.12" // 编译时间 -#define BUILD_TIME "2026-06-13 01:22:52" +#define BUILD_TIME "2026-06-14 17:05:57" // Git 信息 -#define GIT_HASH "d8e4222" +#define GIT_HASH "6a6bc9d" diff --git a/main/monitor/health_monitor.cpp b/main/monitor/health_monitor.cpp index 714e557..8a5aa63 100644 --- a/main/monitor/health_monitor.cpp +++ b/main/monitor/health_monitor.cpp @@ -3,6 +3,8 @@ * 包含:健康状态管理 + WiFi RSSI 监控 + 温度监控 * * 所有功能封装为 HealthMonitor 类方法。 + * 所有对 m_healthState / m_lastTemperature / m_lastWifiRssi 的访问 + * 均通过 m_mutex 保护,消除跨任务竞态。 */ #include #include @@ -18,11 +20,6 @@ #include "debug_log.h" #include "server/wifi_ota.h" -// ============================================================ -// 静态成员 -// ============================================================ -health_state_t HealthMonitor::healthState = SYS_NORMAL; - // ============================================================ // 单例 // ============================================================ @@ -44,36 +41,54 @@ void HealthMonitor::init() } // ============================================================ -// 获取当前健康状态 +// 线程安全的健康状态读写 +// ============================================================ +health_state_t HealthMonitor::getState() +{ + if (m_mutex) xSemaphoreTake(m_mutex, portMAX_DELAY); + health_state_t s = m_healthState; + if (m_mutex) xSemaphoreGive(m_mutex); + return s; +} + +void HealthMonitor::setState(health_state_t newState) +{ + if (m_mutex) xSemaphoreTake(m_mutex, portMAX_DELAY); + m_healthState = newState; + if (m_mutex) xSemaphoreGive(m_mutex); +} + +// ============================================================ +// 获取当前健康状态(public 接口) // ============================================================ health_state_t HealthMonitor::getCurrent() { - health_state_t state; - if (m_mutex) { - xSemaphoreTake(m_mutex, portMAX_DELAY); - } - state = healthState; - if (m_mutex) { - xSemaphoreGive(m_mutex); - } - return state; + return getState(); } float HealthMonitor::getLastTemperature() { - return m_lastTemperature; + if (m_mutex) xSemaphoreTake(m_mutex, portMAX_DELAY); + float t = m_lastTemperature; + if (m_mutex) xSemaphoreGive(m_mutex); + return t; } bool HealthMonitor::getLastWifiRssi(int8_t *out_rssi, uint32_t *out_age_ms) { - if (m_lastWifiRssiMs <= 0) { + if (m_mutex) xSemaphoreTake(m_mutex, portMAX_DELAY); + int64_t lastMs = m_lastWifiRssiMs; + int8_t rssi = m_lastWifiRssi; + if (m_mutex) xSemaphoreGive(m_mutex); + + if (lastMs <= 0) { return false; } int64_t now_ms = esp_timer_get_time() / 1000; - uint32_t age_ms = (now_ms > m_lastWifiRssiMs) ? (uint32_t)(now_ms - m_lastWifiRssiMs) : 0; + uint32_t age_ms = (now_ms > lastMs) ? (uint32_t)(now_ms - lastMs) : 0; - if (out_rssi) *out_rssi = m_lastWifiRssi; + if (out_rssi) *out_rssi = rssi; if (out_age_ms) *out_age_ms = age_ms; return true; } @@ -91,15 +106,18 @@ void HealthMonitor::tempMonitorCallback(void *arg) return; } + if (self.m_mutex) xSemaphoreTake(self.m_mutex, portMAX_DELAY); self.m_lastTemperature = temp; + if (self.m_mutex) xSemaphoreGive(self.m_mutex); if (temp >= TEMP_CRITICAL) { - healthState = SYS_SAFE_SHUTDOWN; + self.setState(SYS_SAFE_SHUTDOWN); DEBUG_ERR("TEMP", "!!! 危险 !!! 温度 %.1f°C >= %d°C,触发安全关机", temp, TEMP_CRITICAL); DEBUG_ERR("TEMP", "健康状态变更为: SYS_SAFE_SHUTDOWN"); } else if (temp >= TEMP_WARNING) { - if (healthState < SYS_WARNING) { - healthState = SYS_WARNING; + health_state_t cur = self.getState(); + if (cur < SYS_WARNING) { + self.setState(SYS_WARNING); DEBUG_WARN("TEMP", "警告: 温度 %.1f°C >= %d°C", temp, TEMP_WARNING); DEBUG_WARN("TEMP", "健康状态变更为: SYS_WARNING"); } @@ -144,8 +162,9 @@ void HealthMonitor::taskEntry(void *pvParameters) if (free_heap < HEAP_WARNING_MIN) { DEBUG_WARN("MON", "堆内存不足: %lu < %lu", free_heap, HEAP_WARNING_MIN); } - if (healthState >= SYS_CRITICAL) { - DEBUG_ERR("MON", "健康状态危险: %d", healthState); + health_state_t cur = self.getState(); + if (cur >= SYS_CRITICAL) { + DEBUG_ERR("MON", "健康状态危险: %d", cur); } vTaskDelay(pdMS_TO_TICKS(5000)); } @@ -165,14 +184,15 @@ void HealthMonitor::logEvent(const char *event, int value) health_status_t HealthMonitor::checkBeforeOta() { health_status_t status = HEALTH_MON_OK; + health_state_t cur = getState(); - if (healthState >= SYS_SAFE_SHUTDOWN) { + if (cur >= SYS_SAFE_SHUTDOWN) { DEBUG_WARN("HEALTH", "OTA 前检查: SYS_SAFE_SHUTDOWN,禁止传输"); return HEALTH_MON_SHUTDOWN; - } else if (healthState >= SYS_CRITICAL) { + } else if (cur >= SYS_CRITICAL) { DEBUG_WARN("HEALTH", "OTA 前检查: SYS_CRITICAL,禁止传输"); return HEALTH_MON_CRITICAL; - } else if (healthState >= SYS_WARNING) { + } else if (cur >= SYS_WARNING) { status = HEALTH_MON_WARNING; DEBUG_WARN("HEALTH", "OTA 前检查: SYS_WARNING,继续传输但需注意"); } @@ -210,18 +230,19 @@ health_status_t HealthMonitor::checkBeforeOta() health_status_t HealthMonitor::checkBeforeOtaRelay() { health_status_t status = HEALTH_MON_OK; + health_state_t cur = getState(); - if (healthState >= SYS_SAFE_SHUTDOWN) { + if (cur >= SYS_SAFE_SHUTDOWN) { DEBUG_WARN("HEALTH", "OTA 中继检查: SYS_SAFE_SHUTDOWN,禁止传输"); return HEALTH_MON_SHUTDOWN; - } else if (healthState >= SYS_CRITICAL) { + } else if (cur >= SYS_CRITICAL) { /* SYS_CRITICAL 可能是由 WiFi RSSI 过低导致的, * 但 SPI 中继阶段不依赖 WiFi,只检查温度/硬件等本地状态。 - * 温度达到 TEMP_CRITICAL(100°C) 时 g_health_state 会被设为 + * 温度达到 TEMP_CRITICAL(100°C) 时 m_healthState 会被设为 * SYS_SAFE_SHUTDOWN,在上面的分支已经拦截。 * 所以 SYS_CRITICAL 在中继阶段只可能是 WiFi 导致的,允许继续。 */ DEBUG_WARN("HEALTH", "OTA 中继检查: SYS_CRITICAL (WiFi 相关),SPI 中继允许继续"); - } else if (healthState >= SYS_WARNING) { + } else if (cur >= SYS_WARNING) { status = HEALTH_MON_WARNING; DEBUG_WARN("HEALTH", "OTA 中继检查: SYS_WARNING,继续传输"); } @@ -243,24 +264,26 @@ health_status_t HealthMonitor::checkBeforeOtaRelay() health_status_t HealthMonitor::updateFromWifiRssi(int8_t rssi) { if (rssi < WIFI_RSSI_CRITICAL) { - healthState = SYS_CRITICAL; + setState(SYS_CRITICAL); DEBUG_WARN("HEALTH", "WiFi RSSI 危险: %d dBm < %d dBm", rssi, WIFI_RSSI_CRITICAL); logEvent("WIFI危险", rssi); return HEALTH_MON_CRITICAL; } else if (rssi < WIFI_RSSI_NORMAL) { - if (healthState < SYS_WARNING) { - healthState = SYS_WARNING; + health_state_t cur = getState(); + if (cur < SYS_WARNING) { + setState(SYS_WARNING); DEBUG_WARN("HEALTH", "WiFi RSSI 警告: %d dBm < %d dBm", rssi, WIFI_RSSI_NORMAL); logEvent("WIFI警告", rssi); } return HEALTH_MON_WARNING; } - if (healthState == SYS_WARNING && rssi >= WIFI_RSSI_NORMAL) { - healthState = SYS_NORMAL; + health_state_t cur = getState(); + if (cur == SYS_WARNING && rssi >= WIFI_RSSI_NORMAL) { + setState(SYS_NORMAL); DEBUG_INFO("HEALTH", "WiFi RSSI 恢复: %d dBm >= %d dBm", rssi, WIFI_RSSI_NORMAL); logEvent("WIFI恢复", rssi); } return HEALTH_MON_OK; -} \ No newline at end of file +} diff --git a/main/monitor/health_monitor.h b/main/monitor/health_monitor.h index 036d6be..fe625de 100644 --- a/main/monitor/health_monitor.h +++ b/main/monitor/health_monitor.h @@ -54,7 +54,7 @@ public: /** @brief 记录一条健康事件日志。 */ void logEvent(const char *event, int value); - /** @brief 获取当前系统健康状态。 */ + /** @brief 获取当前系统健康状态(线程安全)。 */ health_state_t getCurrent(); /** @brief 根据 WiFi RSSI 更新健康状态。 */ @@ -66,10 +66,13 @@ public: /** @brief 获取最近一次 WiFi RSSI 及其年龄。 */ bool getLastWifiRssi(int8_t *out_rssi, uint32_t *out_age_ms); - /** @brief 全局健康状态(public,供外部读取)。 */ - static health_state_t healthState; - private: + /** @brief 加锁读取健康状态。 */ + health_state_t getState(); + + /** @brief 加锁设置健康状态。 */ + void setState(health_state_t newState); + HealthMonitor() = default; ~HealthMonitor() = default; HealthMonitor(const HealthMonitor&) = delete; @@ -77,6 +80,7 @@ private: SemaphoreHandle_t m_mutex = NULL; temperature_sensor_handle_t m_tempHandle = NULL; + health_state_t m_healthState = SYS_NORMAL; float m_lastTemperature = 0.0f; int8_t m_lastWifiRssi = -127; int64_t m_lastWifiRssiMs = 0; diff --git a/main/relay/ota_relay.cpp b/main/relay/ota_relay.cpp index abdc892..3d3a443 100644 --- a/main/relay/ota_relay.cpp +++ b/main/relay/ota_relay.cpp @@ -1,10 +1,11 @@ -/* +/* * ota_relay.cpp - OTA 中继核心状态机实现 * T2.3, T2.4, T3.1 - T3.7 * * 所有功能封装为 OtaRelay 类方法。 */ #include +#include #include #include "freertos/FreeRTOS.h" #include "freertos/task.h" @@ -14,6 +15,8 @@ #include "nvs_flash.h" #include "nvs.h" #include "aes/esp_aes.h" +#define MBEDTLS_DECLARE_PRIVATE_IDENTIFIERS +#include "mbedtls/private/sha256.h" #include "ota_relay.h" #include "spi_master.h" @@ -31,12 +34,81 @@ volatile bool OtaRelay::gd32AppAlive = false; gd32_version_t OtaRelay::gd32Version = {}; QueueHandle_t OtaRelay::dispatchQueue = NULL; QueueHandle_t OtaRelay::resultQueue = NULL; +SemaphoreHandle_t OtaRelay::s_stateMutex = NULL; OtaRelay& OtaRelay::instance() { static OtaRelay inst; return inst; } +void OtaRelay::initMutex() +{ + if (!s_stateMutex) { + s_stateMutex = xSemaphoreCreateMutex(); + } +} + +relay_state_t OtaRelay::getState() +{ + StateLockGuard lock; + return relayState; +} + +uint32_t OtaRelay::getRelaySeq() +{ + StateLockGuard lock; + return relaySeq; +} + +uint32_t OtaRelay::getRelayTotalPackets() +{ + StateLockGuard lock; + return relayTotalPackets; +} + +bool OtaRelay::getGd32AppAlive() +{ + StateLockGuard lock; + return gd32AppAlive; +} + +void OtaRelay::getGd32Version(gd32_version_t *out) +{ + if (!out) return; + StateLockGuard lock; + *out = gd32Version; +} + +void OtaRelay::setState(relay_state_t s) +{ + StateLockGuard lock; + relayState = s; +} + +void OtaRelay::setRelaySeq(uint32_t seq) +{ + StateLockGuard lock; + relaySeq = seq; +} + +void OtaRelay::setRelayTotalPackets(uint32_t total) +{ + StateLockGuard lock; + relayTotalPackets = total; +} + +void OtaRelay::setGd32AppAlive(bool alive) +{ + StateLockGuard lock; + gd32AppAlive = alive; +} + +void OtaRelay::setGd32Version(const gd32_version_t &ver) +{ + StateLockGuard lock; + gd32Version = ver; +} + // ============================================================ @@ -236,6 +308,13 @@ esp_err_t OtaRelay::metaLoad(ota_meta_t *meta) if (nvs_get_u32(handle, "last_seq", &val) == ESP_OK) meta->last_seq = val; if (nvs_get_u32(handle, "chunk_count", &val) == ESP_OK) meta->chunk_count = val; + size_t sha_len = sizeof(meta->checksum); + if (nvs_get_blob(handle, "checksum", meta->checksum, &sha_len) == ESP_OK && sha_len == sizeof(meta->checksum)) { + // checksum 已从 NVS 恢复 + } else { + memset(meta->checksum, 0, sizeof(meta->checksum)); + } + nvs_close(handle); return ESP_OK; } @@ -260,6 +339,7 @@ esp_err_t OtaRelay::metaSave(const ota_meta_t *meta) nvs_set_u32(handle, "stored_size", meta->stored_size); nvs_set_u32(handle, "last_seq", meta->last_seq); nvs_set_u32(handle, "chunk_count", meta->chunk_count); + nvs_set_blob(handle, "checksum", meta->checksum, sizeof(meta->checksum)); err = nvs_commit(handle); nvs_close(handle); @@ -298,6 +378,131 @@ esp_err_t OtaRelay::metaClear() return OtaRelay::metaSave(&meta); } +/** + * @brief 计算 gd32_ota 分区中已存储固件的 SHA-256 + * @param out_sha256 输出 32 字节 SHA-256 摘要。 + * @return ESP_OK 成功,其他失败。 + */ +esp_err_t OtaRelay::computeFlashSha256(uint8_t out_sha256[32]) +{ + if (!out_sha256) return ESP_ERR_INVALID_ARG; + + ota_meta_t meta; + esp_err_t err = metaLoad(&meta); + if (err != ESP_OK || meta.total_size == 0) { + DEBUG_ERR("OTA", "computeSha256: 元数据无效"); + return ESP_ERR_INVALID_STATE; + } + + mbedtls_sha256_context ctx; + mbedtls_sha256_init(&ctx); + if (mbedtls_sha256_starts(&ctx, 0) != 0) { + mbedtls_sha256_free(&ctx); + return ESP_FAIL; + } + + static constexpr size_t kChunkSize = 1024; + uint8_t *buf = (uint8_t *)malloc(kChunkSize); + if (!buf) { + mbedtls_sha256_free(&ctx); + return ESP_ERR_NO_MEM; + } + + uint32_t remaining = meta.total_size; + uint32_t offset = 0; + while (remaining > 0) { + size_t to_read = (remaining > kChunkSize) ? kChunkSize : remaining; + err = flashReadChunk(offset, buf, to_read); + if (err != ESP_OK) { + DEBUG_ERR("OTA", "computeSha256: Flash 读取失败 offset=%" PRIu32, offset); + free(buf); + mbedtls_sha256_free(&ctx); + return err; + } + if (mbedtls_sha256_update(&ctx, buf, to_read) != 0) { + free(buf); + mbedtls_sha256_free(&ctx); + return ESP_FAIL; + } + offset += to_read; + remaining -= to_read; + } + + free(buf); + int ret = mbedtls_sha256_finish(&ctx, out_sha256); + mbedtls_sha256_free(&ctx); + return (ret == 0) ? ESP_OK : ESP_FAIL; +} + +/** + * @brief 固件接收完毕后,计算 SHA-256 并写入元数据 + * @return ESP_OK 成功,其他失败。 + */ +esp_err_t OtaRelay::metaFinalizeChecksum() +{ + ota_meta_t meta; + esp_err_t err = metaLoad(&meta); + if (err != ESP_OK) return err; + + if (meta.total_size == 0 || meta.stored_size < meta.total_size) { + DEBUG_ERR("OTA", "metaFinalize: 固件不完整 stored=%" PRIu32 " total=%" PRIu32, + meta.stored_size, meta.total_size); + return ESP_ERR_INVALID_STATE; + } + + err = computeFlashSha256(meta.checksum); + if (err != ESP_OK) { + DEBUG_ERR("OTA", "metaFinalize: SHA-256 计算失败"); + return err; + } + + char hex[65] = {0}; + for (int i = 0; i < 32; ++i) + snprintf(&hex[i * 2], sizeof(hex) - i * 2, "%02X", meta.checksum[i]); + DEBUG_INFO("OTA", "固件 SHA-256: %s", hex); + + return metaSave(&meta); +} + +/** + * @brief 中继转发前校验固件完整性 + * @return ESP_OK 校验通过,ESP_ERR_INVALID_CRC 校验失败。 + */ +esp_err_t OtaRelay::metaVerifyChecksum() +{ + ota_meta_t meta; + esp_err_t err = metaLoad(&meta); + if (err != ESP_OK) return err; + + // 全零 checksum 表示从未计算过(兼容旧固件) + if (bytesAllValue(meta.checksum, sizeof(meta.checksum), 0x00)) { + DEBUG_WARN("OTA", "metaVerify: checksum 未计算,跳过校验(旧数据兼容)"); + return ESP_OK; + } + + uint8_t computed[32] = {0}; + err = computeFlashSha256(computed); + if (err != ESP_OK) { + DEBUG_ERR("OTA", "metaVerify: SHA-256 计算失败"); + return err; + } + + if (memcmp(computed, meta.checksum, 32) != 0) { + char hex_stored[65] = {0}, hex_computed[65] = {0}; + for (int i = 0; i < 32; ++i) { + snprintf(&hex_stored[i * 2], sizeof(hex_stored) - i * 2, "%02X", meta.checksum[i]); + snprintf(&hex_computed[i * 2], sizeof(hex_computed) - i * 2, "%02X", computed[i]); + } + DEBUG_ERR("OTA", "固件完整性校验失败!"); + DEBUG_ERR("OTA", " 存储 SHA-256: %s", hex_stored); + DEBUG_ERR("OTA", " 计算 SHA-256: %s", hex_computed); + return ESP_ERR_INVALID_CRC; + } + + DEBUG_INFO("OTA", "固件完整性校验通过 (SHA-256 匹配)"); + return ESP_OK; +} + /** * @brief Flash 写入接口 * @param offset 分区内写入偏移。 @@ -316,7 +521,58 @@ esp_err_t OtaRelay::flashWriteChunk(uint32_t offset, const uint8_t *data, size_t } } - return esp_partition_write(instance().m_gd32Partition, offset, data, len); + // --- offset 必须4字节对齐 --- + if (offset % 4 != 0) { + DEBUG_ERR("OTA", "flashWriteChunk offset非4对齐: offset=%" PRIu32, offset); + return ESP_ERR_INVALID_ARG; + } + + const esp_partition_t *part = instance().m_gd32Partition; + + // --- len已对齐:直接写入 --- + if (len % 4 == 0) { + if (offset + len > part->size) { + DEBUG_ERR("OTA", "flashWriteChunk越界: offset=%" PRIu32 " len=%zu part_size=%" PRIu32, + offset, len, part->size); + return ESP_ERR_INVALID_SIZE; + } + return esp_partition_write(part, offset, data, len); + } + + // --- len未对齐:read-modify-write --- + size_t aligned_len = (len + 3) & ~(size_t)3; + + if (offset + aligned_len > part->size) { + DEBUG_ERR("OTA", "flashWriteChunk对齐后越界: offset=%" PRIu32 " len=%zu aligned=%zu part=%" PRIu32, + offset, len, aligned_len, part->size); + return ESP_ERR_INVALID_SIZE; + } + + DEBUG_WARN("OTA", "flashWriteChunk len非4对齐: len=%zu aligned_len=%zu offset=%" PRIu32, + len, aligned_len, offset); + + // 读回尾部padding字节(offset+len处,最多3字节) + size_t pad_len = aligned_len - len; + uint8_t pad[4] = {0}; + esp_err_t err = esp_partition_read(part, offset + len, pad, pad_len); + if (err != ESP_OK) { + DEBUG_ERR("OTA", "flashWriteChunk读回padding失败: offset=%" PRIu32 " pad=%zu err=%s", + offset + len, pad_len, esp_err_to_name(err)); + return err; + } + + // 拼接:data + pad -> aligned_buf + uint8_t *aligned_buf = (uint8_t *)malloc(aligned_len); + if (!aligned_buf) { + DEBUG_ERR("OTA", "flashWriteChunk malloc失败: %zu字节", aligned_len); + return ESP_ERR_NO_MEM; + } + memcpy(aligned_buf, data, len); + memcpy(aligned_buf + len, pad, pad_len); + + err = esp_partition_write(part, offset, aligned_buf, aligned_len); + free(aligned_buf); + return err; } /** @@ -579,7 +835,7 @@ ota_result_t OtaRelay::sendFirmwareSize(uint32_t size) * @brief 从 Flash 向 GD32 转发存储的固件(48B/包) * @return 操作结果码。 */ -ota_result_t OtaRelay::relayPacketsFromFlash() +ota_result_t OtaRelay::relayPacketsFromFlash(TickType_t deadline_tick) { DEBUG_INFO("OTA", "=== 阶段3: 从 Flash 转发固件 ==="); @@ -613,6 +869,12 @@ ota_result_t OtaRelay::relayPacketsFromFlash() } } + /* 全局超时检查:避免单包 WAIT 循环累计导致整体挂起 */ + if (deadline_tick != 0 && xTaskGetTickCount() >= deadline_tick) { + DEBUG_ERR("OTA", "全局超时: 包 %u/%u 已超限,中止转发", seq, total_packets); + return OTA_RESULT_ERR_USER_ABORT; + } + uint8_t plain48[GD32_OTA_PACKET_SIZE]; memset(plain48, 0xFF, sizeof(plain48)); size_t chunk_size = GD32_OTA_PACKET_SIZE; @@ -920,10 +1182,19 @@ ota_result_t OtaRelay::startHandshake() ota_meta_t meta; OtaRelay::metaLoad(&meta); + + // 固件完整性校验 + esp_err_t verify_err = metaVerifyChecksum(); + if (verify_err != ESP_OK) { + DEBUG_ERR("OTA", "固件完整性校验失败,中止转发"); + reset(); + return OTA_RESULT_ERR_CRC; + } + res = sendFirmwareSize(meta.total_size); if (res != OTA_RESULT_OK) { reset(); return res; } - res = relayPacketsFromFlash(); + res = relayPacketsFromFlash(0); if (res != OTA_RESULT_OK) { reset(); return res; } res = finishOta(); @@ -945,40 +1216,82 @@ ota_result_t OtaRelay::forwardFirmware() DEBUG_INFO("OTA", "开始固件转发: %u 字节, %u 块", meta.total_size, meta.chunk_count); + // 固件完整性校验 + { + esp_err_t verify_err = metaVerifyChecksum(); + if (verify_err != ESP_OK) { + DEBUG_ERR("OTA", "固件完整性校验失败,中止转发"); + return OTA_RESULT_ERR_CRC; + } + } + + /* 全局超时:动态计算,min = 5min, max = 10min, 按 total_size/1024*2ms 递增 */ + constexpr uint32_t kMinGlobalTimeoutMs = 5U * 60U * 1000U; + constexpr uint32_t kMaxGlobalTimeoutMs = 10U * 60U * 1000U; + uint32_t dynamic_ms = kMinGlobalTimeoutMs + (meta.total_size / 1024U) * 2U; + uint32_t global_timeout_ms = (dynamic_ms > kMaxGlobalTimeoutMs) ? kMaxGlobalTimeoutMs : dynamic_ms; + TickType_t start_tick = xTaskGetTickCount(); + TickType_t deadline_tick = start_tick + pdMS_TO_TICKS(global_timeout_ms); + + DEBUG_INFO("OTA", "全局超时: %u ms (%u 秒), deadline tick=%u", + global_timeout_ms, global_timeout_ms / 1000U, (unsigned)deadline_tick); + requestGd32AppEnterBootloader(); + /* 在每个阶段前检查全局超时,超时则 reset() 并返回 OTA_RESULT_ERR_USER_ABORT */ + if (xTaskGetTickCount() >= deadline_tick) { + DEBUG_ERR("OTA", "全局超时: 在 requestBootloader 阶段已超限 (%u ms),中止 OTA", global_timeout_ms); + reset(); + return OTA_RESULT_ERR_USER_ABORT; + } + ota_result_t res = sendHandshakeReq(); if (res != OTA_RESULT_OK) { reset(); return res; } + if (xTaskGetTickCount() >= deadline_tick) { + DEBUG_ERR("OTA", "全局超时: 在 sendHandshakeReq 阶段已超限 (%u ms),中止 OTA", global_timeout_ms); + reset(); + return OTA_RESULT_ERR_USER_ABORT; + } + res = sendNonceAndAuthenticate(); if (res != OTA_RESULT_OK) { reset(); return res; } + if (xTaskGetTickCount() >= deadline_tick) { + DEBUG_ERR("OTA", "全局超时: 在 sendNonceAndAuthenticate 阶段已超限 (%u ms),中止 OTA", global_timeout_ms); + reset(); + return OTA_RESULT_ERR_USER_ABORT; + } + res = sendFirmwareSize(meta.total_size); if (res != OTA_RESULT_OK) { reset(); return res; } - res = relayPacketsFromFlash(); + if (xTaskGetTickCount() >= deadline_tick) { + DEBUG_ERR("OTA", "全局超时: 在 sendFirmwareSize 阶段已超限 (%u ms),中止 OTA", global_timeout_ms); + reset(); + return OTA_RESULT_ERR_USER_ABORT; + } + + res = relayPacketsFromFlash(deadline_tick); if (res != OTA_RESULT_OK) { reset(); return res; } + if (xTaskGetTickCount() >= deadline_tick) { + DEBUG_ERR("OTA", "全局超时: 在 relayPacketsFromFlash 阶段已超限 (%u ms),中止 OTA", global_timeout_ms); + reset(); + return OTA_RESULT_ERR_USER_ABORT; + } + res = finishOta(); return res; } -/** - * @brief 获取当前状态 - * @return 当前中继状态枚举值。 - */ -relay_state_t OtaRelay::getState() -{ - /** @brief 当前 OTA 中继状态机状态。 */ - return relayState; -} - /** * @brief 重置状态机 */ void OtaRelay::reset() { + StateLockGuard lock; /** @brief 当前 OTA 中继状态机状态。 */ relayState = RELAY_IDLE; relaySeq = 0; @@ -998,6 +1311,8 @@ void OtaRelay::taskEntry(void *pvParameters) { DEBUG_INFO("OTA", "OTA 中继任务启动"); + initMutex(); + /** @brief OTA 命令分发队列,从 WiFi/BLE 任务接收指令。 */ dispatchQueue = xQueueCreate(4, sizeof(uint32_t)); /** @brief OTA 结果回传队列,将执行结果返回给调用方。 */ @@ -1026,6 +1341,8 @@ void OtaRelay::taskEntry(void *pvParameters) DEBUG_INFO("OTA", "OTA 转发成功完成"); /* 保持 RELAY_DONE 状态 3 秒,让网页轮询有机会看到完成状态 */ vTaskDelay(pdMS_TO_TICKS(3000)); + } else if (result == OTA_RESULT_ERR_USER_ABORT) { + DEBUG_ERR("OTA", "OTA 全局超时中止 (OTA_RESULT_ERR_USER_ABORT=%d)", result); } else { DEBUG_ERR("OTA", "OTA 转发失败,错误码: %d", result); } @@ -1033,4 +1350,3 @@ void OtaRelay::taskEntry(void *pvParameters) } } - diff --git a/main/relay/ota_relay.h b/main/relay/ota_relay.h index 0689605..be8f1d3 100644 --- a/main/relay/ota_relay.h +++ b/main/relay/ota_relay.h @@ -1,4 +1,4 @@ -/* +/* * ota_relay.h - OTA 中继核心状态机 * T2.3, T3.1 - T3.7 * @@ -10,6 +10,7 @@ #include #include "freertos/FreeRTOS.h" #include "freertos/queue.h" +#include "freertos/semphr.h" #include "transport/protocol.h" #include "transport/spi_master.h" #include "esp_partition.h" @@ -66,7 +67,23 @@ public: static void taskEntry(void *pvParameters); ota_result_t startHandshake(); ota_result_t forwardFirmware(); - relay_state_t getState(); + + /* ---- 线程安全的状态访问接口 ---- + * OTA 任务写、HTTP 任务读,通过 mutex 保证一致性。 + * 状态机内部(OTA 任务)可直接写 static 成员, + * 跨任务读/写必须走这些 API。 + */ + static relay_state_t getState(); + static uint32_t getRelaySeq(); + static uint32_t getRelayTotalPackets(); + static bool getGd32AppAlive(); + static void getGd32Version(gd32_version_t *out); + static void setState(relay_state_t s); + static void setRelaySeq(uint32_t seq); + static void setRelayTotalPackets(uint32_t total); + static void setGd32AppAlive(bool alive); + static void setGd32Version(const gd32_version_t &ver); + void reset(); static esp_err_t flashWriteChunk(uint32_t offset, const uint8_t *data, size_t len); @@ -75,8 +92,11 @@ public: static esp_err_t metaSave(const ota_meta_t *meta); static esp_err_t metaUpdate(uint32_t stored_size, uint32_t last_seq); static esp_err_t metaClear(); + static esp_err_t computeFlashSha256(uint8_t out_sha256[32]); + static esp_err_t metaFinalizeChecksum(); + static esp_err_t metaVerifyChecksum(); - // 公开状态 + // 公开状态(状态机内部直接访问;跨任务请用 getter/setter) static relay_state_t relayState; static uint32_t relaySeq; static uint32_t relayTotalPackets; @@ -85,6 +105,26 @@ public: static QueueHandle_t dispatchQueue; static QueueHandle_t resultQueue; + // 线程安全锁 + static SemaphoreHandle_t s_stateMutex; + static void initMutex(); + static constexpr TickType_t kLockTimeout = pdMS_TO_TICKS(50); + + class StateLockGuard { + public: + explicit StateLockGuard(TickType_t timeout = kLockTimeout) { + m_locked = (s_stateMutex && xSemaphoreTake(s_stateMutex, timeout) == pdTRUE); + } + ~StateLockGuard() { + if (m_locked && s_stateMutex) { xSemaphoreGive(s_stateMutex); } + } + bool isLocked() const { return m_locked; } + StateLockGuard(const StateLockGuard&) = delete; + StateLockGuard& operator=(const StateLockGuard&) = delete; + private: + bool m_locked = false; + }; + // 实例状态(public,供状态机方法访问) const esp_partition_t *m_gd32Partition = NULL; session_ctx_t m_sessionCtx; @@ -100,7 +140,7 @@ public: ota_result_t sendHandshakeReq(); ota_result_t sendNonceAndAuthenticate(); ota_result_t sendFirmwareSize(uint32_t size); - ota_result_t relayPacketsFromFlash(); + ota_result_t relayPacketsFromFlash(TickType_t deadline_tick = 0); ota_result_t finishOta(); static int aes128EcbEncryptBlockLocal(const uint8_t *key, const uint8_t *in, uint8_t *out); @@ -141,6 +181,4 @@ private: OtaRelay& operator=(const OtaRelay&) = delete; }; -#endif // OTA_RELAY_H - - +#endif // OTA_RELAY_H \ No newline at end of file diff --git a/main/server/ble_ota.cpp b/main/server/ble_ota.cpp index bd0a9fe..81d167c 100644 --- a/main/server/ble_ota.cpp +++ b/main/server/ble_ota.cpp @@ -34,7 +34,12 @@ #include "services/gatt/ble_svc_gatt.h" #include "host/ble_att.h" +// SHA-256 哈希计算 +#define MBEDTLS_DECLARE_PRIVATE_IDENTIFIERS +#include "mbedtls/private/sha256.h" + #include "app_config.h" +#include "ota_mutex.h" #include "ble_ota.h" #include "ota_relay.h" #include "health_monitor.h" @@ -53,6 +58,9 @@ #define BLE_OTA_FINISH_ERR_OTA_END 0x81 #define BLE_OTA_FINISH_ERR_SET_BOOT 0x82 #define BLE_OTA_ACK_WINDOW 32 +#define BLE_ATT_ERR_OTA_BUSY 0x80 // ESP32 OTA 被其他通道占用 +#define BLE_OTA_FINISH_ERR_MUTEX 0x83 // 互斥拒绝: OTA 被其他通道占用 +#define BLE_OTA_FINISH_ERR_GD32_SIZE 0x84 // GD32 固件超出分区大小 // ============================================================ // 前向声明 @@ -97,6 +105,20 @@ static void ble_esp32_finish_and_reboot_task(void *pvParameters) DEBUG_INFO(TAG, "ESP32 OTA 收尾: 共 %u 字节, 设置启动分区...", (unsigned int)BleOta::instance().m_esp32Received); + // 完成 SHA-256 哈希计算 + if (BleOta::instance().m_sha256Ctx != nullptr) { + mbedtls_sha256_finish(BleOta::instance().m_sha256Ctx, BleOta::instance().m_esp32Sha256); + mbedtls_sha256_free(BleOta::instance().m_sha256Ctx); + BleOta::instance().m_esp32HashComputed = true; + + // 打印SHA-256哈希值 + char hex[65] = {0}; + for (int i = 0; i < 32; ++i) { + snprintf(&hex[i * 2], sizeof(hex) - i * 2, "%02X", BleOta::instance().m_esp32Sha256[i]); + } + DEBUG_INFO(TAG, "ESP32 固件 SHA-256: %s", hex); + } + int64_t t0_us = esp_timer_get_time(); DEBUG_INFO(TAG, "[FINISH] 即将执行 esp_ota_end()"); sendFinishEvent(BLE_OTA_FINISH_BEGIN_OTA_END, 0); @@ -107,6 +129,7 @@ static void ble_esp32_finish_and_reboot_task(void *pvParameters) esp_err_to_name(err), (long long)((t1_us - t0_us) / 1000)); sendFinishEvent(BLE_OTA_FINISH_ERR_OTA_END, (uint32_t)err); esp_ota_abort(BleOta::instance().m_esp32OtaHandle); + esp32_ota_mutex_release(); BleOta::instance().m_esp32OtaActive = false; BleOta::instance().m_esp32OtaHandle = 0; BleOta::instance().m_esp32UpdatePartition = NULL; @@ -128,6 +151,7 @@ static void ble_esp32_finish_and_reboot_task(void *pvParameters) DEBUG_ERR(TAG, "[FINISH] 设置启动分区失败: %s (耗时=%lld ms)", esp_err_to_name(err), (long long)((t3_us - t2_us) / 1000)); sendFinishEvent(BLE_OTA_FINISH_ERR_SET_BOOT, (uint32_t)err); + esp32_ota_mutex_release(); BleOta::instance().m_esp32OtaActive = false; BleOta::instance().m_esp32OtaHandle = 0; BleOta::instance().m_esp32UpdatePartition = NULL; @@ -140,10 +164,12 @@ static void ble_esp32_finish_and_reboot_task(void *pvParameters) (long long)((t3_us - t2_us) / 1000)); sendFinishEvent(BLE_OTA_FINISH_DONE_SET_BOOT, (uint32_t)((t3_us - t2_us) / 1000)); + esp32_ota_mutex_release(); BleOta::instance().m_esp32OtaActive = false; BleOta::instance().m_esp32OtaHandle = 0; BleOta::instance().m_esp32UpdatePartition = NULL; BleOta::instance().m_esp32Received = 0; + // SHA-256哈希已在之前计算完成,保留供前端读取 DEBUG_INFO(TAG, "ESP32 OTA 收尾完成,800ms 后重启..."); int64_t t4_us = esp_timer_get_time(); @@ -177,6 +203,20 @@ static int ble_ota_mbuf_to_buf(struct os_mbuf *om, uint8_t *buf, size_t buf_len) return (int)copy_len; } +/** + * @brief 清理 SHA-256 上下文和哈希状态 + */ +static void ble_esp32_ota_cleanup_sha() +{ + if (BleOta::instance().m_sha256Ctx != nullptr) { + mbedtls_sha256_free(BleOta::instance().m_sha256Ctx); + delete BleOta::instance().m_sha256Ctx; + BleOta::instance().m_sha256Ctx = nullptr; + } + BleOta::instance().m_esp32HashComputed = false; + memset(BleOta::instance().m_esp32Sha256, 0, sizeof(BleOta::instance().m_esp32Sha256)); +} + /** * @brief 初始化 ESP32 本机 OTA 写入会话并选择目标分区。 * @param total_size 固件总大小(字节)。 @@ -184,13 +224,11 @@ static int ble_ota_mbuf_to_buf(struct os_mbuf *om, uint8_t *buf, size_t buf_len) */ static esp_err_t ble_esp32_ota_begin(size_t total_size) { - const esp_partition_t *boot_partition = esp_ota_get_running_partition(); - DEBUG_INFO(TAG, "当前启动分区: %s, size=%u, address=0x%lx", - boot_partition->label, boot_partition->size, boot_partition->address); - + // ★ 如果 BLE 自身已有未完成的 OTA 会话,先中止并释放全局互斥 if (BleOta::instance().m_esp32OtaActive) { DEBUG_WARN(TAG, "检测到未完成的 ESP32 OTA,会先中止旧会话"); esp_ota_abort(BleOta::instance().m_esp32OtaHandle); + esp32_ota_mutex_release(); BleOta::instance().m_esp32OtaActive = false; BleOta::instance().m_esp32OtaHandle = 0; BleOta::instance().m_esp32UpdatePartition = NULL; @@ -199,6 +237,16 @@ static esp_err_t ble_esp32_ota_begin(size_t total_size) BleOta::instance().m_esp32LastSeq = 0xFFFFU; } + // ★ 全局互斥:检查 ESP32 OTA 是否已被其他通道占用 + if (!esp32_ota_mutex_acquire(1000)) { + DEBUG_ERR(TAG, "ESP32 OTA 被其他通道占用,拒绝 BLE OTA begin"); + return ESP_ERR_INVALID_STATE; + } + + const esp_partition_t *boot_partition = esp_ota_get_running_partition(); + DEBUG_INFO(TAG, "当前启动分区: %s, size=%u, address=0x%lx", + boot_partition->label, boot_partition->size, boot_partition->address); + BleOta::instance().m_esp32UpdatePartition = esp_partition_find_first( ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_APP_OTA_0, NULL); if (BleOta::instance().m_esp32UpdatePartition == NULL || @@ -209,6 +257,7 @@ static esp_err_t ble_esp32_ota_begin(size_t total_size) if (BleOta::instance().m_esp32UpdatePartition == NULL) { DEBUG_ERR(TAG, "找不到可用的 OTA 分区"); + esp32_ota_mutex_release(); return ESP_FAIL; } @@ -221,6 +270,7 @@ static esp_err_t ble_esp32_ota_begin(size_t total_size) DEBUG_ERR(TAG, "ESP32 固件太大: %u > 分区大小: %u", total_size, BleOta::instance().m_esp32UpdatePartition->size); BleOta::instance().m_esp32UpdatePartition = NULL; + esp32_ota_mutex_release(); return ESP_FAIL; } @@ -233,6 +283,7 @@ static esp_err_t ble_esp32_ota_begin(size_t total_size) DEBUG_ERR(TAG, "esp_ota_begin 失败: %s", esp_err_to_name(err)); BleOta::instance().m_esp32UpdatePartition = NULL; BleOta::instance().m_esp32OtaHandle = 0; + esp32_ota_mutex_release(); return err; } @@ -241,6 +292,16 @@ static esp_err_t ble_esp32_ota_begin(size_t total_size) BleOta::instance().m_esp32RebootPending = false; BleOta::instance().m_esp32LastCmd = 0; BleOta::instance().m_esp32LastSeq = 0xFFFFU; + + // 初始化 SHA-256 计算上下文 + if (BleOta::instance().m_sha256Ctx == nullptr) { + BleOta::instance().m_sha256Ctx = new mbedtls_sha256_context(); + } + mbedtls_sha256_init(BleOta::instance().m_sha256Ctx); + mbedtls_sha256_starts(BleOta::instance().m_sha256Ctx, 0); + BleOta::instance().m_esp32HashComputed = false; + memset(BleOta::instance().m_esp32Sha256, 0, sizeof(BleOta::instance().m_esp32Sha256)); + DEBUG_INFO(TAG, "ESP32 OTA 开始,目标分区: %s", BleOta::instance().m_esp32UpdatePartition->label); return ESP_OK; } @@ -256,6 +317,12 @@ static esp_err_t ble_esp32_ota_write(const uint8_t *data, size_t len) if (!BleOta::instance().m_esp32OtaActive || BleOta::instance().m_esp32UpdatePartition == NULL) { return ESP_ERR_INVALID_STATE; } + + // 更新 SHA-256 哈希计算 + if (BleOta::instance().m_sha256Ctx != nullptr) { + mbedtls_sha256_update(BleOta::instance().m_sha256Ctx, data, len); + } + esp_err_t err = esp_ota_write(BleOta::instance().m_esp32OtaHandle, data, len); if (err == ESP_OK) { BleOta::instance().m_esp32Received += len; @@ -386,6 +453,52 @@ static int ble_ota_ctrl_access(uint16_t conn_handle, uint16_t attr_handle, } else { DEBUG_INFO(TAG, "ESP32 OTA 收尾任务已创建(由设备端异步完成)"); } + } else if (buf[0] == BLE_OTA_CTRL_VERIFY_SHA256) { + // SHA-256 哈希验证命令 + // 期望数据格式: [cmd(1)] + [sha256(32)] + if (len < 33) { + DEBUG_WARN(TAG, "SHA-256 验证数据长度不足: %d (需要33)", len); + return BLE_ATT_ERR_INVALID_ATTR_VALUE_LEN; + } + + // 提取前端发送的SHA-256哈希 + uint8_t frontend_sha[32]; + memcpy(frontend_sha, &buf[1], 32); + + // 打印前端SHA-256 + char frontend_hex[65] = {0}; + for (int i = 0; i < 32; ++i) { + snprintf(&frontend_hex[i * 2], sizeof(frontend_hex) - i * 2, "%02X", frontend_sha[i]); + } + DEBUG_INFO(TAG, "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); + DEBUG_INFO(TAG, "📊 SHA-256 哈希对比验证"); + DEBUG_INFO(TAG, " 前端计算: %s", frontend_hex); + + // 检查设备端是否已计算SHA-256 + if (!BleOta::instance().m_esp32HashComputed) { + DEBUG_WARN(TAG, "⚠️ 设备端尚未完成SHA-256计算"); + sendFinishEvent(0xA0, 0); // 自定义事件:哈希未就绪 + return 0; + } + + // 打印设备端SHA-256 + char device_hex[65] = {0}; + for (int i = 0; i < 32; ++i) { + snprintf(&device_hex[i * 2], sizeof(device_hex) - i * 2, "%02X", BleOta::instance().m_esp32Sha256[i]); + } + DEBUG_INFO(TAG, " 设备计算: %s", device_hex); + + // 对比哈希值 + if (memcmp(frontend_sha, BleOta::instance().m_esp32Sha256, 32) == 0) { + DEBUG_INFO(TAG, "✅ SHA-256 验证通过!固件传输完整无误"); + DEBUG_INFO(TAG, "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); + sendFinishEvent(0xA1, 1); // 自定义事件:验证成功 + } else { + DEBUG_ERR(TAG, "❌ SHA-256 验证失败!固件传输过程中出现错误"); + DEBUG_ERR(TAG, " 请检查BLE链路质量并重试"); + DEBUG_ERR(TAG, "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"); + sendFinishEvent(0xA2, 0); // 自定义事件:验证失败 + } } return 0; // BLE_ATT_ERR_OK 在 NimBLE 中就是 0 @@ -397,8 +510,8 @@ static int ble_ota_ctrl_access(uint16_t conn_handle, uint16_t attr_handle, * [0] cmd (0x00=数据) * [1-2] seq uint16 LE - 分块序号 * [3-6] total_size uint32 LE - 固件总大小 (仅 seq=0 时有效) - * [7..n-4] payload 固件数据 - * [n-4..n] crc 校验 (预留) + * [7..n] payload 固件数据 + * 注: 已移除CRC字段(BLE链路层已有CRC校验) */ /** * @brief GATT DATA_IN 特征访问回调,接收并处理 OTA 数据块。 @@ -419,187 +532,242 @@ static int ble_ota_data_in_access(uint16_t conn_handle, uint16_t attr_handle, return BLE_ATT_ERR_REQ_NOT_SUPPORTED; } - uint8_t buf[BLE_MTU_SIZE] = {0}; + // 使用较大静态缓冲区:writeValueWithoutResponse 快速发送时, + // NimBLE 可能将多个写请求合并到同一个 mbuf 链中 + // ★ static: NimBLE host task 栈只有 4096 字节,不能用栈分配 + // ★ 4096 字节可容纳 8 个合并包(8×491=3928),避免截断丢包 + static constexpr size_t BLE_OTA_BUF_SIZE = 4096; + static uint8_t buf[BLE_OTA_BUF_SIZE]; int recv_len = ble_ota_mbuf_to_buf(ctxt->om, buf, sizeof(buf)); - if (recv_len < 12) { - DEBUG_WARN(TAG, "DATA_IN 数据太短: %d 字节 (最少 12)", recv_len); + if (recv_len < 8) { + DEBUG_WARN(TAG, "DATA_IN 数据太短: %d 字节 (最少 8: 7头+1数据)", recv_len); return BLE_ATT_ERR_INVALID_ATTR_VALUE_LEN; } - // 解析包头 - uint8_t cmd = buf[0]; - uint16_t seq = (uint16_t)(buf[1] | (buf[2] << 8)); - uint32_t total_size = (uint32_t)(buf[3] | (buf[4] << 8) | - (buf[5] << 16) | (buf[6] << 24)); + // 循环处理 mbuf 中的所有完整数据包 + size_t offset = 0; + while (offset + 7 <= (size_t)recv_len) { + // 解析包头 + uint8_t cmd = buf[offset + 0]; + uint16_t seq = (uint16_t)(buf[offset + 1] | (buf[offset + 2] << 8)); + uint32_t total_size = (uint32_t)(buf[offset + 3] | (buf[offset + 4] << 8) | + (buf[offset + 5] << 16) | (buf[offset + 6] << 24)); - size_t data_offset = 7; - size_t data_len = (size_t)recv_len - data_offset; + size_t data_offset = offset + 7; - // 尾 4 字节 CRC 预留,实际 payload = data_len - 4 - if (data_len < 4) { - DEBUG_WARN(TAG, "DATA_IN 负载不足: data_len=%zu", data_len); - return BLE_ATT_ERR_INVALID_ATTR_VALUE_LEN; - } - data_len -= 4; // 减去尾部 CRC 预留 + // 根据包序号和固件总大小计算期望 payload,而非依赖 recv_len + // 这样即使 NimBLE mbuf 合并导致缓冲区截断,也不会误判包长度 + uint32_t expected_payload = (total_size > 0) + ? ((uint32_t)BLE_OTA_CHUNK_PAYLOAD < (total_size - (uint32_t)seq * (uint32_t)BLE_OTA_CHUNK_PAYLOAD) + ? (uint32_t)BLE_OTA_CHUNK_PAYLOAD + : (total_size - (uint32_t)seq * (uint32_t)BLE_OTA_CHUNK_PAYLOAD)) + : (uint32_t)BLE_OTA_CHUNK_PAYLOAD; - DEBUG_DBG(TAG, "DATA_IN: cmd=0x%02X seq=%u total=%" PRIu32 " payload=%zu", - cmd, seq, total_size, data_len); + size_t data_len = (size_t)expected_payload; + size_t remaining_after_header = (size_t)recv_len - data_offset; - // --- ESP32 自身 OTA:直接写 OTA 分区 --- - if (cmd == BLE_OTA_CMD_ESP32_DATA) { - if (seq == 0) { - if (total_size == 0) { - DEBUG_ERR(TAG, "ESP32 固件大小非法: %" PRIu32, total_size); - return BLE_ATT_ERR_UNLIKELY; - } - const esp_partition_t *boot_partition = esp_ota_get_running_partition(); - const esp_partition_t *check_partition = esp_partition_find_first( - ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_APP_OTA_0, NULL); - if (check_partition == NULL || check_partition->address == boot_partition->address) { - check_partition = esp_partition_find_first( - ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_APP_OTA_1, NULL); - } - if (check_partition == NULL) { - DEBUG_ERR(TAG, "找不到 ESP32 OTA 目标分区"); - return BLE_ATT_ERR_UNLIKELY; - } - if (total_size > check_partition->size) { - DEBUG_ERR(TAG, "ESP32 固件太大: %" PRIu32 " > OTA分区 %u", - total_size, check_partition->size); - return BLE_ATT_ERR_UNLIKELY; - } - esp_err_t err = ble_esp32_ota_begin(total_size); - if (err != ESP_OK) { - return BLE_ATT_ERR_UNLIKELY; + // 如果缓冲区中实际可用数据不足,截断 data_len + if (remaining_after_header < expected_payload) { + if (remaining_after_header == 0) { + // 没有更多数据,退出循环 + break; } + // 缓冲区截断,使用实际可用数据 + data_len = remaining_after_header; } - esp_err_t err = ble_esp32_ota_write(&buf[data_offset], data_len); - if (err != ESP_OK) { - DEBUG_ERR(TAG, "ESP32 OTA 写入失败: seq=%u err=%s", - seq, esp_err_to_name(err)); - return BLE_ATT_ERR_UNLIKELY; - } + // 当前包总长度: header(7) + payload(data_len) + size_t packet_len = 7 + data_len; - DEBUG_DBG(TAG, "ESP32 OTA 写入成功: seq=%u received=%u", - seq, (unsigned int)BleOta::instance().m_esp32Received); + DEBUG_DBG(TAG, "DATA_IN: cmd=0x%02X seq=%u total=%" PRIu32 " payload=%zu offset=%zu", + cmd, seq, total_size, data_len, offset); - // 尾包检测: data_len < 标准负载说明是最后一包(不满一个完整 payload) - bool isLastPacket = (data_len < BLE_OTA_CHUNK_PAYLOAD); - - if ((((uint32_t)seq + 1U) % BLE_OTA_ACK_WINDOW) == 0U || - (total_size > 0 && BleOta::instance().m_esp32Received >= total_size) || - isLastPacket) { - if (isLastPacket) { - DEBUG_INFO(TAG, "ESP32 尾包检测: seq=%u data_len=%zu received=%u total=%" PRIu32, - seq, data_len, (unsigned int)BleOta::instance().m_esp32Received, total_size); - } - sendWindowAck(cmd, seq, (uint32_t)BleOta::instance().m_esp32Received); - } - - if ((total_size > 0 && BleOta::instance().m_esp32Received >= total_size) || isLastPacket) { - DEBUG_INFO(TAG, "★ ESP32 固件接收完毕: %u / %" PRIu32 " 字节 (共 %u 块)%s", - (unsigned int)BleOta::instance().m_esp32Received, total_size, seq + 1, - isLastPacket ? " [尾包]" : ""); - BleOta::instance().m_esp32LastCmd = cmd; - BleOta::instance().m_esp32LastSeq = seq; - - if (!BleOta::instance().m_esp32RebootPending) { - BleOta::instance().m_esp32RebootPending = true; - BaseType_t ok = xTaskCreate(ble_esp32_finish_and_reboot_task, - "ble_ota_finish", - 4096, - NULL, - 5, - NULL); - if (ok != pdPASS) { - DEBUG_ERR(TAG, "创建 ESP32 OTA 收尾任务失败"); - BleOta::instance().m_esp32RebootPending = false; + // --- ESP32 自身 OTA:直接写 OTA 分区 --- + if (cmd == BLE_OTA_CMD_ESP32_DATA) { + if (seq == 0) { + if (total_size == 0) { + DEBUG_ERR(TAG, "ESP32 固件大小非法: %" PRIu32, total_size); return BLE_ATT_ERR_UNLIKELY; } - DEBUG_INFO(TAG, "ESP32 OTA 收尾任务已创建,等待设备端自动重启"); - } else { - DEBUG_WARN(TAG, "ESP32 OTA 收尾任务已存在,忽略重复完成触发"); + const esp_partition_t *boot_partition = esp_ota_get_running_partition(); + const esp_partition_t *check_partition = esp_partition_find_first( + ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_APP_OTA_0, NULL); + if (check_partition == NULL || check_partition->address == boot_partition->address) { + check_partition = esp_partition_find_first( + ESP_PARTITION_TYPE_APP, ESP_PARTITION_SUBTYPE_APP_OTA_1, NULL); + } + if (check_partition == NULL) { + DEBUG_ERR(TAG, "找不到 ESP32 OTA 目标分区"); + return BLE_ATT_ERR_UNLIKELY; + } + if (total_size > check_partition->size) { + DEBUG_ERR(TAG, "ESP32 固件太大: %" PRIu32 " > OTA分区 %u", + total_size, check_partition->size); + return BLE_ATT_ERR_UNLIKELY; + } + esp_err_t err = ble_esp32_ota_begin(total_size); + if (err != ESP_OK) { + if (err == ESP_ERR_INVALID_STATE) { + sendFinishEvent(BLE_OTA_FINISH_ERR_MUTEX, 0); + return BLE_ATT_ERR_OTA_BUSY; + } + return BLE_ATT_ERR_UNLIKELY; + } + } + + esp_err_t err = ble_esp32_ota_write(&buf[data_offset], data_len); + if (err != ESP_OK) { + DEBUG_ERR(TAG, "ESP32 OTA 写入失败: seq=%u err=%s", + seq, esp_err_to_name(err)); + esp_ota_abort(BleOta::instance().m_esp32OtaHandle); + esp32_ota_mutex_release(); + BleOta::instance().m_esp32OtaActive = false; + BleOta::instance().m_esp32OtaHandle = 0; + BleOta::instance().m_esp32UpdatePartition = NULL; + ble_esp32_ota_cleanup_sha(); // 清理SHA状态 + return BLE_ATT_ERR_UNLIKELY; + } + + DEBUG_DBG(TAG, "ESP32 OTA 写入成功: seq=%u received=%u", + seq, (unsigned int)BleOta::instance().m_esp32Received); + + // 尾包检测: 期望 payload < 标准负载说明是真正的最后一包 + // 使用 expected_payload 而非 data_len,避免缓冲区截断导致误触发重启 + bool isLastPacket = (expected_payload < BLE_OTA_CHUNK_PAYLOAD); + + if ((((uint32_t)seq + 1U) % BLE_OTA_ACK_WINDOW) == 0U || + (total_size > 0 && BleOta::instance().m_esp32Received >= total_size) || + isLastPacket) { + if (isLastPacket) { + DEBUG_INFO(TAG, "ESP32 尾包检测: seq=%u data_len=%zu received=%u total=%" PRIu32, + seq, data_len, (unsigned int)BleOta::instance().m_esp32Received, total_size); + } + sendWindowAck(cmd, seq, (uint32_t)BleOta::instance().m_esp32Received); + } + + if ((total_size > 0 && BleOta::instance().m_esp32Received >= total_size) || isLastPacket) { + DEBUG_INFO(TAG, "★ ESP32 固件接收完毕: %u / %" PRIu32 " 字节 (共 %u 块)%s", + (unsigned int)BleOta::instance().m_esp32Received, total_size, seq + 1, + isLastPacket ? " [尾包]" : ""); + BleOta::instance().m_esp32LastCmd = cmd; + BleOta::instance().m_esp32LastSeq = seq; + + if (!BleOta::instance().m_esp32RebootPending) { + BleOta::instance().m_esp32RebootPending = true; + BaseType_t ok = xTaskCreate(ble_esp32_finish_and_reboot_task, + "ble_ota_finish", + 4096, + NULL, + 5, + NULL); + if (ok != pdPASS) { + DEBUG_ERR(TAG, "创建 ESP32 OTA 收尾任务失败"); + esp_ota_abort(BleOta::instance().m_esp32OtaHandle); + esp32_ota_mutex_release(); + BleOta::instance().m_esp32OtaActive = false; + BleOta::instance().m_esp32OtaHandle = 0; + BleOta::instance().m_esp32UpdatePartition = NULL; + BleOta::instance().m_esp32RebootPending = false; + ble_esp32_ota_cleanup_sha(); // 清理SHA状态 + return BLE_ATT_ERR_UNLIKELY; + } + DEBUG_INFO(TAG, "ESP32 OTA 收尾任务已创建,等待设备端自动重启"); + } else { + DEBUG_WARN(TAG, "ESP32 OTA 收尾任务已存在,忽略重复完成触发"); + } } } - return 0; - } + // --- GD32 OTA:首个分块时创建存储元数据 --- + else if (cmd == BLE_OTA_CMD_GD32_DATA) { + if (seq == 0 && total_size > 0) { + uint32_t rounded_size = (uint32_t)(((total_size + (uint32_t)GD32_OTA_PACKET_SIZE - 1U) / (uint32_t)GD32_OTA_PACKET_SIZE) * (uint32_t)GD32_OTA_PACKET_SIZE); + if (total_size == 0U || total_size > OTA_MAX_FIRMWARE_SIZE || rounded_size > OTA_MAX_FIRMWARE_SIZE) { + DEBUG_ERR(TAG, "GD32 固件大小非法: raw=%" PRIu32 " rounded48=%" PRIu32 " max=%zu", + total_size, rounded_size, OTA_MAX_FIRMWARE_SIZE); + sendFinishEvent(BLE_OTA_FINISH_ERR_GD32_SIZE, total_size); + return BLE_ATT_ERR_UNLIKELY; + } - // --- GD32 OTA:首个分块时创建存储元数据 --- - if (seq == 0 && total_size > 0) { - uint32_t rounded_size = (uint32_t)(((total_size + (uint32_t)GD32_OTA_PACKET_SIZE - 1U) / (uint32_t)GD32_OTA_PACKET_SIZE) * (uint32_t)GD32_OTA_PACKET_SIZE); - if (total_size == 0U || total_size > OTA_MAX_FIRMWARE_SIZE || rounded_size > OTA_MAX_FIRMWARE_SIZE) { - DEBUG_ERR(TAG, "GD32 固件大小非法: raw=%" PRIu32 " rounded48=%" PRIu32 " max=%zu", - total_size, rounded_size, OTA_MAX_FIRMWARE_SIZE); - return BLE_ATT_ERR_UNLIKELY; + ota_meta_t meta = {}; + meta.total_size = total_size; + meta.stored_size = 0; + meta.last_seq = 0; + meta.chunk_count = (total_size + expected_payload - 1U) / expected_payload; + + esp_err_t err = OtaRelay::metaSave(&meta); + if (err != ESP_OK) { + DEBUG_ERR(TAG, "元数据保存失败"); + return BLE_ATT_ERR_UNLIKELY; + } + + DEBUG_INFO(TAG, "GD32 固件存储开始: total=%" PRIu32 " chunk_count=%" PRIu32 ", payload=%zu", + meta.total_size, meta.chunk_count, data_len); + } + + ota_meta_t meta = {}; + if (OtaRelay::metaLoad(&meta) != ESP_OK || meta.total_size == 0) { + DEBUG_ERR(TAG, "GD32 元数据无效,拒绝写入: seq=%u", seq); + return BLE_ATT_ERR_UNLIKELY; + } + + if (seq > 0 && meta.last_seq != (uint32_t)(seq - 1)) { + DEBUG_ERR(TAG, "GD32 分包序号不连续: seq=%u last_seq=%" PRIu32, + seq, meta.last_seq); + return BLE_ATT_ERR_UNLIKELY; + } + + // --- GD32 写入 Flash --- + uint32_t flash_offset = meta.stored_size; + if (flash_offset + (uint32_t)data_len > meta.total_size) { + DEBUG_ERR(TAG, "GD32 写入越界: offset=%" PRIu32 " len=%zu total=%" PRIu32, + flash_offset, data_len, meta.total_size); + return BLE_ATT_ERR_UNLIKELY; + } + + esp_err_t err = OtaRelay::flashWriteChunk(flash_offset, &buf[data_offset], data_len); + if (err != ESP_OK) { + DEBUG_ERR(TAG, "Flash 写入失败: seq=%u offset=%" PRIu32 " err=%s", + seq, flash_offset, esp_err_to_name(err)); + return BLE_ATT_ERR_UNLIKELY; + } + + uint32_t stored_size = flash_offset + (uint32_t)data_len; + err = OtaRelay::metaUpdate(stored_size, seq); + if (err != ESP_OK) { + DEBUG_WARN(TAG, "元数据更新失败 (seq=%u)", seq); + } + + if ((((uint32_t)seq + 1U) % BLE_OTA_ACK_WINDOW) == 0U || + (meta.total_size > 0 && stored_size >= meta.total_size) || + (expected_payload < BLE_OTA_CHUNK_PAYLOAD)) { + sendWindowAck(cmd, seq, stored_size); + } + + DEBUG_DBG(TAG, "Flash 写入成功: seq=%u offset=%" PRIu32 " stored=%" PRIu32, + seq, flash_offset, stored_size); + + // --- 检查 GD32 固件是否接收完毕 --- + if (OtaRelay::metaLoad(&meta) == ESP_OK && + meta.total_size > 0 && + meta.stored_size >= meta.total_size) { + DEBUG_INFO(TAG, "★ GD32 固件接收完毕: %" PRIu32 " / %" PRIu32 " 字节 (共 %u 块)", + meta.stored_size, meta.total_size, seq + 1); + // 计算并存储 SHA-256 校验和 + esp_err_t sha_err = OtaRelay::metaFinalizeChecksum(); + if (sha_err != ESP_OK) { + DEBUG_ERR(TAG, "GD32 固件 SHA-256 计算失败: %s", esp_err_to_name(sha_err)); + } + } + } else { + DEBUG_WARN(TAG, "DATA_IN 未知 cmd: 0x%02X offset=%zu", cmd, offset); } - ota_meta_t meta = {}; - meta.total_size = total_size; - meta.stored_size = 0; - meta.last_seq = 0; - meta.chunk_count = (total_size + (uint32_t)data_len - 1U) / (uint32_t)data_len; + // 推进到下一个包 + offset += packet_len; - esp_err_t err = OtaRelay::metaSave(&meta); - if (err != ESP_OK) { - DEBUG_ERR(TAG, "元数据保存失败"); - return BLE_ATT_ERR_UNLIKELY; - } - - DEBUG_INFO(TAG, "GD32 固件存储开始: total=%" PRIu32 " chunk_count=%" PRIu32 ", payload=%zu", - meta.total_size, meta.chunk_count, data_len); - } - - ota_meta_t meta = {}; - if (OtaRelay::metaLoad(&meta) != ESP_OK || meta.total_size == 0) { - DEBUG_ERR(TAG, "GD32 元数据无效,拒绝写入: seq=%u", seq); - return BLE_ATT_ERR_UNLIKELY; - } - - if (seq > 0 && meta.last_seq != (uint32_t)(seq - 1)) { - DEBUG_ERR(TAG, "GD32 分包序号不连续: seq=%u last_seq=%" PRIu32, - seq, meta.last_seq); - return BLE_ATT_ERR_UNLIKELY; - } - - // --- GD32 写入 Flash --- - // 按已存储字节数顺序追加,兼容更大的 BLE payload,避免把步长硬编码死。 - uint32_t flash_offset = meta.stored_size; - if (flash_offset + (uint32_t)data_len > meta.total_size) { - DEBUG_ERR(TAG, "GD32 写入越界: offset=%" PRIu32 " len=%zu total=%" PRIu32, - flash_offset, data_len, meta.total_size); - return BLE_ATT_ERR_UNLIKELY; - } - - esp_err_t err = OtaRelay::flashWriteChunk(flash_offset, &buf[data_offset], data_len); - if (err != ESP_OK) { - DEBUG_ERR(TAG, "Flash 写入失败: seq=%u offset=%" PRIu32 " err=%s", - seq, flash_offset, esp_err_to_name(err)); - return BLE_ATT_ERR_UNLIKELY; - } - - uint32_t stored_size = flash_offset + (uint32_t)data_len; - err = OtaRelay::metaUpdate(stored_size, seq); - if (err != ESP_OK) { - DEBUG_WARN(TAG, "元数据更新失败 (seq=%u)", seq); - } - - if ((((uint32_t)seq + 1U) % BLE_OTA_ACK_WINDOW) == 0U || - (meta.total_size > 0 && stored_size >= meta.total_size) || - (data_len < BLE_OTA_CHUNK_PAYLOAD)) { - sendWindowAck(cmd, seq, stored_size); - } - - DEBUG_DBG(TAG, "Flash 写入成功: seq=%u offset=%" PRIu32 " stored=%" PRIu32, - seq, flash_offset, stored_size); - - // --- 检查 GD32 固件是否接收完毕 --- - if (OtaRelay::metaLoad(&meta) == ESP_OK && - meta.total_size > 0 && - meta.stored_size >= meta.total_size) { - DEBUG_INFO(TAG, "★ GD32 固件接收完毕: %" PRIu32 " / %" PRIu32 " 字节 (共 %u 块)", - meta.stored_size, meta.total_size, seq + 1); + // 检查剩余数据是否足够组成一个完整的包头 + if (offset + 7 > (size_t)recv_len) break; } return 0; @@ -1093,4 +1261,4 @@ void BleOta::taskEntry(void *pvParameters) vTaskDelay(pdMS_TO_TICKS(10000)); } -} +} \ No newline at end of file diff --git a/main/server/ble_ota.h b/main/server/ble_ota.h index e4bebc1..35f3d19 100644 --- a/main/server/ble_ota.h +++ b/main/server/ble_ota.h @@ -9,6 +9,9 @@ #include "esp_err.h" #include "esp_ota_ops.h" +// 前向声明 mbedtls_sha256_context +typedef struct mbedtls_sha256_context mbedtls_sha256_context; + class BleOta { public: static BleOta& instance(); @@ -31,6 +34,11 @@ public: bool m_esp32RebootPending = false; uint8_t m_esp32LastCmd = 0; uint16_t m_esp32LastSeq = 0xFFFFU; + + // SHA-256 哈希校验 + mbedtls_sha256_context *m_sha256Ctx = nullptr; // SHA-256计算上下文 + uint8_t m_esp32Sha256[32] = {0}; // 计算得到的SHA-256哈希值 + bool m_esp32HashComputed = false; // 是否已计算哈希 }; #endif // BLE_OTA_H \ No newline at end of file diff --git a/main/server/wifi_ota.cpp b/main/server/wifi_ota.cpp index bb296d1..9e135c5 100644 --- a/main/server/wifi_ota.cpp +++ b/main/server/wifi_ota.cpp @@ -21,7 +21,12 @@ #include "esp_event.h" #include "esp_mac.h" +// SHA-256 哈希计算 +#define MBEDTLS_DECLARE_PRIVATE_IDENTIFIERS +#include "mbedtls/private/sha256.h" + #include "app_config.h" +#include "ota_mutex.h" #include "wifi_ota.h" #include "ota_relay.h" #include "health_monitor.h" @@ -74,6 +79,69 @@ static esp_err_t http_options_handler(httpd_req_t *req) return ESP_OK; } +// ---------------------------------------- +// STA 后台重连任务(避免在系统事件回调中调用 esp_wifi_connect 死锁) +// ---------------------------------------- +/** + * @brief 独立 FreeRTOS 任务,处理 STA 连接/重连的退避延迟和 esp_wifi_connect() 调用。 + * 事件回调仅通过 xTaskNotifyGive 唤醒本任务,不直接在系统事件任务中阻塞或调用 WiFi API。 + */ +void WifiOta::reconnectTask(void* pvParameters) +{ + (void)pvParameters; + while (1) { + // 等待事件回调通知(阻塞,不消耗 CPU) + ulTaskNotifyTake(pdTRUE, portMAX_DELAY); + + WifiOta& self = WifiOta::instance(); + + // 已连接或已放弃,无需处理 + if (self.getStaConnected() || self.getStaRetryExhausted()) { + continue; + } + + self.stateLock(); + + // 达到最大重试次数 + if (self.getStaRetryCount() >= WIFI_STA_MAX_RETRY) { + DEBUG_ERR("WIFI", "STA 重连失败 %d 次,放弃", self.getStaRetryCount()); + self.setStaRetryExhausted(true); + self.stateUnlock(); + continue; + } + + self.setStaRetryCount(self.getStaRetryCount() + 1); + int retryCount = self.getStaRetryCount(); + + // 计算退避延迟:冷启动前 3 次递增退避,其余固定 1s + int delay_ms = 0; + if (self.m_coldBoot && retryCount <= 3) { + delay_ms = 2000 * retryCount; // 2s, 4s, 6s + DEBUG_INFO("WIFI", "冷启动退避重连第 %d 次 (等 %dms)", + retryCount, delay_ms); + } else if (retryCount > 1) { + delay_ms = 1000; // 普通重连 1s 延迟 + DEBUG_INFO("WIFI", "重连第 %d 次...", retryCount); + } else { + DEBUG_INFO("WIFI", "首次连接..."); + } + + self.stateUnlock(); + + if (delay_ms > 0) { + vTaskDelay(pdMS_TO_TICKS(delay_ms)); + } + + // 延迟后再次检查状态,防止期间已连接或已放弃 + if (!self.getStaConnected() && !self.getStaRetryExhausted()) { + esp_err_t err = esp_wifi_connect(); + if (err != ESP_OK) { + DEBUG_ERR("WIFI", "esp_wifi_connect() 失败: %s", esp_err_to_name(err)); + } + } + } +} + // ---------------------------------------- // WiFi/IP 事件回调(事件驱动连接) // ---------------------------------------- @@ -87,45 +155,49 @@ static esp_err_t http_options_handler(httpd_req_t *req) void WifiOta::wifiEventHandler(void* arg, esp_event_base_t event_base, int32_t event_id, void* event_data) { + WifiOta& inst = WifiOta::instance(); + if (event_base == WIFI_EVENT) { switch (event_id) { case WIFI_EVENT_STA_START: DEBUG_INFO("WIFI", "STA 已启动,开始连接..."); - esp_wifi_connect(); + inst.stateLock(); + inst.setStaRetryCount(0); + inst.setStaRetryExhausted(false); + inst.stateUnlock(); + if (inst.m_reconnectTask) { + xTaskNotifyGive(inst.m_reconnectTask); + } break; case WIFI_EVENT_STA_CONNECTED: DEBUG_INFO("WIFI", "STA 已关联到 AP"); - WifiOta::instance().m_staConnected = true; - WifiOta::instance().m_staRetryCount = 0; - WifiOta::instance().m_staRetryExhausted = false; // ★ 连接成功,重置耗尽标志 - if (WifiOta::instance().m_staConnectedSem) { - xSemaphoreGive(WifiOta::instance().m_staConnectedSem); + inst.stateLock(); + inst.setStaConnected(true); + inst.setStaRetryCount(0); + inst.setStaRetryExhausted(false); // ★ 连接成功,重置耗尽标志 + inst.stateUnlock(); + if (inst.m_staConnectedSem) { + xSemaphoreGive(inst.m_staConnectedSem); } break; case WIFI_EVENT_STA_DISCONNECTED: { wifi_event_sta_disconnected_t* disconn = (wifi_event_sta_disconnected_t*)event_data; - DEBUG_WARN("WIFI", "STA 断开, reason=%d, retry=%d", disconn->reason, WifiOta::instance().m_staRetryCount); - WifiOta::instance().m_staConnected = false; + inst.stateLock(); + inst.setStaConnected(false); + bool exhausted = inst.getStaRetryExhausted(); + int retryCount = inst.getStaRetryCount(); + inst.stateUnlock(); - if (WifiOta::instance().m_staRetryCount < WIFI_STA_MAX_RETRY) { - WifiOta::instance().m_staRetryCount++; - // ★ 冷启动首次连接失败时增加退避延迟 - // 长时间断电后路由器可能还在初始化(ARP/DHCP/信道选择) - // reason 201=NO_AP_FOUND, 15=AUTH_FAIL, 2=AUTH_EXPIRE, 204=HANDSHAKE_TIMEOUT - if (WifiOta::instance().m_coldBoot && WifiOta::instance().m_staRetryCount <= 3) { - int delay_ms = 2000 * WifiOta::instance().m_staRetryCount; // 2s, 4s, 6s 递增退避 - DEBUG_INFO("WIFI", "冷启动退避重连第 %d 次 (reason=%d, 等 %dms)...", - WifiOta::instance().m_staRetryCount, disconn->reason, delay_ms); - vTaskDelay(pdMS_TO_TICKS(delay_ms)); - } else { - DEBUG_INFO("WIFI", "重连第 %d 次...", WifiOta::instance().m_staRetryCount); + if (!exhausted) { + DEBUG_WARN("WIFI", "STA 断开, reason=%d, retry=%d", + disconn->reason, retryCount); + if (inst.m_reconnectTask) { + xTaskNotifyGive(inst.m_reconnectTask); } - esp_wifi_connect(); } else { - DEBUG_ERR("WIFI", "STA 重连失败 %d 次,放弃", WifiOta::instance().m_staRetryCount); - WifiOta::instance().m_staRetryExhausted = true; + DEBUG_WARN("WIFI", "STA 断开, 重试次数已耗尽"); } break; } @@ -225,8 +297,8 @@ esp_err_t WifiOta::initSta() DEBUG_INFO("WIFI", "=== 初始化 WiFi STA 模式 ==="); WifiOta::instance().m_wifiMode = OTA_WIFI_MODE_STA; - WifiOta::instance().m_staRetryCount = 0; - WifiOta::instance().m_staRetryExhausted = false; + WifiOta::instance().setStaRetryCount(0); + WifiOta::instance().setStaRetryExhausted(false); // ★ 设置国家代码:中国,允许信道 1-13 // ESP32 默认国家是 US(只允许 1-11),中国路由可能用 12/13 @@ -307,18 +379,18 @@ esp_err_t WifiOta::waitStaConnected(char *ip_str, size_t ip_len) } DEBUG_INFO("WIFI", "%s 等待成功, 当前 IP: " IPSTR, wait_name, IP2STR(&ip_info.ip)); } - WifiOta::instance().m_staConnected = true; + WifiOta::instance().setStaConnected(true); return ESP_OK; } // ★ 重试已耗尽,不再等待 - if (WifiOta::instance().m_staRetryExhausted) { - DEBUG_ERR("WIFI", "STA 重试已耗尽 (%d 次),立即切换 AP", WifiOta::instance().m_staRetryCount); + if (WifiOta::instance().getStaRetryExhausted()) { + DEBUG_ERR("WIFI", "STA 重试已耗尽 (%d 次),立即切换 AP", WifiOta::instance().getStaRetryCount()); return ESP_ERR_TIMEOUT; } } - DEBUG_ERR("WIFI", "等待 %s 超时 (%d ms), 重连次数: %d", wait_name, WIFI_STA_CONNECT_TIMEOUT_MS, WifiOta::instance().m_staRetryCount); + DEBUG_ERR("WIFI", "等待 %s 超时 (%d ms), 重连次数: %d", wait_name, WIFI_STA_CONNECT_TIMEOUT_MS, WifiOta::instance().getStaRetryCount()); return ESP_ERR_TIMEOUT; } @@ -355,6 +427,18 @@ void WifiOta::setStaticIp() // ---------------------------------------- // ESP32 OTA 内部函数 + +/** + * @brief 清理 WiFi OTA 的 SHA-256 上下文和哈希状态 + */ +static void wifi_esp32_ota_cleanup_sha() +{ + WifiOta::instance().m_esp32HashComputed = false; + memset(WifiOta::instance().m_esp32Sha256, 0, sizeof(WifiOta::instance().m_esp32Sha256)); + WifiOta::instance().m_hasFrontendSha256 = false; + memset(WifiOta::instance().m_frontendSha256, 0, sizeof(WifiOta::instance().m_frontendSha256)); +} + /** * @brief 初始化 ESP32 自身的 OTA 写入流程。 * @param total_size @@ -362,6 +446,12 @@ void WifiOta::setStaticIp() */ static esp_err_t esp32_ota_begin(size_t total_size) { + // ★ 全局互斥:检查 ESP32 OTA 是否已被其他通道占用 + if (!esp32_ota_mutex_acquire(1000)) { + DEBUG_ERR("OTA", "ESP32 OTA 被其他通道占用,拒绝 WiFi OTA begin"); + return ESP_ERR_INVALID_STATE; + } + const esp_partition_t *boot_partition = esp_ota_get_running_partition(); DEBUG_INFO("OTA", "当前启动分区: %s, size=%u, address=0x%lx", boot_partition->label, boot_partition->size, boot_partition->address); @@ -376,6 +466,7 @@ static esp_err_t esp32_ota_begin(size_t total_size) if (WifiOta::instance().m_esp32UpdatePartition == NULL) { DEBUG_ERR("OTA", "找不到可用的 OTA 分区"); + esp32_ota_mutex_release(); return ESP_FAIL; } @@ -387,6 +478,7 @@ static esp_err_t esp32_ota_begin(size_t total_size) if (total_size > WifiOta::instance().m_esp32UpdatePartition->size) { DEBUG_ERR("OTA", "固件太大: %u > 分区大小: %u", total_size, WifiOta::instance().m_esp32UpdatePartition->size); + esp32_ota_mutex_release(); return ESP_FAIL; } @@ -394,49 +486,199 @@ static esp_err_t esp32_ota_begin(size_t total_size) if (err != ESP_OK) { DEBUG_ERR("OTA", "esp_ota_begin 失败: %s", esp_err_to_name(err)); WifiOta::instance().m_esp32UpdatePartition = NULL; + esp32_ota_mutex_release(); return err; } WifiOta::instance().m_esp32Received = 0; WifiOta::instance().m_esp32OtaActive = true; + + // 清理旧状态(SHA-256 将在重启前从分区读回计算,上传期间不再计算) + wifi_esp32_ota_cleanup_sha(); + DEBUG_INFO("OTA", "ESP32 OTA 开始,目标分区: %s", WifiOta::instance().m_esp32UpdatePartition->label); return ESP_OK; } /** * @brief HTTP POST /ota/update - 完成 ESP32 OTA 并重启 + * verify_only=true: 仅校验 SHA-256,不设置启动分区,不重启 + * verify_only=false或缺省: 完整流程(校验 + 设置启动分区 + 重启) * @param req * @return */ static esp_err_t esp32_ota_update_handler(httpd_req_t *req) { - (void)req; http_set_cors_headers(req); if (!WifiOta::instance().m_esp32OtaActive) { httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "OTA not started"); return ESP_FAIL; } - DEBUG_INFO("OTA", "ESP32 OTA 完成, 共 %u 字节, 设置启动分区...", WifiOta::instance().m_esp32Received); + // 读取请求体,获取前端 SHA-256 和 verify_only 标志 + char frontend_sha_hex[65] = {0}; + bool has_frontend_sha = false; + bool verify_only = false; + if (req->content_len > 0 && req->content_len < 256) { + char buf[256] = {0}; + int ret = httpd_req_recv(req, buf, req->content_len); + if (ret > 0) { + buf[ret] = '\0'; + cJSON *root = cJSON_Parse(buf); + if (root) { + cJSON *sha_item = cJSON_GetObjectItem(root, "sha256"); + if (sha_item && cJSON_IsString(sha_item)) { + const char *sha_str = cJSON_GetStringValue(sha_item); + if (sha_str && strlen(sha_str) == 64) { + strncpy(frontend_sha_hex, sha_str, 64); + frontend_sha_hex[64] = '\0'; + has_frontend_sha = true; + strncpy(WifiOta::instance().m_frontendSha256, sha_str, 64); + WifiOta::instance().m_frontendSha256[64] = '\0'; + WifiOta::instance().m_hasFrontendSha256 = true; + DEBUG_INFO("OTA", "Frontend SHA-256: %s", frontend_sha_hex); + } + } + cJSON *verify_item = cJSON_GetObjectItem(root, "verify_only"); + if (verify_item && cJSON_IsBool(verify_item)) { + verify_only = cJSON_IsTrue(verify_item); + } + cJSON_Delete(root); + } + } + } - esp_err_t err = esp_ota_end(WifiOta::instance().m_esp32OtaHandle); + if (verify_only) { + DEBUG_INFO("OTA", "ESP32 OTA verify-only mode, %u bytes", (unsigned)WifiOta::instance().m_esp32Received); + } else { + DEBUG_INFO("OTA", "ESP32 OTA complete, %u bytes, setting boot partition...", (unsigned)WifiOta::instance().m_esp32Received); + + esp_err_t err = esp_ota_end(WifiOta::instance().m_esp32OtaHandle); + if (err != ESP_OK) { + DEBUG_ERR("OTA", "esp_ota_end failed: %s", esp_err_to_name(err)); + esp_ota_abort(WifiOta::instance().m_esp32OtaHandle); + esp32_ota_mutex_release(); + WifiOta::instance().m_esp32OtaActive = false; + WifiOta::instance().m_esp32OtaHandle = 0; + wifi_esp32_ota_cleanup_sha(); + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, esp_err_to_name(err)); + return ESP_FAIL; + } + } + + // ── SHA-256 验证:从 OTA 分区读回并计算哈希 ── + if (has_frontend_sha && WifiOta::instance().m_esp32UpdatePartition != NULL) { + DEBUG_INFO("OTA", "Verifying: reading OTA partition for SHA-256..."); + const esp_partition_t *part = WifiOta::instance().m_esp32UpdatePartition; + mbedtls_sha256_context verify_ctx; + mbedtls_sha256_init(&verify_ctx); + mbedtls_sha256_starts(&verify_ctx, 0); + + uint8_t *read_buf = (uint8_t *)malloc(OTA_BLOCK_SIZE); + if (!read_buf) { + DEBUG_ERR("OTA", "SHA-256 verify: malloc failed"); + mbedtls_sha256_free(&verify_ctx); + } else { + size_t offset = 0; + while (offset < WifiOta::instance().m_esp32Received) { + size_t to_read = WifiOta::instance().m_esp32Received - offset; + if (to_read > OTA_BLOCK_SIZE) to_read = OTA_BLOCK_SIZE; + esp_err_t rd_err = esp_partition_read(part, offset, read_buf, to_read); + if (rd_err != ESP_OK) { + DEBUG_ERR("OTA", "SHA-256 verify: partition read failed at offset %u", (unsigned int)offset); + break; + } + mbedtls_sha256_update(&verify_ctx, read_buf, to_read); + offset += to_read; + } + free(read_buf); + mbedtls_sha256_finish(&verify_ctx, WifiOta::instance().m_esp32Sha256); + mbedtls_sha256_free(&verify_ctx); + WifiOta::instance().m_esp32HashComputed = true; + + char device_sha_hex[65] = {0}; + for (int i = 0; i < 32; ++i) { + snprintf(&device_sha_hex[i * 2], sizeof(device_sha_hex) - i * 2, "%02x", WifiOta::instance().m_esp32Sha256[i]); + } + DEBUG_INFO("OTA", "Device SHA-256: %s", device_sha_hex); + + bool match = true; + for (int i = 0; i < 64; i++) { + if (tolower((unsigned char)frontend_sha_hex[i]) != tolower((unsigned char)device_sha_hex[i])) { + match = false; + break; + } + } + if (match) { + DEBUG_INFO("OTA", "SHA-256 verified OK"); + if (verify_only) { + char ok_resp[256] = {0}; + snprintf(ok_resp, sizeof(ok_resp), + "{\"status\": \"ok\", \"message\": \"SHA-256 verified\", \"sha256\": \"%s\"}", + device_sha_hex); + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, ok_resp, strlen(ok_resp)); + return ESP_OK; + } + } else { + DEBUG_ERR("OTA", "SHA-256 mismatch! frontend=%s device=%s, abort", frontend_sha_hex, device_sha_hex); + if (!verify_only) { + esp_ota_abort(WifiOta::instance().m_esp32OtaHandle); + } + wifi_esp32_ota_cleanup_sha(); + esp32_ota_mutex_release(); + WifiOta::instance().m_esp32OtaActive = false; + WifiOta::instance().m_esp32OtaHandle = 0; + WifiOta::instance().m_esp32UpdatePartition = NULL; + WifiOta::instance().m_esp32Received = 0; + char err_resp[256] = {0}; + snprintf(err_resp, sizeof(err_resp), + "{\"status\": \"error\", \"message\": \"SHA-256 mismatch\", \"frontend\": \"%s\", \"device\": \"%s\"}", + frontend_sha_hex, device_sha_hex); + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, err_resp, strlen(err_resp)); + return ESP_FAIL; + } + } + } + + // ── 非verify_only模式:设置启动分区并重启 ── + if (verify_only) { + // 没有前端SHA或验证被跳过,在verify_only下也返回ok + char ok_resp[128] = {0}; + snprintf(ok_resp, sizeof(ok_resp), + "{\"status\": \"ok\", \"message\": \"verify skipped, no SHA-256 provided\"}"); + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, ok_resp, strlen(ok_resp)); + return ESP_OK; + } + + esp_err_t err = esp_ota_set_boot_partition(WifiOta::instance().m_esp32UpdatePartition); if (err != ESP_OK) { - DEBUG_ERR("OTA", "esp_ota_end 失败: %s", esp_err_to_name(err)); - WifiOta::instance().m_esp32OtaActive = false; - httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, esp_err_to_name(err)); - return ESP_FAIL; - } - - err = esp_ota_set_boot_partition(WifiOta::instance().m_esp32UpdatePartition); - if (err != ESP_OK) { - DEBUG_ERR("OTA", "esp_ota_set_boot_partition 失败: %s", esp_err_to_name(err)); + DEBUG_ERR("OTA", "esp_ota_set_boot_partition failed: %s", esp_err_to_name(err)); + esp32_ota_mutex_release(); WifiOta::instance().m_esp32OtaActive = false; + wifi_esp32_ota_cleanup_sha(); httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, esp_err_to_name(err)); return ESP_FAIL; } + esp32_ota_mutex_release(); WifiOta::instance().m_esp32OtaActive = false; - DEBUG_INFO("OTA", "ESP32 OTA 完成,重启中..."); + DEBUG_INFO("OTA", "ESP32 OTA finished, rebooting..."); + + char resp[256] = {0}; + if (WifiOta::instance().m_esp32HashComputed) { + char device_sha_hex[65] = {0}; + for (int i = 0; i < 32; ++i) { + snprintf(&device_sha_hex[i * 2], sizeof(device_sha_hex) - i * 2, "%02x", WifiOta::instance().m_esp32Sha256[i]); + } + snprintf(resp, sizeof(resp), + "{\"status\": \"ok\", \"message\": \"OTA finished, rebooting\", \"sha256\": \"%s\"}", + device_sha_hex); + } else { + snprintf(resp, sizeof(resp), + "{\"status\": \"ok\", \"message\": \"OTA finished, rebooting\"}"); + } - const char resp[] = "{\"status\": \"ok\", \"message\": \"OTA finished, rebooting\"}"; httpd_resp_set_type(req, "application/json"); httpd_resp_send(req, resp, strlen(resp)); @@ -580,14 +822,22 @@ static esp_err_t ota_upload_handler(httpd_req_t *req) esp_err_t err = esp32_ota_begin(total_size); if (err != ESP_OK) { - httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "OTA begin failed"); + if (err == ESP_ERR_INVALID_STATE) { + httpd_resp_send_err(req, HTTPD_403_FORBIDDEN, "ESP32 OTA occupied by another channel"); + } else { + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "OTA begin failed"); + } return ESP_FAIL; } - // 堆分配缓冲区,避免栈溢出(HTTP server 栈仅 8KB) uint8_t *fw_buf = (uint8_t *)malloc(OTA_BLOCK_SIZE); if (!fw_buf) { + esp_ota_abort(WifiOta::instance().m_esp32OtaHandle); + esp32_ota_mutex_release(); + WifiOta::instance().m_esp32OtaActive = false; + WifiOta::instance().m_esp32OtaHandle = 0; + wifi_esp32_ota_cleanup_sha(); httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "内存分配失败"); return ESP_FAIL; } @@ -601,15 +851,34 @@ static esp_err_t ota_upload_handler(httpd_req_t *req) if (to_read > OTA_BLOCK_SIZE) to_read = OTA_BLOCK_SIZE; int ret = httpd_req_recv(req, (char *)fw_buf, to_read); - if (ret <= 0) break; + if (ret <= 0) { + DEBUG_ERR("WIFI", "HTTP recv failed (ret=%d), aborting OTA", ret); + free(fw_buf); + esp_ota_abort(WifiOta::instance().m_esp32OtaHandle); + esp32_ota_mutex_release(); + WifiOta::instance().m_esp32OtaActive = false; + WifiOta::instance().m_esp32OtaHandle = 0; + wifi_esp32_ota_cleanup_sha(); + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "HTTP recv failed"); + return ESP_FAIL; + } err = esp_ota_write(WifiOta::instance().m_esp32OtaHandle, fw_buf, (size_t)ret); if (err != ESP_OK) { DEBUG_ERR("WIFI", "OTA write failed"); - break; + free(fw_buf); + esp_ota_abort(WifiOta::instance().m_esp32OtaHandle); + esp32_ota_mutex_release(); + WifiOta::instance().m_esp32OtaActive = false; + WifiOta::instance().m_esp32OtaHandle = 0; + wifi_esp32_ota_cleanup_sha(); + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "OTA write failed"); + return ESP_FAIL; } + total_firmware_written += ret; total_received += ret; + WifiOta::instance().m_esp32Received += ret; if (total_size > 0) { int percent = (int)((total_firmware_written * 100) / total_size); @@ -645,12 +914,14 @@ static esp_err_t ota_upload_handler(httpd_req_t *req) httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "firmware too large or alignment overflow"); return ESP_FAIL; } - if (OtaRelay::relayState != RELAY_IDLE) { - DEBUG_WARN("WIFI", "GD32 OTA 开始前复位中继状态: %d", OtaRelay::relayState); + if (OtaRelay::getState() != RELAY_IDLE) { + DEBUG_WARN("WIFI", "GD32 OTA 开始前复位中继状态: %d", OtaRelay::getState()); OtaRelay::instance().reset(); } - WifiOta::instance().m_gd32StorageDone = false; - WifiOta::instance().m_relayTriggered = false; + WifiOta::instance().stateLock(); + WifiOta::instance().setGd32StorageDone(false); + WifiOta::instance().setRelayTriggered(false); + WifiOta::instance().stateUnlock(); const esp_partition_t *gd32_partition = esp_partition_find_first( ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_SPIFFS, "gd32_ota"); @@ -761,8 +1032,16 @@ static esp_err_t ota_upload_handler(httpd_req_t *req) ota_meta_t final_meta; OtaRelay::metaLoad(&final_meta); if (final_meta.total_size > 0 && final_meta.stored_size >= final_meta.total_size) { - WifiOta::instance().m_gd32StorageDone = true; - DEBUG_INFO("WIFI", "GD32 firmware fully stored, auto-triggering relay"); + WifiOta::instance().stateLock(); + WifiOta::instance().setGd32StorageDone(true); + WifiOta::instance().stateUnlock(); + DEBUG_INFO("WIFI", "GD32 firmware fully stored, computing SHA-256..."); + // 计算并存储 SHA-256 校验和 + esp_err_t sha_err = OtaRelay::metaFinalizeChecksum(); + if (sha_err != ESP_OK) { + DEBUG_ERR("WIFI", "GD32 固件 SHA-256 计算失败: %s", esp_err_to_name(sha_err)); + } + DEBUG_INFO("WIFI", "auto-triggering relay"); // 自动触发中继 if (OtaRelay::dispatchQueue) { uint32_t cmd = 1; // 1 = start relay @@ -794,14 +1073,18 @@ static esp_err_t ota_relay_trigger_handler(httpd_req_t *req) return ESP_FAIL; } - if (WifiOta::instance().m_relayTriggered && OtaRelay::relayState != RELAY_IDLE && - OtaRelay::relayState != RELAY_DONE && OtaRelay::relayState != RELAY_APP_VERIFY && OtaRelay::relayState != RELAY_ERROR) { + relay_state_t curState = OtaRelay::getState(); + WifiOta::instance().stateLock(); + if (WifiOta::instance().getRelayTriggered() && curState != RELAY_IDLE && + curState != RELAY_DONE && curState != RELAY_APP_VERIFY && curState != RELAY_ERROR) { + WifiOta::instance().stateUnlock(); httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "relay already triggered"); return ESP_FAIL; } /* 上一轮中继已结束/失败/空闲,重置标志允许重新触发 */ - WifiOta::instance().m_relayTriggered = false; - WifiOta::instance().m_gd32StorageDone = false; + WifiOta::instance().setRelayTriggered(false); + WifiOta::instance().setGd32StorageDone(false); + WifiOta::instance().stateUnlock(); if (!OtaRelay::dispatchQueue) { httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "relay queue not ready"); @@ -809,7 +1092,7 @@ static esp_err_t ota_relay_trigger_handler(httpd_req_t *req) } WifiOta::instance().signalRelay(); - if (!WifiOta::instance().m_relayTriggered) { + if (!WifiOta::instance().getRelayTriggered()) { httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "relay trigger failed"); return ESP_FAIL; } @@ -840,18 +1123,20 @@ static esp_err_t ota_status_handler(httpd_req_t *req) "\"storage_done\": %s, \"relay_state\": %d, \"gd32_app_alive\": %s}", (unsigned long)meta.total_size, (unsigned long)meta.stored_size, (unsigned long)meta.last_seq, - WifiOta::instance().m_gd32StorageDone ? "true" : "false", - OtaRelay::relayState, - OtaRelay::gd32AppAlive ? "true" : "false"); + WifiOta::instance().getGd32StorageDone() ? "true" : "false", + OtaRelay::getState(), + OtaRelay::getGd32AppAlive() ? "true" : "false"); + gd32_version_t snapVer = {}; + OtaRelay::getGd32Version(&snapVer); DEBUG_INFO("WIFI", "OTA 状态: total=%lu, stored=%lu, done=%s, app_alive=%s", (unsigned long)meta.total_size, (unsigned long)meta.stored_size, - WifiOta::instance().m_gd32StorageDone ? "true" : "false", - OtaRelay::gd32AppAlive ? "true" : "false", - OtaRelay::gd32Version.app_major, OtaRelay::gd32Version.app_minor, - OtaRelay::gd32Version.app_patch, OtaRelay::gd32Version.app_build, - OtaRelay::gd32Version.boot_major, OtaRelay::gd32Version.boot_minor, - OtaRelay::gd32Version.boot_patch, OtaRelay::gd32Version.boot_build); + WifiOta::instance().getGd32StorageDone() ? "true" : "false", + OtaRelay::getGd32AppAlive() ? "true" : "false", + snapVer.app_major, snapVer.app_minor, + snapVer.app_patch, snapVer.app_build, + snapVer.boot_major, snapVer.boot_minor, + snapVer.boot_patch, snapVer.boot_build); httpd_resp_set_type(req, "application/json"); httpd_resp_send(req, resp, len); @@ -964,7 +1249,6 @@ static esp_err_t can_send_handler(httpd_req_t *req) if (tx_dlc == 0 && dlc > 0) { tx_dlc = (dlc > CAN_MAX_DLC) ? CAN_MAX_DLC : dlc; } - cJSON_Delete(root); // 发送 CAN 帧到 GD32 @@ -1051,7 +1335,6 @@ static esp_err_t spi_send_handler(httpd_req_t *req) DEBUG_INFO("SPI", "WEB SPI: type=0x%02X, payload=%zu 字节", frame_type, payload_len); - cJSON_Delete(root); char resp[1024]; @@ -1114,7 +1397,7 @@ static esp_err_t wifi_status_handler(httpd_req_t *req) char ip_str[16] = "0.0.0.0"; int8_t rssi = -127; - if (WifiOta::instance().m_wifiMode == OTA_WIFI_MODE_STA && WifiOta::instance().m_staConnected) { + if (WifiOta::instance().m_wifiMode == OTA_WIFI_MODE_STA && WifiOta::instance().getStaConnected()) { esp_netif_ip_info_t ip_info; esp_netif_t *sta_netif = esp_netif_get_handle_from_ifkey("WIFI_STA_DEF"); if (sta_netif && esp_netif_get_ip_info(sta_netif, &ip_info) == ESP_OK) { @@ -1130,7 +1413,7 @@ static esp_err_t wifi_status_handler(httpd_req_t *req) "{\"mode\": \"%s\", \"ip\": \"%s\", \"rssi\": %d, \"connected\": %s}", WifiOta::instance().m_wifiMode == OTA_WIFI_MODE_AP ? "AP" : "STA", ip_str, rssi, - (WifiOta::instance().m_wifiMode == OTA_WIFI_MODE_STA) ? (WifiOta::instance().m_staConnected ? "true" : "false") : "N/A"); + (WifiOta::instance().m_wifiMode == OTA_WIFI_MODE_STA) ? (WifiOta::instance().getStaConnected() ? "true" : "false") : "N/A"); httpd_resp_set_type(req, "application/json"); httpd_resp_send(req, resp, len); @@ -1144,7 +1427,7 @@ static esp_err_t wifi_status_handler(httpd_req_t *req) static esp_err_t version_handler(httpd_req_t *req) { http_set_cors_headers(req); - gd32_version_t gv = OtaRelay::gd32Version; + gd32_version_t gv = {}; OtaRelay::getGd32Version(&gv); char resp[512]; int len = snprintf(resp, sizeof(resp), "{\"version\": \"%s\", \"build_time\": \"%s\", \"git_hash\": \"%s\", " @@ -1152,7 +1435,7 @@ static esp_err_t version_handler(httpd_req_t *req) FW_VERSION, FW_BUILD_TIME, FW_GIT_HASH, gv.app_major, gv.app_minor, gv.app_patch, gv.app_build, gv.boot_major, gv.boot_minor, gv.boot_patch, gv.boot_build, - OtaRelay::gd32AppAlive ? "true" : "false"); + OtaRelay::getGd32AppAlive() ? "true" : "false"); httpd_resp_set_type(req, "application/json"); httpd_resp_send(req, resp, len); return ESP_OK; @@ -1170,7 +1453,7 @@ static esp_err_t system_status_handler(httpd_req_t *req) int8_t rssi = -127; bool connected = false; - if (WifiOta::instance().m_wifiMode == OTA_WIFI_MODE_STA && WifiOta::instance().m_staConnected) { + if (WifiOta::instance().m_wifiMode == OTA_WIFI_MODE_STA && WifiOta::instance().getStaConnected()) { esp_netif_ip_info_t ip_info; esp_netif_t *sta_netif = esp_netif_get_handle_from_ifkey("WIFI_STA_DEF"); if (sta_netif && esp_netif_get_ip_info(sta_netif, &ip_info) == ESP_OK) { @@ -1182,11 +1465,13 @@ static esp_err_t system_status_handler(httpd_req_t *req) } connected = true; } + gd32_version_t pingVer = {}; + OtaRelay::getGd32Version(&pingVer); /* 若尚无 GD32 版本信息且 OTA 中继空闲,尝试一次 SPI PING 获取 */ - if (OtaRelay::gd32Version.app_major == 0 && - OtaRelay::gd32Version.boot_major == 0 && - OtaRelay::relayState == RELAY_IDLE) { + if (pingVer.app_major == 0 && + pingVer.boot_major == 0 && + OtaRelay::getState() == RELAY_IDLE) { static uint32_t last_ping_ms = 0; uint32_t now_ms = (uint32_t)(esp_timer_get_time() / 1000); if (now_ms - last_ping_ms > 5000) { /* 5 秒节流 */ @@ -1194,10 +1479,10 @@ static esp_err_t system_status_handler(httpd_req_t *req) uint32_t uptime = 0; gd32_version_t ver = {}; if (SpiMaster::instance().appPing(500, &uptime, &ver) == ESP_OK) { - OtaRelay::gd32AppAlive = true; - OtaRelay::gd32Version = ver; + OtaRelay::setGd32AppAlive(true); + OtaRelay::setGd32Version(ver); } else { - OtaRelay::gd32AppAlive = false; + OtaRelay::setGd32AppAlive(false); } } } @@ -1206,6 +1491,8 @@ static esp_err_t system_status_handler(httpd_req_t *req) memset(&meta, 0, sizeof(meta)); OtaRelay::metaLoad(&meta); + gd32_version_t snapVer2 = {}; + OtaRelay::getGd32Version(&snapVer2); char resp[1024]; int len = snprintf(resp, sizeof(resp), "{\"version\": \"%s\", \"build_time\": \"%s\", \"git_hash\": \"%s\", \"health\": %d, \"free_heap\": %lu, \"min_free_heap\": %lu, \"temperature\": %.1f, \"uptime_sec\": %lu, \"wifi\": {\"mode\": \"%s\", \"ip\": \"%s\", \"rssi\": %d, \"connected\": %s}, \"ota\": {\"total_size\": %lu, \"stored_size\": %lu, \"storage_done\": %s, \"relay_state\": %d, \"relay_seq\": %lu, \"relay_total_packets\": %lu, \"gd32_app_alive\": %s, \"gd32_app\": \"v%d.%d.%d.%d\", \"gd32_boot\": \"v%d.%d.%d.%d\"}}", @@ -1220,15 +1507,15 @@ static esp_err_t system_status_handler(httpd_req_t *req) connected ? "true" : "false", (unsigned long)meta.total_size, (unsigned long)meta.stored_size, - WifiOta::instance().m_gd32StorageDone ? "true" : "false", - OtaRelay::relayState, - (unsigned long)OtaRelay::relaySeq, - (unsigned long)OtaRelay::relayTotalPackets, - OtaRelay::gd32AppAlive ? "true" : "false", - OtaRelay::gd32Version.app_major, OtaRelay::gd32Version.app_minor, - OtaRelay::gd32Version.app_patch, OtaRelay::gd32Version.app_build, - OtaRelay::gd32Version.boot_major, OtaRelay::gd32Version.boot_minor, - OtaRelay::gd32Version.boot_patch, OtaRelay::gd32Version.boot_build); + WifiOta::instance().getGd32StorageDone() ? "true" : "false", + OtaRelay::getState(), + (unsigned long)OtaRelay::getRelaySeq(), + (unsigned long)OtaRelay::getRelayTotalPackets(), + OtaRelay::getGd32AppAlive() ? "true" : "false", + snapVer2.app_major, snapVer2.app_minor, + snapVer2.app_patch, snapVer2.app_build, + snapVer2.boot_major, snapVer2.boot_minor, + snapVer2.boot_patch, snapVer2.boot_build); httpd_resp_set_type(req, "application/json"); httpd_resp_send(req, resp, len); @@ -1247,6 +1534,8 @@ esp_err_t WifiOta::httpServerStart() config.server_port = HTTP_SERVER_PORT; config.stack_size = HTTP_SERVER_STACK_SIZE; config.max_uri_handlers = 32; // 现有业务端点 + 对应 OPTIONS 预检 + config.max_open_sockets = 4; // LWIP_MAX_SOCKETS 默认 7,HTTP 内部占 3,最多 4 + config.lru_purge_enable = true; // socket 满时自动清理最久未用的连接 config.recv_wait_timeout = 30; // GD32 上传前会擦除 Flash,5 秒默认值过紧 config.send_wait_timeout = 30; @@ -1264,7 +1553,7 @@ esp_err_t WifiOta::httpServerStart() ota_meta_t boot_meta; if (OtaRelay::metaLoad(&boot_meta) == ESP_OK && boot_meta.total_size > 0 && boot_meta.stored_size >= boot_meta.total_size) { - WifiOta::instance().m_gd32StorageDone = true; + WifiOta::instance().setGd32StorageDone(true); DEBUG_INFO("WIFI", "从 NVS 恢复: 固件已就绪 (%u bytes)", boot_meta.total_size); } } @@ -1501,16 +1790,19 @@ void WifiOta::signalRelay() (unsigned long)meta.stored_size, (unsigned long)meta.total_size); return; } - - if (WifiOta::instance().m_relayTriggered && OtaRelay::relayState != RELAY_IDLE && - OtaRelay::relayState != RELAY_DONE && OtaRelay::relayState != RELAY_APP_VERIFY && OtaRelay::relayState != RELAY_ERROR) { - DEBUG_WARN("WIFI", "转发正在执行中,状态: %d", OtaRelay::relayState); + relay_state_t st = OtaRelay::getState(); + WifiOta::instance().stateLock(); + if (WifiOta::instance().getRelayTriggered() && st != RELAY_IDLE && + st != RELAY_DONE && st != RELAY_APP_VERIFY && st != RELAY_ERROR) { + WifiOta::instance().stateUnlock(); + DEBUG_WARN("WIFI", "转发正在执行中,状态: %d", st); return; } /* 上一轮中继已结束/失败/空闲,重置标志 */ - WifiOta::instance().m_relayTriggered = false; - WifiOta::instance().m_gd32StorageDone = false; - WifiOta::instance().m_relayTriggered = true; + WifiOta::instance().setRelayTriggered(false); + WifiOta::instance().setGd32StorageDone(false); + WifiOta::instance().setRelayTriggered(true); + WifiOta::instance().stateUnlock(); if (OtaRelay::dispatchQueue) { uint32_t msg = 1; @@ -1519,11 +1811,11 @@ void WifiOta::signalRelay() DEBUG_INFO("WIFI", "OTA 调度信号已发送到中继任务"); } else { DEBUG_ERR("WIFI", "OTA 调度信号发送失败"); - WifiOta::instance().m_relayTriggered = false; + WifiOta::instance().setRelayTriggered(false); } } else { DEBUG_ERR("WIFI", "OTA 中继队列尚未就绪"); - WifiOta::instance().m_relayTriggered = false; + WifiOta::instance().setRelayTriggered(false); } } @@ -1542,7 +1834,7 @@ ota_wifi_mode_t WifiOta::getMode() */ int8_t WifiOta::getRssi() { - if (WifiOta::instance().m_wifiMode != OTA_WIFI_MODE_STA || !WifiOta::instance().m_staConnected) { + if (WifiOta::instance().m_wifiMode != OTA_WIFI_MODE_STA || !WifiOta::instance().getStaConnected()) { return -127; } wifi_ap_record_t ap_info; @@ -1558,7 +1850,7 @@ int8_t WifiOta::getRssi() */ bool WifiOta::isConnected() { - return WifiOta::instance().m_staConnected; + return WifiOta::instance().getStaConnected(); } // ---------------------------------------- @@ -1631,12 +1923,36 @@ bool WifiOta::earlyInit() } } + // ★ 创建状态互斥锁(保护 WiFi 连接状态、OTA 中继标志等跨任务共享变量) + if (WifiOta::instance().getStateMutex() == NULL) { + SemaphoreHandle_t mtx = xSemaphoreCreateMutex(); + if (mtx) { + // 通过临时获取再赋值(私有成员在同类内可访问) + WifiOta::instance().m_stateMutex = mtx; + DEBUG_INFO("WIFI", "状态互斥锁已创建"); + } else { + DEBUG_ERR("WIFI", "创建状态互斥锁失败"); + } + } + WifiOta::instance().m_staConnectedSem = xSemaphoreCreateBinary(); WifiOta::instance().m_staGotIpSem = xSemaphoreCreateBinary(); if (!WifiOta::instance().m_staConnectedSem || !WifiOta::instance().m_staGotIpSem) { DEBUG_ERR("WIFI", "创建信号量失败"); } + // 创建后台重连任务:避免在系统事件任务中调用 esp_wifi_connect()/vTaskDelay 导致死锁 + if (WifiOta::instance().m_reconnectTask == nullptr) { + BaseType_t ret = xTaskCreate(&WifiOta::reconnectTask, "wifi_reconnect", + 4096, NULL, 5, + &WifiOta::instance().m_reconnectTask); + if (ret != pdPASS) { + DEBUG_ERR("WIFI", "创建重连任务失败"); + } else { + DEBUG_INFO("WIFI", "WiFi 重连任务已创建"); + } + } + // 注册 WiFi/IP 事件回调(必须在 esp_wifi_init 之前) ESP_ERROR_CHECK(esp_event_handler_register(WIFI_EVENT, ESP_EVENT_ANY_ID, &WifiOta::wifiEventHandler, NULL)); ESP_ERROR_CHECK(esp_event_handler_register(IP_EVENT, ESP_EVENT_ANY_ID, &WifiOta::wifiEventHandler, NULL)); @@ -1776,5 +2092,3 @@ void WifiOta::taskEntry(void *pvParameters) } } - - diff --git a/main/server/wifi_ota.h b/main/server/wifi_ota.h index 3b80b9d..b76a6a2 100644 --- a/main/server/wifi_ota.h +++ b/main/server/wifi_ota.h @@ -1,4 +1,4 @@ -/* +/* * wifi_ota.h - WiFi OTA 服务器 * T4.1 - T4.5 * @@ -11,7 +11,9 @@ #include "esp_ota_ops.h" #include "esp_http_server.h" #include "esp_event.h" +#include +// SHA-256 验证所需类型前向声明 typedef enum { OTA_WIFI_MODE_AP, OTA_WIFI_MODE_STA @@ -29,28 +31,57 @@ public: static int8_t getRssi(); static bool isConnected(); - // 以下状态为 public,供 C 回调函数访问 + // ── WiFi 连接状态(WiFi 事件回调 / HTTP / reconnect 任务共享,需 mutex 保护)── + bool getStaConnected() const { return m_staConnected.load(); } + void setStaConnected(bool v) { m_staConnected.store(v); } + int getStaRetryCount() const { return m_staRetryCount.load(); } + void setStaRetryCount(int v) { m_staRetryCount.store(v); } + bool getStaRetryExhausted() const { return m_staRetryExhausted.load(); } + void setStaRetryExhausted(bool v) { m_staRetryExhausted.store(v); } + bool getGd32StorageDone() const { return m_gd32StorageDone.load(); } + void setGd32StorageDone(bool v) { m_gd32StorageDone.store(v); } + bool getRelayTriggered() const { return m_relayTriggered.load(); } + void setRelayTriggered(bool v) { m_relayTriggered.store(v); } + + // ── 状态互斥锁(保护复合读-改-写操作)── + SemaphoreHandle_t getStateMutex() const { return m_stateMutex; } + void stateLock() { if (m_stateMutex) xSemaphoreTake(m_stateMutex, portMAX_DELAY); } + void stateUnlock() { if (m_stateMutex) xSemaphoreGive(m_stateMutex); } + + // ── 初始化/运行时状态(仅 OTA 任务访问,无需额外锁)── ota_wifi_mode_t m_wifiMode = OTA_WIFI_MODE_AP; bool m_coldBoot = false; - bool m_relayTriggered = false; - bool m_staConnected = false; - int m_staRetryCount = 0; - bool m_staRetryExhausted = false; SemaphoreHandle_t m_staConnectedSem = NULL; SemaphoreHandle_t m_staGotIpSem = NULL; + TaskHandle_t m_reconnectTask = nullptr; bool m_esp32OtaActive = false; esp_ota_handle_t m_esp32OtaHandle = 0; const esp_partition_t *m_esp32UpdatePartition = NULL; size_t m_esp32Received = 0; - bool m_gd32StorageDone = false; httpd_handle_t m_httpServer = NULL; + // ── SHA-256 哈希校验 ── + uint8_t m_esp32Sha256[32] = {0}; // 重启前验证计算的SHA-256哈希值 + bool m_esp32HashComputed = false; // 是否已计算哈希 + char m_frontendSha256[65] = {0}; // 前端提供的SHA-256(上传时存储) + bool m_hasFrontendSha256 = false; // 前端是否提供了SHA-256 + +private: + // ── 跨任务共享状态(atomic + m_stateMutex 保护)── + std::atomic m_staConnected{false}; + std::atomic m_staRetryCount{0}; + std::atomic m_staRetryExhausted{false}; + std::atomic m_gd32StorageDone{false}; + std::atomic m_relayTriggered{false}; + SemaphoreHandle_t m_stateMutex = NULL; + void initAp(); esp_err_t initSta(); void setStaticIp(); static void wifiEventHandler(void *arg, esp_event_base_t event_base, int32_t event_id, void *event_data); + static void reconnectTask(void *pvParameters); esp_err_t httpServerStart(); esp_err_t waitStaConnected(char *ip_str, size_t ip_len); }; diff --git a/main/transport/spi_master.cpp b/main/transport/spi_master.cpp index ccbb022..cbd1344 100644 --- a/main/transport/spi_master.cpp +++ b/main/transport/spi_master.cpp @@ -10,6 +10,7 @@ #include #include "freertos/FreeRTOS.h" #include "freertos/task.h" +#include "freertos/semphr.h" #include "driver/spi_master.h" #include "soc/spi_struct.h" #include "esp_err.h" @@ -32,7 +33,8 @@ SpiMaster& SpiMaster::instance() // 内部 SPI 传输 // ============================================================ esp_err_t SpiMaster::transferClocked(const uint8_t *tx_buf, size_t tx_len, - uint8_t *rx_buf, size_t rx_len) + uint8_t *rx_buf, size_t rx_len, + uint32_t timeout_ms) { if (m_spiDevice == NULL) { return ESP_ERR_INVALID_STATE; @@ -75,21 +77,33 @@ esp_err_t SpiMaster::transferClocked(const uint8_t *tx_buf, size_t tx_len, t.tx_buffer = tx_ptr; t.rx_buffer = (rx_len > 0U) ? rx_ptr : NULL; - esp_err_t ret = spi_device_transmit(m_spiDevice, &t); + TickType_t ticks = (timeout_ms == 0U) ? portMAX_DELAY : pdMS_TO_TICKS(timeout_ms); + esp_err_t ret = spi_device_queue_trans(m_spiDevice, &t, ticks); if (ret != ESP_OK) { - DEBUG_WARN("SPI", "spi_device_transmit fail: %s len=%u tx=%p rx=%p", - esp_err_to_name(ret), (unsigned)(total_len * 8U), t.tx_buffer, t.rx_buffer); + DEBUG_WARN("SPI", "spi_device_queue_trans fail: %s len=%u", + esp_err_to_name(ret), (unsigned)(total_len * 8U)); + return ret; } - if (ret == ESP_OK && rx_len > 0U && rx_ptr != rx_buf) { + spi_transaction_t *ret_trans = NULL; + ret = spi_device_get_trans_result(m_spiDevice, &ret_trans, ticks); + (void)ret_trans; + if (ret != ESP_OK) { + DEBUG_WARN("SPI", "spi_device_get_trans_result timeout/fail: %s len=%u tx=%p rx=%p", + esp_err_to_name(ret), (unsigned)(total_len * 8U), t.tx_buffer, t.rx_buffer); + return ret; + } + + if (rx_len > 0U && rx_ptr != rx_buf) { memcpy(rx_buf, rx_ptr, rx_len); } - return ret; + return ESP_OK; } esp_err_t SpiMaster::transferRequestResponse(uint8_t cmd, const uint8_t *payload, size_t payload_len, - uint8_t *rx_buf, size_t rx_len) + uint8_t *rx_buf, size_t rx_len, + uint32_t timeout_ms) { if ((payload_len > 0U && payload == NULL) || (rx_len > 0U && rx_buf == NULL)) { return ESP_ERR_INVALID_ARG; @@ -121,7 +135,8 @@ esp_err_t SpiMaster::transferRequestResponse(uint8_t cmd, esp_err_t ret = transferClocked(tx_staging, total_len, (rx_len > 0U) ? rx_staging : NULL, - (rx_len > 0U) ? total_len : 0U); + (rx_len > 0U) ? total_len : 0U, + timeout_ms); if (ret == ESP_OK && rx_len > 0U) { memcpy(rx_buf, &rx_staging[cmd_len + payload_len], rx_len); } @@ -209,6 +224,14 @@ bool SpiMaster::otaFindFrame(const uint8_t *buf, size_t buf_len, // ============================================================ esp_err_t SpiMaster::init() { + LockGuard lock(m_mutex); + // 创建 SPI 总线互斥锁(必须在 SPI 设备初始化之前) + m_mutex = xSemaphoreCreateMutex(); + if (m_mutex == NULL) { + DEBUG_ERR("SPI", "SPI 互斥锁创建失败"); + return ESP_ERR_NO_MEM; + } + DEBUG_INFO("SPI", "=== 初始化 SPI2 总线 ==="); spi_bus_config_t bus_cfg = {}; @@ -255,19 +278,20 @@ esp_err_t SpiMaster::syncTransfer(uint8_t cmd, const uint8_t *payload, size_t payload_len, uint8_t *recv_buf, size_t recv_len, uint32_t timeout_ms) { - (void)timeout_ms; - return transferRequestResponse(cmd, payload, payload_len, recv_buf, recv_len); + LockGuard lock(m_mutex); + return transferRequestResponse(cmd, payload, payload_len, recv_buf, recv_len, timeout_ms); } esp_err_t SpiMaster::send(uint8_t cmd, const uint8_t *payload, size_t len) { - return transferRequestResponse(cmd, payload, len, NULL, 0); + LockGuard lock(m_mutex); + return transferRequestResponse(cmd, payload, len, NULL, 0, 0U); } esp_err_t SpiMaster::recv(uint8_t cmd, uint8_t *recv_buf, size_t len, uint32_t timeout_ms) { - (void)timeout_ms; - return transferRequestResponse(cmd, NULL, 0, recv_buf, len); + LockGuard lock(m_mutex); + return transferRequestResponse(cmd, NULL, 0, recv_buf, len, timeout_ms); } // ============================================================ @@ -277,6 +301,7 @@ esp_err_t SpiMaster::otaTransfer(uint8_t type, const uint8_t *payload, size_t pa uint8_t *resp_type, uint8_t *resp_payload, size_t resp_max, size_t *resp_len, uint32_t timeout_ms) { + LockGuard lock(m_mutex); uint8_t tx_frame[SPI_OTA_FRAME_MAX]; size_t tx_len = 0; if (!otaBuildFrame(type, payload, payload_len, tx_frame, &tx_len)) { @@ -285,17 +310,24 @@ esp_err_t SpiMaster::otaTransfer(uint8_t type, const uint8_t *payload, size_t pa DEBUG_DBG("SPI", "OTA TX: type=0x%02X len=%u first4=%02X%02X%02X%02X", type, (unsigned)tx_len, tx_frame[0], tx_frame[1], tx_frame[2], tx_frame[3]); - esp_err_t ret = transferClocked(tx_frame, tx_len, NULL, 0); + esp_err_t ret = transferClocked(tx_frame, tx_len, NULL, 0, timeout_ms); if (ret != ESP_OK) { DEBUG_WARN("SPI", "OTA 请求发送失败: %s", esp_err_to_name(ret)); return ret; } - return otaPollResponse(resp_type, resp_payload, resp_max, resp_len, timeout_ms); + return otaPollResponseUnlocked(resp_type, resp_payload, resp_max, resp_len, timeout_ms); } esp_err_t SpiMaster::otaPollResponse(uint8_t *resp_type, uint8_t *resp_payload, size_t resp_max, size_t *resp_len, uint32_t timeout_ms) +{ + LockGuard lock(m_mutex); + return otaPollResponseUnlocked(resp_type, resp_payload, resp_max, resp_len, timeout_ms); +} + +esp_err_t SpiMaster::otaPollResponseUnlocked(uint8_t *resp_type, uint8_t *resp_payload, + size_t resp_max, size_t *resp_len, uint32_t timeout_ms) { constexpr size_t kPollBytes = 96U; TickType_t deadline = xTaskGetTickCount() + pdMS_TO_TICKS(timeout_ms); @@ -305,7 +337,12 @@ esp_err_t SpiMaster::otaPollResponse(uint8_t *resp_type, uint8_t *resp_payload, uint8_t dummy[kPollBytes]; memset(dummy, 0xFF, sizeof(dummy)); - esp_err_t ret = transferClocked(dummy, sizeof(dummy), rx_raw, sizeof(rx_raw)); + /* 用剩余时间作为单次 SPI 事务超时,避免总等待远超 deadline */ + TickType_t now = xTaskGetTickCount(); + uint32_t remain_ms = (deadline > now) ? ((deadline - now) * portTICK_PERIOD_MS) : 0U; + if (remain_ms == 0U) break; + + esp_err_t ret = transferClocked(dummy, sizeof(dummy), rx_raw, sizeof(rx_raw), remain_ms); if (ret != ESP_OK) { DEBUG_WARN("SPI", "OTA 响应读取失败: %s", esp_err_to_name(ret)); vTaskDelay(pdMS_TO_TICKS(10)); @@ -355,6 +392,7 @@ esp_err_t SpiMaster::otaPollResponse(uint8_t *resp_type, uint8_t *resp_payload, esp_err_t SpiMaster::appRequestBootloader() { + LockGuard lock(m_mutex); DEBUG_INFO("SPI", "请求 GD32 APP 进入 Bootloader..."); constexpr uint32_t kMaxRetries = 3U; @@ -393,6 +431,7 @@ esp_err_t SpiMaster::appRequestBootloader() esp_err_t SpiMaster::appPing(uint32_t timeout_ms, uint32_t *uptime_sec, gd32_version_t *version) { + LockGuard lock(m_mutex); constexpr uint8_t kAppPingType = 0xA1U; constexpr size_t kAppPingPollBytes = 32U; @@ -511,13 +550,14 @@ esp_err_t SpiMaster::transferWithRetry(uint8_t cmd, const uint8_t *payload, size_t payload_len, uint8_t *recv_buf, size_t recv_len, int max_retries) { + LockGuard lock(m_mutex); esp_err_t ret; int retry_count = 0; DEBUG_INFO("SPI", "SPI 传输 (带重试): cmd=0x%02X, 最大重试=%d", cmd, max_retries); while (retry_count < max_retries) { - ret = syncTransfer(cmd, payload, payload_len, + ret = syncTransferUnlocked(cmd, payload, payload_len, recv_buf, recv_len, 3000); if (ret == ESP_OK) { if (retry_count > 0) { @@ -544,6 +584,7 @@ int SpiMaster::canSend(can_channel_t channel, uint32_t ext_id, uint8_t *recv_buf, uint8_t recv_max, uint32_t timeout_ms) { + LockGuard lock(m_mutex); if (channel >= CAN_CHANNEL_MAX || dlc > CAN_MAX_DLC || !data) { return -1; } @@ -559,7 +600,7 @@ int SpiMaster::canSend(can_channel_t channel, uint32_t ext_id, memcpy(&tx_buf[tx_len], data, dlc); tx_len += dlc; - esp_err_t ret = syncTransfer(0, tx_buf, tx_len, recv_buf, recv_max, timeout_ms); + esp_err_t ret = syncTransferUnlocked(0, tx_buf, tx_len, recv_buf, recv_max, timeout_ms); if (ret != ESP_OK) { return -1; } @@ -570,6 +611,7 @@ int SpiMaster::canSend(can_channel_t channel, uint32_t ext_id, int SpiMaster::canSendNoReply(can_channel_t channel, uint32_t ext_id, const uint8_t *data, uint8_t dlc) { + LockGuard lock(m_mutex); if (channel >= CAN_CHANNEL_MAX || dlc > CAN_MAX_DLC || !data) { return -1; } @@ -585,7 +627,7 @@ int SpiMaster::canSendNoReply(can_channel_t channel, uint32_t ext_id, memcpy(&tx_buf[tx_len], data, dlc); tx_len += dlc; - esp_err_t ret = send(0, tx_buf, tx_len); + esp_err_t ret = sendUnlocked(0, tx_buf, tx_len); if (ret != ESP_OK) { return -1; } @@ -593,4 +635,17 @@ int SpiMaster::canSendNoReply(can_channel_t channel, uint32_t ext_id, return 0; } +// ============================================================ +// 无锁内部版本实现 +// ============================================================ +esp_err_t SpiMaster::syncTransferUnlocked(uint8_t cmd, const uint8_t *payload, + size_t payload_len, uint8_t *recv_buf, + size_t recv_len, uint32_t timeout_ms) +{ + return transferRequestResponse(cmd, payload, payload_len, recv_buf, recv_len, timeout_ms); +} +esp_err_t SpiMaster::sendUnlocked(uint8_t cmd, const uint8_t *payload, size_t len) +{ + return transferRequestResponse(cmd, payload, len, NULL, 0, 0U); +} diff --git a/main/transport/spi_master.h b/main/transport/spi_master.h index a84d6e5..ed0e2ab 100644 --- a/main/transport/spi_master.h +++ b/main/transport/spi_master.h @@ -9,6 +9,8 @@ #include #include "driver/spi_master.h" +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" // SPI 配置常量 #define SPI_MASTER_CS_GPIO (4) // CS 片选引脚 @@ -23,7 +25,7 @@ #define SPI_OTA_CMD_DATA (0x02) #define SPI_OTA_CMD_CANCEL (0x03) #define SPI_APP_ENTER_BOOT_CMD (0xB0) -#define SPI_APP_ENTER_BOOT_ACK_OK (0xA5) +#define SPI_APP_ENTER_BOOT_ACK_OK (0xB5) // GD32 APP 固件须同步修改 ACK 字节为 0xB5 #define SPI_OTA_RSP_ACK (0x80) #define SPI_OTA_RSP_WAIT (0x81) #define SPI_OTA_RSP_ERROR (0x82) @@ -140,13 +142,39 @@ private: SpiMaster& operator=(const SpiMaster&) = delete; spi_device_handle_t m_spiDevice = NULL; + SemaphoreHandle_t m_mutex = NULL; + + // RAII 锁守卫 + class LockGuard { + public: + explicit LockGuard(SemaphoreHandle_t mutex) : m_lock(mutex) { + if (m_lock) { xSemaphoreTake(m_lock, portMAX_DELAY); } + } + ~LockGuard() { + if (m_lock) { xSemaphoreGive(m_lock); } + } + LockGuard(const LockGuard&) = delete; + LockGuard& operator=(const LockGuard&) = delete; + private: + SemaphoreHandle_t m_lock; + }; // 内部 SPI 传输 esp_err_t transferClocked(const uint8_t *tx_buf, size_t tx_len, - uint8_t *rx_buf, size_t rx_len); + uint8_t *rx_buf, size_t rx_len, + uint32_t timeout_ms = 0U); esp_err_t transferRequestResponse(uint8_t cmd, const uint8_t *payload, size_t payload_len, - uint8_t *rx_buf, size_t rx_len); + uint8_t *rx_buf, size_t rx_len, + uint32_t timeout_ms = 0U); + + // 无锁内部版本(供已持锁的公共方法调用,避免递归死锁) + esp_err_t syncTransferUnlocked(uint8_t cmd, const uint8_t *payload, + size_t payload_len, uint8_t *recv_buf, + size_t recv_len, uint32_t timeout_ms); + esp_err_t sendUnlocked(uint8_t cmd, const uint8_t *payload, size_t len); + esp_err_t otaPollResponseUnlocked(uint8_t *resp_type, uint8_t *resp_payload, + size_t resp_max, size_t *resp_len, uint32_t timeout_ms); // OTA 帧工具(静态,无状态) static uint8_t otaCrc8(const uint8_t *data, size_t len); static bool otaBuildFrame(uint8_t type, const uint8_t *payload, size_t payload_len, diff --git a/ota_upload_test.py b/ota_upload_test.py new file mode 100644 index 0000000..c18bf64 --- /dev/null +++ b/ota_upload_test.py @@ -0,0 +1,53 @@ +import urllib.request, json, time, sys + +ESP_IP = "192.168.31.127" + +# Step 1: Upload ESP32 firmware +bin_path = r"G:\Robot_v2\DCU\ESP32_COMM\build\ESP32_COMM.bin" +with open(bin_path, "rb") as f: + fw_data = f.read() + +header = json.dumps({"type": "esp32", "total_size": len(fw_data)}).encode("utf-8") +body = header + fw_data +print(f"[1/3] Uploading {len(fw_data)} bytes...") + +req = urllib.request.Request(f"http://{ESP_IP}/ota/upload", data=body, method="POST") +req.add_header("Content-Type", "application/octet-stream") +try: + with urllib.request.urlopen(req, timeout=120) as resp: + result = resp.read().decode() + print(f" Upload OK: {result}") +except urllib.error.HTTPError as e: + body_text = e.read().decode() + print(f" Upload FAILED: HTTP {e.code} - {body_text}") + sys.exit(1) + +# Step 2: Trigger update +print("[2/3] Triggering OTA update...") +time.sleep(1) +req2 = urllib.request.Request(f"http://{ESP_IP}/ota/update", data=b"", method="POST") +try: + with urllib.request.urlopen(req2, timeout=10) as resp: + result2 = resp.read().decode() + print(f" Update OK: {result2}") +except (ConnectionResetError, urllib.error.URLError, OSError) as e: + print(f" Update triggered (connection reset expected): {type(e).__name__}") + +# Step 3: Wait for ESP32 to come back +print("[3/3] Waiting for ESP32 to reboot with new firmware...") +for attempt in range(1, 31): + time.sleep(5) + try: + req3 = urllib.request.Request(f"http://{ESP_IP}/system/status") + with urllib.request.urlopen(req3, timeout=5) as resp: + data = json.loads(resp.read().decode()) + ver = data.get("version", "?") + bld = data.get("build_time", "?") + upt = data.get("uptime_sec", 0) + print(f" Online! version={ver} build={bld} uptime={upt}s") + break + except Exception: + if attempt % 5 == 0: + print(f" Still waiting... ({attempt}/30)") +else: + print(" ESP32 did not come back within 150 seconds") diff --git a/patch.txt b/patch.txt deleted file mode 100644 index bd76464..0000000 --- a/patch.txt +++ /dev/null @@ -1,12 +0,0 @@ -*** Begin Patch -*** Update File: G:\Robot_v2\DCU\ESP32_COMM\main\transport\spi_master.h -@@ #define SPI_CLOCK_HZ (4 * 1000 * 1000) // 4MHz -- #define SPI_CLOCK_HZ (4 * 1000 * 1000) // 4MHz -+ #define SPI_CLOCK_HZ (10 * 1000 * 1000) // 10MHz -@@ #define SPI_QUEUE_SIZE (1) -- #define SPI_QUEUE_SIZE (1) -+ #define SPI_QUEUE_SIZE (3) -@@ #define SPI_OTA_FRAME_MAX (96) -- #define SPI_OTA_FRAME_MAX (96) -+ #define SPI_OTA_FRAME_MAX (128) -*** End Patch diff --git a/sdkconfig.defaults b/sdkconfig.defaults index 33c03da..c313fd7 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -1,5 +1,6 @@ # WiFi perf -- OTA acceleration CONFIG_ESP_WIFI_RX_BA_WIN=6 CONFIG_ESP_WIFI_TX_BA_WIN=6 +CONFIG_LWIP_MAX_SOCKETS=10 CONFIG_LWIP_TCP_WND_DEFAULT=11480 CONFIG_LWIP_TCP_SND_BUF_DEFAULT=11520 diff --git a/serial_monitor.py b/serial_monitor.py new file mode 100644 index 0000000..f410418 --- /dev/null +++ b/serial_monitor.py @@ -0,0 +1,15 @@ +import serial, sys, time +ser = serial.Serial('COM15', 115200, timeout=1) +outfile = open('G:\\Robot_v2\\DCU\\ESP32_COMM\\serial_log.txt', 'w', encoding='utf-8') +start = time.time() +while time.time() - start < 300: # 最多 5 分钟 + line = ser.readline() + if line: + try: + text = line.decode('utf-8', errors='replace').rstrip() + outfile.write(text + '\n') + outfile.flush() + except: + pass +outfile.close() +ser.close() diff --git a/tools/app.js b/tools/app.js index 8a3a857..af7a44d 100644 --- a/tools/app.js +++ b/tools/app.js @@ -15,6 +15,87 @@ 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) { + // 已移除确认/重试/取消按钮:SHA-256 校验通过后自动重启,校验失败仅记录日志 + void type; +} function setActivePhase(phase) { activePhase = phase; @@ -29,18 +110,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 +128,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 +140,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 +153,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 +164,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 +179,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 +206,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 +235,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 +274,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 +300,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 +336,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 +357,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 <= 4) { - all[activeStage - 1].className = 'stage-node live'; + 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; + } + // 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 +466,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 +495,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 +517,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 === 17) { - // Relay done, waiting for APP verify + } else if (rs >= 14 && rs <= 15) { + // Transfer complete setActivePhase('gd32Relay'); - setProgress(100, '转发完成,等待 APP 启动...'); + setProgress(100, '数据转发完成 (' + relayTotal + ' 包)'); + } else if (rs === 16) { + // OTA end sent + setActivePhase('gd32Relay'); + setProgress(100, 'OTA 刷写中...'); + } else if (rs === 17) { + // OTA complete + setActivePhase('gd32Relay'); + 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 +568,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 +645,6 @@ async function sendCanFrame() { showToast('发送失败', 'error'); } } - function clearCanFields() { document.getElementById('canId').value = '0x18DA00F1'; document.getElementById('canDlc').value = '8'; @@ -542,13 +652,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 +679,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 +696,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 +708,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 +734,6 @@ async function sendSpiFrame() { showToast('SPI 发送失败: ' + e.message, 'error'); } } - function clearSpiFields() { document.getElementById('spiType').value = '01'; document.getElementById('spiData').value = ''; @@ -642,13 +741,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 +776,6 @@ async function pickFileViaFSAPI(chip) { } return false; } - function setupDropZone(zoneId, chip) { var zone = document.getElementById(zoneId); if (!zone) return; @@ -697,15 +793,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 +812,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 +828,6 @@ function parseIntelHex(hexText) { if ((sum & 0xFF) !== 0) { throw new Error('HEX 行 ' + (i + 1) + ' 校验和错误: ' + line); } - if (recType === 0x01) { // EOF break; @@ -763,11 +851,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 +864,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 +879,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 +906,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 +926,6 @@ function processFile(file, chip) { reader.readAsText(file); return; // 异步,回调里设置 gd32File } - // .bin 文件或 ESP32 文件: 直接使用 if (chip === 'esp32') esp32File = file; else gd32File = file; @@ -859,23 +937,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 +1007,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 +1076,6 @@ async function startUpload(chip) { document.getElementById('bOTAProgress').textContent = pct + '%'; } }); - xhr.addEventListener('load', function () { isUploading = false; uploadBtn.disabled = false; @@ -970,22 +1087,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 +1107,6 @@ async function startUpload(chip) { showToast(detail ? ('上传失败: ' + detail) : '上传失败', 'error'); } }); - xhr.addEventListener('error', function () { isUploading = false; uploadBtn.disabled = false; @@ -1002,7 +1115,6 @@ async function startUpload(chip) { addLog('E', '上传', '上传过程发生错误'); showToast('上传出错', 'error'); }); - xhr.open('POST', url); xhr.send(body); } catch (e) { @@ -1014,28 +1126,133 @@ async function startUpload(chip) { updateUploadButtonLabel(); } } - // ==================== ESP32 OTA 完成升级 ==================== -async function triggerEsp32Update() { - addLog('I', 'OTA', '=== ESP32 OTA 完成,开始重启 ==='); - showToast('ESP32 正在重启...', 'success'); +// 一次调用完成:校验 SHA-256 → 设置启动分区 → 自动重启(无需确认按钮) +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 { + // verify_only=false: 服务器端会先校验 SHA-256,匹配后自动设置启动分区并重启 + // 单次请求即完成全部流程,无需前端再发送"应用"请求 + var requestBody = {}; + if (sha256) requestBody.sha256 = sha256; + requestBody.verify_only = false; + 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) }); - if (resp.ok) { - var data = await resp.json(); - addLog('I', 'OTA', '升级完成: ' + data.message); - showToast('ESP32 重启中...', 'success'); + + 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 (resultEl) { + if (sha256 && data.sha256 && sha256.toLowerCase() === data.sha256.toLowerCase()) { + resultEl.textContent = '验证通过'; + resultEl.className = 'svi-val ok'; + } else { + resultEl.textContent = '已应用(未比对 SHA)'; + resultEl.className = 'svi-val ok'; + } + } + // 服务器已设置启动分区并自动重启,前端直接进入重启等待状态 + addLog('I', 'OTA', 'SHA-256 校验通过,设备自动重启中...'); + showToast('校验通过,设备重启中...', 'success'); + showVerifyActions('none'); + updateEsp32StagesFromPhase(4); // 应用固件 isEsp32Rebooting = true; + updateEsp32StagesFromPhase(5); // 重启中 rebootStartTime = Date.now(); + } 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'); + addLog('W', 'OTA', '应用请求出现异常(设备可能已重启): ' + e.message); + // 设备在收到请求后即会重启,网络中断属于正常现象,直接进入重启等待 + showVerifyActions('none'); + isEsp32Rebooting = true; + updateEsp32StagesFromPhase(5); + rebootStartTime = Date.now(); } } +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 || '')); + 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('W', 'OTA', '应用请求出现异常(设备可能已重启): ' + e.message); + isEsp32Rebooting = true; + updateEsp32StagesFromPhase(5); + rebootStartTime = Date.now(); + } +} + + async function restartEsp32() { if (!confirm('确定要重启 ESP32 吗?')) return; addLog('I', '系统', '=== 请求重启 ESP32 ==='); @@ -1056,12 +1273,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 +1296,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 +1375,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 +1425,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 +1442,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 +1453,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); - diff --git a/tools/ble_ota.html b/tools/ble_ota.html index 27fc6cb..f9b9432 100644 --- a/tools/ble_ota.html +++ b/tools/ble_ota.html @@ -1,362 +1,41 @@ - + ESP32 BLE OTA 控制台 - + -
+
- + ESP32 BLE OTA 控制台
-
- 未连接 +
+ 未连接 - - 未读取
-
+
🔗 蓝牙连接
-
+
本页走 BLE GATT,不依赖 WiFi。适合现场、移动端或无法直接访问设备 IP 的场景。支持 GD32 中继 与 ESP32 自升级 两种目标。
- +
- - 回 WiFi 页面 ↗ + + 回 WiFi 页面 ↗
@@ -365,7 +44,7 @@
🎯 升级目标
- + + +
- - + +
-
- 当前目标:GD32。页面会使用 cmd=0x00 发送数据,写入完成后自动触发 SPI OTA 中继。协议格式:cmd(1) + seq(2) + total_size(4) + payload + crc占位4。 +
+ 当前目标:GD32。页面会使用 cmd=0x00 发送数据,写入完成后自动触发 SPI OTA 中继。协议格式:cmd(1) + seq(2) + total_size(4) + payload。
@@ -391,7 +70,7 @@
📁 固件文件
- +
📤

点击或拖拽文件到此处

@@ -402,7 +81,7 @@
📄 - - +
类型-
@@ -410,6 +89,10 @@
总包数0
已写包0
+
+ SHA-256 + - +