feat(ota): 实现GD32 SPI OTA中继功能并优化版本管理

- 实现了完整的GD32 SPI OTA中继机制,支持通过BLE触发GD32固件升级
- 添加了SHA-256校验机制确保固件传输完整性
- 重构了版本号管理系统,PATCH版本改为基于Git提交次数自动递增
- 修复了flash写入4字节对齐问题,提升OTA可靠性
- 优化了BLE OTA协议的消息格式和错误处理机制

BREAKING CHANGE: OTA协议消息格式发生变化,需要前端同步更新
This commit is contained in:
小龙虾
2026-06-18 01:37:30 +08:00
parent 2586582e57
commit 41fee936df
7 changed files with 727 additions and 270 deletions
+149 -116
View File
@@ -19,7 +19,7 @@ function toggleCollapse(headEl) {
headEl.closest('.card').classList.toggle('collapsed');
}
// ==================== 刷写进度 ====================
var activePhase = null; // 'esp32Ota' | 'gd32Upload' | 'gd32Relay' | null
var activePhase = null; // 'esp32Ota' | 'gd32Ota' | null
var currentOtaTarget = null; // 'esp32' | 'gd32' | null — 当前 OTA 目标,决定显示哪套阶段
// 切换 OTA 阶段面板显示
function switchStageFlow(target) {
@@ -103,8 +103,9 @@ function showVerifyActions(type) {
}
function setActivePhase(phase) {
if (phase === 'gd32Upload' || phase === 'gd32Relay') phase = 'gd32Ota';
activePhase = phase;
var stepMap = { esp32Ota: 'ppEsp32', gd32Upload: 'ppGd32Up', gd32Relay: 'ppGd32Relay' };
var stepMap = { esp32Ota: 'ppEsp32', gd32Ota: 'ppGd32Ota' };
Object.keys(stepMap).forEach(function(p) {
var el = document.getElementById(stepMap[p]);
if (!el) return;
@@ -116,11 +117,12 @@ function setActivePhase(phase) {
if (fill) fill.classList.remove('done');
}
function markPhaseDone(phase) {
var stepMap = { esp32Ota: 'ppEsp32', gd32Upload: 'ppGd32Up', gd32Relay: 'ppGd32Relay' };
if (phase === 'gd32Upload' || phase === 'gd32Relay') phase = 'gd32Ota';
var stepMap = { esp32Ota: 'ppEsp32', gd32Ota: 'ppGd32Ota' };
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' || phase === 'esp32Ota') {
if (phase === 'gd32Ota' || phase === 'esp32Ota') {
var fill = document.getElementById('progressFill');
if (fill) fill.classList.add('done');
}
@@ -138,7 +140,7 @@ function resetProgress() {
activePhase = null;
esp32OtaPhase = 0;
switchStageFlow(null);
['ppEsp32', 'ppGd32Up', 'ppGd32Relay'].forEach(function(id) {
['ppEsp32', 'ppGd32Ota'].forEach(function(id) {
var el = document.getElementById(id);
if (el) el.classList.remove('active', 'done');
});
@@ -361,23 +363,16 @@ function updateHealthUi(healthCode, 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
// Relay state comes from relay_state_t in main/relay/ota_relay.h:
// 0=IDLE, 1=HANDSHAKING, 2=SIZE_NEG, 3=TRANSFERRING,
// 4=FINISHING, 5=DONE, 255=ERROR.
var relayStateDetailMap = {
0: '待机',
1: '等待握手', 2: '握手已发', 3: '挑战已发',
4: '收到握手响应', 5: '会话密钥已发', 6: '认证完成',
7: '大小已发', 8: '大小确认',
9: '接收中', 10: '转发中', 11: '数据包已发', 12: 'ACK正常', 13: '等待重发',
14: '接收完成', 15: '传输完成',
16: '结束已发', 17: 'OTA完成',
18: 'APP验证',
1: '认证握手',
2: '传输协商',
3: '数据转发',
4: '收尾验证',
5: 'OTA完成',
255: '错误'
};
function updateStagesFromRelayState(rs) {
@@ -403,16 +398,17 @@ function updateStagesFromRelayState(rs) {
return;
}
var activeStage = 0;
if (rs >= 1 && rs <= 6) activeStage = 1;
else if (rs >= 7 && rs <= 8) activeStage = 2;
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;
if (rs === 1) activeStage = 1;
else if (rs === 2) activeStage = 2;
else if (rs === 3) activeStage = 3;
else if (rs === 4) activeStage = 5;
else if (rs === 5) activeStage = 6;
for (var i = 0; i < activeStage - 1; i++) {
if (all[i]) all[i].className = 'stage-node done';
}
if (activeStage >= 1 && activeStage <= 6) {
if (rs === 5) {
if (all[5]) all[5].className = 'stage-node done';
} else if (activeStage >= 1 && activeStage <= 6) {
if (all[activeStage - 1]) all[activeStage - 1].className = 'stage-node live';
}
// Show detail text for the active stage
@@ -503,75 +499,97 @@ function updateSystemStatusUi(data) {
return;
}
var rs = ota.relay_state;
var relayText = relayStateDetailMap[rs] || '待机';
var sysState = ota.sys_state || '';
var sysStateCode = Number.isFinite(ota.sys_state_code) ? ota.sys_state_code : -1;
var isGd32Storing = sysState === 'GD32_STORING' || sysStateCode === 1;
var isGd32Relaying = sysState === 'GD32_RELAYING' || sysStateCode === 3;
var isGd32Complete = sysState === 'GD32_COMPLETE' || sysStateCode === 4;
var isGd32Error = sysState === 'ERROR' || sysStateCode === 8 || rs === 255;
var relayText = isGd32Complete ? 'GD32 OTA 完成'
: isGd32Storing ? 'GD32 OTA 上传中'
: (relayStateDetailMap[rs] || '待机');
document.getElementById('pillRelayTxt').textContent = relayText;
var pr = document.getElementById('pillRelay');
if (rs === 255) pr.className = 'pill offline';
else if (rs > 0 && rs < 5) pr.className = 'pill processing';
else if (rs === 5) pr.className = 'pill online';
if (isGd32Error) pr.className = 'pill offline';
else if (isGd32Complete || rs === 5) pr.className = 'pill online';
else if (isGd32Storing || isGd32Relaying || (rs > 0 && rs < 5)) pr.className = 'pill processing';
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;
var storageProgress = total > 0 ? Math.min(100, Math.round(stored * 100 / total)) : 0;
var bp = document.getElementById('bOTAProgress');
if (bp) {
bp.textContent = storageProgress + '%';
bp.className = 'v ' + (storageProgress === 100 ? 'ok' : storageProgress > 0 ? 'warn' : '');
function setOtaProgressBadge(pct) {
var safePct = Math.max(0, Math.min(100, Math.round(pct || 0)));
if (bp) {
bp.textContent = safePct + '%';
bp.className = 'v ' + (safePct >= 100 ? 'ok' : safePct > 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;
// ★ 修复:后端实际状态机枚举 (main/relay/ota_relay.h):
// 0=RELAY_IDLE, 1=RELAY_HANDSHAKING, 2=RELAY_SIZE_NEG,
// 3=RELAY_TRANSFERRING, 4=RELAY_FINISHING, 5=RELAY_DONE, 0xFF=RELAY_ERROR
// 旧版 1-18 范围判断已与新状态机不匹配,进度条永远停在认证中阶段。
if (rs === 1) {
var displayRelayState = isGd32Complete ? 5 : (isGd32Relaying && rs === 0 ? 1 : rs);
if (currentOtaTarget !== 'esp32') { updateStagesFromRelayState(displayRelayState); }
if (isGd32Error) {
setActivePhase('gd32Ota');
setOtaProgressBadge(0);
setProgress(0, 'GD32 OTA 失败');
} else if (isGd32Complete || rs === 5 || (gd32AppAlive && sysState === 'GD32_COMPLETE')) {
markPhaseDone('gd32Ota');
setOtaProgressBadge(100);
setProgress(100, 'GD32 刷写完成');
var sbDone = document.getElementById('stageStatusBar');
var stDone = document.getElementById('stageStatusText');
if (sbDone) sbDone.className = 'stage-status-bar ok';
if (stDone) stDone.textContent = 'GD32 刷写完成';
} else if (isGd32Storing || (total > 0 && stored < total && !isUploading)) {
setActivePhase('gd32Ota');
var storeOverall = Math.min(45, Math.round(storageProgress * 0.45));
setOtaProgressBadge(storeOverall);
if (!isUploading) {
setProgress(storeOverall, '已缓存 ' + formatBytes(stored) + ' / ' + formatBytes(total));
}
} else if (rs === 1) {
// HANDSHAKING: 握手 + 挑战认证
setActivePhase('gd32Relay');
setProgress(0, '握手与认证中...');
setActivePhase('gd32Ota');
setOtaProgressBadge(50);
setProgress(50, '握手与认证中...');
} else if (rs === 2) {
// SIZE_NEG: 固件大小协商
setActivePhase('gd32Relay');
setProgress(0, '协商固件大小...');
setActivePhase('gd32Ota');
setOtaProgressBadge(55);
setProgress(55, '协商固件大小...');
} else if (rs === 3) {
// TRANSFERRING: 数据包传输
setActivePhase('gd32Relay');
setProgress(relayProgress, '已转发 ' + relaySeq + ' / ' + relayTotal + ' 包');
setActivePhase('gd32Ota');
var relayOverall = 60 + Math.round(relayProgress * 0.30);
setOtaProgressBadge(relayOverall);
setProgress(relayOverall, '已转发 ' + relaySeq + ' / ' + relayTotal + ' 包');
} else if (rs === 4) {
// FINISHING: 结束OTA + APP存活验证
setActivePhase('gd32Relay');
setActivePhase('gd32Ota');
setOtaProgressBadge(95);
if (gd32AppAlive) {
markPhaseDone('gd32Relay');
markPhaseDone('gd32Ota');
setProgress(100, 'GD32 刷写完成');
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 启动...');
setProgress(95, '收尾中,等待 APP 启动...');
}
} else if (rs === 5) {
// DONE: 中继完成(metaClear 已清空,relaySeq/relayTotal 会被 reset 为 0)
setActivePhase('gd32Relay');
setProgress(100, 'OTA 完成');
} else if (total > 0 && stored < total && !isUploading) {
// GD32 upload to cache in progress (handled by upload progress if uploading)
setActivePhase('gd32Upload');
setProgress(storageProgress, '已缓存 ' + formatBytes(stored) + ' / ' + formatBytes(total));
} else if (gd32AppAlive && rs === 0 && total > 0 && stored >= total) {
// 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 刷写完成';
} else if (total > 0 && stored >= total) {
setActivePhase('gd32Ota');
setOtaProgressBadge(45);
setProgress(45, '上传完成,等待自动启动 GD32 OTA...');
} else {
setOtaProgressBadge(storageProgress);
}
// stage flow from real state
if (currentOtaTarget !== 'esp32') { updateStagesFromRelayState(rs); }
}
async function testConnection() {
// 如果在降速模式,先恢复正常轮询
@@ -876,51 +894,61 @@ function isIntelHexText(text) {
}
return false;
}
/**
* 公共 HEX→BIN 转换:如果文件是 .hex 格式,自动解析为 bin。
* BLE / WiFi 共用此函数,避免转换逻辑重复。
* @param {File} file 原始文件
* @returns {Promise<{file: File, converted: boolean, baseAddr: number|null}>}
*/
async function convertHexFileToBin(file) {
if (!file || !file.name.toLowerCase().endsWith('.hex')) {
return { file: file, converted: false, baseAddr: null };
}
var text = await file.text();
if (!isIntelHexText(text)) {
return { file: file, converted: false, baseAddr: null };
}
var result = parseIntelHex(text);
var binName = file.name.replace(/\.hex$/i, '.bin');
var binBlob = new Blob([result.data], { type: 'application/octet-stream' });
var binFile = new File([binBlob], binName, { type: 'application/octet-stream', lastModified: file.lastModified });
return { file: binFile, converted: true, baseAddr: result.baseAddr, endAddr: result.endAddr };
}
// ==================== 文件处理 ====================
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();
reader.onload = function (e) {
try {
var text = e.target.result;
// 二次确认: 内容也像 HEX
if (!isIntelHexText(text)) {
// 文件名 .hex 但内容不像,直接用原始文件
addLog('W', '文件', file.name + ' 扩展名为 .hex 但内容不像 HEX 格式,按原始文件处理');
gd32File = file;
document.getElementById('fileInfo' + suffix).classList.add('show');
document.getElementById('fileName' + suffix).textContent = file.name;
document.getElementById('fileType' + suffix).textContent = 'Binary(?)';
document.getElementById('fileSize' + suffix).textContent = formatBytes(file.size);
updateUploadButtonLabel();
return;
}
var result = parseIntelHex(text);
var binName = file.name.replace(/\.hex$/i, '.bin');
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');
convertHexFileToBin(file).then(function(result) {
if (!result.converted) {
// 文件名 .hex 但内容不像,直接用原始文件
addLog('W', '文件', file.name + ' 扩展名为 .hex 但内容不像 HEX 格式,按原始文件处理');
gd32File = file;
document.getElementById('fileInfo' + suffix).classList.add('show');
document.getElementById('fileName' + suffix).textContent = binName + ' (由 ' + file.name + ' 转换)';
document.getElementById('fileType' + suffix).textContent = 'Binary (HEX转换)';
document.getElementById('fileSize' + suffix).textContent = formatBytes(result.data.length);
updateUploadButtonLabel();
} catch (err) {
addLog('E', '文件', 'HEX 解析失败: ' + err.message);
showToast('HEX 文件解析失败: ' + err.message, 'error');
gd32File = null;
document.getElementById('fileInfo' + suffix).classList.remove('show');
document.getElementById('fileName' + suffix).textContent = file.name;
document.getElementById('fileType' + suffix).textContent = 'Binary(?)';
document.getElementById('fileSize' + suffix).textContent = formatBytes(file.size);
updateUploadButtonLabel();
return;
}
};
reader.readAsText(file);
gd32File = result.file;
var baseHex = '0x' + result.baseAddr.toString(16).toUpperCase().padStart(8, '0');
addLog('I', '文件', 'HEX → BIN 转换完成: ' + file.name + ' → ' + result.file.name);
addLog('I', '文件', '基地址: ' + baseHex + ', 数据大小: ' + formatBytes(result.file.size));
showToast('HEX 已自动转换为 BIN', 'success');
document.getElementById('fileInfo' + suffix).classList.add('show');
document.getElementById('fileName' + suffix).textContent = result.file.name + ' (由 ' + file.name + ' 转换)';
document.getElementById('fileType' + suffix).textContent = 'Binary (HEX转换)';
document.getElementById('fileSize' + suffix).textContent = formatBytes(result.file.size);
updateUploadButtonLabel();
}).catch(function(err) {
addLog('E', '文件', 'HEX 解析失败: ' + err.message);
showToast('HEX 文件解析失败: ' + err.message, 'error');
gd32File = null;
document.getElementById('fileInfo' + suffix).classList.remove('show');
updateUploadButtonLabel();
});
return; // 异步,回调里设置 gd32File
}
// .bin 文件或 ESP32 文件: 直接使用
@@ -1010,7 +1038,7 @@ async function startUpload(chip) {
uploadBtn.disabled = true;
uploadBtn.textContent = '上传中...';
switchStageFlow(chip);
setActivePhase(chip === 'esp32' ? 'esp32Ota' : 'gd32Upload');
setActivePhase(chip === 'esp32' ? 'esp32Ota' : 'gd32Ota');
if (chip === 'esp32') updateEsp32StagesFromPhase(1);
// Reset verification panel
var verifyInfo = document.getElementById('stageVerifyInfo');
@@ -1069,9 +1097,16 @@ async function startUpload(chip) {
if (e.lengthComputable) {
var pct = Math.round((e.loaded / e.total) * 100);
uploadProgress = pct;
setProgress(pct, '已上传 ' + formatBytes(e.loaded) + ' / ' + formatBytes(e.total));
var el = document.getElementById('bOTAProgress');
if (el) el.textContent = pct + '%';
if (chip === 'gd32') {
var overallPct = Math.min(45, Math.round(pct * 0.45));
setProgress(overallPct, '已上传 ' + formatBytes(e.loaded) + ' / ' + formatBytes(e.total));
var el = document.getElementById('bOTAProgress');
if (el) el.textContent = overallPct + '%';
} else {
setProgress(pct, '已上传 ' + formatBytes(e.loaded) + ' / ' + formatBytes(e.total));
var el = document.getElementById('bOTAProgress');
if (el) el.textContent = pct + '%';
}
}
});
xhr.addEventListener('load', function () {
@@ -1081,7 +1116,7 @@ async function startUpload(chip) {
updateUploadButtonLabel();
if (xhr.status === 200) {
addLog('I', '上传', '固件上传完成!');
showToast('固件上传成功', 'success');
showToast(chip === 'gd32' ? '上传完成,GD32 OTA 已自动开始' : '固件上传成功', 'success');
setProgress(100, '上传完成');
if (chip === 'esp32') {
markPhaseDone('esp32Ota');
@@ -1090,9 +1125,8 @@ async function startUpload(chip) {
verifyEsp32Firmware();
} else {
switchStageFlow('gd32');
markPhaseDone('gd32Upload');
setActivePhase('gd32Relay');
setProgress(0, '等待触发转发...');
setActivePhase('gd32Ota');
setProgress(45, '上传完成,已自动触发 GD32 OTA...');
}
} else if (xhr.status === 403) {
addLog('E', '上传', 'ESP32 OTA 被其他通道占用(BLE/WiFi 互斥),请稍后重试');
@@ -1271,20 +1305,20 @@ async function restartEsp32() {
showToast('重启失败', 'error');
}
}
// ==================== 触发 OTA 中继 (真实状态驱动) ====================
// ==================== 手动触发 GD32 OTA (调试/旧页面兜底) ====================
async function triggerRelay() {
addLog('I', 'OTA', '触发 GD32 OTA 中继...');
showToast('正在触发 OTA 中继', 'success');
addLog('I', 'OTA', '手动触发 GD32 OTA...');
showToast('正在触发 GD32 OTA', 'success');
try {
var resp = await fetch(getBaseUrl() + '/ota/relay', {
method: 'POST', signal: AbortSignal.timeout(5000)
});
if (resp.ok) {
addLog('I', 'OTA', 'OTA 中继已触发,等待 GD32 响应...');
addLog('I', 'OTA', 'GD32 OTA 已触发,等待设备响应...');
// 不再使用假的 setTimeout 推进阶段。
// pollStatus 的 updateSystemStatusUi 会从 ota.relay_state
// 自动更新 stage flow 和 header 状态。
showToast('中继已触发,等待设备响应', 'success');
showToast('GD32 OTA 已触发,等待设备响应', 'success');
} else {
addLog('E', 'OTA', '触发失败: HTTP ' + resp.status);
showToast('触发失败', 'error');
@@ -1463,4 +1497,3 @@ setTimeout(function () {
});
}
}, 500);
+294 -75
View File
@@ -15,6 +15,15 @@
const BLE_OTA_NOTIFY_FINISH = 0xB1;
const BLE_OTA_NOTIFY_SHA256 = 0xC1;
const BLE_OTA_ACK_WINDOW = 32;
const BLE_OTA_FINISH_ESP32_DONE = 0x02;
const BLE_OTA_FINISH_REBOOT_NOW = 0x07;
const BLE_OTA_FINISH_GD32_RELAY_START = 0x10;
const BLE_OTA_FINISH_GD32_RELAY_DONE = 0x11;
const BLE_OTA_FINISH_ERR_FINALIZE = 0x81;
const BLE_OTA_FINISH_ERR_MUTEX = 0x83;
const BLE_OTA_FINISH_ERR_GD32_SIZE = 0x84;
const BLE_OTA_FINISH_ERR_SHA_MISMATCH = 0x85;
const BLE_OTA_FINISH_ERR_GD32_RELAY = 0x86;
const TARGETS = {
gd32: {
@@ -63,6 +72,9 @@
lastAckSeq: -1,
lastAckBytes: 0,
ackWaiter: null,
otaResultWaiter: null,
otaRunId: 0,
lastEsp32DoneSession: null,
fileSha256: null,
deviceSha256: null,
expectedReboot: false, // 发送 0xFA 后置 true,重连后清
@@ -185,6 +197,71 @@
setBleStageText('错误: ' + (detail || ''));
}
function clearOtaResultWaiter() {
if (state.otaResultWaiter) {
clearTimeout(state.otaResultWaiter.timer);
state.otaResultWaiter = null;
}
}
function resolveOtaResult(status, detail) {
if (!state.otaResultWaiter) return;
var waiter = state.otaResultWaiter;
state.otaResultWaiter = null;
clearTimeout(waiter.timer);
waiter.resolve({ status: status, detail: detail || {} });
}
function rejectOtaResult(message, detail) {
if (!state.otaResultWaiter) return;
var waiter = state.otaResultWaiter;
state.otaResultWaiter = null;
clearTimeout(waiter.timer);
var err = new Error(message || 'OTA 收尾失败');
err.detail = detail || {};
waiter.reject(err);
}
function nextOtaSessionId() {
state.otaRunId = (state.otaRunId + 1) & 0xFFFF;
if (state.otaRunId === 0) state.otaRunId = 1;
return state.otaRunId;
}
function finishBelongsToWaiter(targetKey, value) {
if (!state.otaResultWaiter || state.otaResultWaiter.targetKey !== targetKey) return false;
return (value & 0xFFFF) === state.otaResultWaiter.sessionId;
}
function finishSessionId(value) {
return value & 0xFFFF;
}
function hasCurrentWaiter(targetKey) {
return !!(state.otaResultWaiter && state.otaResultWaiter.targetKey === targetKey);
}
function waitForOtaResult(targetKey, sessionId, timeoutMs) {
clearOtaResultWaiter();
var runId = state.otaRunId;
return new Promise(function(resolve, reject) {
var timer = setTimeout(function() {
if (state.otaResultWaiter && state.otaResultWaiter.runId === runId) {
state.otaResultWaiter = null;
}
reject(new Error(targetKey === 'gd32' ? '等待 GD32 OTA 转发结果超时' : '等待 ESP32 OTA 收尾结果超时'));
}, timeoutMs);
state.otaResultWaiter = {
targetKey: targetKey,
sessionId: sessionId,
runId: runId,
timer: timer,
resolve: resolve,
reject: reject,
};
});
}
function showBleVerifyInfo(frontSha, devSha, result) {
var panel = document.getElementById('stageVerifyInfo');
if (panel) { panel.className = 'stage-verify-info'; panel.style.display = 'block'; }
@@ -479,22 +556,71 @@
const stage = bytes[1];
const value = (bytes[2]) | (bytes[3] << 8) | (bytes[4] << 16) | (bytes[5] << 24);
const finishMap = {
0x02: '[FINISH] ESP32 OTA 收尾完成',
0x07: '[FINISH] ESP32 即将重启',
0x81: `[FINISH] ESP32 OTA 收尾失败: err=${value}`,
0x83: '[FINISH] ESP32 OTA 被其他通道占用,互斥拒绝',
0x84: `[FINISH] GD32 固件超出分区大小: ${formatBytes(value)} (上限 216KB)`,
0x85: '[FINISH] ESP32 OTA SHA-256 校验失败',
[BLE_OTA_FINISH_ESP32_DONE]: '[FINISH] ESP32 OTA 收尾完成',
[BLE_OTA_FINISH_REBOOT_NOW]: '[FINISH] ESP32 即将重启',
[BLE_OTA_FINISH_GD32_RELAY_START]: '[FINISH] GD32 OTA 转发已开始',
[BLE_OTA_FINISH_GD32_RELAY_DONE]: '[FINISH] GD32 OTA 转发完成',
[BLE_OTA_FINISH_ERR_FINALIZE]: `[FINISH] ESP32 OTA 收尾失败: err=${value}`,
[BLE_OTA_FINISH_ERR_MUTEX]: '[FINISH] OTA 被其他通道占用,互斥拒绝',
[BLE_OTA_FINISH_ERR_GD32_SIZE]: `[FINISH] GD32 固件超出分区大小: ${formatBytes(value)} (上限 216KB)`,
[BLE_OTA_FINISH_ERR_SHA_MISMATCH]: '[FINISH] OTA SHA-256 校验失败',
[BLE_OTA_FINISH_ERR_GD32_RELAY]: `[FINISH] GD32 OTA 转发失败: result=${value >> 16}`,
};
const finishText = finishMap[stage] || `[FINISH] 未知阶段 stage=0x${stage.toString(16).padStart(2, '0').toUpperCase()} value=${value}`;
bleLog('FINISH', finishText);
const finalizeStage = TARGETS.esp32.finalizeStage;
const resultCode = (value >>> 16);
if (stage === BLE_OTA_FINISH_GD32_RELAY_START) {
if (!finishBelongsToWaiter('gd32', value)) return;
if (typeof switchStageFlow === 'function') switchStageFlow('gd32');
if (typeof setActivePhase === 'function') setActivePhase('gd32Ota');
if (typeof updateStagesFromRelayState === 'function') updateStagesFromRelayState(1);
setProgress(50, '[GD32] OTA 转发已开始,正在认证握手...');
showBleVerifyInfo(state.fileSha256 || '-', '等待 GD32 APP 启动确认...', { text: '缓存写入完成,正在通过 SPI 转发' });
return;
}
if (stage === BLE_OTA_FINISH_GD32_RELAY_DONE) {
if (!finishBelongsToWaiter('gd32', value)) return;
if (typeof switchStageFlow === 'function') switchStageFlow('gd32');
if (typeof updateStagesFromRelayState === 'function') updateStagesFromRelayState(5);
if (typeof markPhaseDone === 'function') markPhaseDone('gd32Ota');
setProgress(100, '[GD32] OTA 转发完成,APP 已启动');
showBleVerifyInfo(state.fileSha256 || '-', 'GD32 APP 已确认启动', { text: 'GD32 OTA 完成', ok: true });
var gd32Bar = document.getElementById('stageStatusBar');
var gd32Text = document.getElementById('stageStatusText');
if (gd32Bar) gd32Bar.className = 'stage-status-bar ok';
if (gd32Text) gd32Text.textContent = 'GD32 刷写完成';
state.lastUploadStatus = 'success';
showToast('GD32 OTA 刷写完成', 'success');
resolveOtaResult('success', { stage: stage, value: value });
return;
}
if (stage === BLE_OTA_FINISH_ERR_GD32_RELAY) {
if (!finishBelongsToWaiter('gd32', value)) return;
if (typeof switchStageFlow === 'function') switchStageFlow('gd32');
if (typeof updateStagesFromRelayState === 'function') updateStagesFromRelayState(255);
setProgress(0, '[GD32] OTA 转发失败');
state.lastUploadStatus = 'failed';
showToast('GD32 OTA 转发失败', 'error');
showBleVerifyInfo(state.fileSha256 || '-', 'GD32 未完成转发', { text: 'GD32 OTA 转发失败: ' + resultCode, err: true });
rejectOtaResult('GD32 OTA 转发失败: ' + resultCode, { stage: stage, value: value });
return;
}
// Update BLE stage flow based on FINISH notifications
if (stage === 0x02) {
if (stage === BLE_OTA_FINISH_ESP32_DONE) {
if (!finishBelongsToWaiter('esp32', value)) return;
state.lastEsp32DoneSession = finishSessionId(value);
setBleStage(finalizeStage, '收尾完成', null);
setBleStageText('OTA 收尾完成,等待重启');
} else if (stage === 0x07) {
state.lastUploadStatus = 'success';
setProgress(100, '[ESP32] OTA 收尾完成,等待重启');
resolveOtaResult('success', { stage: stage, value: value });
} else if (stage === BLE_OTA_FINISH_REBOOT_NOW) {
if (state.lastEsp32DoneSession !== null && state.lastEsp32DoneSession !== finishSessionId(value)) return;
state.expectedReboot = true;
setBleStage(finalizeStage, '设备重启', null);
setBleStageText('ESP32 即将重启');
@@ -503,30 +629,45 @@
}
// OTA 结果 toast 提示(用户可见,不只是在日志里)
if (stage === 0x83) {
showToast('ESP32 OTA 被其他通道占用,请稍后重试', 'error');
if (stage === BLE_OTA_FINISH_ERR_MUTEX) {
if (hasCurrentWaiter('esp32') && !finishBelongsToWaiter('esp32', value)) return;
if (hasCurrentWaiter('gd32') && !finishBelongsToWaiter('gd32', value)) return;
showToast('OTA 被其他通道占用,请稍后重试', 'error');
setProgress(0, 'OTA 被其他通道占用');
state.lastUploadStatus = 'failed';
setBleStageError('OTA 被其他通道占用');
} else if (stage === 0x84) {
rejectOtaResult('OTA 被其他通道占用', { stage: stage, value: value });
} else if (stage === BLE_OTA_FINISH_ERR_GD32_SIZE) {
if (hasCurrentWaiter('gd32') && !finishBelongsToWaiter('gd32', value)) return;
showToast(`GD32 固件超出分区大小 (${formatBytes(value)},上限 216KB)`, 'error');
setProgress(0, 'GD32 固件太大,无法刷写');
state.lastUploadStatus = 'failed';
setBleStageError('固件超出分区大小');
} else if (stage === 0x81) {
rejectOtaResult('GD32 固件超出分区大小', { stage: stage, value: value });
} else if (stage === BLE_OTA_FINISH_ERR_FINALIZE) {
if (!finishBelongsToWaiter('esp32', value)) return;
showToast('ESP32 OTA 刷写失败', 'error');
setProgress(0, '刷写失败');
state.lastUploadStatus = 'failed';
setBleStageError('OTA 刷写失败');
showBleVerifyInfo(state.fileSha256 || '-', state.deviceSha256 || '设备未上报', { text: '刷写失败', err: true });
} else if (stage === 0x85) {
showToast('ESP32 OTA 校验失败,未触发重启', 'error');
rejectOtaResult('ESP32 OTA 刷写失败', { stage: stage, value: value });
} else if (stage === BLE_OTA_FINISH_ERR_SHA_MISMATCH) {
const isGd32Waiter = hasCurrentWaiter('gd32');
const targetKey = isGd32Waiter ? 'gd32' : 'esp32';
if (!finishBelongsToWaiter(targetKey, value)) return;
const msg = isGd32Waiter
? 'GD32 缓存 SHA-256 校验失败,未进入 SPI 转发'
: 'ESP32 OTA 校验失败,未触发重启';
showToast(msg, 'error');
setProgress(0, '设备端 SHA-256 校验失败');
state.lastUploadStatus = 'failed';
setBleStage(5, 'SHA-256 ✗', null);
setBleStage(flowTarget().verifyStage, 'SHA-256 ✗', null);
setBleStageError('设备端 SHA-256 校验失败');
showBleVerifyInfo(state.fileSha256 || '-', state.deviceSha256 || '设备未上报', { text: '设备端校验失败,已中止 OTA', err: true });
} else if (stage === 0x07) {
showBleVerifyInfo(state.fileSha256 || '-', state.deviceSha256 || '设备未上报', { text: msg, err: true });
rejectOtaResult(msg, { stage: stage, value: value });
} else if (stage === BLE_OTA_FINISH_REBOOT_NOW) {
if (state.lastEsp32DoneSession !== null && state.lastEsp32DoneSession !== finishSessionId(value)) return;
showToast('ESP32 OTA 刷写完成,等待重启', 'success');
// If device SHA-256 hasn't arrived yet, show what we have
var devSha = state.deviceSha256 || '设备未上报';
@@ -556,6 +697,14 @@
// SHA-256 verification from device (after transfer complete)
if (bytes[0] === BLE_OTA_NOTIFY_SHA256 && bytes.length >= 33) {
if (bytes.length >= 35) {
var shaSessionId = bytes[33] | (bytes[34] << 8);
if (!state.otaResultWaiter ||
state.otaResultWaiter.sessionId !== shaSessionId) {
bleLog('VERIFY', '忽略非本轮 SHA-256 通知: session=' + shaSessionId, 'W');
return;
}
}
var devSha = Array.from(bytes.slice(1, 33)).map(function(b) {
return b.toString(16).padStart(2, '0');
}).join('').toUpperCase();
@@ -735,7 +884,63 @@
else els.fileInfo.classList.remove('show');
}
// SHA-256 computation and display
// ★ HEX → BIN 自动转换:复用 app.js 公共函数 convertHexFileToBin
if (file && file.name.toLowerCase().endsWith('.hex') && typeof convertHexFileToBin === 'function') {
return convertHexFileToBin(file).then(function(result) {
if (!result.converted) {
bleLog('FILE', file.name + ' 扩展名为 .hex 但内容不像 HEX 格式,按原始文件处理', 'W');
finalizeFileInfo(file);
computeFileSha256(file);
return;
}
state.selectedFile = result.file;
var baseHex = '0x' + result.baseAddr.toString(16).toUpperCase().padStart(8, '0');
bleLog('FILE', 'HEX → BIN 转换完成: ' + file.name + ' → ' + result.file.name + ' (基地址 ' + baseHex + ', ' + formatBytes(result.file.size) + ')');
showToast('HEX 已自动转换为 BIN', 'success');
// 更新 UI 显示转换后的信息
els.fileName.textContent = result.file.name + ' (由 ' + file.name + ' 转换)';
els.fileType.textContent = 'Binary (HEX转换)';
els.fileSize.textContent = formatBytes(result.file.size);
var newPackets = Math.ceil(result.file.size / payloadSize);
els.packetCount.textContent = String(newPackets);
finalizeFileInfo(result.file);
computeFileSha256(result.file);
}).catch(function(err) {
bleLog('FILE', 'HEX 解析失败: ' + err.message, 'E');
showToast('HEX 文件解析失败: ' + err.message, 'error');
state.selectedFile = null;
state.fileSha256 = null;
if (els.fileInfo) els.fileInfo.classList.remove('show');
setConnectionState(!!state.server);
});
}
// 非 HEX 文件直接完成
finalizeFileInfo(file);
computeFileSha256(file);
return Promise.resolve();
}
function finalizeFileInfo(file) {
setConnectionState(!!state.server);
if (file) {
setProgress(0, `已选择 ${file.name}`);
bleLog('FILE', `已选择: ${file.name} (${formatBytes(file.size)}), 目标=${currentTarget().label}`);
if (state.server) {
setBleStage(3, '计算哈希...', 'active');
setBleStageText('正在计算文件 SHA-256...');
}
} else {
setProgress(0, '等待选择文件');
if (state.server) {
resetBleStages();
setBleStage(3, '', null);
setBleStageText('BLE 连接就绪,请选择固件文件');
}
}
}
function computeFileSha256(file) {
var shaElem = document.getElementById('fileSha256');
if (file && shaElem) {
shaElem.textContent = '计算中...';
@@ -761,31 +966,13 @@
shaElem.textContent = '-';
state.fileSha256 = null;
}
setConnectionState(!!state.server);
if (file) {
setProgress(0, `已选择 ${file.name}`);
bleLog('FILE', `已选择: ${file.name} (${formatBytes(file.size)}), 目标=${currentTarget().label}`);
// If connected, show stage 3 as active
if (state.server) {
setBleStage(3, '计算哈希...', 'active');
setBleStageText('正在计算文件 SHA-256...');
}
} else {
setProgress(0, '等待选择文件');
if (state.server) {
resetBleStages();
setBleStage(3, '', null);
setBleStageText('BLE 连接就绪,请选择固件文件');
}
}
}
function syncSelectedFileFromInput() {
async function syncSelectedFileFromInput() {
// FSAPI 选择的文件以 selectedFileHandle 为准,不从 input 元素同步,避免切标签页回来误清空
if (state.selectedFileHandle) return;
const file = (els.firmwareFile && els.firmwareFile.files && els.firmwareFile.files[0]) || null;
updateFileInfo(file);
await updateFileInfo(file);
}
// 通过 File System Access API 选择文件(支持热重载)
@@ -803,7 +990,7 @@
const handle = handles[0];
const file = await handle.getFile();
state.selectedFileHandle = handle;
updateFileInfo(file);
await updateFileInfo(file);
bleLog('FILE', '已通过 FSAPI 选择文件(支持热重载): ' + file.name);
return true;
}
@@ -856,13 +1043,14 @@
if (state.selectedFileHandle) {
try {
const freshFile = await state.selectedFileHandle.getFile();
updateFileInfo(freshFile);
bleLog('FILE', '已重新读取本地文件: ' + freshFile.name + ' (' + formatBytes(freshFile.size) + ')');
await updateFileInfo(freshFile);
var actualFile = state.selectedFile || freshFile;
bleLog('FILE', '已重新读取本地文件: ' + actualFile.name + ' (' + formatBytes(actualFile.size) + ')');
} catch (e) {
bleLog('FILE', '重新读取文件失败: ' + e.message + ',将使用缓存版本', 'W');
}
} else {
syncSelectedFileFromInput();
await syncSelectedFileFromInput();
}
if (!state.selectedFile || !state.dataInChar) return false;
@@ -874,12 +1062,15 @@
state.abortRequested = false;
state.uploading = true;
state.lastUploadStatus = 'uploading';
const otaSessionId = nextOtaSessionId();
clearOtaResultWaiter();
switchStageFlow('ble');
state.lastAckCmd = null;
state.lastAckSeq = -1;
state.lastAckBytes = 0;
state.ackGeneration = 0;
state.deviceSha256 = null;
state.lastEsp32DoneSession = null;
if (state.ackWaiter) {
clearTimeout(state.ackWaiter.timer);
state.ackWaiter = null;
@@ -1056,8 +1247,7 @@
// Stage 4 done
setBleStage(4, '完成', null);
setProgress(100, `[${cfg.label}] 固件数据已全部写入 DATA_IN`);
state.lastUploadStatus = 'success';
setProgress(cfg.label === 'GD32' ? 45 : 90, `[${cfg.label}] 固件数据已全部写入 DATA_IN`);
// Stage 5: 发送 0xF8 + 32B 文件 SHA-256,由设备端执行收尾并完成比对
if (cfg.label === 'ESP32') {
@@ -1067,9 +1257,12 @@
for (let i = 0; i < 32; i++) {
frontShaBytes[i] = parseInt(frontSha.slice(i * 2, i * 2 + 2), 16);
}
const trigger = new Uint8Array(33);
const trigger = new Uint8Array(35);
trigger[0] = 0xF8;
trigger.set(frontShaBytes, 1);
trigger[33] = otaSessionId & 0xFF;
trigger[34] = (otaSessionId >> 8) & 0xFF;
const resultPromise = waitForOtaResult('esp32', otaSessionId, 45000);
showBleVerifyInfo(frontSha, '等待设备端比对...', { text: '已发送文件 SHA-256,等待设备端校验结果' });
setBleStage(5, '发送校验...', 'active');
setBleStageText('发送 0xF8 + SHA-256,等待设备端校验...');
@@ -1077,6 +1270,7 @@
await state.ctrlChar.writeValue(trigger);
bleLog('OTA', '0xF8 + 32B SHA-256 已发送,等待设备端校验');
} catch (e) {
clearOtaResultWaiter();
state.lastUploadStatus = 'failed';
setProgress(0, `0xF8 发送失败: ${e.message}`);
bleLog('OTA', `0xF8 发送失败: ${e.message}`, 'E');
@@ -1086,20 +1280,44 @@
setBleStage(5, '等待设备端校验', null);
setBleStage(6, '设备端收尾中', 'active');
setBleStageText('0xF8 已发,等待设备端完成 OTA 收尾...');
try {
await resultPromise;
} catch (e) {
state.lastUploadStatus = 'failed';
setProgress(0, e.message);
setBleStageError(e.message);
clearOtaResultWaiter();
return false;
}
return true;
}
// GD32: send trigger command
// GD32: send 0xF7 + 32B SHA-256 + session, then wait for SPI relay result.
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);
}
state.deviceSha256 = null;
setBleStage(5, '发送触发...', 'active');
setBleStageText('正在发送触发命令...');
setBleStageText('发送 0xF7 + SHA-256,等待设备端缓存校验...');
showBleVerifyInfo(frontSha, '等待设备端比对...', { text: '已发送文件 SHA-256,等待 GD32 缓存校验' });
await sleep(30);
const cmdHex = els.ctrlHex.value || cfg.ctrlHex;
const cmd = hexToBytes(cmdHex);
const cmdBase = hexToBytes(cmdHex);
const cmd = new Uint8Array(cmdBase.length + 32 + 2);
cmd.set(cmdBase, 0);
cmd.set(frontShaBytes, cmdBase.length);
cmd[cmdBase.length + 32] = otaSessionId & 0xFF;
cmd[cmdBase.length + 33] = (otaSessionId >> 8) & 0xFF;
const resultPromise = waitForOtaResult('gd32', otaSessionId, 120000);
try {
await state.ctrlChar.writeValue(cmd);
setBleStage(5, '已触发', null);
bleLog('OTA', `触发命令已发送: ${toHex(cmd)}`);
setBleStage(6, '等待设备端校验', 'active');
bleLog('OTA', `0xF7 + 32B SHA-256 已发送,等待设备端缓存校验与转发`);
} catch (_) {
clearOtaResultWaiter();
state.lastUploadStatus = 'failed';
setProgress(0, `触发命令发送失败: ${toHex(cmd)}`);
bleLog('OTA', `触发命令发送失败: ${toHex(cmd)}`, 'E');
@@ -1107,24 +1325,18 @@
return false;
}
// Stage 6: SHA-256 verification (GD32 mode)
setBleStage(6, 'GD32 无回传', null);
setBleStageText('GD32 OTA 中继已触发');
state.deviceSha256 = null;
showBleVerifyInfo(fileSha256.toUpperCase(), 'GD32 中继不回传 SHA', { text: 'GD32 模式无设备端校验(仅前端 SHA-256)' });
bleLog('OTA', `✅ 固件刷写完成!`);
bleLog('OTA', `📊 文件 SHA-256: ${fileSha256.toUpperCase()}`);
// Mark all stages done
for (var si = 0; si < bleStageIds.length; si++) {
var el = document.getElementById(bleStageIds[si]);
if (el && el.className !== 'stage-node err') el.className = 'stage-node done';
setBleStageText('GD32 缓存校验已发起,等待 SPI 转发流程...');
try {
await resultPromise;
} catch (e) {
state.lastUploadStatus = 'failed';
setProgress(0, e.message);
setBleStageError(e.message);
clearOtaResultWaiter();
return false;
}
var bar = document.getElementById('stageStatusBar');
if (bar) bar.className = 'stage-status-bar ok';
setBleStageText('刷写完成');
bleLog('OTA', `✅ GD32 OTA 刷写完成!`);
bleLog('OTA', `📊 文件 SHA-256: ${fileSha256.toUpperCase()}`);
return true;
} catch (err) {
state.lastUploadStatus = 'failed';
@@ -1140,6 +1352,9 @@
clearTimeout(state.ackWaiter.timer);
state.ackWaiter = null;
}
if (state.lastUploadStatus === 'failed') {
clearOtaResultWaiter();
}
state.uploading = false;
state.uploadTargetKey = null;
setConnectionState(!!state.server);
@@ -1155,19 +1370,22 @@
// (name/size/lastModified 任一不同即视为变化)
// 避免对同一文件重复触发 updateFileInfo → 产生重复的 [FILE] SHA-256 日志
const cached = state.selectedFile;
// HEX 文件转换后 cached.name 是 .bin 而 freshFile.name 是 .hex,
// 因此 HEX 文件用 lastModified 判断是否变化,避免每次都重新转换
const isHex = freshFile.name.toLowerCase().endsWith('.hex');
const changed = !cached ||
cached.name !== freshFile.name ||
cached.size !== freshFile.size ||
(isHex ? false : (cached.name !== freshFile.name || cached.size !== freshFile.size)) ||
cached.lastModified !== freshFile.lastModified;
if (changed) {
updateFileInfo(freshFile);
bleLog('FILE', '已重新读取本地文件(已变更): ' + freshFile.name + ' (' + formatBytes(freshFile.size) + ')');
await updateFileInfo(freshFile);
var actualFile = state.selectedFile || freshFile;
bleLog('FILE', '已重新读取本地文件(已变更): ' + actualFile.name + ' (' + formatBytes(actualFile.size) + ')');
}
} catch (e) {
bleLog('FILE', '重新读取文件失败: ' + e.message + ',将使用缓存版本', 'W');
}
} else {
syncSelectedFileFromInput();
await syncSelectedFileFromInput();
}
if (!state.selectedFile || !state.server) return;
@@ -1180,11 +1398,12 @@
bleLog('AUTO', '一键刷写中止', 'W');
return;
}
setProgress(100, `[${cfg.label}] 刷写完成`);
if (cfg.label === 'ESP32') {
setProgress(100, '[ESP32] OTA 完成,等待设备重启');
bleLog('AUTO', 'ESP32 OTA 完成,等待设备重启');
} else {
bleLog('AUTO', 'GD32 OTA 中继已触发');
setProgress(100, '[GD32] OTA 转发完成,APP 已启动');
bleLog('AUTO', 'GD32 OTA 完成');
}
} catch (err) {
bleLog('AUTO', `一键刷写异常: ${err && err.message ? err.message : err}`, 'E');
@@ -1226,10 +1445,10 @@
},
disconnect: disconnectBle,
showToast: showToast,
notifyFileChange: function(file, handle) {
notifyFileChange: async function(file, handle) {
if (file) {
if (handle) state.selectedFileHandle = handle;
updateFileInfo(file);
await updateFileInfo(file);
}
},
clearFile: clearSelectedFile,
+8 -13
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ESP32 OTA 中继控制台</title>
<link rel="stylesheet" href="style.css?v=20260614c">
<link rel="stylesheet" href="style.css?v=20260617a">
</head>
<body>
<div class="container">
@@ -162,11 +162,8 @@
</button>
</div>
<div id="wifiGd32ActionPane" style="display:none;">
<button class="btn btn-success btn-block" id="uploadGd32Btn" onclick="startUpload('gd32')" disabled style="margin-bottom:8px;">
上传固件到缓存
</button>
<button class="btn btn-outline btn-block" id="relayBtn" onclick="triggerRelay()">
触发 GD32 OTA 中继
<button class="btn btn-success btn-block" id="uploadGd32Btn" onclick="startUpload('gd32')" disabled>
GD32 OTA
</button>
</div>
<div class="row-2" id="bleActionPane" style="display:none;">
@@ -184,8 +181,7 @@
<!-- Phase step indicators -->
<div class="progress-phase-row">
<div class="pp-step active" id="ppEsp32"><span class="pp-ico">📦</span><span class="pp-txt">ESP32 OTA</span></div>
<div class="pp-step" id="ppGd32Up"><span class="pp-ico">📤</span><span class="pp-txt">GD32 上传</span></div>
<div class="pp-step" id="ppGd32Relay"><span class="pp-ico">🔀</span><span class="pp-txt">GD32 转发</span></div>
<div class="pp-step" id="ppGd32Ota"><span class="pp-ico">📤</span><span class="pp-txt">GD32 OTA</span></div>
</div>
<!-- Unified progress bar -->
<div class="progress-wrap show" id="progressWrap">
@@ -374,7 +370,7 @@
<span id="toastMsg">操作成功</span>
</div>
<script src="app.js?v=20260614f"></script>
<script src="app.js?v=20260617b"></script>
<script>
window.connectionMode = window.connectionMode || 'wifi';
window.flashTarget = window.flashTarget || 'esp32';
@@ -423,7 +419,7 @@ window.sharedFirmwareFiles = window.sharedFirmwareFiles || {
window.bleOta.refreshButtons();
}
} else {
window.switchStageFlow(window.activePhase === 'esp32Ota' ? 'esp32' : 'gd32');
window.switchStageFlow(window.flashTarget || (window.activePhase === 'esp32Ota' ? 'esp32' : 'gd32'));
}
};
@@ -610,7 +606,7 @@ window.sharedFirmwareFiles = window.sharedFirmwareFiles || {
if (isBle) {
hint.innerHTML = 'BLE 模式:连接设备 → 选择固件 → 一键刷写。';
} else if (isGd32) {
hint.innerHTML = 'WiFi 模式:上传 GD32 固件到缓存后,再触发 SPI OTA 中继。';
hint.innerHTML = 'WiFi 模式:上传 GD32 固件后自动执行 SPI OTA。';
} else {
hint.innerHTML = 'WiFi 模式:上传 ESP32 固件后自动执行 OTA 并重启。';
}
@@ -620,7 +616,6 @@ window.sharedFirmwareFiles = window.sharedFirmwareFiles || {
applySharedModeUi();
})();
</script>
<script src="ble_ota.js?v=20260614f"></script>
<script src="ble_ota.js?v=20260617b"></script>
</body>
</html>