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:
@@ -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
@@ -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
@@ -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');
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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'; }
|
||||
}
|
||||
Reference in New Issue
Block a user