feat(ota): 解耦ESP32 OTA核心逻辑到独立模块并增强BLE控制功能

- 新增ota目录和esp32_ota_core.cpp实现ESP32 OTA核心功能,支持begin/write/finalize操作
- 修改CMakeLists.txt添加ota源文件和包含目录
- 重构ble_ota.cpp将OTA写入/收尾逻辑委托给Esp32OtaCore,移除mbedtls直接引用
- 新增BLE_OTA_CTRL_RESTART_ESP32(0xFA)命令用于重启ESP32设备
- 更新BLE_OTA_CTRL_TRIGGER_ESP32(0xF8)逻辑,要求前端提供SHA-256校验并比对
- 添加SHA-256校验不匹配错误码BLE_OTA_FINISH_ERR_SHA_MISMATCH(0x85)
- 优化OTA收尾流程,设备端读取分区计算SHA-256并与前端提供的哈希比对
- 添加BLE_OTA_NOTIFY_SHA256通知类型用于上报设备端计算的SHA-256值
- 更新app_config.h中的BLE OTA命令描述文档
- 移除BleOta类中的OTA状态变量,仅保留BLE传输层相关状态
- 更新版本构建时间和Git哈希信息
This commit is contained in:
小龙虾
2026-06-14 23:03:28 +08:00
parent 2caf10b9de
commit 9ca53ae66e
19 changed files with 1338 additions and 572 deletions
+1 -5
View File
@@ -1390,17 +1390,13 @@ async function pollStatus() {
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) {
if (consecutivePollFailures === 5 && !pollSlowMode && !isUploading && !isEsp32Rebooting) {
addLog('W', '诊断', '设备连续 5 次无响应,正在诊断网络...');
var diag = await diagnoseNetwork();
addLog('W', '诊断', diag);
+175 -23
View File
@@ -35,6 +35,7 @@
</div>
<div class="sub-actions">
<button id="versionBtn" class="btn btn-outline" disabled>读取版本</button>
<button id="restartBtn" class="btn btn-outline" disabled title="确认后会重启 ESP32,重连后页面会自动恢复">🔄 重启设备</button>
<a class="btn btn-outline" href="index.html" target="_blank">回 WiFi 页面 ↗</a>
</div>
</div>
@@ -47,7 +48,7 @@
<label class="form-label" for="targetChip">目标芯片</label>
<select id="targetChip">
<option value="gd32">GD32(写入后自动触发中继)</option>
<option value="esp32">ESP32(写入后重启升级)</option>
<option value="esp32" selected>ESP32(写入后重启升级)</option>
</select>
</div>
<div class="row-2">
@@ -149,8 +150,8 @@
<div class="stage-node" id="bs2"><div class="stage-dot">2</div><div class="stage-label">GATT 准备</div><div class="stage-detail" id="bsd2"></div></div>
<div class="stage-node" id="bs3"><div class="stage-dot">3</div><div class="stage-label">文件准备</div><div class="stage-detail" id="bsd3"></div></div>
<div class="stage-node" id="bs4"><div class="stage-dot">4</div><div class="stage-label">数据写入</div><div class="stage-detail" id="bsd4"></div></div>
<div class="stage-node" id="bs5"><div class="stage-dot">5</div><div class="stage-label">完成触发</div><div class="stage-detail" id="bsd5"></div></div>
<div class="stage-node" id="bs6"><div class="stage-dot">6</div><div class="stage-label">校验比对</div><div class="stage-detail" id="bsd6"></div></div>
<div class="stage-node" id="bs5"><div class="stage-dot">5</div><div class="stage-label" id="bsl5">校验比对</div><div class="stage-detail" id="bsd5"></div></div>
<div class="stage-node" id="bs6"><div class="stage-dot">6</div><div class="stage-label" id="bsl6">完成触发</div><div class="stage-detail" id="bsd6"></div></div>
</div>
<div class="stage-status-bar" id="bleStageBar">
<span class="ssb-dot"></span>
@@ -197,7 +198,11 @@
ctrlHex: 'F7',
recommendedPayload: 480,
finishText: '自动触发中继',
hint: '当前目标:<b>GD32</b>。页面会使用 <b>cmd=0x00</b> 发送数据,写入完成后自动触发 SPI OTA 中继。'
hint: '当前目标:<b>GD32</b>。页面会使用 <b>cmd=0x00</b> 发送数据,写入完成后自动触发 SPI OTA 中继。',
verifyStage: 6,
finalizeStage: 5,
stage5Label: '完成触发',
stage6Label: '校验比对'
},
esp32: {
label: 'ESP32',
@@ -205,7 +210,11 @@
ctrlHex: 'F8',
recommendedPayload: 480,
finishText: '自动完成 OTA',
hint: '当前目标:<b>ESP32</b>。页面会使用 <b>cmd=0x01</b> 发送数据,最后一包到齐后由设备端自动完成 OTA 并重启。'
hint: '当前目标:ESP32。页面会使用 <b>cmd=0x01</b> 发送数据,写入完成后发 <b>0xF8</b> + 32 字节 SHA-256 触发设备端收尾:设备端读 OTA 分区算 SHA 并比对,一致则 end+set_boot+reboot;不一致上报 0x85,不重启。',
verifyStage: 5,
finalizeStage: 6,
stage5Label: '校验比对',
stage6Label: '完成触发'
}
};
@@ -231,6 +240,10 @@
ackWaiter: null,
fileSha256: null,
deviceSha256: null,
expectedReboot: false, // 发送 0xFA 后置 true,重连后清
reconnecting: false, // 自动重连中
reconnectAttempts: 0, // 重连尝试次数
uploadTargetKey: null,
};
const $ = (id) => document.getElementById(id);
@@ -258,6 +271,7 @@
otaState: $('otaState'),
fileInfo: $('fileInfo'),
clearFileBtn: $('clearFileBtn'),
restartBtn: $('restartBtn'),
progressFill: $('progressFill'),
progressText: $('progressText'),
progressPercent: $('progressPercent'),
@@ -273,6 +287,10 @@
return TARGETS[els.targetChip.value] || TARGETS.gd32;
}
function flowTarget() {
return TARGETS[state.uploadTargetKey] || currentTarget();
}
const MAX_LOG_LINES = 1000;
function log(tag, message, level = 'I') {
@@ -298,6 +316,14 @@
// ====== BLE OTA Stage Flow Management ======
var bleStageIds = ['bs1','bs2','bs3','bs4','bs5','bs6'];
var bleStageDetailIds = ['bsd1','bsd2','bsd3','bsd4','bsd5','bsd6'];
var bleStageLabelIds = ['bsl5', 'bsl6'];
function updateBleStageLabels(cfg) {
var stage5 = document.getElementById(bleStageLabelIds[0]);
var stage6 = document.getElementById(bleStageLabelIds[1]);
if (stage5) stage5.textContent = cfg.stage5Label || '完成触发';
if (stage6) stage6.textContent = cfg.stage6Label || '校验比对';
}
function resetBleStages() {
bleStageIds.forEach(function(id) {
@@ -442,6 +468,10 @@
}
els.disconnectBtn.disabled = !connected;
els.versionBtn.disabled = !connected;
els.targetChip.disabled = state.uploading;
els.payloadSize.disabled = state.uploading;
els.ctrlHex.disabled = state.uploading;
if (els.restartBtn) els.restartBtn.disabled = !connected || state.reconnecting || state.expectedReboot || state.uploading;
els.uploadBtn.disabled = !idle;
els.autoFlashBtn.disabled = !idle;
els.abortBtn.disabled = !state.uploading;
@@ -502,9 +532,10 @@
function refreshTargetUi() {
const cfg = currentTarget();
updateBleStageLabels(cfg);
els.ctrlHex.value = cfg.ctrlHex;
els.payloadSize.value = String(cfg.recommendedPayload);
els.protocolHint.innerHTML = `${cfg.hint} 推荐 payload:<b>${cfg.recommendedPayload}B</b>。协议格式:<b>cmd(1) + seq(2) + total_size(4) + payload</b>。<br><b>传输机制:</b>每 <b>${BLE_OTA_ACK_WINDOW}</b> 包等待一次 DATA_OUT ACK。${cfg.label === 'ESP32' ? ' <br><b>ESP32 模式:</b>最后一包到齐后由设备端自动完成 OTA 并重启。': '<br><b>GD32 模式:</b>数据写入后自动触发 SPI OTA 中继。'}`;
els.protocolHint.innerHTML = `${cfg.hint} 推荐 payload:<b>${cfg.recommendedPayload}B</b>。协议格式:<b>cmd(1) + seq(2) + total_size(4) + payload</b>。<br><b>传输机制:</b>每 <b>${BLE_OTA_ACK_WINDOW}</b> 包等待一次 DATA_OUT ACK。${cfg.label === 'ESP32' ? ' <br><b>ESP32 模式:</b>数据写入完成后发送 <mark>0xF8 + 32B 文件 SHA-256</mark> 触发设备端收尾;设备端自行读回 OTA 分区、完成 SHA-256 比对,并决定是否 end + set_boot + reboot。': '<br><b>GD32 模式:</b>数据写入后自动触发 SPI OTA 中继。'}`;
if (state.selectedFile) updateFileInfo(state.selectedFile);
setConnectionState(!!state.server);
log('CFG', `目标切换为 ${cfg.label}: packetCmd=0x${cfg.packetCmd.toString(16).padStart(2, '0').toUpperCase()}, payload=${cfg.recommendedPayload}`);
@@ -528,7 +559,7 @@
});
state.device = device;
state.device.addEventListener('gattserverdisconnected', onDisconnected);
state.device.addEventListener('gattserverdisconnected', onDisconnected, { once: true });
els.deviceName.textContent = device.name || '未知设备';
els.deviceId.textContent = device.id || '-';
@@ -567,10 +598,20 @@
function onDisconnected() {
log('BLE', '设备已断开', 'W');
var wasExpected = state.expectedReboot;
resetUiAfterDisconnect();
if (wasExpected && state.device) {
// 是主动重启引起的断开,启动自动重连
state.reconnecting = true;
state.reconnectAttempts = 0;
tryReconnect();
}
}
async function disconnectBle() {
// 用户主动断开:取消任何进行中的重连 / expectedReboot
state.expectedReboot = false;
state.reconnecting = false;
try {
if (state.dataOutChar && state.notificationsEnabled) {
await state.dataOutChar.stopNotifications();
@@ -653,17 +694,17 @@
};
const finishText = finishMap[stage] || `[FINISH] 未知阶段 stage=0x${stage.toString(16).padStart(2, '0').toUpperCase()} value=${value}`;
log('FINISH', finishText);
const finalizeStage = TARGETS.esp32.finalizeStage;
// Update BLE stage flow based on FINISH notifications
if (stage >= 0x01 && stage <= 0x04) {
setBleStage(5, 'OTA 刷写中...', null);
setBleStage(finalizeStage, 'OTA 刷写中...', null);
setBleStageText(finishText.replace('[FINISH] ', ''));
} else if (stage >= 0x05 && stage <= 0x06) {
setBleStage(5, '等待重启...', null);
setBleStage(finalizeStage, '等待重启...', null);
setBleStageText(finishText.replace('[FINISH] ', ''));
} else if (stage === 0x07) {
setBleStage(5, '即将重启', null);
setBleStage(6, '设备重启', null);
setBleStage(finalizeStage, '设备重启', null);
setBleStageText('ESP32 即将重启');
var bar = document.getElementById('bleStageBar');
if (bar) bar.className = 'stage-status-bar ok';
@@ -686,6 +727,13 @@
state.lastUploadStatus = 'failed';
setBleStageError('OTA 刷写失败');
showBleVerifyInfo(state.fileSha256 || '-', state.deviceSha256 || '设备未上报', { text: '刷写失败', err: true });
} else if (stage === 0x85) {
showToast('ESP32 OTA 校验失败,未触发重启', 'error');
setProgress(0, '设备端 SHA-256 校验失败');
state.lastUploadStatus = 'failed';
setBleStage(5, 'SHA-256 ✗', null);
setBleStageError('设备端 SHA-256 校验失败');
showBleVerifyInfo(state.fileSha256 || '-', state.deviceSha256 || '设备未上报', { text: '设备端校验失败,已中止 OTA', err: true });
} else if (stage === 0x07) {
showToast('ESP32 OTA 刷写完成,等待重启', 'success');
// If device SHA-256 hasn't arrived yet, show what we have
@@ -716,21 +764,22 @@
state.deviceSha256 = devSha;
log('VERIFY', '设备端 SHA-256: ' + devSha);
// Compare with front-end SHA-256
// 设备端上报 SHA-256 后,前端仅做展示与状态同步。
var frontSha = state.fileSha256;
var verifyStage = flowTarget().verifyStage;
if (frontSha && frontSha.toUpperCase() === devSha) {
log('VERIFY', 'SHA-256 校验通过,前端与设备端一致', 'I');
showBleVerifyInfo(frontSha, devSha, { text: '校验通过 — SHA-256 一致', ok: true });
setBleStage(6, 'SHA-256 ✓', null);
setBleStage(verifyStage, 'SHA-256 ✓', null);
setBleStageText('校验通过 — 固件完整性已确认');
} else if (frontSha) {
log('VERIFY', 'SHA-256 校验失败!前端: ' + frontSha + ' 设备: ' + devSha, 'E');
showBleVerifyInfo(frontSha, devSha, { text: '校验失败 — SHA-256 不一致', err: true });
setBleStage(6, 'SHA-256 ✗', null);
setBleStage(verifyStage, 'SHA-256 ✗', null);
setBleStageError('SHA-256 不一致');
} else {
showBleVerifyInfo(undefined, devSha, { text: '前端 SHA-256 不可用,无法比对' });
setBleStage(6, '无法比对', null);
setBleStage(verifyStage, '无法比对', null);
}
return;
}
@@ -739,6 +788,88 @@
log('NOTIFY', hex);
}
// ===== 重启设备 + 自动重连 =====
async function restartEsp32() {
if (!state.ctrlChar || !state.device) return;
if (!confirm('确定要重启 ESP32 吗?\n\n重启后页面会自动重连。')) return;
if (state.expectedReboot) return;
state.expectedReboot = true;
state.reconnectAttempts = 0;
els.connState.textContent = '重启中';
if (els.connState.parentElement) els.connState.parentElement.className = 'pill processing';
if (els.otaState) { els.otaState.textContent = '重启中'; els.otaState.className = 'v warn'; }
setConnectionState(!!state.server);
log('SYS', '=== 触发 ESP32 重启 (0xFA) ===');
showToast('ESP32 重启中...', 'success');
try {
await state.ctrlChar.writeValue(new Uint8Array([0xFA]));
log('SYS', '0xFA 已发,等待设备端推送 FINISH_REBOOT_NOW 后断开');
} catch (e) {
log('SYS', '0xFA 发送失败: ' + e.message, 'E');
showToast('0xFA 发送失败', 'error');
state.expectedReboot = false;
setConnectionState(!!state.server);
}
}
async function tryReconnect() {
if (!state.device || !state.expectedReboot) {
state.reconnecting = false;
return;
}
if (!state.device.gatt) {
// 设备引用已丧失,重连不了
state.reconnecting = false;
state.expectedReboot = false;
showToast('设备引用丧失,请手动重连', 'error');
return;
}
state.reconnecting = true;
state.reconnectAttempts += 1;
var attempt = state.reconnectAttempts;
// 退避:1s, 2s, 4s, 8s, 上限 10s
var backoff = Math.min(10000, 1000 * Math.pow(1.6, attempt - 1));
if (backoff > 200) await sleep(backoff);
els.connState.textContent = '重连中(' + attempt + ')';
if (els.connState.parentElement) els.connState.parentElement.className = 'pill processing';
if (els.otaState) { els.otaState.textContent = '重连中'; els.otaState.className = 'v warn'; }
setConnectionState(!!state.server);
log('BLE', '自动重连第 ' + attempt + ' 次' + (backoff > 0 ? '(等待 ' + Math.round(backoff) + 'ms)' : '') + '...', 'W');
try {
var server = await state.device.gatt.connect();
state.server = server;
state.service = await server.getPrimaryService(UUIDS.service);
state.ctrlChar = await state.service.getCharacteristic(UUIDS.ctrl);
state.dataInChar = await state.service.getCharacteristic(UUIDS.dataIn);
state.dataOutChar = await state.service.getCharacteristic(UUIDS.dataOut);
state.versionChar = await state.service.getCharacteristic(UUIDS.version);
state.notificationsEnabled = false;
await enableNotifications();
state.expectedReboot = false;
state.reconnecting = false;
state.reconnectAttempts = 0;
// 重连后重注 onDisconnected({once: true},旧的已被上次断开时消耗)
state.device.addEventListener('gattserverdisconnected', onDisconnected, { once: true });
log('BLE', '✅ 自动重连成功', 'I');
showToast('BLE 已重连', 'success');
setConnectionState(true);
// 重连后默认刷新一下版本
try { await readVersion(); } catch (_) {}
} catch (e) {
log('BLE', '重连第 ' + attempt + ' 次失败: ' + e.message, 'W');
if (state.expectedReboot && attempt < 12) {
tryReconnect();
} else {
state.reconnecting = false;
state.expectedReboot = false;
showToast('重连失败,请手动连接', 'error');
log('BLE', '自动重连放弃,请点击「连接设备」重连', 'E');
setConnectionState(!!state.server);
}
}
}
function waitForAck(cmd, expectedSeq, timeoutMs = 8000, opts = {}) {
const gen = state.ackGeneration;
if (state.lastAckCmd === cmd && state.lastAckSeq >= expectedSeq && gen === state.ackGeneration) {
@@ -937,6 +1068,7 @@
if (!state.selectedFile || !state.dataInChar) return false;
const cfg = currentTarget();
state.uploadTargetKey = els.targetChip.value;
const payloadSize = Math.max(32, Math.min(498, Number(els.payloadSize.value) || 480));
const file = state.selectedFile;
@@ -1079,15 +1211,33 @@
setProgress(100, `[${cfg.label}] 固件数据已全部写入 DATA_IN`);
state.lastUploadStatus = 'success';
// Stage 5: Completion trigger
// Stage 5: 发送 0xF8 + 32B 文件 SHA-256,由设备端执行收尾并完成比对
if (cfg.label === 'ESP32') {
setBleStage(5, '自动完成', null);
setBleStage(5, '完成', null);
setBleStage(6, '等待设备校验...', null);
setBleStageText('ESP32 OTA 数据写入完成,等待设备端处理与校验');
// Show verify info — wait for device SHA-256
state.deviceSha256 = null;
showBleVerifyInfo(fileSha256.toUpperCase(), '等待设备上报...', { text: '等待设备端处理并上报 SHA-256' });
const frontSha = fileSha256.toUpperCase();
const frontShaBytes = new Uint8Array(32);
for (let i = 0; i < 32; i++) {
frontShaBytes[i] = parseInt(frontSha.slice(i * 2, i * 2 + 2), 16);
}
const trigger = new Uint8Array(33);
trigger[0] = 0xF8;
trigger.set(frontShaBytes, 1);
showBleVerifyInfo(frontSha, '等待设备端比对...', { text: '已发送文件 SHA-256,等待设备端校验结果' });
setBleStage(5, '发送校验...', 'active');
setBleStageText('发送 0xF8 + SHA-256,等待设备端收尾...');
try {
await state.ctrlChar.writeValue(trigger);
log('OTA', '0xF8 + 32B SHA-256 已发送,等待设备端比对并收尾');
} catch (e) {
state.lastUploadStatus = 'failed';
setProgress(0, `0xF8 发送失败: ${e.message}`);
log('OTA', `0xF8 发送失败: ${e.message}`, 'E');
setBleStageError('0xF8 发送失败');
return false;
}
setBleStage(5, '等待设备端校验', null);
setBleStage(6, '设备端收尾中', 'active');
setBleStageText('0xF8 已发,等待设备端 SHA 比对与 end+set_boot+reboot...');
return true;
}
@@ -1140,6 +1290,7 @@
state.ackWaiter = null;
}
state.uploading = false;
state.uploadTargetKey = null;
setConnectionState(!!state.server);
}
}
@@ -1190,7 +1341,8 @@
els.connectBtn.addEventListener('click', connectBle);
els.disconnectBtn.addEventListener('click', disconnectBle);
els.versionBtn.addEventListener('click', readVersion);
if (els.versionBtn) els.versionBtn.addEventListener('click', readVersion);
if (els.restartBtn) els.restartBtn.addEventListener('click', restartEsp32);
els.uploadBtn.addEventListener('click', uploadFirmware);
els.autoFlashBtn.addEventListener('click', autoFlash);
els.abortBtn.addEventListener('click', abortTask);
+1 -1
View File
@@ -128,7 +128,7 @@
</div>
<div style="margin-top:12px;">
<button class="btn btn-outline btn-block" onclick="restartEsp32()">重启 ESP32</button>
<a class="btn btn-outline btn-block" href="ble_ota.html?v=20260509-0046" target="_blank" style="margin-top:10px;text-decoration:none;">
<a class="btn btn-outline btn-block" href="ble_ota.html?v=20260614-ota-order-fix" target="_blank" style="margin-top:10px;text-decoration:none;">
切换到 BLE 蓝牙刷写 ↗
</a>
</div>
+23
View File
@@ -474,6 +474,29 @@ select option { background: var(--bg-panel); color: var(--text); }
.svi-val.ok { color: var(--green); font-weight: 600; }
.svi-val.err { color: var(--red); font-weight: 600; }
/* Stage result panel - 上位机确认 0x07/0x85 收尾 */
.stage-result {
margin-top: 12px; padding: 14px 16px;
border-radius: var(--radius-sm);
background: var(--bg-elevated); border: 1px solid var(--border);
display: flex; flex-direction: column; gap: 10px;
}
.stage-result.ok { border-color: rgba(34,197,94,.45); background: rgba(34,197,94,.08); }
.stage-result.err { border-color: rgba(239,68,68,.45); background: rgba(239,68,68,.08); }
.stage-result .sr-head { display: flex; align-items: center; gap: 10px; }
.stage-result .sr-ico {
width: 32px; height: 32px; border-radius: 50%;
display: inline-flex; align-items: center; justify-content: center;
font-size: 18px; font-weight: 700; color: #07121c;
}
.stage-result.ok .sr-ico { background: var(--green); }
.stage-result.err .sr-ico { background: var(--red); }
.stage-result .sr-title { font-size: 16px; font-weight: 700; }
.stage-result.ok .sr-title { color: var(--green); }
.stage-result.err .sr-title { color: var(--red); }
.stage-result .sr-detail { font-size: 13px; color: var(--text); line-height: 1.5; }
.stage-result .sr-actions { display: flex; justify-content: flex-end; gap: 8px; }
/* === LOG PANEL === */
.log-panel { display: flex; flex-direction: column; min-height: 380px; }
.log-panel .card-body { flex: 1; display: flex; flex-direction: column; padding: 0; }