feat(ota): 统一BLE/WiFi OTA协调器接口并优化并发控制
- 添加 beginEsp32OtaWithRetry 统一重试逻辑,防止锁残留导致的并发问题 - 实现 finalizeForReboot 和 restartEsp32 统一封装收尾重启流程 - 修复BLE/WiFi双通道并发OTA的安全隐患,增强系统状态检查 refactor(ota): 优化SHA计算内存管理 - 使用静态缓冲区替代动态分配,减少内存碎片 - 重构OTA协调器状态机,增强错误处理机制 perf(monitor): 增强堆内存监控功能 - 添加最大空闲块大小监控,提供更详细的内存状态信息 - 更新健康监测算法,提高内存异常检测准确性
This commit is contained in:
+112
-35
@@ -10,6 +10,7 @@ let logEntries = [];
|
||||
let lastLogHash = null;
|
||||
let autoScroll = true;
|
||||
let logSocket = null;
|
||||
let pageVisible = !document.hidden; // 页面可见性:切到后台/最小化/切标签会变 false
|
||||
let latestSystemStatus = null;
|
||||
let isEsp32Rebooting = false;
|
||||
let rebootStartTime = 0;
|
||||
@@ -298,10 +299,6 @@ function resetStageFlow() {
|
||||
function applyDisconnectedRuntimeState() {
|
||||
document.getElementById('pillIP').textContent = '--';
|
||||
document.getElementById('pillRSSI').textContent = '--';
|
||||
document.getElementById('pillRelayTxt').textContent = '离线';
|
||||
document.getElementById('pillRelay').className = 'pill offline';
|
||||
var bp = document.getElementById('bOTAProgress');
|
||||
if (bp) { bp.textContent = '--'; bp.className = 'v err'; }
|
||||
resetProgress();
|
||||
resetStageFlow();
|
||||
}
|
||||
@@ -318,8 +315,6 @@ function applyRebootingRuntimeState() {
|
||||
var bc = document.getElementById('bConn');
|
||||
if (bc) bc.textContent = '重启中';
|
||||
if (bc) bc.className = 'v warn';
|
||||
document.getElementById('pillRelayTxt').textContent = '重启中';
|
||||
document.getElementById('pillRelay').className = 'pill processing';
|
||||
}
|
||||
function applyUploadingRuntimeState() {
|
||||
var pc = document.getElementById('pillConn');
|
||||
@@ -493,9 +488,6 @@ function updateSystemStatusUi(data) {
|
||||
}
|
||||
// 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;
|
||||
@@ -505,15 +497,6 @@ function updateSystemStatusUi(data) {
|
||||
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 (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;
|
||||
@@ -994,9 +977,50 @@ function clearFile(chip) {
|
||||
addLog('I', '文件', chip.toUpperCase() + ' 文件已清除');
|
||||
}
|
||||
// ==================== 上传固件 ====================
|
||||
// ★ 跨通道互斥检查:WiFi 与 BLE 共用同一个 ESP32 OTA 状态机与全局互斥,
|
||||
// 但前端只用一个 `isUploading` bool 锁同一通道。这里在每次 startUpload
|
||||
// 入口主动查询 /system/status,若发现另一通道已经进入 OTA 活跃态
|
||||
// (ESP32 三个状态或 GD32 存储/中继),直接拒绝并提示用户。
|
||||
async function startUpload(chip) {
|
||||
var file = chip === 'esp32' ? esp32File : gd32File;
|
||||
if (!file || isUploading) return;
|
||||
// ★ 必须先抢锁再 await:守卫同步设置,await 期间重复点击才能被拦下。
|
||||
// 旧实现把 isUploading 设在 await handle.getFile() 之后 → 用户在 BLE 期间
|
||||
// 狂点 WiFi 上传按钮时,多个 startUpload 都能通过守卫,最终并发 xhr.send,
|
||||
// 设备端互斥被绕过、403 路径走漏。
|
||||
isUploading = true;
|
||||
// 跨通道互斥前置检查
|
||||
try {
|
||||
var probe = await fetch(getBaseUrl() + '/system/status', {
|
||||
signal: AbortSignal.timeout(2000)
|
||||
});
|
||||
if (probe.ok) {
|
||||
var st = await probe.json();
|
||||
var ota = st.ota || {};
|
||||
var ssc = Number.isFinite(ota.sys_state_code) ? ota.sys_state_code : -1;
|
||||
// ESP32 三种活跃态(5=RECEIVING, 6=FINALIZING, 7=REBOOTING)
|
||||
var esp32Busy = (ssc === 5 || ssc === 6 || ssc === 7);
|
||||
// GD32 存储/中继(1=STORING, 3=RELAYING)→ 阻止新上传,避免 NVS 与 Flash 互踩
|
||||
var gd32Busy = (ssc === 1 || ssc === 3);
|
||||
// 同通道(chip=esp32)已在进行中:本通道会重入;不做阻拦
|
||||
// 另一通道活跃:拒绝
|
||||
if (chip === 'esp32' && (esp32Busy || gd32Busy)) {
|
||||
addLog('E', '上传', '设备已有 OTA 进行中 (sys_state=' + (ota.sys_state || ssc) + '),拒绝本次 ESP32 上传');
|
||||
showToast('设备正在 OTA 中,请等待结束后再试', 'error');
|
||||
isUploading = false;
|
||||
return;
|
||||
}
|
||||
if (chip === 'gd32' && (esp32Busy || gd32Busy)) {
|
||||
addLog('E', '上传', '设备已有 OTA 进行中 (sys_state=' + (ota.sys_state || ssc) + '),拒绝本次 GD32 上传');
|
||||
showToast('设备正在 OTA 中,请等待结束后再试', 'error');
|
||||
isUploading = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// 探测失败:可能是设备瞬时忙或网络问题。保守继续走,由后端 403 兜底。
|
||||
addLog('W', '上传', '跨通道互斥状态探测失败: ' + e.message + '(继续尝试,由设备端兜底)');
|
||||
}
|
||||
// 每次上传前尝试重新从本地读取文件
|
||||
var handle = chip === 'esp32' ? esp32FileHandle : gd32FileHandle;
|
||||
if (handle) {
|
||||
@@ -1029,10 +1053,10 @@ async function startUpload(chip) {
|
||||
addLog('E', '文件', '本地文件已变更或失效,请重新选择文件');
|
||||
showToast('文件已变更,请重新选择', 'error');
|
||||
clearFile(chip);
|
||||
isUploading = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
isUploading = true;
|
||||
var uploadBtn = document.getElementById(chip === 'esp32' ? 'uploadEsp32Btn' : 'uploadGd32Btn');
|
||||
var origText = uploadBtn.textContent;
|
||||
uploadBtn.disabled = true;
|
||||
@@ -1053,7 +1077,7 @@ async function startUpload(chip) {
|
||||
showToast('开始上传固件到 ' + chip.toUpperCase(), 'success');
|
||||
try {
|
||||
var arrayBuffer = await file.arrayBuffer();
|
||||
// 刷写时重新计算 SHA-256
|
||||
// 刷写时重新计算 SHA-256(ESP32 和 GD32 都算)
|
||||
if (chip === 'esp32') {
|
||||
try {
|
||||
var hashBuffer = await crypto.subtle.digest('SHA-256', arrayBuffer);
|
||||
@@ -1066,6 +1090,17 @@ async function startUpload(chip) {
|
||||
} catch (e) {
|
||||
addLog('W', '文件', 'SHA-256 重新计算失败: ' + e.message);
|
||||
}
|
||||
} else if (chip === 'gd32') {
|
||||
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._gd32FileSha256 = hashHex.toUpperCase();
|
||||
addLog('I', '文件', 'GD32 SHA-256: ' + window._gd32FileSha256);
|
||||
} catch (e) {
|
||||
addLog('W', '文件', 'GD32 SHA-256 重新计算失败: ' + e.message);
|
||||
window._gd32FileSha256 = null;
|
||||
}
|
||||
}
|
||||
var fileSize = file.size;
|
||||
var body, url = getBaseUrl() + '/ota/upload';
|
||||
@@ -1126,7 +1161,8 @@ async function startUpload(chip) {
|
||||
} else {
|
||||
switchStageFlow('gd32');
|
||||
setActivePhase('gd32Ota');
|
||||
setProgress(45, '上传完成,已自动触发 GD32 OTA...');
|
||||
setProgress(45, '上传完成,校验并触发 GD32 OTA...');
|
||||
triggerGd32Relay();
|
||||
}
|
||||
} else if (xhr.status === 403) {
|
||||
addLog('E', '上传', 'ESP32 OTA 被其他通道占用(BLE/WiFi 互斥),请稍后重试');
|
||||
@@ -1305,29 +1341,55 @@ async function restartEsp32() {
|
||||
showToast('重启失败', 'error');
|
||||
}
|
||||
}
|
||||
// ==================== 手动触发 GD32 OTA (调试/旧页面兜底) ====================
|
||||
async function triggerRelay() {
|
||||
addLog('I', 'OTA', '手动触发 GD32 OTA...');
|
||||
// ==================== GD32 OTA 触发(带 SHA-256 校验,对齐 BLE 0xF7)====================
|
||||
async function triggerGd32Relay() {
|
||||
var sha256 = window._gd32FileSha256;
|
||||
if (!sha256) {
|
||||
addLog('E', 'OTA', '缺少 GD32 文件 SHA-256,请先上传文件');
|
||||
showToast('SHA-256 缺失', 'error');
|
||||
return;
|
||||
}
|
||||
addLog('I', 'OTA', 'GD32 SHA-256: ' + sha256 + ' → 触发中继...');
|
||||
showToast('正在触发 GD32 OTA', 'success');
|
||||
try {
|
||||
var resp = await fetch(getBaseUrl() + '/ota/relay', {
|
||||
method: 'POST', signal: AbortSignal.timeout(5000)
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ sha256: sha256 }),
|
||||
signal: AbortSignal.timeout(30000) // 中继可能耗时较长
|
||||
});
|
||||
var text = await resp.text();
|
||||
if (resp.ok) {
|
||||
try {
|
||||
var data = JSON.parse(text);
|
||||
if (data.sha256) addLog('I', 'OTA', '设备端 SHA-256: ' + data.sha256);
|
||||
} catch (_) {}
|
||||
addLog('I', 'OTA', 'GD32 OTA 已触发,等待设备响应...');
|
||||
// 不再使用假的 setTimeout 推进阶段。
|
||||
// pollStatus 的 updateSystemStatusUi 会从 ota.relay_state
|
||||
// 自动更新 stage flow 和 header 状态。
|
||||
showToast('GD32 OTA 已触发,等待设备响应', 'success');
|
||||
showToast('GD32 OTA 已触发', 'success');
|
||||
} else {
|
||||
addLog('E', 'OTA', '触发失败: HTTP ' + resp.status);
|
||||
showToast('触发失败', 'error');
|
||||
// 尝试解析错误响应中的 device SHA
|
||||
try {
|
||||
var err = JSON.parse(text);
|
||||
if (err.device) {
|
||||
addLog('E', 'OTA', 'GD32 SHA-256 不匹配: 设备=' + err.device + ' 前端=' + sha256);
|
||||
showToast('SHA-256 不匹配', 'error');
|
||||
return;
|
||||
}
|
||||
} catch (_) {}
|
||||
addLog('E', 'OTA', 'GD32 触发失败: HTTP ' + resp.status + ' ' + text);
|
||||
showToast('GD32 触发失败', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
addLog('E', 'OTA', '触发失败: ' + e.message);
|
||||
showToast('触发失败', 'error');
|
||||
addLog('E', 'OTA', 'GD32 触发失败: ' + e.message);
|
||||
showToast('GD32 触发失败', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 手动触发 GD32 OTA (调试/旧页面兜底) ====================
|
||||
async function triggerRelay() {
|
||||
// 转发到统一入口:自动使用最近一次上传的 GD32 文件 SHA
|
||||
await triggerGd32Relay();
|
||||
}
|
||||
// ==================== 轮询状态 ====================
|
||||
var consecutivePollFailures = 0;
|
||||
var statusTimerId = null;
|
||||
@@ -1426,9 +1488,11 @@ async function pollStatus() {
|
||||
updateSystemStatusUi(data);
|
||||
}
|
||||
} catch (e) {
|
||||
consecutivePollFailures++;
|
||||
// 后台期间 setInterval 会被 Chrome/Edge 节流到最低 1 分钟/次,
|
||||
// 因此后台累积的失败计数不应递增,避免切回前台瞬间误触发 5-fail 诊断。
|
||||
if (pageVisible) consecutivePollFailures++;
|
||||
// 传输中屏蔽网关无响应的检测功能(上传期间设备忙,无法响应轮询是正常的)
|
||||
if (consecutivePollFailures === 5 && !pollSlowMode && !isUploading && !isEsp32Rebooting) {
|
||||
if (pageVisible && consecutivePollFailures >= 5 && !pollSlowMode && !isUploading && !isEsp32Rebooting) {
|
||||
addLog('W', '诊断', '设备连续 5 次无响应,正在诊断网络...');
|
||||
var diag = await diagnoseNetwork();
|
||||
addLog('W', '诊断', diag);
|
||||
@@ -1460,6 +1524,19 @@ async function pollStatus() {
|
||||
}
|
||||
}
|
||||
startPolling();
|
||||
// ==================== Page Visibility 补偿 ====================
|
||||
// Chrome/Edge 在标签隐藏/窗口最小化时会把 setInterval 节流到最低 1 分钟/次。
|
||||
// 切回前台时立即拉一次状态 + 日志,并清零后台期间累积的失败计数,
|
||||
// 避免在"刚切回前台那一轮"被误判为 5-fail 离线。
|
||||
document.addEventListener('visibilitychange', function () {
|
||||
pageVisible = !document.hidden;
|
||||
if (!pageVisible) return;
|
||||
consecutivePollFailures = 0;
|
||||
pollStatus();
|
||||
pollLogs();
|
||||
// 之前若在慢速模式(10s),切回前台先用 1s 观察一会儿
|
||||
if (pollSlowMode) restorePolling();
|
||||
});
|
||||
// ==================== ESP32 实时日志拉取 (增量) ====================
|
||||
var lastLogTimestamp = 0;
|
||||
async function pollLogs() {
|
||||
|
||||
+117
-24
@@ -81,6 +81,8 @@
|
||||
reconnecting: false, // 自动重连中
|
||||
reconnectAttempts: 0, // 重连尝试次数
|
||||
uploadTargetKey: null,
|
||||
activeTotalSize: 0,
|
||||
activePacketTotal: 0,
|
||||
};
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
@@ -314,6 +316,25 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ★ 以设备端 ACK 字节数为唯一进度数据源
|
||||
// 仅当处于上传阶段(state.uploading)且未中止时才刷 UI,避免陈旧 ACK / abort 后覆盖
|
||||
function updateProgressFromAck(totalSize, packetTotal, ackBytes) {
|
||||
if (!state.uploading) return;
|
||||
if (state.abortRequested) return;
|
||||
if (!Number.isFinite(totalSize) || totalSize <= 0) return;
|
||||
const safeBytes = Math.max(0, Math.min(totalSize, ackBytes >>> 0));
|
||||
const percent = (safeBytes / totalSize) * 100;
|
||||
const payloadSize = Math.max(1, Number(els.payloadSize.value) || 480);
|
||||
const ackedPackets = Math.min(packetTotal, Math.ceil(safeBytes / payloadSize));
|
||||
const cfg = currentTarget();
|
||||
setProgress(percent, `[${cfg.label}] 已确认 ${ackedPackets}/${packetTotal} 包`);
|
||||
els.sentCount.textContent = String(ackedPackets);
|
||||
// 阶段 4 detail 文本:只在写入阶段刷新,避免与 stage 5/6 冲突
|
||||
var detailEl = document.getElementById('bsd4');
|
||||
if (detailEl) detailEl.textContent = `${ackedPackets}/${packetTotal} 包`;
|
||||
setBleStageText(`数据写入 ${Math.round(percent)}%`);
|
||||
}
|
||||
|
||||
function setReadyHint(html) {
|
||||
const el = $('readyHint');
|
||||
if (el) el.innerHTML = html;
|
||||
@@ -441,6 +462,12 @@
|
||||
setConnectionState(true);
|
||||
bleLog('BLE', 'GATT 服务与特征获取完成');
|
||||
await enableNotifications();
|
||||
// ★ enableNotifications 内部已包含重试 + 主动断开逻辑;
|
||||
// 若返回时仍为 false,说明连接半死,UI 已被断开事件重置,不要继续推进到 Stage 3
|
||||
if (!state.notificationsEnabled) {
|
||||
bleLog('BLE', '连接就绪但 DATA_OUT 通知开启失败,等待用户重连', 'E');
|
||||
return;
|
||||
}
|
||||
// Mark stages 1-2 as done
|
||||
setBleStage(3, '', null);
|
||||
setBleStageText('BLE 连接就绪,请选择固件文件');
|
||||
@@ -517,10 +544,33 @@
|
||||
const ackCmd = bytes[1];
|
||||
const ackSeq = bytes[2] | (bytes[3] << 8);
|
||||
const ackBytes = (bytes[4]) | (bytes[5] << 8) | (bytes[6] << 16) | (bytes[7] << 24);
|
||||
// ★ 保护:陈旧 ACK 来自跨 run / 旧窗口时直接丢弃。
|
||||
// 仅以「本 run 上界」过滤(ackSeq 不能超过文件总包数 + 一个窗口余量),
|
||||
// 不再用 ackMinSeenSeq 作下界 —— 因为本 run 第一个窗口的 seq 本身就是 0..31,
|
||||
// 下界过严会把正常 ACK 也误判为越界(导致 ackBytes 永远 0)。
|
||||
if (state.uploading && state.activeTotalSize > 0) {
|
||||
const payloadSize = Math.max(1, Number(els.payloadSize.value) || 480);
|
||||
const maxExpectedSeq = Math.ceil(state.activeTotalSize / payloadSize) - 1;
|
||||
if (ackSeq > maxExpectedSeq + BLE_OTA_ACK_WINDOW) {
|
||||
bleLog('ACK', `忽略越界 ACK: seq=${ackSeq} (run 范围 0..${maxExpectedSeq})`, 'W');
|
||||
return;
|
||||
}
|
||||
} else if (!state.uploading) {
|
||||
// 非上传阶段:清空状态,避免污染下次 run
|
||||
state.lastAckCmd = null;
|
||||
state.lastAckSeq = -1;
|
||||
state.lastAckBytes = 0;
|
||||
return;
|
||||
}
|
||||
state.lastAckCmd = ackCmd;
|
||||
state.lastAckSeq = ackSeq;
|
||||
state.lastAckBytes = ackBytes >>> 0;
|
||||
|
||||
// ★ 以设备端真实 ACK 字节数驱动 UI 进度
|
||||
if (state.uploading && state.activeTotalSize > 0 && state.activePacketTotal > 0) {
|
||||
updateProgressFromAck(state.activeTotalSize, state.activePacketTotal, state.lastAckBytes);
|
||||
}
|
||||
|
||||
const ackText = `[ACK] cmd=0x${ackCmd.toString(16).padStart(2, '0').toUpperCase()} seq=${ackSeq} bytes=${state.lastAckBytes} (${formatBytes(state.lastAckBytes)}) raw=${hex}`;
|
||||
bleLog('ACK', ackText);
|
||||
|
||||
@@ -577,7 +627,7 @@
|
||||
if (typeof setActivePhase === 'function') setActivePhase('gd32Ota');
|
||||
if (typeof updateStagesFromRelayState === 'function') updateStagesFromRelayState(1);
|
||||
setProgress(50, '[GD32] OTA 转发已开始,正在认证握手...');
|
||||
showBleVerifyInfo(state.fileSha256 || '-', '等待 GD32 APP 启动确认...', { text: '缓存写入完成,正在通过 SPI 转发' });
|
||||
showBleVerifyInfo(state.fileSha256 || '-', state.deviceSha256 || '等待设备端上报...', { text: '缓存写入完成,正在通过 SPI 转发' });
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -587,7 +637,7 @@
|
||||
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 });
|
||||
showBleVerifyInfo(state.fileSha256 || '-', state.deviceSha256 || '设备未上报', { text: 'GD32 OTA 完成', ok: true });
|
||||
var gd32Bar = document.getElementById('stageStatusBar');
|
||||
var gd32Text = document.getElementById('stageStatusText');
|
||||
if (gd32Bar) gd32Bar.className = 'stage-status-bar ok';
|
||||
@@ -623,9 +673,14 @@
|
||||
if (state.lastEsp32DoneSession !== null && state.lastEsp32DoneSession !== finishSessionId(value)) return;
|
||||
state.expectedReboot = true;
|
||||
setBleStage(finalizeStage, '设备重启', null);
|
||||
setBleStageText('ESP32 即将重启');
|
||||
var bar = document.getElementById('stageStatusBar');
|
||||
if (bar) bar.className = 'stage-status-bar ok';
|
||||
// ★ ESP32 收尾完成 = 成功路径:保持 status bar 绿色,不要切回 active 蓝
|
||||
var rebootBar = document.getElementById('stageStatusBar');
|
||||
var rebootBarTxt = document.getElementById('stageStatusText');
|
||||
if (rebootBar) rebootBar.className = 'stage-status-bar ok';
|
||||
if (rebootBarTxt) rebootBarTxt.textContent = 'ESP32 刷写完成,等待重启';
|
||||
if (typeof markPhaseDone === 'function') {
|
||||
markPhaseDone('esp32Ota');
|
||||
}
|
||||
}
|
||||
|
||||
// OTA 结果 toast 提示(用户可见,不只是在日志里)
|
||||
@@ -718,7 +773,14 @@
|
||||
bleLog('VERIFY', 'SHA-256 校验通过,前端与设备端一致', 'I');
|
||||
showBleVerifyInfo(frontSha, devSha, { text: '校验通过 — SHA-256 一致', ok: true });
|
||||
setBleStage(verifyStage, 'SHA-256 ✓', null);
|
||||
setBleStageText('校验通过 — 固件完整性已确认');
|
||||
// ★ 校验通过 = 刷写成功:status bar 标绿 + 顶部胶囊推进
|
||||
var okBar = document.getElementById('stageStatusBar');
|
||||
var okTxt = document.getElementById('stageStatusText');
|
||||
if (okBar) okBar.className = 'stage-status-bar ok';
|
||||
if (okTxt) okTxt.textContent = '刷写成功 — SHA-256 校验通过';
|
||||
if (typeof markPhaseDone === 'function') {
|
||||
markPhaseDone(state.uploadTargetKey === 'gd32' ? 'gd32Ota' : 'esp32Ota');
|
||||
}
|
||||
} else if (frontSha) {
|
||||
bleLog('VERIFY', 'SHA-256 校验失败!前端: ' + frontSha + ' 设备: ' + devSha, 'E');
|
||||
showBleVerifyInfo(frontSha, devSha, { text: '校验失败 — SHA-256 不一致', err: true });
|
||||
@@ -853,18 +915,36 @@
|
||||
}
|
||||
|
||||
async function enableNotifications() {
|
||||
try {
|
||||
await state.dataOutChar.startNotifications();
|
||||
state.dataOutChar.removeEventListener('characteristicvaluechanged', handleNotify);
|
||||
state.dataOutChar.addEventListener('characteristicvaluechanged', handleNotify);
|
||||
state.notificationsEnabled = true;
|
||||
setConnectionState(!!state.server);
|
||||
bleLog('BLE', 'DATA_OUT 通知已自动开启');
|
||||
} catch (err) {
|
||||
state.notificationsEnabled = false;
|
||||
setConnectionState(!!state.server);
|
||||
bleLog('BLE', `自动开启 DATA_OUT 通知失败: ${err.message}`, 'E');
|
||||
// 修复偶发的 GATT CCC 竞态:startNotifications 与 NimBLE gap 完成握手存在窗口冲突。
|
||||
// 现象:Chrome 报 "GATT operation failed for unknown reason",NimBLE 实际返回 EBUSY/ENOTCONN。
|
||||
// 处理:内部退避重试 + 最终失败时主动断开,让上层进入干净的重连路径,而不是带着半死连接去刷写。
|
||||
const MAX_RETRY = 3;
|
||||
for (let attempt = 1; attempt <= MAX_RETRY; attempt++) {
|
||||
try {
|
||||
await state.dataOutChar.startNotifications();
|
||||
state.dataOutChar.removeEventListener('characteristicvaluechanged', handleNotify);
|
||||
state.dataOutChar.addEventListener('characteristicvaluechanged', handleNotify);
|
||||
state.notificationsEnabled = true;
|
||||
setConnectionState(!!state.server);
|
||||
bleLog('BLE', 'DATA_OUT 通知已自动开启' + (attempt > 1 ? ` (重试 ${attempt}/${MAX_RETRY} 成功)` : ''));
|
||||
return;
|
||||
} catch (err) {
|
||||
bleLog('BLE', `自动开启 DATA_OUT 通知失败(第 ${attempt}/${MAX_RETRY} 次): ${err.message}`, 'E');
|
||||
if (attempt < MAX_RETRY) {
|
||||
// 200ms / 400ms 退避,给 NimBLE 主机任务时间把 connect/MTU 事件消化完
|
||||
await new Promise(r => setTimeout(r, 200 * attempt));
|
||||
}
|
||||
}
|
||||
}
|
||||
state.notificationsEnabled = false;
|
||||
setConnectionState(!!state.server);
|
||||
// ★ 最终失败:连接处于半死状态,主动断开让用户重连,避免带着残废连接走到刷写
|
||||
try {
|
||||
if (state.device && state.device.gatt && state.device.gatt.connected) {
|
||||
state.device.gatt.disconnect();
|
||||
}
|
||||
} catch (_) {}
|
||||
bleLog('BLE', 'DATA_OUT 通知开启彻底失败,已主动断开,请重连后重试', 'E');
|
||||
}
|
||||
|
||||
function updateFileInfo(file) {
|
||||
@@ -1064,6 +1144,9 @@
|
||||
state.lastUploadStatus = 'uploading';
|
||||
const otaSessionId = nextOtaSessionId();
|
||||
clearOtaResultWaiter();
|
||||
if (typeof setActivePhase === 'function') {
|
||||
setActivePhase(cfg.label === 'GD32' ? 'gd32Ota' : 'esp32Ota');
|
||||
}
|
||||
switchStageFlow('ble');
|
||||
state.lastAckCmd = null;
|
||||
state.lastAckSeq = -1;
|
||||
@@ -1071,6 +1154,8 @@
|
||||
state.ackGeneration = 0;
|
||||
state.deviceSha256 = null;
|
||||
state.lastEsp32DoneSession = null;
|
||||
state.activeTotalSize = 0;
|
||||
state.activePacketTotal = 0;
|
||||
if (state.ackWaiter) {
|
||||
clearTimeout(state.ackWaiter.timer);
|
||||
state.ackWaiter = null;
|
||||
@@ -1120,6 +1205,11 @@
|
||||
|
||||
setBleStage(4, '写入中...', null);
|
||||
setBleStageText(`正在写入 ${packetTotal} 包数据...`);
|
||||
// ★ 写入阶段已读出文件大小/包数,记录到 state 供 handleNotify 在 ACK 到达时使用
|
||||
state.activeTotalSize = totalSize;
|
||||
state.activePacketTotal = packetTotal;
|
||||
els.sentCount.textContent = '0';
|
||||
setProgress(0, `[${cfg.label}] 等待设备确认 0/${packetTotal} 包`);
|
||||
bleLog('OTA', `开始写入 DATA_IN: target=${cfg.label}, file=${file.name}, total=${totalSize}, packets=${packetTotal}, payload=${payloadSize}, windowAck=${BLE_OTA_ACK_WINDOW}`);
|
||||
|
||||
const MAX_WINDOW_RETRIES = 3;
|
||||
@@ -1131,6 +1221,7 @@
|
||||
const endSeq = Math.min(windowStart + BLE_OTA_ACK_WINDOW, packetTotal) - 1;
|
||||
|
||||
// 流水线发送:不逐包 await,减少 BLE 栈 per-packet 开销
|
||||
// ★ 不再在循环内 setProgress:进度完全由设备 ACK 后的 state.lastAckBytes 驱动
|
||||
for (let seq = windowStart; seq <= endSeq; seq++) {
|
||||
const start = seq * payloadSize;
|
||||
const end = Math.min(start + payloadSize, totalSize);
|
||||
@@ -1162,12 +1253,6 @@
|
||||
state.dataInChar.writeValueWithoutResponse(packet); // fire-and-forget
|
||||
}
|
||||
bleWriteTs = performance.now();
|
||||
|
||||
const percent = ((seq + 1) / packetTotal) * 100;
|
||||
els.sentCount.textContent = String(seq + 1);
|
||||
setProgress(percent, `[${cfg.label}] 已写 ${seq + 1}/${packetTotal} 包`);
|
||||
setBleStage(4, `${seq + 1}/${packetTotal} 包`, null);
|
||||
setBleStageText(`数据写入 ${Math.round(percent)}%`);
|
||||
}
|
||||
|
||||
state.ackGeneration++;
|
||||
@@ -1246,8 +1331,16 @@
|
||||
}
|
||||
|
||||
// Stage 4 done
|
||||
// ★ 不再用本地 seq 推算固定百分比:进度以设备端 ACK 字节数为准
|
||||
const confirmedPct = state.activeTotalSize > 0
|
||||
? Math.min(100, (state.lastAckBytes >>> 0) / state.activeTotalSize * 100)
|
||||
: 100;
|
||||
// 阶段 4 完成后,base 进度:GD32 留 55% 给中继/校验,ESP32 留 10% 给收尾
|
||||
const phaseBase = cfg.label === 'GD32' ? 45 : 90;
|
||||
// ACK 推进到的进度 = 阶段 4 在整体里占的份额 (phaseBase) × 设备真实确认比例
|
||||
const stage4Share = phaseBase * (confirmedPct / 100);
|
||||
setBleStage(4, '完成', null);
|
||||
setProgress(cfg.label === 'GD32' ? 45 : 90, `[${cfg.label}] 固件数据已全部写入 DATA_IN`);
|
||||
setProgress(Math.min(phaseBase, stage4Share), `[${cfg.label}] 固件数据已全部写入 DATA_IN`);
|
||||
|
||||
// Stage 5: 发送 0xF8 + 32B 文件 SHA-256,由设备端执行收尾并完成比对
|
||||
if (cfg.label === 'ESP32') {
|
||||
|
||||
+7
-2
@@ -20,7 +20,6 @@
|
||||
<span class="pill" id="pillVer">v--</span>
|
||||
<span class="pill" id="pillIP">--</span>
|
||||
<span class="pill" id="pillRSSI">--</span>
|
||||
<span class="pill idle" id="pillRelay"><span class="indicator"></span><span id="pillRelayTxt">待机</span></span>
|
||||
<span class="pill" id="pillUptime">运行 --</span>
|
||||
</div>
|
||||
<div class="header-sub">
|
||||
@@ -370,7 +369,7 @@
|
||||
<span id="toastMsg">操作成功</span>
|
||||
</div>
|
||||
|
||||
<script src="app.js?v=20260617b"></script>
|
||||
<script src="app.js?v=20260619c"></script>
|
||||
<script>
|
||||
window.connectionMode = window.connectionMode || 'wifi';
|
||||
window.flashTarget = window.flashTarget || 'esp32';
|
||||
@@ -415,6 +414,9 @@ window.sharedFirmwareFiles = window.sharedFirmwareFiles || {
|
||||
syncBleFileFromSelectedTarget();
|
||||
if (isBle) {
|
||||
window.switchStageFlow('ble');
|
||||
if (typeof setActivePhase === 'function') {
|
||||
setActivePhase(window.flashTarget === 'gd32' ? 'gd32Ota' : 'esp32Ota');
|
||||
}
|
||||
if (window.bleOta && typeof window.bleOta.refreshButtons === 'function') {
|
||||
window.bleOta.refreshButtons();
|
||||
}
|
||||
@@ -429,6 +431,9 @@ window.sharedFirmwareFiles = window.sharedFirmwareFiles || {
|
||||
syncBleFileFromSelectedTarget();
|
||||
if (window.connectionMode === 'ble') {
|
||||
window.switchStageFlow('ble');
|
||||
if (typeof setActivePhase === 'function') {
|
||||
setActivePhase(target === 'gd32' ? 'gd32Ota' : 'esp32Ota');
|
||||
}
|
||||
} else {
|
||||
window.switchStageFlow(window.flashTarget);
|
||||
}
|
||||
|
||||
+2
-3
@@ -603,9 +603,8 @@ code { color: #d3f9ff; font-family: var(--font-mono); }
|
||||
/* ===== Header sub-row (设备名/设备ID) ===== */
|
||||
.header-sub {
|
||||
display: flex; gap: 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
margin-top: 4px;
|
||||
padding: 6px 20px 4px;
|
||||
margin-top: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.pill-val {
|
||||
font-weight: 600;
|
||||
|
||||
Reference in New Issue
Block a user