- 添加了upload-esp32.js用于处理ESP32的OTA上传,包括文件上传、SHA-256验证和设备重启。 - 引入了upload-gd32.js,用于触发GD32 OTA中继并跟踪中继进度。 - 针对OTA流程,增强了会话管理和错误处理能力。 - 通过在上传过程中显示吐司通知和进度更新,改进了用户反馈。
490 lines
22 KiB
JavaScript
490 lines
22 KiB
JavaScript
// ==================== 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 { 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;
|
||
}
|
||
|
||
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() {
|
||
// 每次刷写前尝试重新从本地读取文件
|
||
if (state.selectedFileHandle) {
|
||
try {
|
||
const freshFile = await state.selectedFileHandle.getFile();
|
||
await updateFileInfo(freshFile);
|
||
var actualFile = state.selectedFile || freshFile;
|
||
bleLog('FILE', '已重新读取本地文件: ' + actualFile.name + ' (' + formatBytes(actualFile.size) + ')');
|
||
} catch (e) {
|
||
bleLog('FILE', '重新读取文件失败: ' + e.message + ',将使用缓存版本', 'W');
|
||
}
|
||
} else {
|
||
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}`);
|
||
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}`);
|
||
if (totalSize !== file.size) {
|
||
bleLog('OTA', `⚠️ file.size(${file.size}) ≠ arrayBuffer(${totalSize}),以 arrayBuffer 为准,请确认页面已 Ctrl+F5 刷新`, 'W');
|
||
}
|
||
|
||
// SHA-256 已在文件选择时(updateFileInfo)计算并存入 state.fileSha256。
|
||
// 此处不再重复打日志(避免与 [FILE] SHA-256 重复),仅保证 fileSha256 变量有值。
|
||
var fileSha256;
|
||
if (state.fileSha256) {
|
||
fileSha256 = state.fileSha256.toLowerCase();
|
||
} else {
|
||
// 极端兜底:state.fileSha256 未初始化(理论上 updateFileInfo 总会先设置)
|
||
bleLog('OTA', '正在计算文件 SHA-256 哈希...');
|
||
const hashBuffer = await crypto.subtle.digest('SHA-256', fileBytes.buffer);
|
||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||
fileSha256 = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
|
||
state.fileSha256 = fileSha256.toUpperCase();
|
||
}
|
||
// Update SHA display in file info
|
||
var shaElem = document.getElementById('fileSha256');
|
||
if (shaElem) shaElem.textContent = fileSha256.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}`);
|
||
|
||
const MAX_WINDOW_RETRIES = 3;
|
||
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 驱动
|
||
for (let seq = windowStart; seq <= endSeq; seq++) {
|
||
// ★ 中止检查放在循环内:abortTask 可能在本窗口发送过程中触发
|
||
// (windowStart 处的检查无法覆盖窗口中途的 abort)
|
||
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(带响应):
|
||
// seq=0 触发设备端 esp_ota_begin()(flash 擦除 + OTA 会话创建),
|
||
// 若用 writeValueWithoutResponse 在 BLE 链路层丢失,
|
||
// 设备永远不会启动 OTA 会话,后续所有包都被丢弃。
|
||
if (seq === 0) {
|
||
try {
|
||
await state.dataInChar.writeValueWithResponse(packet);
|
||
bleLog('OTA', 'seq=0 已用 writeValueWithResponse 确认送达', 'I');
|
||
} catch (e) {
|
||
bleLog('OTA', `seq=0 写入失败: ${e.message}`, 'E');
|
||
throw new Error('seq=0 写入失败,OTA 会话无法启动: ' + e.message);
|
||
}
|
||
// seq=0 写入后等待设备完成 esp_ota_begin()(flash 擦除较耗时)
|
||
await sleep(200);
|
||
} else {
|
||
await state.dataInChar.writeValueWithoutResponse(packet);
|
||
}
|
||
}
|
||
|
||
state.ackGeneration++;
|
||
try {
|
||
await waitForAck(cfg.packetCmd, endSeq, 5000, { 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
|
||
if (seq === 0) {
|
||
try {
|
||
await state.dataInChar.writeValueWithResponse(packet);
|
||
bleLog('OTA', '重试 seq=0 已用 writeValueWithResponse 确认', 'I');
|
||
} catch (e) {
|
||
bleLog('OTA', `重试 seq=0 写入失败: ${e.message}`, 'E');
|
||
}
|
||
await sleep(200);
|
||
} else {
|
||
await state.dataInChar.writeValueWithoutResponse(packet);
|
||
}
|
||
}
|
||
|
||
state.ackGeneration++;
|
||
try {
|
||
await waitForAck(cfg.packetCmd, endSeq, 3000, { expectedTotalSize: totalSize, payloadSize });
|
||
} catch (e) {
|
||
bleLog('OTA', `重试 ACK 等待失败: ${e.message}`, 'W');
|
||
}
|
||
expectedBytes = Math.min((endSeq + 1) * payloadSize, totalSize);
|
||
}
|
||
|
||
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 = new Uint8Array(32);
|
||
for (let i = 0; i < 32; i++) {
|
||
frontShaBytes[i] = parseInt(frontSha.slice(i * 2, i * 2 + 2), 16);
|
||
}
|
||
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 已发送,等待设备端校验');
|
||
} 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 = new Uint8Array(32);
|
||
for (let i = 0; i < 32; i++) {
|
||
frontShaBytes[i] = parseInt(frontSha.slice(i * 2, i * 2 + 2), 16);
|
||
}
|
||
state.deviceSha256 = null;
|
||
setBleStage(5, '发送触发...', 'active');
|
||
setBleStageText('发送 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 已发送,等待设备端缓存校验与转发`);
|
||
} 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') {
|
||
clearOtaResultWaiter();
|
||
}
|
||
state.uploading = false;
|
||
state.uploadTargetKey = null;
|
||
setConnectionState(!!state.server);
|
||
}
|
||
}
|
||
|
||
/** @description 自动刷写流程:上传 ESP32 固件 → SHA验证 → 触发 GD32 中继。 */
|
||
export async function autoFlash() {
|
||
// 每次刷写前尝试重新从本地读取文件(支持热重载:用户重新编译后无需重新选择)
|
||
if (state.selectedFileHandle) {
|
||
try {
|
||
const freshFile = await state.selectedFileHandle.getFile();
|
||
// 仅在文件变化时才刷新元数据并重算 SHA-256
|
||
// (name/size/lastModified 任一不同即视为变化)
|
||
// 避免对同一文件重复触发 updateFileInfo → 产生重复的 [FILE] SHA-256 日志
|
||
const cached = state.selectedFile;
|
||
// HEX 文件转换后 cached.name 是 .bin 而 freshFile.name 是 .hex,
|
||
// 因此 HEX 文件用 lastModified 判断是否变化,避免每次都重新转换
|
||
const isHex = freshFile.name.toLowerCase().endsWith('.hex');
|
||
const changed = !cached ||
|
||
(isHex ? false : (cached.name !== freshFile.name || cached.size !== freshFile.size)) ||
|
||
cached.lastModified !== freshFile.lastModified;
|
||
if (changed) {
|
||
await updateFileInfo(freshFile);
|
||
var actualFile = state.selectedFile || freshFile;
|
||
bleLog('FILE', '已重新读取本地文件(已变更): ' + actualFile.name + ' (' + formatBytes(actualFile.size) + ')');
|
||
}
|
||
} catch (e) {
|
||
bleLog('FILE', '重新读取文件失败: ' + e.message + ',将使用缓存版本', 'W');
|
||
}
|
||
} else {
|
||
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);
|
||
}
|
||
}
|