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 高风险代码评审报告
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
🚨 TOP 3 最危险 Bug
+
+
+
+
+
+
+ 文件:
+ 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在两次传输之间释放),高优先级任务可在协议序列中间抢占。
+
+class SpiMaster {
+private:
+ spi_device_handle_t m_spiDevice = NULL;
+};
+
+
⚠ 影响
+ 帧交错(A任务的请求配对B任务的响应)、GD32状态机混乱、固件传输数据损坏。生产环境高频CAN通信+OTA并发时必现。
+
+
+
🔧 修复建议
+ 在 SpiMaster 中添加
SemaphoreHandle_t m_mutex,所有公共方法入口加锁。对于多步协议操作(otaTransfer等),使用统一的锁粒度保护整个请求-响应序列。
+
+
+
+
+
+
+
+
+
+ 文件:
+ 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,
+ 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,
+ 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B
+};
+
+
⚠ 影响
+ 攻击者可轻易伪造认证握手,向 GD32 注入恶意固件。GD32_CHIP_UID占位值意味着所有设备共享相同"身份"(如果运行时未正确覆盖)。
+
+
+
🔧 修复建议
+ 从安全存储(NVS/efuse)读取密钥;移除所有硬编码占位值;添加编译期断言确保非测试密钥;MASTER_KEY和SCRAMBLE_CONST应在首次设备配准时由安全随机数生成。
+
+
+
+
+
+
+
+
+
+ 文件:
+ 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);
+}
+
两个调用路径产生非对齐值:(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 级发现
+
+
+
+
+
+ 文件: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)。
+
+
+
+
+
+
+
+ 文件: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];
+ return crc;
+}
+
XOR校验无法检测:偶数个相同位置的位翻转、字节交换、全零段丢失。固件数据的静默损坏几乎不可检测。
+
🔧 修复建议
替换为 CRC-8/MAXIM 或 CRC-16/CCITT,与GD32团队协商。
+
+
+
+
+
+
+
+ 文件:spi_master.h:21,26
+ 验证:✔ HIGH置信度
+
+
#define SPI_OTA_SOF (0xA5)
+#define SPI_APP_ENTER_BOOT_ACK_OK (0xA5)
+
同一值 0xA5 表示两种完全不同的语义。GD32在OTA窗口回复ACK时,ESP32可能将ACK字节误解析为OTA帧起始,导致协议状态机混乱。
+
🔧 修复建议
修改其中一个常量值,消除歧义。例如将 SPI_APP_ENTER_BOOT_ACK_OK 改为 0xB5。
+
+
+
+
+
+
+
+ 文件: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分钟),超时则中止并清理。
+
+
+
+
+
+
+
+ 文件:wifi_ota.cpp:58
+ 验证:✔ HIGH置信度
+
+
httpd_resp_set_hdr(req, "Access-Control-Allow-Origin", "*");
+
任何网页都可以向 ESP32 发起 OTA 请求。结合占位密钥问题,攻击链完整:恶意网页 → 上传伪造固件 → 通过占位密钥认证 → 烧录恶意GD32固件。
+
🔧 修复建议
限制 CORS 为本地网络来源,或移除 CORS头并使用同源策略。
+
+
+
+
+
+
+
🟠 HIGH 级关键发现
+
+ 共 18 项 HIGH 级发现,点击展开详情
+
+
+
+
+ | # | 严重度 | 文件 | 问题 | 影响 |
+
+
+ | 9 | HIGH | wifi_ota.cpp:121 | WiFi事件回调中调用 esp_wifi_connect() 重连 | 在系统事件任务中调用可能死锁 |
+ | 10 | HIGH | health_monitor.cpp | healthState 静态变量无锁跨任务读写 | 竞态导致健康判断错误 |
+ | 11 | HIGH | ota_relay.h:80-84 | 5个静态变量(relayState等)无锁跨任务访问 | 协议状态不一致 |
+ | 12 | HIGH | wifi_ota.h:33-47 | WifiOta 所有成员 public 且无锁 | HTTP回调与WiFi事件回调竞争 |
+ | 13 | HIGH | ble_ota.h | BleOta 成员无锁,BLE GATT回调与主任务竞争 | BLE OTA 数据损坏 |
+ | 14 | HIGH | debug_log.cpp:296 | log() 栈分配 msg[512] + log_buf[600] = 1112字节 | 低栈任务中栈溢出 |
+ | 15 | HIGH | wifi_ota.cpp:1008 | spi_send_handler 栈分配 1728+ 字节 | HTTP server栈仅8KB,接近溢出 |
+ | 16 | HIGH | ota_relay.cpp:456 | sendNonceAndAuthenticate() nonce 仅用 esp_random() 一次 | 认证会话密钥可预测 |
+ | 17 | HIGH | ota_relay.h:25 | ota_meta_t.checksum[32] 从未填充/验证 | 固件完整性无保障 |
+ | 18 | HIGH | wifi_ota.cpp:429-444 | ESP32 OTA 完成→重启,无回滚验证 | 新固件若无法启动将变砖 |
+ | 19 | HIGH | spi_master.cpp:258 | syncTransfer/recv 忽略 timeout_ms 参数 | SPI事务可能无限等待 |
+ | 20 | HIGH | wifi_ota.cpp:599-629 | esp_ota_write 失败后 break,未调用 esp_ota_abort | OTA分区处于不一致状态 |
+ | 21 | HIGH | ble_ota.cpp:439-444 | BLE OTA 帧预留4字节CRC但减去计算不匹配 | 最后几个字节被误截断 |
+ | 22 | HIGH | spi_master.h | SPI仅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 |
+ 替换硬编码密钥为安全存储 |
+ 中 |
+ 极高 — 安全漏洞 |
+ |
+
+
+ | P0 |
+ Flash 写入对齐检查 |
+ 小 |
+ 极高 — 数据损坏 |
+ |
+
+
+ | P1 |
+ 添加全局 OTA 超时+中止机制 |
+ 中 |
+ 高 — 设备挂死 |
+ |
+
+
+ | P1 |
+ ESP32 OTA 添加回滚验证 |
+ 中 |
+ 高 — 变砖风险 |
+ |
+
+
+ | P1 |
+ 替换 XOR 为 CRC-8/16 校验 |
+ 小 |
+ 高 — 固件损坏 |
+ |
+
+
+ | P1 |
+ 消除 0xA5 值碰撞 |
+ 极小 |
+ 高 — 协议混乱 |
+ |
+
+
+ | P2 |
+ 添加 OTA 单源互斥 (BLE/WiFi) |
+ 中 |
+ 中高 |
+ |
+
+
+ | P2 |
+ 为所有跨任务状态添加锁/atomic |
+ 大 |
+ 中高 |
+ |
+
+
+ | P2 |
+ 修复 WiFi 事件回调中的重连调用 |
+ 小 |
+ 中 |
+ |
+
+
+ | P3 |
+ 修复大栈分配问题 |
+ 小 |
+ 中 |
+ |
+
+
+ | P3 |
+ 限制 CORS 来源 |
+ 小 |
+ 中 |
+ |
+
+
+ | P3 |
+ 实现 ota_meta_t 校验填充与验证 |
+ 中 |
+ 中 |
+ |
+
+
+
+
+
+
+
+
✅ 已驳回发现(误报)
+
+
+ | 原始严重度 | 问题描述 | 驳回原因 |
+
+
+
+ | CRITICAL |
+ SPI DMA缓冲区栈分配导致数据损坏/硬Fault |
+ ESP-IDF 6.0 的 spi_master 驱动内置 setup_dma_priv_buffer() 函数自动检测并对齐DMA缓冲区,栈分配仅导致性能损失,不引发数据损坏 |
+
+
+ | HIGH |
+ TOCTOU: 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 控制台
-
+
-