2026-09-03 21:30:32 +08:00
|
|
|
|
// ==================== 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';
|
2026-09-16 02:50:12 +08:00
|
|
|
|
import { deviceLink } from '../common/device-link.js';
|
2026-09-03 21:30:32 +08:00
|
|
|
|
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') {
|
2026-10-13 00:48:34 +08:00
|
|
|
|
window.__bleDeviceName = device.name || '未知设备';
|
|
|
|
|
|
window.__bleDeviceId = device.id || '-';
|
|
|
|
|
|
/* 顶栏 BLE 胶囊:连接后直接显示「设备名 · 设备ID」(替代未连接) */
|
|
|
|
|
|
try {
|
|
|
|
|
|
var pct = document.getElementById('pillBleTxt');
|
|
|
|
|
|
if (pct) pct.textContent = window.__bleDeviceName + ' · ' + window.__bleDeviceId;
|
|
|
|
|
|
if (window.updateLinkPills) window.updateLinkPills(null, true);
|
|
|
|
|
|
} catch (_) {}
|
2026-09-03 21:30:32 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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) {
|
2026-09-16 02:53:53 +08:00
|
|
|
|
try { deviceLink.reportBle(false, ''); } catch (_) {}
|
2026-09-03 21:30:32 +08:00
|
|
|
|
bleLog('BLE', '连接就绪但 DATA_OUT 通知开启失败,等待用户重连', 'E');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-09-16 02:53:53 +08:00
|
|
|
|
try { deviceLink.reportBle(true, device.name || ''); } catch (_) {}
|
2026-09-03 21:30:32 +08:00
|
|
|
|
// Mark stages 1-2 as done
|
|
|
|
|
|
setBleStage(3, '', null);
|
|
|
|
|
|
setBleStageText('BLE 连接就绪,请选择固件文件');
|
|
|
|
|
|
} catch (err) {
|
2026-09-16 02:53:53 +08:00
|
|
|
|
try { deviceLink.reportBle(false, ''); } catch (_) {}
|
2026-09-03 21:30:32 +08:00
|
|
|
|
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;
|
2026-09-16 02:50:12 +08:00
|
|
|
|
try { deviceLink.reportBle(false, ''); } catch (_) {}
|
2026-09-03 21:30:32 +08:00
|
|
|
|
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();
|
|
|
|
|
|
}
|
2026-09-16 02:50:12 +08:00
|
|
|
|
try { deviceLink.reportBle(false, ''); } catch (_) {}
|
2026-09-03 21:30:32 +08:00
|
|
|
|
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');
|
|
|
|
|
|
}
|