init: DCU ESP32 调试控制台前端与本地页面服务

纯前端上位机界面 + esp32_server.py 静态服务/桌面窗口外壳, 不含固件、
不直接访问硬件; 业务 API 由 ESP32_COMM 固件 80 端口跨域提供。

- tabs.html 单窗口多标签外壳, iframe 承载各功能页
- index.html OTA 中继(ESP32 WiFi / GD32 relay / BLE), js/ota + js/ble
- can.html / can_monitor.html CAN 通信与接收监控, js/can
- motor.html 电机 CAN FD 控制台, js/motor
- continuity_test.html LAN9252 通断测试, spi_test.html SPI 测速(临时, 自包含)
- 三入口: esp32_app.vbs 桌面窗口 / esp32_web.vbs 浏览器 / serve.bat 控制台调试
- 补 README(页面与目录约定、?v= 缓存版本等改动须知)、.gitignore、
  .gitattributes(* -text 保持 Windows 换行原样)
This commit is contained in:
cbb
2026-09-03 21:30:32 +08:00
commit d2c86c425a
54 changed files with 20643 additions and 0 deletions
+60
View File
@@ -0,0 +1,60 @@
// ==================== BLE OTA 协议常量 ====================
// 与 WiFi OTA 共享界面元素:进度条、阶段流程、日志面板、连接状态、文件选择。
// 仅传输层不同 (GATT vs HTTP)。通过 connectionMode 变量判断当前模式。
export const UUIDS = {
service: 0xFFFF,
ctrl: 0x0001,
dataIn: 0x0002,
dataOut: 0x0003,
version: 0x0004,
};
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;
// ★ 须与后端 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;
export const BLE_OTA_FINISH_GD32_RELAY_DONE = 0x11;
export const BLE_OTA_FINISH_ABORTED = 0x20; // 用户主动中止(BLE 不断开)
export const BLE_OTA_CTRL_ABORT_OTA = 0xFB; // 中止当前 OTA(不断开 BLE)
export const BLE_OTA_FINISH_ERR_FINALIZE = 0x81;
export const BLE_OTA_FINISH_ERR_MUTEX = 0x83;
export const BLE_OTA_FINISH_ERR_GD32_SIZE = 0x84;
export const BLE_OTA_FINISH_ERR_SHA_MISMATCH = 0x85;
export const BLE_OTA_FINISH_ERR_GD32_RELAY = 0x86;
export const BLE_OTA_FINISH_ERR_FW_IDENTITY = 0x87; // 包身份不符(疑似 Bootloader/非 APP 包),拒绝中继
export const TARGETS = {
gd32: {
label: 'GD32',
packetCmd: 0x00,
ctrlHex: 'F7',
recommendedPayload: 480,
finishText: '自动触发中继',
hint: '当前目标:<b>GD32</b>。页面会使用 <b>cmd=0x00</b> 发送数据,写入完成后自动触发 SPI OTA 中继。',
verifyStage: 6,
finalizeStage: 5,
stage5Label: '完成触发',
stage6Label: '校验比对'
},
esp32: {
label: 'ESP32',
packetCmd: 0x01,
ctrlHex: 'F8',
recommendedPayload: 480,
finishText: '自动完成 OTA',
hint: '当前目标:ESP32。页面会使用 <b>cmd=0x01</b> 发送数据,写入完成后发 <b>0xF8</b> + 32 字节 SHA-256 触发设备端收尾:设备端读 OTA 分区算 SHA 并比对,一致则 end+set_boot+reboot;不一致上报 0x85,不重启。',
verifyStage: 5,
finalizeStage: 6,
stage5Label: '校验比对',
stage6Label: '完成触发'
}
};
export const MAX_LOG_LINES = 1000;
+253
View File
@@ -0,0 +1,253 @@
// ==================== BLE OTA 文件准备与分包 ====================
//
// ★ 上传中拦截策略(全文件统一):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 { showToast } from '../common/toast.js';
import { computeFileSha, checkGd32ImageFile } from '../ota/file-handle.js';
/** 任一通道上传中(BLE 或 WiFi)。focus/change 等不分通道的事件用此判定。 */
function isAnyChannelUploading() {
return isBleUploading() || (typeof window.isUploading !== 'undefined' && !!window.isUploading);
}
export function updateFileInfo(file) {
// ★ 上传进行中禁止重置进度/阶段
if (isBleUploading() && file && state.selectedFile) {
bleLog('FILE', '上传进行中,跳过 updateFileInfo 进度重置', 'D');
return Promise.resolve();
}
els.fileName.textContent = file ? file.name : '-';
els.fileType.textContent = file ? (file.name.toLowerCase().includes('.hex') ? 'Intel HEX' : 'Binary') : '-';
els.fileSize.textContent = file ? formatBytes(file.size) : '0 B';
const payloadSize = currentPayloadSize();
const packets = file ? Math.ceil(file.size / payloadSize) : 0;
els.packetCount.textContent = String(packets);
els.sentCount.textContent = '0';
state.selectedFile = file || null;
state.lastUploadStatus = 'idle';
if (els.fileInfo) {
if (file) els.fileInfo.classList.add('show');
else els.fileInfo.classList.remove('show');
}
// ★ HEX → BIN:必须用 window.convertHexFileToBin(本模块未 import,裸名会 ReferenceError)
if (file && file.name.toLowerCase().endsWith('.hex') && typeof window.convertHexFileToBin === 'function') {
return window.convertHexFileToBin(file).then(function(result) {
if (!result.converted) {
bleLog('FILE', file.name + ' 扩展名为 .hex 但内容不像 HEX 格式,按原始文件处理', 'W');
finalizeAndHash(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');
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);
finalizeAndHash(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);
});
}
finalizeAndHash(file);
return Promise.resolve();
}
function finalizeAndHash(file) {
/* GD32 目标:选文件时即判定包身份,BOOT 包不进上传队列(设备端还有兜底)
file 为 null 表示"没有选文件"(清空 / focus 时 input 为空),不是身份不符,不得报错 */
var chip = (window.flashTarget === 'gd32') ? 'gd32' : 'esp32';
if (chip === 'gd32' && file) {
return checkGd32ImageFile(file).then(function (id) {
if (!id.ok) {
var msg = id.kind === 'bootloader'
? '检测到 Bootloader 固件,不能刷入 APP 区,请选择 APP 固件'
: id.kind === 'none'
? '未选择固件文件'
: '文件不是合法的 GD32 APP 固件(向量表非法),已拒绝';
bleLog('FILE', msg, 'E');
showToast(msg, 'error');
state.selectedFile = null;
state.fileSha256 = null;
els.fileName.textContent = '-';
if (els.fileInfo) els.fileInfo.classList.remove('show');
setConnectionState(!!state.server);
return;
}
finalizeFileInfo(file);
computeBleFileSha(file);
});
}
finalizeFileInfo(file);
computeBleFileSha(file);
return Promise.resolve();
}
function finalizeFileInfo(file) {
if (isBleUploading()) {
bleLog('FILE', '上传进行中,跳过 finalizeFileInfo 进度重置', 'D');
return;
}
setConnectionState(!!state.server);
if (file) {
setProgress(0, `已选择 ${file.name}`);
bleLog('FILE', `已选择: ${file.name} (${formatBytes(file.size)}), 目标=${currentTarget().label}`);
} else {
setProgress(0, '等待选择文件');
if (state.server) {
resetBleStages();
setBleStage(3, '', null);
setBleStageText('BLE 连接就绪,请选择固件文件');
}
}
}
/**
* BLE 侧:读对应芯片槽;槽里已有则直接用,没有才算一次。
* 不跨芯片、不互切。
*/
function computeBleFileSha(file) {
var shaElem = document.getElementById('fileSha256');
if (!file) {
if (shaElem) shaElem.textContent = '-';
state.fileSha256 = null;
return;
}
var chip = (window.flashTarget === 'gd32') ? 'gd32' : 'esp32';
var existing = chip === 'gd32' ? window._gd32FileSha256 : window._esp32FileSha256;
if (existing) {
state.fileSha256 = existing;
if (shaElem) shaElem.textContent = existing;
if (state.server) {
setBleStage(3, 'SHA-256 已计算', null);
setBleStage(4, '', null);
setBleStageText('文件就绪,可以开始刷写');
}
return;
}
if (state.server) {
setBleStage(3, '计算哈希...', 'active');
setBleStageText('正在计算文件 SHA-256...');
}
computeFileSha(file, chip).then(function(hashHex) {
if (!hashHex) {
state.fileSha256 = null;
return;
}
state.fileSha256 = hashHex;
if (state.server) {
setBleStage(3, 'SHA-256 已计算', null);
setBleStage(4, '', null);
setBleStageText('文件就绪,可以开始刷写');
}
});
}
export async function syncSelectedFileFromInput() {
// ★ 上传进行中禁止同步:window focus / file input change 事件会在上传期间
// 触发本函数,若不拦截会通过 updateFileInfo 把进度条重置为"等待选择文件 0%"。
// ★ 必须同时拦截 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;
// ★ 拖拽 / notifyFileChange 送进来的文件不在 input 元素里(handle 也为 null),
// focus 时 input 为空不代表用户取消了选择,不能用 null 覆盖已有选择。
if (!file && state.selectedFile) return;
await updateFileInfo(file);
}
// 通过 File System Access API 选择文件(支持热重载)
export async function pickFileViaFSAPI() {
if (!window.showOpenFilePicker) {
bleLog('FILE', '当前浏览器不支持 File System Access API', 'W');
return false;
}
try {
const pickerOpts = {
id: 'dcu-fw-ble',
types: [{ description: '固件文件', accept: { 'application/octet-stream': ['.bin', '.hex'] } }],
multiple: false
};
/* 再次选择时定位到刷新/上次已选文件所在目录 */
if (state.selectedFileHandle) {
pickerOpts.startIn = state.selectedFileHandle;
}
const handles = await window.showOpenFilePicker(pickerOpts);
if (handles && handles.length > 0) {
const handle = handles[0];
const file = await handle.getFile();
state.selectedFileHandle = handle;
await updateFileInfo(file);
bleLog('FILE', '已通过 FSAPI 选择文件(支持热重载): ' + file.name);
return true;
}
} catch (e) {
if (e.name !== 'AbortError') {
bleLog('FILE', 'FSAPI 选择失败: ' + e.message, 'W');
}
}
return false;
}
export function clearSelectedFile() {
// ★ 上传进行中禁止清除文件:防止目标切换/模式切换在上传期间清空文件引用,
// 导致进度条被重置为"等待选择文件 0%"并中断上传数据源。
if (isBleUploading()) {
bleLog('FILE', '上传进行中,拒绝清除文件', 'W');
return;
}
state.selectedFile = null;
state.selectedFileHandle = null;
state.lastUploadStatus = 'idle';
state.fileSha256 = null;
state.deviceSha256 = null;
if (els.firmwareFile) els.firmwareFile.value = '';
updateFileInfo(null); // 内部 finalizeFileInfo(null) 已重置进度条并恢复「连接就绪」stage
hideBleVerifyInfo();
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) {
if (isAnyChannelUploading()) {
bleLog('FILE', '上传进行中,忽略外部 notifyFileChange', 'D');
return;
}
if (file) {
/* 始终更新 handle(含 null),避免切目标后仍热重载到上一芯片的文件 */
state.selectedFileHandle = handle || null;
await updateFileInfo(file);
} else {
state.selectedFileHandle = null;
await updateFileInfo(null);
}
}
+564
View File
@@ -0,0 +1,564 @@
// ==================== BLE OTA 连接管理 ====================
import {
UUIDS,
BLE_OTA_NOTIFY_ACK,
BLE_OTA_NOTIFY_FINISH,
BLE_OTA_NOTIFY_SHA256,
BLE_OTA_ACK_WINDOW,
BLE_OTA_FINISH_ESP32_DONE,
BLE_OTA_FINISH_REBOOT_NOW,
BLE_OTA_FINISH_GD32_RELAY_START,
BLE_OTA_FINISH_GD32_RELAY_DONE,
BLE_OTA_FINISH_ABORTED,
BLE_OTA_FINISH_ERR_FINALIZE,
BLE_OTA_FINISH_ERR_MUTEX,
BLE_OTA_FINISH_ERR_GD32_SIZE,
BLE_OTA_FINISH_ERR_SHA_MISMATCH,
BLE_OTA_FINISH_ERR_GD32_RELAY,
BLE_OTA_FINISH_ERR_FW_IDENTITY,
TARGETS,
} from './constants.js';
import {
state,
els,
bleLog,
toHex,
setConnectionState,
resetUiAfterDisconnect,
currentPayloadSize,
flowTarget,
currentTarget,
sleep,
} from './state.js';
import { setBleStage, setBleStageError, setBleStageText, setBleStageStatus, resetBleStages } from './stages.js';
import { setProgress, updateProgressFromAck } from './transfer.js';
import { showBleVerifyInfo } from './verify.js';
import {
clearOtaResultWaiter,
resolveOtaResult,
rejectOtaResult,
finishBelongsToWaiter,
finishSessionId,
isSessionlessFinish,
hasCurrentWaiter,
} from './session.js';
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) {
bleLog('BLE', '当前浏览器不支持 Web Bluetooth', 'E');
alert('当前浏览器不支持 Web Bluetooth,请使用 Chrome / Edge。');
return;
}
try {
resetBleStages();
setBleStage(1, '扫描中...', 'active');
setBleStageText('正在打开设备选择器...');
bleLog('BLE', '打开设备选择器...');
const device = await navigator.bluetooth.requestDevice({
filters: [{ namePrefix: 'ESP32-OTA' }],
optionalServices: [UUIDS.service],
});
state.device = device;
state.device.addEventListener('gattserverdisconnected', onDisconnected, { once: true });
if (typeof connectionMode === 'undefined' || connectionMode === 'ble') {
els.deviceName.textContent = device.name || '未知设备';
els.deviceId.textContent = device.id || '-';
}
setBleStage(1, '已选择', null);
setBleStage(2, '连接GATT...', 'active');
setBleStageText('正在连接 GATT 服务...');
bleLog('BLE', `已选择设备: ${device.name || 'unknown'}`);
const server = await device.gatt.connect();
state.server = server;
setBleStage(2, '获取服务...', null);
setBleStageText('获取 GATT 服务和特征...');
state.service = await server.getPrimaryService(UUIDS.service);
state.ctrlChar = await state.service.getCharacteristic(UUIDS.ctrl);
state.dataInChar = await state.service.getCharacteristic(UUIDS.dataIn);
state.dataOutChar = await state.service.getCharacteristic(UUIDS.dataOut);
state.versionChar = await state.service.getCharacteristic(UUIDS.version);
setBleStage(2, '已就绪', null);
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 连接就绪,请选择固件文件');
} catch (err) {
bleLog('BLE', `连接失败: ${err.message}`, 'E');
if (err.name !== 'NotFoundError') {
setBleStageError(err.message);
} else {
resetBleStages();
}
resetUiAfterDisconnect();
}
}
export function onDisconnected() {
bleLog('BLE', '设备已断开', 'W');
var wasExpected = state.expectedReboot;
resetUiAfterDisconnect();
if (wasExpected && state.device) {
// 是主动重启引起的断开,启动自动重连
state.reconnecting = true;
state.reconnectAttempts = 0;
tryReconnect();
}
}
/** @description 断开 BLE 连接并清理资源。 */
export async function disconnectBle() {
// 用户主动断开:取消任何进行中的重连 / expectedReboot
state.expectedReboot = false;
state.reconnecting = false;
try {
if (state.dataOutChar && state.notificationsEnabled) {
await state.dataOutChar.stopNotifications();
}
} catch (_) {}
if (state.device && state.device.gatt && state.device.gatt.connected) {
state.device.gatt.disconnect();
}
resetUiAfterDisconnect();
}
export async function readVersion() {
try {
const value = await state.versionChar.readValue();
const text = new TextDecoder().decode(value.buffer).replace(/\0/g, '').trim() || '(空)';
if (typeof connectionMode === 'undefined' || connectionMode === 'ble') {
if (els.versionText) els.versionText.textContent = text;
// 更新头部版本显示:仅显示版本号,编译时间放在 title 中悬停显示
var pv = document.getElementById('pillVer');
if (pv) {
var spaceIdx = text.indexOf(' ');
if (spaceIdx > 0) {
pv.textContent = 'v' + text.substring(0, spaceIdx);
pv.dataset.tooltip = '编译: ' + text.substring(spaceIdx + 1);
} else {
pv.textContent = 'v' + text;
delete pv.dataset.tooltip;
}
}
}
bleLog('BLE', `版本: ${text}`);
} catch (err) {
bleLog('BLE', `读取版本失败: ${err.message}`, 'E');
}
}
/** @description 处理 BLE Notify 事件(解析 ACK/ERROR/PROGRESS 等响应类型)。 */
export function handleNotify(event) {
const view = event.target.value;
const bytes = new Uint8Array(view.buffer.slice(view.byteOffset, view.byteOffset + view.byteLength));
const hex = toHex(bytes);
state.lastNotificationHex = hex;
if (bytes.length >= 8 && bytes[0] === BLE_OTA_NOTIFY_ACK) {
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, currentPayloadSize());
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);
}
/* 传输期少打日志:整窗 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)})`, 'D');
}
// ★ 放宽匹配条件:只要 cmd 匹配 + generation 匹配 + 字节数足够,即接受 ACK
// 不再要求 ackSeq >= expectedSeq,因为 ESP32 可能用较低的 seq 发送 ACK
// 但字节数已覆盖期望位置(例如 ACK seq=255 bytes=138240 覆盖 seq=287 的数据)
if (state.ackWaiter && state.ackWaiter.cmd === ackCmd
&& state.ackWaiter.generation === state.ackGeneration) {
const waiter = state.ackWaiter;
const expectedBytes = waiter.expectedTotalSize !== undefined
? Math.min((waiter.expectedSeq + 1) * waiter.payloadSize, waiter.expectedTotalSize)
: 0;
// ★ ACK 字节数不足:立即 resolve,让重试循环立即续传,不等超时
if (expectedBytes > 0 && state.lastAckBytes < expectedBytes) {
bleLog('ACK', `ACK 字节数不足: ${state.lastAckBytes}/${expectedBytes},立即进入重试`, 'W');
state.ackWaiter = null;
clearTimeout(waiter.timer);
waiter.resolve({ seq: ackSeq, bytes: state.lastAckBytes });
return;
}
// 字节数足够,resolve
state.ackWaiter = null;
clearTimeout(waiter.timer);
waiter.resolve({ seq: ackSeq, bytes: state.lastAckBytes });
}
return;
}
if (bytes.length >= 6 && bytes[0] === BLE_OTA_NOTIFY_FINISH) {
const stage = bytes[1];
const value = (bytes[2]) | (bytes[3] << 8) | (bytes[4] << 16) | (bytes[5] << 24);
const finishMap = {
[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_ABORTED]: '[FINISH] OTA 已中止(BLE 保持连接)',
[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}`,
[BLE_OTA_FINISH_ERR_FW_IDENTITY]: value >> 16 === 1
? '[FINISH] 检测到 Bootloader 固件,已拒绝刷入 APP 区'
: '[FINISH] 固件身份校验失败(非 GD32 APP 包),已拒绝刷写',
};
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 || '-', state.deviceSha256 || '等待设备端上报...', { 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 || '-', state.deviceSha256 || '设备未上报', { text: 'GD32 OTA 完成', ok: true });
setBleStageStatus('ok', 'GD32 刷写完成');
state.lastUploadStatus = 'success';
showToast('GD32 OTA 刷写完成', 'success');
resolveOtaResult('success', { stage: stage, value: value });
return;
}
// ── 0x20 ABORTED:ESP32 已中止 OTA(BLE 保持连接)──
// 中止可由用户主动点"中止"按钮(abortTask 发 0xFB)触发,
// 也可能是设备端因错误自动中止。两种情况都走同一清理路径。
if (stage === BLE_OTA_FINISH_ABORTED) {
state.lastUploadStatus = 'aborted';
setProgress(0, 'OTA 已中止');
setBleStageError('OTA 已中止');
showToast('OTA 已中止(BLE 保持连接)', 'warning');
abortAckWaiter('OTA 已中止');
rejectOtaResult('OTA 已中止', { 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;
}
// ── 0x87 包身份不符:设备端在 SPI 转发前拒绝,GD32 APP 区未被擦除 ──
if (stage === BLE_OTA_FINISH_ERR_FW_IDENTITY) {
if (!finishBelongsToWaiter('gd32', value)) return;
if (typeof switchStageFlow === 'function') switchStageFlow('gd32');
if (typeof updateStagesFromRelayState === 'function') updateStagesFromRelayState(255);
const idMsg = resultCode === 1
? '检测到 Bootloader 固件,不能刷入 APP 区,请选择 APP 固件'
: '固件身份校验失败(非 GD32 APP 包),已拒绝刷写';
setProgress(0, '[GD32] 固件身份不符,已拒绝');
state.lastUploadStatus = 'failed';
showToast(idMsg, 'error');
setBleStageError('固件身份不符');
showBleVerifyInfo(state.fileSha256 || '-', '固件身份校验失败', { text: idMsg, err: true });
rejectOtaResult(idMsg, { stage: stage, value: value });
return;
}
// Update BLE stage flow based on FINISH notifications
if (stage === BLE_OTA_FINISH_ESP32_DONE) {
if (!finishBelongsToWaiter('esp32', value)) return;
state.lastEsp32DoneSession = finishSessionId(value);
setBleStage(finalizeStage, '收尾完成', null);
setBleStageText('OTA 收尾完成,等待重启');
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);
// ★ ESP32 收尾完成 = 成功路径:保持 status bar 绿色,不要切回 active 蓝
setBleStageStatus('ok', 'ESP32 刷写完成,等待重启');
if (typeof markPhaseDone === 'function') {
markPhaseDone('esp32Ota');
}
}
// OTA 结果 toast 提示(用户可见,不只是在日志里)
if (stage === BLE_OTA_FINISH_ERR_MUTEX) {
if (!isSessionlessFinish(value)) {
if (hasCurrentWaiter('esp32') && !finishBelongsToWaiter('esp32', value)) return;
if (hasCurrentWaiter('gd32') && !finishBelongsToWaiter('gd32', value)) return;
}
showToast('OTA 被其他通道占用,请稍后重试', 'error');
state.abortRequested = true;
setProgress(0, 'OTA 被其他通道占用');
state.lastUploadStatus = 'failed';
setBleStageError('OTA 被其他通道占用');
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('固件超出分区大小');
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 });
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(flowTarget().verifyStage, 'SHA-256 ✗', null);
setBleStageError('设备端 SHA-256 校验失败');
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 || '设备未上报';
if (state.deviceSha256 && state.fileSha256 && state.deviceSha256.toUpperCase() === state.fileSha256.toUpperCase()) {
showBleVerifyInfo(state.fileSha256 || '-', devSha, { text: '校验通过 — SHA-256 一致,设备重启中', ok: true });
} else if (state.deviceSha256) {
showBleVerifyInfo(state.fileSha256 || '-', devSha, { text: '校验失败 — SHA-256 不一致', err: true });
} else {
showBleVerifyInfo(state.fileSha256 || '-', devSha, { text: '刷写成功,设备未上报 SHA-256(重启中)' });
}
}
// 错误类 FINISH 事件立即中断 waitForAck,并中止发送循环
if (stage >= 0x80) {
abortAckWaiter(`设备端 FINISH 错误: stage=0x${stage.toString(16).padStart(2, '0').toUpperCase()} value=${value}`);
}
return;
}
// 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();
state.deviceSha256 = devSha;
bleLog('VERIFY', '设备端 SHA-256: ' + devSha, 'D');
// 设备端上报 SHA-256 后,前端仅做展示与状态同步。
var frontSha = state.fileSha256;
var verifyStage = flowTarget().verifyStage;
if (frontSha && frontSha.toUpperCase() === devSha) {
bleLog('VERIFY', 'SHA-256 校验通过,前端与设备端一致', 'I');
showBleVerifyInfo(frontSha, devSha, { text: '校验通过 — SHA-256 一致', ok: true });
setBleStage(verifyStage, 'SHA-256 ✓', null);
// ★ 校验通过 = 刷写成功:status bar 标绿 + 顶部胶囊推进
setBleStageStatus('ok', '刷写成功 — 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 });
setBleStage(verifyStage, 'SHA-256 ✗', null);
setBleStageError('SHA-256 不一致');
} else {
showBleVerifyInfo(undefined, devSha, { text: '前端 SHA-256 不可用,无法比对' });
setBleStage(verifyStage, '无法比对', null);
}
return;
}
bleLog('NOTIFY', hex);
}
// ===== 自动重连 =====
export async function tryReconnect() {
if (!state.device || !state.expectedReboot) {
state.reconnecting = false;
return;
}
if (!state.device.gatt) {
// 设备引用已丧失,重连不了
state.reconnecting = false;
state.expectedReboot = false;
showToast('设备引用丧失,请手动重连', 'error');
return;
}
state.reconnecting = true;
state.reconnectAttempts += 1;
var attempt = state.reconnectAttempts;
// 退避:约 1s / 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';
}
setConnectionState(!!state.server);
bleLog('BLE', '自动重连第 ' + attempt + ' 次' + (backoff > 0 ? '(等待 ' + Math.round(backoff) + 'ms)' : '') + '...', 'W');
try {
var server = await state.device.gatt.connect();
state.server = server;
state.service = await server.getPrimaryService(UUIDS.service);
state.ctrlChar = await state.service.getCharacteristic(UUIDS.ctrl);
state.dataInChar = await state.service.getCharacteristic(UUIDS.dataIn);
state.dataOutChar = await state.service.getCharacteristic(UUIDS.dataOut);
state.versionChar = await state.service.getCharacteristic(UUIDS.version);
state.notificationsEnabled = false;
await enableNotifications();
state.expectedReboot = false;
state.reconnecting = false;
state.reconnectAttempts = 0;
// 重连后重注 onDisconnected({once: true},旧的已被上次断开时消耗)
state.device.addEventListener('gattserverdisconnected', onDisconnected, { once: true });
bleLog('BLE', '✅ 自动重连成功', 'I');
showToast('BLE 已重连', 'success');
setConnectionState(true);
// 重连后默认刷新一下版本
try { await readVersion(); } catch (_) {}
} catch (e) {
bleLog('BLE', '重连第 ' + attempt + ' 次失败: ' + e.message, 'W');
if (state.expectedReboot && attempt < 12) {
tryReconnect();
} else {
state.reconnecting = false;
state.expectedReboot = false;
showToast('重连失败,请手动连接', 'error');
bleLog('BLE', '自动重连放弃,请点击「连接设备」重连', 'E');
setConnectionState(!!state.server);
}
}
}
export async function enableNotifications() {
// 修复偶发的 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');
}
+160
View File
@@ -0,0 +1,160 @@
// ==================== BLE OTA 结果等待与中止 ====================
import { BLE_OTA_CTRL_ABORT_OTA } from './constants.js';
import { state, els, bleLog } from './state.js';
import { connectBle, disconnectBle } from './gatt.js';
import { autoFlash } from './transfer.js';
import { syncSelectedFileFromInput } from './file.js';
export function clearOtaResultWaiter() {
if (state.otaResultWaiter) {
clearTimeout(state.otaResultWaiter.timer);
state.otaResultWaiter = null;
}
}
export function resolveOtaResult(status, detail) {
if (!state.otaResultWaiter) return;
var waiter = state.otaResultWaiter;
state.otaResultWaiter = null;
clearTimeout(waiter.timer);
waiter.resolve({ status: status, detail: detail || {} });
}
export 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);
}
export function nextOtaSessionId() {
state.otaRunId = (state.otaRunId + 1) & 0xFFFF;
if (state.otaRunId === 0) state.otaRunId = 1;
return state.otaRunId;
}
export function finishBelongsToWaiter(targetKey, value) {
if (!state.otaResultWaiter || state.otaResultWaiter.targetKey !== targetKey) return false;
return (value & 0xFFFF) === state.otaResultWaiter.sessionId;
}
export function finishSessionId(value) {
return value & 0xFFFF;
}
export function isSessionlessFinish(value) {
return finishSessionId(value) === 0;
}
export function hasCurrentWaiter(targetKey) {
return !!(state.otaResultWaiter && state.otaResultWaiter.targetKey === targetKey);
}
export 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,
};
});
}
/**
* @description 中止当前 BLE OTA 任务并清理状态。
* 改进:发 0xFB 控制命令让 ESP32 主动清理 OTA 会话,BLE 保持连接。
* 旧做法是直接断开 BLE 触发 GAP disconnect 清理,导致用户需重连。
* 若 0xFB 发送失败(如 CTRL 特征不可用),回退到设置标志等待发包循环自然退出。
*/
export async function abortTask() {
state.abortRequested = true; // 立即停止发送循环
bleLog('OTA', '已请求中止,发送 0xFB 让设备端清理(BLE 保持连接)');
if (state.ctrlChar && state.server && state.device.gatt.connected) {
try {
const session = state.sessionId || 0;
const abortCmd = new Uint8Array([BLE_OTA_CTRL_ABORT_OTA, session & 0xFF, (session >> 8) & 0xFF]);
await state.ctrlChar.writeValue(abortCmd);
bleLog('OTA', '0xFB 已发送,等待设备端回复 ABORTED (stage=0x20)');
} catch (e) {
bleLog('OTA', '0xFB 发送失败: ' + e.message + ',回退到本地中止', 'E');
// CTRL 写入失败时回退:本地立即标记失败,不再等设备端 ABORTED 回复
state.lastUploadStatus = 'failed';
if (state.ackWaiter) {
const waiter = state.ackWaiter;
state.ackWaiter = null;
clearTimeout(waiter.timer);
waiter.reject(new Error('中止失败: ' + e.message));
}
rejectOtaResult('中止失败: ' + e.message, { stage: 0xFB, value: 0 });
}
} else {
// CTRL 特征未就绪(连接已断或未完成握手),仅本地清理
bleLog('OTA', 'CTRL 特征不可用,仅本地中止', 'W');
state.lastUploadStatus = 'failed';
rejectOtaResult('CTRL 特征不可用', { stage: 0xFB, value: 0 });
}
}
export function bindEvents() {
if (els.bleConnectToggle) {
// launch 风格按钮:用 click 而非 change 触发——navigator.bluetooth.requestDevice
// 必须在 user gesture 内调用,change 事件不被识别为 user gesture,会报
// "Must be handling a user gesture to show a permission request."
// preventDefault 阻止 label 默认勾选,checked 由 setConnectionState 按实际结果驱动。
els.bleConnectToggle.addEventListener('click', (e) => {
e.preventDefault();
if (state._bleConnecting) return; // 连接进行中,忽略重复点击
var btn = document.querySelector('.ble-launch-btn');
if (state.server) {
els.bleConnectToggle.checked = false;
if (btn) btn.setAttribute('data-ble-state', 'idle');
disconnectBle(); // → resetUiAfterDisconnect 置 idle
} else {
state._bleConnecting = true;
els.bleConnectToggle.checked = true; // 即时视觉反馈
if (btn) btn.setAttribute('data-ble-state', 'connecting');
// 连接结束后按实际结果收口:成功→connected / 失败→idle
// 不依赖 state.js 的 setConnectionState(ES 模块子导入可能被浏览器缓存为旧版)
connectBle().finally(() => {
state._bleConnecting = false;
var b = document.querySelector('.ble-launch-btn');
if (b) b.setAttribute('data-ble-state', state.server ? 'connected' : 'idle');
});
}
});
// 兜底:自动断开(onDisconnected)、重连等路径绕过 click handler,
// 用轮询同步 data-ble-state,确保按钮状态始终与 state.server 一致。
setInterval(function() {
if (state._bleConnecting) return; // 连接中不干扰
var btn = document.querySelector('.ble-launch-btn');
if (!btn || !btn.offsetParent) return; // 按钮不可见(WiFi 模式)时跳过
var expected = state.server ? 'connected' : 'idle';
if (btn.getAttribute('data-ble-state') !== expected) {
btn.setAttribute('data-ble-state', expected);
}
}, 800);
}
els.autoFlashBtn.addEventListener('click', autoFlash);
els.abortBtn.addEventListener('click', abortTask);
// 上传区事件由 app.js 的 setupSharedUploadZone 统一处理
// 文件输入变化时同步 BLE 文件状态
els.firmwareFile.addEventListener('change', syncSelectedFileFromInput);
els.firmwareFile.addEventListener('input', syncSelectedFileFromInput);
window.addEventListener('focus', () => setTimeout(syncSelectedFileFromInput, 0));
}
+123
View File
@@ -0,0 +1,123 @@
// ==================== BLE OTA Stage Flow Management ====================
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 || '完成触发';
}
export function resetBleStages() {
bleStageIds.forEach(function(id) {
var el = document.getElementById(id);
if (el) el.className = 'stage-node';
});
bleStageDetailIds.forEach(function(id) {
var el = document.getElementById(id);
if (el) el.textContent = '';
});
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===末步:全部 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';
else if (i + 1 === stageNum) el.className = finished ? 'stage-node done' : 'stage-node live';
else el.className = 'stage-node';
}
// Set detail text
if (stageNum >= 1 && stageNum <= stageCount) {
var detailEl = document.getElementById(bleStageDetailIds[stageNum - 1]);
if (detailEl && detail !== undefined) detailEl.textContent = detail;
}
// Update status bar
var bar = getStageStatusBar();
if (bar) {
var cls = barState || (finished ? 'ok' : 'active');
bar.className = 'stage-status-bar' + (cls ? ' ' + cls : '');
}
}
export function setBleStageText(text) {
var txt = getStageStatusText();
if (txt) txt.textContent = text;
}
/** @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 || '未知错误'));
}
+198
View File
@@ -0,0 +1,198 @@
// ==================== BLE OTA 状态与元素缓存 ====================
import { TARGETS } from './constants.js';
import { addLog } from '../common/log.js';
import { updateBleStageLabels, resetBleStages } from './stages.js';
import { hideBleVerifyInfo } from './verify.js';
export const state = {
device: null,
server: null,
service: null,
ctrlChar: null,
dataInChar: null,
dataOutChar: null,
versionChar: null,
notificationsEnabled: false,
selectedFile: null,
selectedFileHandle: null, // FileSystemFileHandle for hot reload
abortRequested: false,
uploading: false,
autoFlashing: false,
lastUploadStatus: 'idle',
lastNotificationHex: '',
lastAckCmd: null,
lastAckSeq: -1,
lastAckBytes: 0,
ackWaiter: null,
otaResultWaiter: null,
otaRunId: 0,
lastEsp32DoneSession: null,
fileSha256: null, // 当前 BLE 会话用的 SHA(与对应芯片槽同步)
deviceSha256: null,
expectedReboot: false, // 发送 0xFA 后置 true,重连后清
reconnecting: false, // 自动重连中
reconnectAttempts: 0, // 重连尝试次数
uploadTargetKey: null,
activeTotalSize: 0,
activePacketTotal: 0,
};
export const $ = (id) => document.getElementById(id);
export const els = {
bleConnectToggle: $('bleConnectToggle'),
autoFlashBtn: $('autoFlashBtn'),
abortBtn: $('abortBtn'),
uploadZone: $('uploadZone'),
firmwareFile: $('firmwareFile'),
connState: $('connState'),
connBadge: $('connBadge'),
deviceName: $('deviceName'),
deviceId: $('deviceId'),
gattState: $('gattState'),
versionText: $('versionText'),
fileInfo: $('fileInfo'),
clearFileBtn: $('clearFileBtn'),
progressFill: $('progressFill'), // 共享进度条
progressTxt: $('progressTxt'), // 共享进度文本
progressPct: $('progressPct'), // 共享进度百分比
fileName: $('fileName'),
fileType: $('fileType'),
fileSize: $('fileSize'),
packetCount: $('packetCount'),
sentCount: $('sentCount'),
logScroll: $('logScroll'), // 共享日志面板
};
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));
}
export function flowTarget() {
return TARGETS[state.uploadTargetKey] || currentTarget();
}
export function bleLog(tag, message, level) {
level = level || 'I';
if (typeof addLog === 'function') {
addLog(level, 'BLE-' + tag, message);
}
}
export function toHex(uint8) {
return Array.from(uint8).map(b => b.toString(16).padStart(2, '0').toUpperCase()).join(' ');
}
export function hexToBytes(hex) {
const clean = hex.replace(/[^0-9a-fA-F]/g, '');
if (clean.length === 0 || clean.length % 2 !== 0) throw new Error('HEX 长度必须为偶数');
const out = new Uint8Array(clean.length / 2);
for (let i = 0; i < clean.length; i += 2) out[i / 2] = parseInt(clean.slice(i, i + 2), 16);
return out;
}
export async function sleep(ms) {
return new Promise(r => setTimeout(r, ms));
}
export function setReadyHint(html) {
const el = $('readyHint');
if (el) el.innerHTML = html;
}
export function setConnectionState(connected) {
const idle = connected && state.selectedFile && !state.uploading;
// BLE 模式:连接状态只写顶栏胶囊,避免下方重复文案
if (typeof connectionMode !== 'undefined' && connectionMode === 'ble') {
var pc = document.getElementById('pillConn');
var pct = document.getElementById('pillConnTxt');
if (pc) pc.className = connected ? 'pill online' : 'pill offline';
if (pct) pct.textContent = connected ? 'BLE 已连接' : 'BLE 未连接';
if (els.connState) {
els.connState.textContent = connected ? '已连接' : '未连接';
if (els.connState.parentElement) {
els.connState.parentElement.className = connected ? 'pill online' : 'pill offline';
}
}
if (els.connBadge) {
els.connBadge.textContent = connected ? 'online' : 'offline';
els.connBadge.className = `v ${connected ? 'ok' : 'bad'}`;
}
if (els.gattState) els.gattState.textContent = connected ? 'GATT 已就绪' : '未就绪';
}
// launch 风格按钮:checked 与 data-ble-state 由连接结果驱动
// connecting 态由点击时设置;此处仅在连接结束(成功→connected / 失败→idle)时收口
if (els.bleConnectToggle) {
if (els.bleConnectToggle.checked !== connected) {
els.bleConnectToggle.checked = !!connected;
}
}
var btn = document.querySelector('.ble-launch-btn');
if (btn) btn.setAttribute('data-ble-state', connected ? 'connected' : 'idle');
els.autoFlashBtn.disabled = !idle;
els.abortBtn.disabled = !state.uploading;
if (!connected) {
setReadyHint('当前未连接设备。建议顺序:连接设备 → 选择固件 → 开始刷写。');
} else if (!state.notificationsEnabled) {
setReadyHint('设备已连接,正在自动准备刷写通道。若长时间无响应,请断开后重连。');
} else if (!state.selectedFile) {
setReadyHint('设备已连接。下一步请选择目标芯片与固件文件。');
} else {
setReadyHint('设备与文件都已就绪,可以直接开始刷写或执行一键刷写。');
}
}
export function resetUiAfterDisconnect() {
state.server = null;
state.service = null;
state.ctrlChar = null;
state.dataInChar = null;
state.dataOutChar = null;
state.versionChar = null;
state.notificationsEnabled = false;
state.uploading = false;
state.autoFlashing = false;
state.lastUploadStatus = 'idle';
state.deviceSha256 = null;
if (typeof connectionMode === 'undefined' || connectionMode === 'ble') {
els.deviceName.textContent = '-';
els.deviceId.textContent = '-';
if (els.versionText) els.versionText.textContent = '未读取';
var pv = document.getElementById('pillVer');
if (pv) pv.textContent = 'v--';
}
resetBleStages();
hideBleVerifyInfo();
setConnectionState(false);
}
export function refreshTargetUi() {
const cfg = currentTarget();
updateBleStageLabels(cfg);
/* 与 WiFi 一致:文件由 syncBleFileFromSelectedTarget 按 flashTarget 绑定;
* 此处只刷新标签/包数/阶段说明。 */
if (state.selectedFile) {
const payloadSize = currentPayloadSize();
const packets = Math.ceil(state.selectedFile.size / payloadSize);
if (els.packetCount) els.packetCount.textContent = String(packets);
}
setConnectionState(!!state.server);
var espTab = document.getElementById('targetEsp32Tab');
var gdTab = document.getElementById('targetGd32Tab');
var isEsp = cfg.label === 'ESP32';
if (espTab) espTab.classList.toggle('active', isEsp);
if (gdTab) gdTab.classList.toggle('active', !isEsp);
var autoBtn = document.getElementById('autoFlashBtn');
if (autoBtn && !state.uploading && !state.autoFlashing) {
autoBtn.textContent = isEsp ? '一键刷写 ESP32' : '一键刷写 GD32';
}
}
+496
View File
@@ -0,0 +1,496 @@
// ==================== BLE OTA 数据写入与 ACK ====================
import { BLE_OTA_ACK_WINDOW, UI_BUILD } from './constants.js';
import { state, els, bleLog, currentTarget, currentPayloadSize, flowTarget, setConnectionState, sleep, toHex, hexToBytes } from './state.js';
import { setBleStage, setBleStageText, setBleStageError } from './stages.js';
import { showBleVerifyInfo, hideBleVerifyInfo } from './verify.js';
import { clearOtaResultWaiter, waitForOtaResult, nextOtaSessionId } from './session.js';
import { enableNotifications } from './gatt.js';
import { syncSelectedFileFromInput, updateFileInfo } from './file.js';
import { computeFileSha } from '../ota/file-handle.js';
import { formatBytes } from '../common/utils.js';
import { showToast } from '../common/toast.js';
export function setProgress(percent, text) {
const p = Math.max(0, Math.min(100, percent));
els.progressFill.style.width = `${p}%`;
els.progressPct.textContent = `${p.toFixed(0)}%`;
els.progressTxt.textContent = text;
// Toggle done class for green finish effect
if (p >= 100) {
els.progressFill.classList.add('done');
} else {
els.progressFill.classList.remove('done');
}
}
// ★ 以设备端 ACK 字节数为唯一进度数据源
// 仅当处于上传阶段(state.uploading)且未中止时才刷 UI,避免陈旧 ACK / abort 后覆盖
export 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, currentPayloadSize());
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)}%`);
}
/** @description 构建 BLE OTA 数据包(seq + totalSize + payload + cmd + CRC)。 */
function buildPacket(seq, totalSize, payload, cmd) {
const packet = new Uint8Array(7 + payload.length);
packet[0] = cmd;
packet[1] = seq & 0xFF;
packet[2] = (seq >> 8) & 0xFF;
packet[3] = totalSize & 0xFF;
packet[4] = (totalSize >> 8) & 0xFF;
packet[5] = (totalSize >> 16) & 0xFF;
packet[6] = (totalSize >> 24) & 0xFF;
packet.set(payload, 7);
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,避免在已中止的会话上盲等超时。
if (state.abortRequested) {
return Promise.reject(new Error('OTA 已中止'));
}
const gen = state.ackGeneration;
// ★ 提前返回:只要 cmd 匹配 + generation 匹配 + 字节数足够,即可 resolve
// 不再要求 lastAckSeq >= expectedSeq(与 handleNotify 保持一致)
if (state.lastAckCmd === cmd && gen === state.ackGeneration) {
const expectedBytes = opts.expectedTotalSize !== undefined
? Math.min((expectedSeq + 1) * (opts.payloadSize || 480), opts.expectedTotalSize)
: 0;
if (expectedBytes === 0 || state.lastAckBytes >= expectedBytes) {
return Promise.resolve({ seq: state.lastAckSeq, bytes: state.lastAckBytes });
}
// 字节数不够,不提前 resolve,等待后续 ACK
}
if (state.ackWaiter) {
clearTimeout(state.ackWaiter.timer);
state.ackWaiter.reject(new Error('ACK waiter 被新的窗口覆盖'));
state.ackWaiter = null;
}
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
if (state.ackWaiter && state.ackWaiter.expectedSeq === expectedSeq && state.ackWaiter.cmd === cmd) {
state.ackWaiter = null;
}
reject(new Error(`等待 ACK 超时: cmd=0x${cmd.toString(16).padStart(2, '0').toUpperCase()} seq=${expectedSeq}`));
}, timeoutMs);
state.ackWaiter = {
cmd, expectedSeq, resolve, reject, timer, generation: gen,
expectedTotalSize: opts.expectedTotalSize,
payloadSize: opts.payloadSize,
};
});
}
/** @description 分块上传固件到 ESP32(通过 BLE GATT 写入)。 */
export async function uploadFirmware() {
// 热重载:有 handle 则重新读盘(本地编译会改同路径固件);否则用已选/input
if (state.selectedFileHandle) {
try {
const freshFile = await state.selectedFileHandle.getFile();
await updateFileInfo(freshFile);
} catch (e) {
bleLog('FILE', '重新读取文件失败: ' + e.message + ',将使用缓存版本', 'W');
}
} else if (!state.selectedFile) {
await syncSelectedFileFromInput();
}
if (!state.selectedFile || !state.dataInChar) return false;
const cfg = currentTarget();
state.uploadTargetKey = window.flashTarget || 'esp32';
const payloadSize = Math.max(32, Math.min(498, currentPayloadSize()));
const file = state.selectedFile;
state.abortRequested = false;
state.uploading = true;
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;
state.lastAckBytes = 0;
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;
}
setConnectionState(true);
// Stage 4: Data writing
setBleStage(4, '准备中...', 'active');
setBleStageText('正在准备数据写入...');
hideBleVerifyInfo();
try {
if (!state.notificationsEnabled) {
await enableNotifications();
}
if (!state.notificationsEnabled) {
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}`, '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}`, 'D');
if (totalSize !== file.size) {
bleLog('OTA', `⚠️ file.size(${file.size}) ≠ arrayBuffer(${totalSize}),以 arrayBuffer 为准,请确认页面已 Ctrl+F5 刷新`, 'W');
}
/* 上传前始终按本次读到的内容重算 SHA,不沿用选文件时的旧值 */
var chipKey = (cfg.label === 'GD32') ? 'gd32' : 'esp32';
var shaHex = await computeFileSha(file, chipKey, fileBytes.buffer);
if (!shaHex) throw new Error('SHA-256 计算失败');
state.fileSha256 = shaHex;
var fileSha256 = shaHex.toLowerCase();
var shaElem = document.getElementById('fileSha256');
if (shaElem) shaElem.textContent = shaHex.toUpperCase();
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: 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
const BEGIN_READY_TIMEOUT_MS = 120000;
const WINDOW_ACK_TIMEOUT_MS = 8000;
for (let windowStart = 0; windowStart < packetTotal; windowStart += BLE_OTA_ACK_WINDOW) {
if (state.abortRequested) throw new Error('用户中止');
const endSeq = Math.min(windowStart + BLE_OTA_ACK_WINDOW, packetTotal) - 1;
// Windows Web Bluetooth 同一时刻只允许一个 GATT 操作。逐包等待仅保证
// 提交顺序;实际吞吐由设备请求的高速 BLE 连接参数决定。
// ★ 不再在循环内 setProgress:进度完全由设备 ACK 后的 state.lastAckBytes 驱动
//
// ★ 首窗特殊流控:seq=0 触发设备 begin+整分区预擦(worker 线程,可阻塞很久)。
// 旧逻辑 sleep(200) 后立刻 withoutResponse 连发 → 队列满丢包 → 整窗重传,体感极慢。
// 现改为:seq=0 送达后等设备「首包落盘 ACK」再发 1..endSeq。
for (let seq = windowStart; seq <= endSeq; seq++) {
if (state.abortRequested) throw new Error('用户中止');
const start = seq * payloadSize;
const end = Math.min(start + payloadSize, totalSize);
const payload = fileBytes.slice(start, end);
const packet = buildPacket(seq, totalSize, payload, cfg.packetCmd);
if (seq === 0) {
try {
await state.dataInChar.writeValueWithResponse(packet);
bleLog('OTA', 'seq=0 已用 writeValueWithResponse 确认送达,等待设备 begin/预擦完成…', 'D');
setBleStageText('设备预擦 Flash 中,请等待…');
} catch (e) {
bleLog('OTA', `seq=0 写入失败: ${e.message}`, 'E');
throw new Error('seq=0 写入失败,OTA 会话无法启动: ' + e.message);
}
state.ackGeneration++;
const firstBytes = Math.min(payloadSize, totalSize);
try {
await waitForAck(cfg.packetCmd, 0, BEGIN_READY_TIMEOUT_MS, {
expectedTotalSize: totalSize,
payloadSize: payloadSize
});
} catch (e) {
throw new Error('等待设备 begin/预擦完成超时: ' + e.message);
}
if (state.lastAckBytes < firstBytes) {
throw new Error('设备 begin 就绪 ACK 字节不足: got=' + state.lastAckBytes + ' need=' + firstBytes);
}
bleLog('OTA', '设备 begin 就绪 (ackBytes=' + state.lastAckBytes + '),继续发送窗口数据', 'D');
setBleStageText('正在写入 ' + packetTotal + ' 包数据...');
continue;
}
/* Windows WebBT 同时只允许一个 GATT 操作,必须逐包 await,禁止 Promise.all 并发写 */
await state.dataInChar.writeValueWithoutResponse(packet);
}
state.ackGeneration++;
try {
await waitForAck(cfg.packetCmd, endSeq, WINDOW_ACK_TIMEOUT_MS, { expectedTotalSize: totalSize, payloadSize });
} catch (e) {
bleLog('OTA', `ACK 等待失败: ${e.message},进入重试`, 'W');
}
let expectedBytes = Math.min((endSeq + 1) * payloadSize, totalSize);
let retryCount = 0;
let prevAckBytes = state.lastAckBytes; // ★ 进度跟踪:检测重试是否有效
// ★ 仅以字节数判断是否完成,不依赖 ackOk(generation 不匹配时 ackOk 不可靠)
while (state.lastAckBytes < expectedBytes && retryCount < MAX_WINDOW_RETRIES) {
// ★ 中止检查:0x20 ABORTED 可能在上一轮 waitForAck 超时期间到达,
// 或在 sleep/续发过程中到达。立即抛出,避免无谓重试已中止的会话。
if (state.abortRequested) throw new Error('用户中止');
// 如果 ESP32 已确认所有数据(ACK 延迟到达),直接跳出
if (state.lastAckBytes >= expectedBytes) break;
// ★ 进度检测:如果重试后字节数没有增长,说明续传无效
if (retryCount > 0 && state.lastAckBytes <= prevAckBytes) {
bleLog('OTA', `上次重试暂未推进: ackBytes=${state.lastAckBytes} (前次=${prevAckBytes}),继续按缺口重试`, 'W');
}
prevAckBytes = state.lastAckBytes;
// 从 ESP32 实际确认的位置续传,而非重发整个窗口
const resumeSeq = Math.max(windowStart, Math.ceil(state.lastAckBytes / payloadSize));
const missing = expectedBytes - state.lastAckBytes;
const missingPackets = Math.ceil(missing / payloadSize);
retryCount++;
bleLog('OTA', `丢包检测: seq=${endSeq} 丢失${missing}字节(${missingPackets}包),从 seq=${resumeSeq} 续发 (非全窗口重发),第${retryCount}次`, 'W');
setBleStage(4, `续发 #${retryCount} (seq=${resumeSeq})`, null);
setBleStageText(`丢包续发中 (${retryCount}/${MAX_WINDOW_RETRIES})...`);
// ★ 等待设备端 reorder buffer flush + ACK 回传。
// 设备端可能正在 flush 缺口包(getReceived 跳跃增长),
// 留足时间让最新的 ACK 到达,避免重发已 flush 的包。
await sleep(10);
for (let seq = resumeSeq; seq <= endSeq; seq++) {
// ★ 中止检查:续发过程中可能收到 0x20 ABORTED
if (state.abortRequested) throw new Error('用户中止');
const start = seq * payloadSize;
const end = Math.min(start + payloadSize, totalSize);
const payload = fileBytes.slice(start, end);
const packet = buildPacket(seq, totalSize, payload, cfg.packetCmd);
// ★ 重试路径中 seq=0 也必须用 writeValueWithResponse,并等 begin 就绪 ACK
if (seq === 0) {
try {
await state.dataInChar.writeValueWithResponse(packet);
bleLog('OTA', '重试 seq=0 已送达,等待 begin 就绪 ACK…', 'I');
state.ackGeneration++;
try {
await waitForAck(cfg.packetCmd, 0, BEGIN_READY_TIMEOUT_MS, {
expectedTotalSize: totalSize,
payloadSize: payloadSize
});
} catch (e2) {
bleLog('OTA', '重试 seq=0 等待 begin ACK 失败: ' + e2.message, 'W');
}
} catch (e) {
bleLog('OTA', `重试 seq=0 写入失败: ${e.message}`, 'E');
}
} else {
await state.dataInChar.writeValueWithoutResponse(packet);
}
}
state.ackGeneration++;
try {
await waitForAck(cfg.packetCmd, endSeq, WINDOW_ACK_TIMEOUT_MS, { expectedTotalSize: totalSize, payloadSize });
} catch (e) {
bleLog('OTA', `重试 ACK 等待失败: ${e.message}`, 'W');
}
}
if (state.lastAckBytes < expectedBytes) {
const missing = expectedBytes - state.lastAckBytes;
throw new Error(`BLE 丢包检测: seq=${endSeq} 期望${expectedBytes}字节 实际${state.lastAckBytes}字节 丢失${missing}字节(${(missing / payloadSize).toFixed(1)}包) 已重试${retryCount}次`);
}
}
// 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(Math.min(phaseBase, stage4Share), `[${cfg.label}] 固件数据已全部写入 DATA_IN`);
// Stage 5: 发送 0xF8 + 32B 文件 SHA-256,由设备端执行收尾并完成比对
if (cfg.label === 'ESP32') {
state.deviceSha256 = null;
const frontSha = fileSha256.toUpperCase();
const frontShaBytes = sha256HexToBytes(fileSha256);
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,等待设备端校验...');
try {
await state.ctrlChar.writeValue(trigger);
bleLog('OTA', '0xF8 + 32B SHA-256 已发送,等待设备端校验', 'D');
} catch (e) {
clearOtaResultWaiter();
state.lastUploadStatus = 'failed';
setProgress(0, `0xF8 发送失败: ${e.message}`);
bleLog('OTA', `0xF8 发送失败: ${e.message}`, 'E');
setBleStageError('0xF8 发送失败');
return false;
}
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 0xF7 + 32B SHA-256 + session, then wait for SPI relay result.
const frontSha = fileSha256.toUpperCase();
const frontShaBytes = sha256HexToBytes(fileSha256);
state.deviceSha256 = null;
setBleStage(5, '发送触发...', 'active');
setBleStageText('发送 0xF7 + SHA-256,等待设备端缓存校验...');
showBleVerifyInfo(frontSha, '等待设备端比对...', { text: '已发送文件 SHA-256,等待 GD32 缓存校验' });
await sleep(30);
const cmdHex = cfg.ctrlHex;
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);
setBleStage(6, '等待设备端校验', 'active');
bleLog('OTA', `0xF7 + 32B SHA-256 已发送,等待设备端缓存校验与转发`, 'D');
} catch (_) {
clearOtaResultWaiter();
state.lastUploadStatus = 'failed';
setProgress(0, `触发命令发送失败: ${toHex(cmd)}`);
bleLog('OTA', `触发命令发送失败: ${toHex(cmd)}`, 'E');
setBleStageError('触发命令发送失败');
return false;
}
setBleStageText('GD32 缓存校验已发起,等待 SPI 转发流程...');
try {
await resultPromise;
} catch (e) {
state.lastUploadStatus = 'failed';
setProgress(0, e.message);
setBleStageError(e.message);
clearOtaResultWaiter();
return false;
}
bleLog('OTA', `✅ GD32 OTA 刷写完成!`);
bleLog('OTA', `📊 文件 SHA-256: ${fileSha256.toUpperCase()}`);
return true;
} catch (err) {
// ★ 中止不算失败:0x20 ABORTED handler 已更新 UI(progress/stage/toast),
// 这里只记录日志并返回 false,避免把"用户中止"误报为写入失败。
if (state.abortRequested || state.lastUploadStatus === 'aborted') {
bleLog('OTA', `写入已中止: ${err.message}`, 'W');
return false;
}
state.lastUploadStatus = 'failed';
state.expectedReboot = false; // ★ 刷写失败,取消重连退避
state.reconnecting = false;
state.reconnectAttempts = 0;
bleLog('OTA', `写入失败: ${err.message}`, 'E');
setProgress(0, `写入失败: ${err.message}`);
setBleStageError(err.message);
// ★ BLE OTA 失败后不再强制断开 BLE:错误 FINISH 已通过 0xFB 路径或设备端
// 自动 abort 清理了 ESP32 OTA 会话。保留连接让用户可以快速重试或查看日志。
// 仅当连接已半死(gatt.connected=false)时由 onDisconnected 走重连路径。
return false;
} finally {
if (state.ackWaiter) {
clearTimeout(state.ackWaiter.timer);
state.ackWaiter = null;
}
if (state.lastUploadStatus === 'failed' || state.lastUploadStatus === 'aborted') {
clearOtaResultWaiter();
// 终态后允许下一次 uploadFirmware 干净起步(abortRequested 在下次入口也会清)
// 此处不强制清 abortRequested:中止循环可能仍在 unwind
}
state.uploading = false;
state.uploadTargetKey = null;
setConnectionState(!!state.server);
}
}
/** @description 自动刷写流程:上传 ESP32 固件 → SHA验证 → 触发 GD32 中继。 */
export async function autoFlash() {
/* 与 uploadFirmware 一致:有 handle 则重新读盘,SHA 在 upload 内按内容重算 */
if (state.selectedFileHandle) {
try {
const freshFile = await state.selectedFileHandle.getFile();
await updateFileInfo(freshFile);
} catch (e) {
bleLog('FILE', '重新读取文件失败: ' + e.message + ',将使用缓存版本', 'W');
}
} else if (!state.selectedFile) {
await syncSelectedFileFromInput();
}
if (!state.selectedFile || !state.server) return;
const cfg = currentTarget();
state.autoFlashing = true;
try {
bleLog('AUTO', `开始一键刷写 -> ${cfg.label}`);
const ok = await uploadFirmware();
if (!ok || state.abortRequested) {
bleLog('AUTO', '一键刷写中止', 'W');
// ★ 中止/失败后不再断开 BLE:0xFB 已让设备端清理 OTA 会话,保留连接方便重试。
// 若连接已半死,onDisconnected 会自动走重连路径。
return;
}
if (cfg.label === 'ESP32') {
setProgress(100, '[ESP32] OTA 完成,等待设备重启');
bleLog('AUTO', 'ESP32 OTA 完成,等待设备重启');
} else {
setProgress(100, '[GD32] OTA 转发完成,APP 已启动');
bleLog('AUTO', 'GD32 OTA 完成');
}
} catch (err) {
bleLog('AUTO', `一键刷写异常: ${err && err.message ? err.message : err}`, 'E');
setProgress(0, `一键刷写异常: ${err && err.message ? err.message : err}`);
} finally {
state.autoFlashing = false;
setConnectionState(!!state.server);
}
}
+26
View File
@@ -0,0 +1,26 @@
// ==================== BLE OTA SHA-256 校验展示 ====================
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 frontEl = document.getElementById('sviFrontSha');
if (frontEl) frontEl.textContent = frontSha || '-';
}
if (devSha !== undefined) {
var devEl = document.getElementById('sviDeviceSha');
if (devEl) devEl.textContent = devSha || '-';
}
if (result) {
var resultEl = document.getElementById('sviResult');
if (resultEl) {
resultEl.textContent = result.text || '-';
resultEl.className = 'svi-val' + (result.ok ? ' ok' : result.err ? ' err' : '');
}
}
}
export function hideBleVerifyInfo() {
var panel = document.getElementById('stageVerifyInfo');
if (panel) { panel.className = 'stage-verify-info'; panel.style.display = 'none'; }
}