feat(ble): 完善BLE OTA架构并优化性能
- 实现BLE OTA数据路径分离:添加ble_ota_data.cpp处理重排序/工作线程/DATA_IN, 和ble_ota_host.cpp处理GATT/GAP/广播/主机功能 - 将滑动ACK窗口从32增大到64,实测Windows WebBT环境下固件传输速度提升约10% (从40KB/s提升到44KB/s),内存消耗约30KB重排序缓冲区 - 优化调试日志级别,将OTA相关信息从INFO调整为DBG以减少日志冗余 refactor(can): 改进CAN反馈监听机制 - 将CAN反馈任务从1Hz主动轮询模式改为被动事件监听模式, 使用SpiMaster::takeEvent()替代主动发送0xC1轮询请求 - 更新相关头文件注释说明架构变更,将"周期性轮询"改为"被动接收推送" - 修改调试日志级别,将暂停/恢复状态日志从INFO调整为DBG
This commit is contained in:
@@ -10,11 +10,13 @@ export const UUIDS = {
|
||||
version: 0x0004,
|
||||
};
|
||||
|
||||
export const UI_BUILD = '2026-07-18-ble-fix';
|
||||
export const UI_BUILD = '2026-07-18-ble-log-trim';
|
||||
export const BLE_OTA_NOTIFY_ACK = 0xA1;
|
||||
export const BLE_OTA_NOTIFY_FINISH = 0xB1;
|
||||
export const BLE_OTA_NOTIFY_SHA256 = 0xC1;
|
||||
export const BLE_OTA_ACK_WINDOW = 32;
|
||||
// ★ 须与后端 main/include/app_config.h BLE_OTA_ACK_WINDOW 保持一致
|
||||
// 64:实测 Windows 瓶颈在每窗口 ACK 往返,窗口 32→64 提速 ~10%,再试 96 无收益。
|
||||
export const BLE_OTA_ACK_WINDOW = 64;
|
||||
export const BLE_OTA_FINISH_ESP32_DONE = 0x02;
|
||||
export const BLE_OTA_FINISH_REBOOT_NOW = 0x07;
|
||||
export const BLE_OTA_FINISH_GD32_RELAY_START = 0x10;
|
||||
|
||||
+68
-60
@@ -1,17 +1,27 @@
|
||||
// ==================== BLE OTA 文件准备与分包 ====================
|
||||
import { state, els, bleLog, currentTarget, currentPayloadSize, setConnectionState } from './state.js';
|
||||
//
|
||||
// ★ 上传中拦截策略(全文件统一):file.js 是 BLE 通道文件操作的唯一入口,
|
||||
// transfer.js / state.js / session.js / 外部 bleOta.notifyFileChange 都汇聚到这里。
|
||||
// isBleUploading()(state.js)= state.uploading || state.autoFlashing;
|
||||
// window.isUploading = WiFi 通道 OTA 会话是否处于 UPLOADING(ota/session.js 维护)。
|
||||
// 两者任一为真即视为上传中,禁止重置进度条/阶段/清空文件,避免覆盖进行中的 OTA。
|
||||
import { state, els, bleLog, currentTarget, currentPayloadSize, setConnectionState, isBleUploading } from './state.js';
|
||||
import { setBleStage, setBleStageText, resetBleStages } from './stages.js';
|
||||
import { setProgress } from './transfer.js';
|
||||
import { hideBleVerifyInfo } from './verify.js';
|
||||
import { formatBytes } from '../common/utils.js';
|
||||
import { formatBytes, bytesToHex } from '../common/utils.js';
|
||||
import { showToast } from '../common/toast.js';
|
||||
|
||||
/** 任一通道上传中(BLE 或 WiFi)。focus/change 等不分通道的事件用此判定。 */
|
||||
function isAnyChannelUploading() {
|
||||
return isBleUploading() || (typeof window.isUploading !== 'undefined' && !!window.isUploading);
|
||||
}
|
||||
|
||||
export function updateFileInfo(file) {
|
||||
// ★ 上传进行中禁止重置进度/阶段:目标切换、模式切换、syncBleFileFromSelectedTarget
|
||||
// 等事件会在上传期间调用本函数,若不拦截会触发 finalizeFileInfo → setProgress(0, ...)
|
||||
// 把进度条从"已确认 N/M 包"错误地刷回"等待选择文件 0%"。
|
||||
var isUploading = state.uploading || state.autoFlashing;
|
||||
if (isUploading && file && state.selectedFile) {
|
||||
if (isBleUploading() && file && state.selectedFile) {
|
||||
// 上传中且文件未变:仅刷新元数据(文件名/大小等),不碰进度条和阶段
|
||||
bleLog('FILE', '上传进行中,跳过 updateFileInfo 进度重置', 'D');
|
||||
return Promise.resolve();
|
||||
@@ -33,13 +43,12 @@ export function updateFileInfo(file) {
|
||||
else els.fileInfo.classList.remove('show');
|
||||
}
|
||||
|
||||
// ★ HEX → BIN 自动转换:复用 app.js 公共函数 convertHexFileToBin
|
||||
if (file && file.name.toLowerCase().endsWith('.hex') && typeof convertHexFileToBin === 'function') {
|
||||
// ★ HEX → BIN 自动转换:复用 app.js 公共函数 window.convertHexFileToBin
|
||||
if (file && file.name.toLowerCase().endsWith('.hex') && typeof window.convertHexFileToBin === 'function') {
|
||||
return convertHexFileToBin(file).then(function(result) {
|
||||
if (!result.converted) {
|
||||
bleLog('FILE', file.name + ' 扩展名为 .hex 但内容不像 HEX 格式,按原始文件处理', 'W');
|
||||
finalizeFileInfo(file);
|
||||
computeFileSha256(file);
|
||||
finalizeAndHash(file);
|
||||
return;
|
||||
}
|
||||
state.selectedFile = result.file;
|
||||
@@ -52,8 +61,7 @@ export function updateFileInfo(file) {
|
||||
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);
|
||||
finalizeAndHash(result.file);
|
||||
}).catch(function(err) {
|
||||
bleLog('FILE', 'HEX 解析失败: ' + err.message, 'E');
|
||||
showToast('HEX 文件解析失败: ' + err.message, 'error');
|
||||
@@ -65,16 +73,20 @@ export function updateFileInfo(file) {
|
||||
}
|
||||
|
||||
// 非 HEX 文件直接完成
|
||||
finalizeAndHash(file);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
/** finalizeFileInfo + computeFileSha256 的统一出口:HEX 转换完成分支与非 HEX 分支共用。 */
|
||||
function finalizeAndHash(file) {
|
||||
finalizeFileInfo(file);
|
||||
computeFileSha256(file);
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
function finalizeFileInfo(file) {
|
||||
// ★ 上传进行中禁止重置进度条/阶段:防止目标切换等事件在上传期间
|
||||
// 通过 finalizeFileInfo → setProgress(0, '等待选择文件') 覆盖上传进度。
|
||||
var isUploading = state.uploading || state.autoFlashing;
|
||||
if (isUploading) {
|
||||
if (isBleUploading()) {
|
||||
bleLog('FILE', '上传进行中,跳过 finalizeFileInfo 进度重置', 'D');
|
||||
return;
|
||||
}
|
||||
@@ -82,10 +94,6 @@ function finalizeFileInfo(file) {
|
||||
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) {
|
||||
@@ -96,42 +104,51 @@ function finalizeFileInfo(file) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算文件 SHA-256 并推进 BLE stage 流。
|
||||
* stage 推进逻辑收敛在此函数内,避免与 finalizeFileInfo 隐式耦合:
|
||||
* - 已连接且 file 非空:进入「计算中」态(stage 3 active + 状态栏文案)
|
||||
* - 哈希算完:stage 3 done + 推进 stage 4 + 状态栏「文件就绪」
|
||||
* - file 为空 / 未连接:不碰 stage(由 finalizeFileInfo 的 else 分支 + clearSelectedFile 负责)
|
||||
*/
|
||||
function computeFileSha256(file) {
|
||||
var shaElem = document.getElementById('fileSha256');
|
||||
if (file && 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();
|
||||
state.fileSha256 = hashHex.toUpperCase();
|
||||
bleLog('FILE', 'SHA-256: ' + hashHex.toUpperCase());
|
||||
// If connected, advance to stage 3 done
|
||||
if (state.server) {
|
||||
setBleStage(3, 'SHA-256 已计算', null);
|
||||
setBleStage(4, '', null);
|
||||
setBleStageText('文件就绪,可以开始刷写');
|
||||
}
|
||||
}).catch(function(err) {
|
||||
shaElem.textContent = '计算失败: ' + err.message;
|
||||
state.fileSha256 = null;
|
||||
});
|
||||
} else if (shaElem) {
|
||||
shaElem.textContent = '-';
|
||||
if (!file || !shaElem) {
|
||||
if (shaElem) shaElem.textContent = '-';
|
||||
state.fileSha256 = null;
|
||||
return;
|
||||
}
|
||||
// 已连接:进入「计算中」态(旧逻辑放在 finalizeFileInfo 里,现收敛至此)
|
||||
if (state.server) {
|
||||
setBleStage(3, '计算哈希...', 'active');
|
||||
setBleStageText('正在计算文件 SHA-256...');
|
||||
}
|
||||
shaElem.textContent = '计算中...';
|
||||
file.arrayBuffer().then(function(buf) {
|
||||
return crypto.subtle.digest('SHA-256', buf);
|
||||
}).then(function(hashBuffer) {
|
||||
var hashHex = bytesToHex(new Uint8Array(hashBuffer)).toUpperCase();
|
||||
shaElem.textContent = hashHex;
|
||||
state.fileSha256 = hashHex;
|
||||
bleLog('FILE', 'SHA-256: ' + hashHex);
|
||||
if (state.server) {
|
||||
setBleStage(3, 'SHA-256 已计算', null);
|
||||
setBleStage(4, '', null);
|
||||
setBleStageText('文件就绪,可以开始刷写');
|
||||
}
|
||||
}).catch(function(err) {
|
||||
shaElem.textContent = '计算失败: ' + err.message;
|
||||
state.fileSha256 = null;
|
||||
});
|
||||
}
|
||||
|
||||
export async function syncSelectedFileFromInput() {
|
||||
// ★ 上传进行中禁止同步:window focus / file input change 事件会在上传期间
|
||||
// 触发本函数,若不拦截会通过 updateFileInfo 把进度条重置为"等待选择文件 0%"。
|
||||
// ★ 必须同时拦截 WiFi 通道上传 (app.js 全局 isUploading):focus 事件不分通道,
|
||||
// WiFi 刷写时点击网页/切标签回来会触发本函数 → finalizeFileInfo → setProgress(0),
|
||||
// 把进度"闪一下"归零,直到下一次 XHR progress 事件才恢复。
|
||||
if (state.uploading || state.autoFlashing) return;
|
||||
if (typeof isUploading !== 'undefined' && isUploading) return;
|
||||
// ★ 必须同时拦截 WiFi 通道上传:focus 事件不分通道,WiFi 刷写时点击网页/切标签
|
||||
// 回来会触发本函数 → finalizeFileInfo → setProgress(0),把进度"闪一下"归零,
|
||||
// 直到下一次 XHR progress 事件才恢复。
|
||||
if (isAnyChannelUploading()) return;
|
||||
// FSAPI 选择的文件以 selectedFileHandle 为准,不从 input 元素同步,避免切标签页回来误清空
|
||||
if (state.selectedFileHandle) return;
|
||||
const file = (els.firmwareFile && els.firmwareFile.files && els.firmwareFile.files[0]) || null;
|
||||
@@ -174,7 +191,7 @@ export async function pickFileViaFSAPI() {
|
||||
export function clearSelectedFile() {
|
||||
// ★ 上传进行中禁止清除文件:防止目标切换/模式切换在上传期间清空文件引用,
|
||||
// 导致进度条被重置为"等待选择文件 0%"并中断上传数据源。
|
||||
if (state.uploading || state.autoFlashing) {
|
||||
if (isBleUploading()) {
|
||||
bleLog('FILE', '上传进行中,拒绝清除文件', 'W');
|
||||
return;
|
||||
}
|
||||
@@ -184,32 +201,23 @@ export function clearSelectedFile() {
|
||||
state.fileSha256 = null;
|
||||
state.deviceSha256 = null;
|
||||
if (els.firmwareFile) els.firmwareFile.value = '';
|
||||
updateFileInfo(null);
|
||||
updateFileInfo(null); // 内部 finalizeFileInfo(null) 已重置进度条并恢复「连接就绪」stage
|
||||
hideBleVerifyInfo();
|
||||
// Reset stages back to connection-ready state
|
||||
if (state.server) {
|
||||
resetBleStages();
|
||||
setBleStage(3, '', null);
|
||||
setBleStageText('BLE 连接就绪,请选择固件文件');
|
||||
}
|
||||
bleLog('FILE', '文件已清除');
|
||||
}
|
||||
|
||||
// 别名导出,与 window.bleOta.clearFile 对齐
|
||||
export { clearSelectedFile as clearFile };
|
||||
|
||||
/**
|
||||
* 外部文件变更通知(index.html 拖拽 / console-mode 切换等)。
|
||||
* ★ 守卫集中在此:file.js 是 BLE 文件操作唯一入口,外部包装层(ble_ota.js)不再重复守卫。
|
||||
*/
|
||||
export async function notifyFileChange(file, handle) {
|
||||
// ★ 上传进行中拒绝外部文件变更通知:防止 index.html 的 syncBleFileFromSelectedTarget
|
||||
// 在上传期间调用本函数 → updateFileInfo → setProgress(0, ...) 覆盖上传进度。
|
||||
if (state.uploading || state.autoFlashing) {
|
||||
if (isAnyChannelUploading()) {
|
||||
bleLog('FILE', '上传进行中,忽略外部 notifyFileChange', 'D');
|
||||
return;
|
||||
}
|
||||
// ★ WiFi 通道上传期间同样拒绝:setConsoleMode('ble') 等外部触发会覆盖 WiFi 进度
|
||||
if (typeof isUploading !== 'undefined' && isUploading) {
|
||||
bleLog('FILE', 'WiFi 上传进行中,忽略外部 notifyFileChange', 'D');
|
||||
return;
|
||||
}
|
||||
if (file) {
|
||||
if (handle) state.selectedFileHandle = handle;
|
||||
await updateFileInfo(file);
|
||||
|
||||
+33
-38
@@ -29,7 +29,7 @@ import {
|
||||
currentTarget,
|
||||
sleep,
|
||||
} from './state.js';
|
||||
import { setBleStage, setBleStageError, setBleStageText, resetBleStages } from './stages.js';
|
||||
import { setBleStage, setBleStageError, setBleStageText, setBleStageStatus, resetBleStages } from './stages.js';
|
||||
import { setProgress, updateProgressFromAck } from './transfer.js';
|
||||
import { showBleVerifyInfo } from './verify.js';
|
||||
import {
|
||||
@@ -44,6 +44,23 @@ import {
|
||||
import { formatBytes } from '../common/utils.js';
|
||||
import { showToast } from '../common/toast.js';
|
||||
|
||||
/**
|
||||
* @description 中止当前 ACK 等待 Promise,并复位重连状态。
|
||||
* 用于所有"错误类 FINISH"和"用户中止"路径,避免 ackWaiter 悬挂。
|
||||
*/
|
||||
function abortAckWaiter(reason) {
|
||||
if (state.ackWaiter) {
|
||||
const waiter = state.ackWaiter;
|
||||
state.ackWaiter = null;
|
||||
clearTimeout(waiter.timer);
|
||||
waiter.reject(new Error(reason));
|
||||
}
|
||||
state.abortRequested = true;
|
||||
state.expectedReboot = false;
|
||||
state.reconnecting = false;
|
||||
state.reconnectAttempts = 0;
|
||||
}
|
||||
|
||||
/** @description 连接 BLE 设备(扫描→连接→发现服务→使能通知)。 */
|
||||
export async function connectBle() {
|
||||
if (!navigator.bluetooth) {
|
||||
@@ -199,9 +216,10 @@ export function handleNotify(event) {
|
||||
updateProgressFromAck(state.activeTotalSize, state.activePacketTotal, state.lastAckBytes);
|
||||
}
|
||||
|
||||
/* 传输期少打日志:整窗 ACK 频繁时 hex 刷屏拖慢 UI/主线程 */
|
||||
/* 传输期少打日志:整窗 ACK 频繁时 hex 刷屏拖慢 UI/主线程。
|
||||
* ★ 降为 D 级:成功路径默认不显示,需要时切过滤级查看。 */
|
||||
if (!state.uploading || (ackSeq % 16) === 0 || ackSeq < 2) {
|
||||
bleLog('ACK', `cmd=0x${ackCmd.toString(16).padStart(2, '0').toUpperCase()} seq=${ackSeq} bytes=${state.lastAckBytes} (${formatBytes(state.lastAckBytes)})`);
|
||||
bleLog('ACK', `cmd=0x${ackCmd.toString(16).padStart(2, '0').toUpperCase()} seq=${ackSeq} bytes=${state.lastAckBytes} (${formatBytes(state.lastAckBytes)})`, 'D');
|
||||
}
|
||||
|
||||
// ★ 放宽匹配条件:只要 cmd 匹配 + generation 匹配 + 字节数足够,即接受 ACK
|
||||
@@ -269,10 +287,7 @@ export function handleNotify(event) {
|
||||
if (typeof markPhaseDone === 'function') markPhaseDone('gd32Ota');
|
||||
setProgress(100, '[GD32] OTA 转发完成,APP 已启动');
|
||||
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';
|
||||
if (gd32Text) gd32Text.textContent = 'GD32 刷写完成';
|
||||
setBleStageStatus('ok', 'GD32 刷写完成');
|
||||
state.lastUploadStatus = 'success';
|
||||
showToast('GD32 OTA 刷写完成', 'success');
|
||||
resolveOtaResult('success', { stage: stage, value: value });
|
||||
@@ -283,21 +298,11 @@ export function handleNotify(event) {
|
||||
// 中止可由用户主动点"中止"按钮(abortTask 发 0xFB)触发,
|
||||
// 也可能是设备端因错误自动中止。两种情况都走同一清理路径。
|
||||
if (stage === BLE_OTA_FINISH_ABORTED) {
|
||||
state.abortRequested = true;
|
||||
state.expectedReboot = false;
|
||||
state.reconnecting = false;
|
||||
state.reconnectAttempts = 0;
|
||||
state.lastUploadStatus = 'aborted';
|
||||
setProgress(0, 'OTA 已中止');
|
||||
setBleStageError('OTA 已中止');
|
||||
showToast('OTA 已中止(BLE 保持连接)', 'warning');
|
||||
// 解除任何等待 ACK / OTA 结果的 Promise
|
||||
if (state.ackWaiter) {
|
||||
const waiter = state.ackWaiter;
|
||||
state.ackWaiter = null;
|
||||
clearTimeout(waiter.timer);
|
||||
waiter.reject(new Error('OTA 已中止'));
|
||||
}
|
||||
abortAckWaiter('OTA 已中止');
|
||||
rejectOtaResult('OTA 已中止', { stage: stage, value: value });
|
||||
return;
|
||||
}
|
||||
@@ -328,10 +333,7 @@ export function handleNotify(event) {
|
||||
state.expectedReboot = true;
|
||||
setBleStage(finalizeStage, '设备重启', null);
|
||||
// ★ 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 刷写完成,等待重启';
|
||||
setBleStageStatus('ok', 'ESP32 刷写完成,等待重启');
|
||||
if (typeof markPhaseDone === 'function') {
|
||||
markPhaseDone('esp32Ota');
|
||||
}
|
||||
@@ -393,16 +395,7 @@ export function handleNotify(event) {
|
||||
}
|
||||
// 错误类 FINISH 事件立即中断 waitForAck,并中止发送循环
|
||||
if (stage >= 0x80) {
|
||||
state.abortRequested = true; // ★ 阻止 fire-and-forget 继续发包
|
||||
state.expectedReboot = false; // ★ 刷写失败,取消任何重连退避
|
||||
state.reconnecting = false;
|
||||
state.reconnectAttempts = 0;
|
||||
if (state.ackWaiter) {
|
||||
const waiter = state.ackWaiter;
|
||||
state.ackWaiter = null;
|
||||
clearTimeout(waiter.timer);
|
||||
waiter.reject(new Error(`设备端 FINISH 错误: stage=0x${stage.toString(16).padStart(2, '0').toUpperCase()} value=${value}`));
|
||||
}
|
||||
abortAckWaiter(`设备端 FINISH 错误: stage=0x${stage.toString(16).padStart(2, '0').toUpperCase()} value=${value}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -421,7 +414,7 @@ export function handleNotify(event) {
|
||||
return b.toString(16).padStart(2, '0');
|
||||
}).join('').toUpperCase();
|
||||
state.deviceSha256 = devSha;
|
||||
bleLog('VERIFY', '设备端 SHA-256: ' + devSha);
|
||||
bleLog('VERIFY', '设备端 SHA-256: ' + devSha, 'D');
|
||||
|
||||
// 设备端上报 SHA-256 后,前端仅做展示与状态同步。
|
||||
var frontSha = state.fileSha256;
|
||||
@@ -431,10 +424,7 @@ export function handleNotify(event) {
|
||||
showBleVerifyInfo(frontSha, devSha, { text: '校验通过 — SHA-256 一致', ok: true });
|
||||
setBleStage(verifyStage, 'SHA-256 ✓', null);
|
||||
// ★ 校验通过 = 刷写成功: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 校验通过';
|
||||
setBleStageStatus('ok', '刷写成功 — SHA-256 校验通过');
|
||||
if (typeof markPhaseDone === 'function') {
|
||||
markPhaseDone(state.uploadTargetKey === 'gd32' ? 'gd32Ota' : 'esp32Ota');
|
||||
}
|
||||
@@ -471,9 +461,14 @@ export async function tryReconnect() {
|
||||
state.reconnecting = true;
|
||||
state.reconnectAttempts += 1;
|
||||
var attempt = state.reconnectAttempts;
|
||||
// 退避:1s, 2s, 4s, 8s, 上限 10s
|
||||
// 退避:约 1s / 1.6s / 2.6s / 4.1s / 6.6s / 10.5s…,上限 10s
|
||||
var backoff = Math.min(10000, 1000 * Math.pow(1.6, attempt - 1));
|
||||
if (backoff > 200) await sleep(backoff);
|
||||
// ★ 退避期间用户可能已手动断开(disconnectBle 会清 expectedReboot),需二次校验避免幽灵重连
|
||||
if (!state.expectedReboot || !state.device) {
|
||||
state.reconnecting = false;
|
||||
return;
|
||||
}
|
||||
if (typeof connectionMode !== 'undefined' && connectionMode === 'ble') {
|
||||
if (els.connState) els.connState.textContent = '重连中(' + attempt + ')';
|
||||
if (els.connState && els.connState.parentElement) els.connState.parentElement.className = 'pill processing';
|
||||
|
||||
+78
-24
@@ -4,11 +4,37 @@ export var bleStageIds = ['bs1','bs2','bs3','bs4','bs5','bs6'];
|
||||
export var bleStageDetailIds = ['bsd1','bsd2','bsd3','bsd4','bsd5','bsd6'];
|
||||
export var bleStageLabelIds = ['bsl5', 'bsl6'];
|
||||
|
||||
/* 状态栏 DOM 元素懒缓存(BLE OTA 高频调用路径用,避免每次 getElementById)。
|
||||
* DOM 未就绪或节点被外部重建时回退到 getElementById(与 ota/progress.js 的 setProgress 同款模式)。 */
|
||||
var stageStatusBarEl = null;
|
||||
var stageStatusTextEl = null;
|
||||
/* 当前激活阶段序号(1-based,0=无激活)。由 setBleStage 维护,供 setBleStageError
|
||||
* 直接定位错误节点,避免依赖 className 字符串匹配(脆弱且易被外部改动打破)。 */
|
||||
var currentBleStageIndex = 0;
|
||||
|
||||
function getStageStatusBar() {
|
||||
if (!stageStatusBarEl || !stageStatusBarEl.isConnected) {
|
||||
stageStatusBarEl = document.getElementById('stageStatusBar');
|
||||
}
|
||||
return stageStatusBarEl;
|
||||
}
|
||||
|
||||
function getStageStatusText() {
|
||||
if (!stageStatusTextEl || !stageStatusTextEl.isConnected) {
|
||||
stageStatusTextEl = document.getElementById('stageStatusText');
|
||||
}
|
||||
return stageStatusTextEl;
|
||||
}
|
||||
|
||||
export function updateBleStageLabels(cfg) {
|
||||
/* JS 兜底默认值与 constants.js TARGETS.esp32/gd32 的 stage5Label/stage6Label 对齐:
|
||||
* esp32: stage5Label='校验比对', stage6Label='完成触发'
|
||||
* gd32: stage5Label='完成触发', stage6Label='校验比对'
|
||||
* 实际 cfg 由 currentTarget() 返回,始终带 label,此分支仅为防御性兜底。 */
|
||||
var stage5 = document.getElementById(bleStageLabelIds[0]);
|
||||
var stage6 = document.getElementById(bleStageLabelIds[1]);
|
||||
if (stage5) stage5.textContent = cfg.stage5Label || '完成触发';
|
||||
if (stage6) stage6.textContent = cfg.stage6Label || '校验比对';
|
||||
if (stage5) stage5.textContent = cfg.stage5Label || '校验比对';
|
||||
if (stage6) stage6.textContent = cfg.stage6Label || '完成触发';
|
||||
}
|
||||
|
||||
export function resetBleStages() {
|
||||
@@ -20,15 +46,34 @@ export function resetBleStages() {
|
||||
var el = document.getElementById(id);
|
||||
if (el) el.textContent = '';
|
||||
});
|
||||
var bar = document.getElementById('stageStatusBar');
|
||||
if (bar) { bar.className = 'stage-status-bar'; bar.innerHTML = '<span class="ssb-dot"></span><span class="ssb-text" id="stageStatusText">待机</span>'; }
|
||||
currentBleStageIndex = 0;
|
||||
/* ★ 只改 className + textContent,不重写 innerHTML:
|
||||
* 1) 保留 #stageStatusDot(CSS dotPulse 动画依赖该节点,重写后 id 丢失);
|
||||
* 2) 保留 #stageStatusText 节点引用,避免外部缓存引用失效;
|
||||
* 3) 与 ota/progress.js、ota/stages.js、app.js 的状态栏操作约定一致。 */
|
||||
var bar = getStageStatusBar();
|
||||
if (bar) bar.className = 'stage-status-bar';
|
||||
var txt = getStageStatusText();
|
||||
if (txt) txt.textContent = '待机';
|
||||
}
|
||||
|
||||
export function setBleStage(stageNum, detail, barState) {
|
||||
// stageNum: 1-6, detail: sub-step text
|
||||
// 终态 stageNum===6:全部 done(绿);中间步当前为 live(蓝)
|
||||
var finished = stageNum >= 6;
|
||||
for (var i = 0; i < bleStageIds.length; i++) {
|
||||
// 终态 stageNum===末步:全部 done(绿);中间步当前为 live(蓝)
|
||||
var stageCount = bleStageIds.length;
|
||||
// 边界守卫:stageNum<1 直接清空所有节点(不推进 currentBleStageIndex),避免误判 active
|
||||
if (!Number.isFinite(stageNum) || stageNum < 1) {
|
||||
bleStageIds.forEach(function(id) {
|
||||
var el = document.getElementById(id);
|
||||
if (el) el.className = 'stage-node';
|
||||
});
|
||||
currentBleStageIndex = 0;
|
||||
return;
|
||||
}
|
||||
if (stageNum > stageCount) stageNum = stageCount;
|
||||
currentBleStageIndex = stageNum;
|
||||
var finished = stageNum >= stageCount;
|
||||
for (var i = 0; i < stageCount; i++) {
|
||||
var el = document.getElementById(bleStageIds[i]);
|
||||
if (!el) continue;
|
||||
if (i + 1 < stageNum) el.className = 'stage-node done';
|
||||
@@ -36,34 +81,43 @@ export function setBleStage(stageNum, detail, barState) {
|
||||
else el.className = 'stage-node';
|
||||
}
|
||||
// Set detail text
|
||||
if (stageNum >= 1 && stageNum <= 6) {
|
||||
if (stageNum >= 1 && stageNum <= stageCount) {
|
||||
var detailEl = document.getElementById(bleStageDetailIds[stageNum - 1]);
|
||||
if (detailEl && detail !== undefined) detailEl.textContent = detail;
|
||||
}
|
||||
// Update status bar
|
||||
var bar = document.getElementById('stageStatusBar');
|
||||
var bar = getStageStatusBar();
|
||||
if (bar) {
|
||||
var cls = barState || (finished ? 'ok' : (stageNum > 0 ? 'active' : ''));
|
||||
var cls = barState || (finished ? 'ok' : 'active');
|
||||
bar.className = 'stage-status-bar' + (cls ? ' ' + cls : '');
|
||||
}
|
||||
}
|
||||
|
||||
export function setBleStageText(text) {
|
||||
var txt = document.getElementById('stageStatusText');
|
||||
var txt = getStageStatusText();
|
||||
if (txt) txt.textContent = text;
|
||||
}
|
||||
|
||||
export function setBleStageError(detail) {
|
||||
// Mark current active stage as error
|
||||
for (var i = 0; i < bleStageIds.length; i++) {
|
||||
var el = document.getElementById(bleStageIds[i]);
|
||||
if (el && el.className === 'stage-node live') {
|
||||
el.className = 'stage-node err';
|
||||
var detailEl = document.getElementById(bleStageDetailIds[i]);
|
||||
if (detailEl && detail) detailEl.textContent = detail;
|
||||
}
|
||||
}
|
||||
var bar = document.getElementById('stageStatusBar');
|
||||
if (bar) bar.className = 'stage-status-bar err';
|
||||
setBleStageText('错误: ' + (detail || ''));
|
||||
/** @description 一次性更新状态栏的样式与文本(避免每次通知都重复 getElementById)。 */
|
||||
export function setBleStageStatus(state, text) {
|
||||
var bar = getStageStatusBar();
|
||||
if (bar) bar.className = 'stage-status-bar' + (state ? ' ' + state : '');
|
||||
var txt = getStageStatusText();
|
||||
if (txt && text !== undefined) txt.textContent = text;
|
||||
}
|
||||
|
||||
export function setBleStageError(detail) {
|
||||
// 直接用 currentBleStageIndex 定位错误节点(由 setBleStage 维护),
|
||||
// 不再依赖 className === 'stage-node live' 字符串匹配——后者在 className
|
||||
// 顺序变化或被外部改动时会漏标错误阶段。
|
||||
var stageCount = bleStageIds.length;
|
||||
if (currentBleStageIndex >= 1 && currentBleStageIndex <= stageCount) {
|
||||
var el = document.getElementById(bleStageIds[currentBleStageIndex - 1]);
|
||||
if (el) el.className = 'stage-node err';
|
||||
var detailEl = document.getElementById(bleStageDetailIds[currentBleStageIndex - 1]);
|
||||
if (detailEl && detail) detailEl.textContent = detail;
|
||||
}
|
||||
var bar = getStageStatusBar();
|
||||
if (bar) bar.className = 'stage-status-bar err';
|
||||
setBleStageText('错误: ' + (detail || '未知错误'));
|
||||
}
|
||||
+17
-15
@@ -71,6 +71,11 @@ export function currentTarget() {
|
||||
return TARGETS[window.flashTarget] || TARGETS.esp32;
|
||||
}
|
||||
|
||||
/** BLE 通道是否正在上传/自动刷写。集中判定,避免各处重复 state.uploading || state.autoFlashing。 */
|
||||
export function isBleUploading() {
|
||||
return !!(state.uploading || state.autoFlashing);
|
||||
}
|
||||
|
||||
export function currentPayloadSize() {
|
||||
const cfg = currentTarget();
|
||||
return Math.max(32, Math.min(498, Number(cfg.recommendedPayload) || 480));
|
||||
@@ -110,26 +115,23 @@ export function setReadyHint(html) {
|
||||
|
||||
export function setConnectionState(connected) {
|
||||
const idle = connected && state.selectedFile && !state.uploading;
|
||||
// 仅在 BLE 模式下更新共享连接指示器,避免覆盖 WiFi 轮询的状态
|
||||
// 仅在 BLE 模式下更新共享连接指示器与 BLE 专用徽章,避免覆盖 WiFi 轮询的状态
|
||||
if (typeof connectionMode !== 'undefined' && connectionMode === 'ble') {
|
||||
if (els.connStatus) els.connStatus.className = connected ? 'conn-indicator ok' : 'conn-indicator err';
|
||||
if (els.connStatusTxt) els.connStatusTxt.textContent = connected ? 'BLE 已连接' : 'BLE 未连接';
|
||||
}
|
||||
// BLE 专用连接徽章 — 仅在 BLE 模式下更新,避免覆盖 WiFi 状态
|
||||
if (typeof connectionMode !== 'undefined' && connectionMode === 'ble') {
|
||||
if (els.connState) els.connState.textContent = connected ? '已连接' : '未连接';
|
||||
if (els.connState && els.connState.parentElement) {
|
||||
els.connState.parentElement.className = connected ? 'pill online' : 'pill offline';
|
||||
if (els.connState) {
|
||||
els.connState.textContent = connected ? '已连接' : '未连接';
|
||||
if (els.connState.parentElement) {
|
||||
els.connState.parentElement.className = connected ? 'pill online' : 'pill offline';
|
||||
}
|
||||
}
|
||||
els.connBadge.textContent = connected ? 'online' : 'offline';
|
||||
els.connBadge.className = `v ${connected ? 'ok' : 'bad'}`;
|
||||
els.gattState.textContent = connected ? 'GATT 已就绪' : '未就绪';
|
||||
}
|
||||
if (connected) {
|
||||
els.connectBtn.disabled = true;
|
||||
} else {
|
||||
els.connectBtn.disabled = false;
|
||||
if (els.connBadge) {
|
||||
els.connBadge.textContent = connected ? 'online' : 'offline';
|
||||
els.connBadge.className = `v ${connected ? 'ok' : 'bad'}`;
|
||||
}
|
||||
if (els.gattState) els.gattState.textContent = connected ? 'GATT 已就绪' : '未就绪';
|
||||
}
|
||||
els.connectBtn.disabled = connected;
|
||||
els.disconnectBtn.disabled = !connected;
|
||||
els.autoFlashBtn.disabled = !idle;
|
||||
els.abortBtn.disabled = !state.uploading;
|
||||
|
||||
+22
-19
@@ -55,6 +55,16 @@ function buildPacket(seq, totalSize, payload, cmd) {
|
||||
return packet;
|
||||
}
|
||||
|
||||
/** @description 将 64 字符十六进制 SHA-256 字符串转为 32 字节数组。 */
|
||||
function sha256HexToBytes(shaHex) {
|
||||
const hex = shaHex.toUpperCase();
|
||||
const out = new Uint8Array(32);
|
||||
for (let i = 0; i < 32; i++) {
|
||||
out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function waitForAck(cmd, expectedSeq, timeoutMs = 8000, opts = {}) {
|
||||
// ★ 中止快速退出:若 0x20 ABORTED 已先于本调用到达(ackWaiter 当时为 null),
|
||||
// 这里立即 reject,避免在已中止的会话上盲等超时。
|
||||
@@ -101,7 +111,7 @@ export async function uploadFirmware() {
|
||||
const freshFile = await state.selectedFileHandle.getFile();
|
||||
await updateFileInfo(freshFile);
|
||||
var actualFile = state.selectedFile || freshFile;
|
||||
bleLog('FILE', '已重新读取本地文件: ' + actualFile.name + ' (' + formatBytes(actualFile.size) + ')');
|
||||
bleLog('FILE', '已重新读取本地文件: ' + actualFile.name + ' (' + formatBytes(actualFile.size) + ')', 'D');
|
||||
} catch (e) {
|
||||
bleLog('FILE', '重新读取文件失败: ' + e.message + ',将使用缓存版本', 'W');
|
||||
}
|
||||
@@ -151,13 +161,13 @@ export async function uploadFirmware() {
|
||||
throw new Error('DATA_OUT 通知未开启,无法使用 ACK 模式');
|
||||
}
|
||||
|
||||
bleLog('OTA', `开始准备固件: target=${cfg.label}, file=${file.name}, payload=${payloadSize}, cmd=0x${cfg.packetCmd.toString(16).padStart(2, '0').toUpperCase()}, ackWindow=${BLE_OTA_ACK_WINDOW}`);
|
||||
bleLog('OTA', `开始准备固件: target=${cfg.label}, file=${file.name}, payload=${payloadSize}, cmd=0x${cfg.packetCmd.toString(16).padStart(2, '0').toUpperCase()}, ackWindow=${BLE_OTA_ACK_WINDOW}`, 'D');
|
||||
setProgress(0, `[${cfg.label}] 正在读取固件文件...`);
|
||||
setBleStage(4, '读取固件...', null);
|
||||
const fileBytes = new Uint8Array(await file.arrayBuffer());
|
||||
const totalSize = fileBytes.length;
|
||||
const packetTotal = Math.ceil(totalSize / payloadSize);
|
||||
bleLog('OTA', `固件已读取: totalSize=${totalSize}, packetTotal=${packetTotal}, file.size=${file.size}`);
|
||||
bleLog('OTA', `固件已读取: totalSize=${totalSize}, packetTotal=${packetTotal}, file.size=${file.size}`, 'D');
|
||||
if (totalSize !== file.size) {
|
||||
bleLog('OTA', `⚠️ file.size(${file.size}) ≠ arrayBuffer(${totalSize}),以 arrayBuffer 为准,请确认页面已 Ctrl+F5 刷新`, 'W');
|
||||
}
|
||||
@@ -169,7 +179,7 @@ export async function uploadFirmware() {
|
||||
fileSha256 = state.fileSha256.toLowerCase();
|
||||
} else {
|
||||
// 极端兜底:state.fileSha256 未初始化(理论上 updateFileInfo 总会先设置)
|
||||
bleLog('OTA', '正在计算文件 SHA-256 哈希...');
|
||||
bleLog('OTA', '正在计算文件 SHA-256 哈希...', 'D');
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', fileBytes.buffer);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
fileSha256 = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
@@ -186,7 +196,7 @@ export async function uploadFirmware() {
|
||||
state.activePacketTotal = packetTotal;
|
||||
els.sentCount.textContent = '0';
|
||||
setProgress(0, `[${cfg.label}] 等待设备确认 0/${packetTotal} 包`);
|
||||
bleLog('OTA', `开始写入 DATA_IN: build=${UI_BUILD}, target=${cfg.label}, file=${file.name}, total=${totalSize}, packets=${packetTotal}, payload=${payloadSize}, windowAck=${BLE_OTA_ACK_WINDOW}`);
|
||||
bleLog('OTA', `开始写入 DATA_IN: build=${UI_BUILD}, target=${cfg.label}, file=${file.name}, total=${totalSize}, packets=${packetTotal}, payload=${payloadSize}, windowAck=${BLE_OTA_ACK_WINDOW}`, 'D');
|
||||
|
||||
const MAX_WINDOW_RETRIES = 3;
|
||||
// 整分区预擦可长达数十秒;首包 ACK 超时必须覆盖 begin/erase
|
||||
@@ -214,7 +224,7 @@ export async function uploadFirmware() {
|
||||
if (seq === 0) {
|
||||
try {
|
||||
await state.dataInChar.writeValueWithResponse(packet);
|
||||
bleLog('OTA', 'seq=0 已用 writeValueWithResponse 确认送达,等待设备 begin/预擦完成…', 'I');
|
||||
bleLog('OTA', 'seq=0 已用 writeValueWithResponse 确认送达,等待设备 begin/预擦完成…', 'D');
|
||||
setBleStageText('设备预擦 Flash 中,请等待…');
|
||||
} catch (e) {
|
||||
bleLog('OTA', `seq=0 写入失败: ${e.message}`, 'E');
|
||||
@@ -233,7 +243,7 @@ export async function uploadFirmware() {
|
||||
if (state.lastAckBytes < firstBytes) {
|
||||
throw new Error('设备 begin 就绪 ACK 字节不足: got=' + state.lastAckBytes + ' need=' + firstBytes);
|
||||
}
|
||||
bleLog('OTA', '设备 begin 就绪 (ackBytes=' + state.lastAckBytes + '),继续发送窗口数据', 'I');
|
||||
bleLog('OTA', '设备 begin 就绪 (ackBytes=' + state.lastAckBytes + '),继续发送窗口数据', 'D');
|
||||
setBleStageText('正在写入 ' + packetTotal + ' 包数据...');
|
||||
continue;
|
||||
}
|
||||
@@ -315,7 +325,6 @@ export async function uploadFirmware() {
|
||||
} catch (e) {
|
||||
bleLog('OTA', `重试 ACK 等待失败: ${e.message}`, 'W');
|
||||
}
|
||||
expectedBytes = Math.min((endSeq + 1) * payloadSize, totalSize);
|
||||
}
|
||||
|
||||
if (state.lastAckBytes < expectedBytes) {
|
||||
@@ -340,10 +349,7 @@ export async function uploadFirmware() {
|
||||
if (cfg.label === 'ESP32') {
|
||||
state.deviceSha256 = null;
|
||||
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 frontShaBytes = sha256HexToBytes(fileSha256);
|
||||
const trigger = new Uint8Array(35);
|
||||
trigger[0] = 0xF8;
|
||||
trigger.set(frontShaBytes, 1);
|
||||
@@ -355,7 +361,7 @@ export async function uploadFirmware() {
|
||||
setBleStageText('发送 0xF8 + SHA-256,等待设备端校验...');
|
||||
try {
|
||||
await state.ctrlChar.writeValue(trigger);
|
||||
bleLog('OTA', '0xF8 + 32B SHA-256 已发送,等待设备端校验');
|
||||
bleLog('OTA', '0xF8 + 32B SHA-256 已发送,等待设备端校验', 'D');
|
||||
} catch (e) {
|
||||
clearOtaResultWaiter();
|
||||
state.lastUploadStatus = 'failed';
|
||||
@@ -381,10 +387,7 @@ export async function uploadFirmware() {
|
||||
|
||||
// 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);
|
||||
}
|
||||
const frontShaBytes = sha256HexToBytes(fileSha256);
|
||||
state.deviceSha256 = null;
|
||||
setBleStage(5, '发送触发...', 'active');
|
||||
setBleStageText('发送 0xF7 + SHA-256,等待设备端缓存校验...');
|
||||
@@ -402,7 +405,7 @@ export async function uploadFirmware() {
|
||||
await state.ctrlChar.writeValue(cmd);
|
||||
setBleStage(5, '已触发', null);
|
||||
setBleStage(6, '等待设备端校验', 'active');
|
||||
bleLog('OTA', `0xF7 + 32B SHA-256 已发送,等待设备端缓存校验与转发`);
|
||||
bleLog('OTA', `0xF7 + 32B SHA-256 已发送,等待设备端缓存校验与转发`, 'D');
|
||||
} catch (_) {
|
||||
clearOtaResultWaiter();
|
||||
state.lastUploadStatus = 'failed';
|
||||
@@ -478,7 +481,7 @@ export async function autoFlash() {
|
||||
if (changed) {
|
||||
await updateFileInfo(freshFile);
|
||||
var actualFile = state.selectedFile || freshFile;
|
||||
bleLog('FILE', '已重新读取本地文件(已变更): ' + actualFile.name + ' (' + formatBytes(actualFile.size) + ')');
|
||||
bleLog('FILE', '已重新读取本地文件(已变更): ' + actualFile.name + ' (' + formatBytes(actualFile.size) + ')', 'D');
|
||||
}
|
||||
} catch (e) {
|
||||
bleLog('FILE', '重新读取文件失败: ' + e.message + ',将使用缓存版本', 'W');
|
||||
|
||||
+10
-10
@@ -4,18 +4,18 @@ export function showBleVerifyInfo(frontSha, devSha, result) {
|
||||
var panel = document.getElementById('stageVerifyInfo');
|
||||
if (panel) { panel.className = 'stage-verify-info'; panel.style.display = 'block'; }
|
||||
if (frontSha !== undefined) {
|
||||
var el = document.getElementById('sviFrontSha');
|
||||
if (el) el.textContent = frontSha || '-';
|
||||
var frontEl = document.getElementById('sviFrontSha');
|
||||
if (frontEl) frontEl.textContent = frontSha || '-';
|
||||
}
|
||||
if (devSha !== undefined) {
|
||||
var el = document.getElementById('sviDeviceSha');
|
||||
if (el) el.textContent = devSha || '-';
|
||||
var devEl = document.getElementById('sviDeviceSha');
|
||||
if (devEl) devEl.textContent = devSha || '-';
|
||||
}
|
||||
if (result !== undefined) {
|
||||
var el = document.getElementById('sviResult');
|
||||
if (el) {
|
||||
el.textContent = result.text || '-';
|
||||
el.className = 'svi-val' + (result.ok ? ' ok' : result.err ? ' err' : '');
|
||||
if (result) {
|
||||
var resultEl = document.getElementById('sviResult');
|
||||
if (resultEl) {
|
||||
resultEl.textContent = result.text || '-';
|
||||
resultEl.className = 'svi-val' + (result.ok ? ' ok' : result.err ? ' err' : '');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -23,4 +23,4 @@ export function showBleVerifyInfo(frontSha, devSha, result) {
|
||||
export function hideBleVerifyInfo() {
|
||||
var panel = document.getElementById('stageVerifyInfo');
|
||||
if (panel) { panel.className = 'stage-verify-info'; panel.style.display = 'none'; }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user