Files
ESP32_COMM/tools/ble_ota.html
T
小龙虾 2caf10b9de feat(ota): 添加OTA回滚保护和固件完整性校验功能
- 启用CONFIG_BOOTLOADER_APP_ROLLBACK_ENABLE和CONFIG_APP_ROLLBACK_ENABLE配置选项
- 在main.cpp中添加esp_ota_mark_app_valid_cancel_rollback()调用,防止意外回滚
- 实现SHA-256固件完整性校验,包括计算、存储和验证功能
- 添加ota_mutex.h头文件用于ESP32 OTA全局互斥锁管理

feat(health_monitor): 实现线程安全的健康状态管理

- 将HealthMonitor类的健康状态改为私有成员并通过mutex保护
- 添加getState()和setState()方法提供线程安全的状态访问
- 修复多任务并发访问健康状态时的竞争条件问题

feat(ota_relay): 增强中继模块的线程安全性和可靠性

- 为OtaRelay类添加静态mutex保护状态变量访问
- 实现StateLockGuard RAII锁管理器确保异常安全
- 添加全局超时机制防止OTA过程无限等待
- 改进flash写入逻辑支持非4字节对齐数据处理

refactor(app_config): 更新版本信息和配置常量

- 更新BUILD_TIME至最新编译时间
- 更新GIT_HASH至最新提交
- 添加BLE_OTA_CTRL_VERIFY_SHA256命令常量定义
2026-06-14 17:10:24 +08:00

1249 lines
50 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ESP32 BLE OTA 控制台</title>
<link rel="stylesheet" href="style.css?v=20260614e">
</head>
<body>
<div class="container">
<header class="header-bar">
<div class="header-brand">
<span class="dot-mark"></span>
<strong>ESP32 BLE OTA 控制台</strong>
</div>
<div class="header-status">
<span class="pill offline"><span class="indicator"></span><span id="connState">未连接</span></span>
<span class="pill"><span id="deviceName">-</span></span>
<span class="pill"><span id="deviceId">-</span></span>
<span class="pill"><span id="versionText">未读取</span></span>
</div>
</header>
<div class="main-layout">
<div>
<div class="card">
<div class="card-head">🔗 蓝牙连接</div>
<div class="card-body">
<div class="helper-note" style="margin-bottom:12px;">
本页走 <b>BLE GATT</b>,不依赖 WiFi。适合现场、移动端或无法直接访问设备 IP 的场景。支持 <b>GD32 中继</b> 与 <b>ESP32 自升级</b> 两种目标。
</div>
<div class="row-2">
<button id="connectBtn" class="btn btn-primary">连接设备</button>
<button id="disconnectBtn" class="btn btn-outline" disabled>断开连接</button>
</div>
<div class="sub-actions">
<button id="versionBtn" class="btn btn-outline" disabled>读取版本</button>
<a class="btn btn-outline" href="index.html" target="_blank">回 WiFi 页面 ↗</a>
</div>
</div>
</div>
<div class="card">
<div class="card-head">🎯 升级目标</div>
<div class="card-body">
<div class="form-row">
<label class="form-label" for="targetChip">目标芯片</label>
<select id="targetChip">
<option value="gd32">GD32(写入后自动触发中继)</option>
<option value="esp32">ESP32(写入后重启升级)</option>
</select>
</div>
<div class="row-2">
<div class="form-row" style="margin:0;">
<label class="form-label" for="payloadSize">每包 Payload</label>
<input id="payloadSize" class="input" type="number" min="32" max="498" value="480">
</div>
<div class="form-row" style="margin:0;">
<label class="form-label" for="ctrlHex">触发命令(HEX)</label>
<input id="ctrlHex" class="input" type="text" value="F7" maxlength="8">
</div>
</div>
<div class="helper-note" id="protocolHint" style="margin-top:12px;">
当前目标:<b>GD32</b>。页面会使用 <b>cmd=0x00</b> 发送数据,写入完成后自动触发 SPI OTA 中继。协议格式:<b>cmd(1) + seq(2) + total_size(4) + payload</b>。
</div>
</div>
</div>
<div class="card">
<div class="card-head">📁 固件文件</div>
<div class="card-body">
<div class="form-row">
<label class="form-label">固件文件</label>
<div class="upload-zone" id="uploadZone">
<div class="upload-ico">📤</div>
<h4>点击或拖拽文件到此处</h4>
<p>支持 .bin 和 .hex 格式</p>
</div>
<input id="firmwareFile" type="file" accept=".bin,.hex,application/octet-stream" style="display:none;">
<div class="file-info" id="fileInfo">
<div class="file-info-row">
<span style="font-weight:600;">📄 <span id="fileName">-</span></span>
<button id="clearFileBtn" class="btn btn-outline" type="button">清除</button>
</div>
<div class="file-info-grid">
<div class="file-meta-item"><span>类型</span><b id="fileType">-</b></div>
<div class="file-meta-item"><span>大小</span><b id="fileSize">0 B</b></div>
<div class="file-meta-item"><span>总包数</span><b id="packetCount">0</b></div>
<div class="file-meta-item"><span>已写包</span><b id="sentCount">0</b></div>
</div>
<div class="file-meta-item" style="margin-top:8px;">
<span>SHA-256</span>
<b id="fileSha256" style="font-size:10px;word-break:break-all;font-family:var(--font-mono);">-</b>
</div>
</div>
</div>
<div class="footer-note">
建议使用 <b>Chrome / Edge / Android Chromium</b>。若首包就失败,通常是当前设备协商到的 ATT MTU 偏小,可将 payload 从 <b>480B</b> 手动降回 <b>240B</b> 或更低再试。
</div>
</div>
</div>
<div class="card">
<div class="card-head">⚡ 执行操作</div>
<div class="card-body">
<div class="helper-note" id="readyHint" style="margin-bottom:12px;">当前未连接设备。建议顺序:连接设备 → 选择固件 → 开始刷写。</div>
<div class="row-4" style="margin-top:12px;">
<button id="uploadBtn" class="btn btn-primary" disabled>开始刷写</button>
<button id="autoFlashBtn" class="btn btn-primary" disabled>一键刷写</button>
<button id="abortBtn" class="btn btn-danger" disabled>中止任务</button>
<button id="clearLogBtn" class="btn btn-outline" type="button">清空日志</button>
</div>
</div>
</div>
</div>
<div>
<div class="card">
<div class="card-head">📡 设备状态</div>
<div class="card-body">
<div class="badge-grid">
<div class="badge-stat"><div class="k">连接状态</div><div class="v bad" id="connBadge">offline</div></div>
<div class="badge-stat"><div class="k">GATT / MTU</div><div class="v" id="gattState">未就绪</div></div>
<div class="badge-stat"><div class="k">版本</div><div class="v accent" id="versionMirror">未读取</div></div>
<div class="badge-stat"><div class="k">OTA 状态</div><div class="v warn" id="otaState">待机</div></div>
</div>
</div>
</div>
<div class="card">
<div class="card-head">📊 OTA 进度</div>
<div class="card-body">
<div class="progress-wrap show">
<div class="progress-track"><div class="progress-fill" id="progressFill"></div></div>
<div class="progress-text">
<span id="progressText">等待选择文件</span>
<span id="progressPercent">0%</span>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-head">🔀 OTA 流程阶段</div>
<div class="card-body">
<div class="stage-flow">
<div class="stage-node" id="bs1"><div class="stage-dot">1</div><div class="stage-label">BLE 连接</div><div class="stage-detail" id="bsd1"></div></div>
<div class="stage-node" id="bs2"><div class="stage-dot">2</div><div class="stage-label">GATT 准备</div><div class="stage-detail" id="bsd2"></div></div>
<div class="stage-node" id="bs3"><div class="stage-dot">3</div><div class="stage-label">文件准备</div><div class="stage-detail" id="bsd3"></div></div>
<div class="stage-node" id="bs4"><div class="stage-dot">4</div><div class="stage-label">数据写入</div><div class="stage-detail" id="bsd4"></div></div>
<div class="stage-node" id="bs5"><div class="stage-dot">5</div><div class="stage-label">完成触发</div><div class="stage-detail" id="bsd5"></div></div>
<div class="stage-node" id="bs6"><div class="stage-dot">6</div><div class="stage-label">校验比对</div><div class="stage-detail" id="bsd6"></div></div>
</div>
<div class="stage-status-bar" id="bleStageBar">
<span class="ssb-dot"></span>
<span class="ssb-text" id="bleStageText">待机</span>
</div>
<div class="stage-verify-info" id="bleVerifyInfo" style="display:none;">
<div class="svi-row"><span class="svi-key">前端 SHA-256</span><span class="svi-val" id="bviFileSha">-</span></div>
<div class="svi-row"><span class="svi-key">设备 SHA-256</span><span class="svi-val" id="bviDevSha">-</span></div>
<div class="svi-row"><span class="svi-key">校验结果</span><span class="svi-val" id="bviResult">等待中</span></div>
</div>
</div>
</div>
<div class="card">
<div class="card-head">🖥️ 运行日志</div>
<div class="card-body">
<div class="log-scroll" id="logBox"></div>
</div>
</div>
</div>
</div>
</div>
<script>
(() => {
const UUIDS = {
service: 0xFFFF,
ctrl: 0x0001,
dataIn: 0x0002,
dataOut: 0x0003,
version: 0x0004,
};
const UI_BUILD = '2026-05-08-ble-console-v1';
const BLE_OTA_NOTIFY_ACK = 0xA1;
const BLE_OTA_NOTIFY_FINISH = 0xB1;
const BLE_OTA_NOTIFY_SHA256 = 0xC1;
const BLE_OTA_ACK_WINDOW = 32;
const TARGETS = {
gd32: {
label: 'GD32',
packetCmd: 0x00,
ctrlHex: 'F7',
recommendedPayload: 480,
finishText: '自动触发中继',
hint: '当前目标:<b>GD32</b>。页面会使用 <b>cmd=0x00</b> 发送数据,写入完成后自动触发 SPI OTA 中继。'
},
esp32: {
label: 'ESP32',
packetCmd: 0x01,
ctrlHex: 'F8',
recommendedPayload: 480,
finishText: '自动完成 OTA',
hint: '当前目标:<b>ESP32</b>。页面会使用 <b>cmd=0x01</b> 发送数据,最后一包到齐后由设备端自动完成 OTA 并重启。'
}
};
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,
fileSha256: null,
deviceSha256: null,
};
const $ = (id) => document.getElementById(id);
const els = {
connectBtn: $('connectBtn'),
disconnectBtn: $('disconnectBtn'),
versionBtn: $('versionBtn'),
uploadBtn: $('uploadBtn'),
autoFlashBtn: $('autoFlashBtn'),
abortBtn: $('abortBtn'),
clearLogBtn: $('clearLogBtn'),
uploadZone: $('uploadZone'),
firmwareFile: $('firmwareFile'),
payloadSize: $('payloadSize'),
targetChip: $('targetChip'),
ctrlHex: $('ctrlHex'),
protocolHint: $('protocolHint'),
connState: $('connState'),
connBadge: $('connBadge'),
deviceName: $('deviceName'),
deviceId: $('deviceId'),
gattState: $('gattState'),
versionText: $('versionText'),
versionMirror: $('versionMirror'),
otaState: $('otaState'),
fileInfo: $('fileInfo'),
clearFileBtn: $('clearFileBtn'),
progressFill: $('progressFill'),
progressText: $('progressText'),
progressPercent: $('progressPercent'),
fileName: $('fileName'),
fileType: $('fileType'),
fileSize: $('fileSize'),
packetCount: $('packetCount'),
sentCount: $('sentCount'),
logBox: $('logBox'),
};
function currentTarget() {
return TARGETS[els.targetChip.value] || TARGETS.gd32;
}
const MAX_LOG_LINES = 1000;
function log(tag, message, level = 'I') {
const time = new Date().toLocaleTimeString('zh-CN', { hour12: false });
const line = document.createElement('div');
line.className = 'log-line';
line.innerHTML = `<span class="t">[${time}]</span> <span class="tag">[${level}/${tag}]</span> ${escapeHtml(message)}`;
els.logBox.appendChild(line);
while (els.logBox.children.length > MAX_LOG_LINES) {
els.logBox.removeChild(els.logBox.firstChild);
}
els.logBox.scrollTop = els.logBox.scrollHeight;
}
function clearLogs() {
els.logBox.innerHTML = '';
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, (m) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[m]));
}
// ====== BLE OTA Stage Flow Management ======
var bleStageIds = ['bs1','bs2','bs3','bs4','bs5','bs6'];
var bleStageDetailIds = ['bsd1','bsd2','bsd3','bsd4','bsd5','bsd6'];
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 = '';
});
var bar = document.getElementById('bleStageBar');
if (bar) { bar.className = 'stage-status-bar'; bar.innerHTML = '<span class="ssb-dot"></span><span class="ssb-text" id="bleStageText">待机</span>'; }
}
function setBleStage(stageNum, detail, barState) {
// stageNum: 1-6, detail: sub-step text
for (var i = 0; i < bleStageIds.length; 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 = 'stage-node live';
else el.className = 'stage-node';
}
// Set detail text
if (stageNum >= 1 && stageNum <= 6) {
var detailEl = document.getElementById(bleStageDetailIds[stageNum - 1]);
if (detailEl && detail !== undefined) detailEl.textContent = detail;
}
// Update status bar
var bar = document.getElementById('bleStageBar');
if (bar) {
bar.className = 'stage-status-bar' + (barState ? ' ' + barState : (stageNum > 0 ? ' active' : ''));
}
}
function setBleStageText(text) {
var txt = document.getElementById('bleStageText');
if (txt) txt.textContent = text;
}
function setBleStageError(detail) {
// Mark current active stage as error
for (var i = 0; i < bleStageIds.length; i++) {
var el = document.getElementById(bleStageIds[i]);
if (el && el.className === 'stage-node live') {
el.className = 'stage-node err';
var detailEl = document.getElementById(bleStageDetailIds[i]);
if (detailEl && detail) detailEl.textContent = detail;
}
}
var bar = document.getElementById('bleStageBar');
if (bar) bar.className = 'stage-status-bar err';
setBleStageText('错误: ' + (detail || ''));
}
function showBleVerifyInfo(frontSha, devSha, result) {
var panel = document.getElementById('bleVerifyInfo');
if (panel) { panel.className = 'stage-verify-info'; panel.style.display = 'block'; }
if (frontSha !== undefined) {
var el = document.getElementById('bviFileSha');
if (el) el.textContent = frontSha || '-';
}
if (devSha !== undefined) {
var el = document.getElementById('bviDevSha');
if (el) el.textContent = devSha || '-';
}
if (result !== undefined) {
var el = document.getElementById('bviResult');
if (el) {
el.textContent = result.text || '-';
el.className = 'svi-val' + (result.ok ? ' ok' : result.err ? ' err' : '');
}
}
}
function hideBleVerifyInfo() {
var panel = document.getElementById('bleVerifyInfo');
if (panel) { panel.className = 'stage-verify-info'; panel.style.display = 'none'; }
}
function formatBytes(bytes) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
}
function toHex(uint8) {
return Array.from(uint8).map(b => b.toString(16).padStart(2, '0').toUpperCase()).join(' ');
}
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;
}
function setProgress(percent, text) {
const p = Math.max(0, Math.min(100, percent));
els.progressFill.style.width = `${p}%`;
els.progressPercent.textContent = `${p.toFixed(0)}%`;
els.progressText.textContent = text;
// Toggle done class for green finish effect
if (p >= 100) {
els.progressFill.classList.add('done');
} else {
els.progressFill.classList.remove('done');
}
if (els.otaState) {
if (p >= 100) {
els.otaState.textContent = '完成';
els.otaState.className = 'v ok';
} else if (p > 0) {
els.otaState.textContent = '上传中';
els.otaState.className = 'v warn';
} else {
els.otaState.textContent = state.uploading ? '准备中' : '待机';
els.otaState.className = `v ${state.uploading ? 'warn' : 'accent'}`;
}
}
}
function setReadyHint(html) {
const el = $('readyHint');
if (el) el.innerHTML = html;
}
function setConnectionState(connected) {
const idle = connected && state.selectedFile && !state.uploading;
els.connState.textContent = connected ? '已连接' : '未连接';
if (els.connState.parentElement) {
els.connState.parentElement.className = connected ? 'pill online' : 'pill offline';
}
els.connBadge.textContent = connected ? 'online' : 'offline';
els.connBadge.className = `v ${connected ? 'ok' : 'bad'}`;
els.gattState.textContent = connected ? 'GATT 已就绪' : '未就绪';
if (connected) {
els.connectBtn.disabled = true;
} else {
els.connectBtn.disabled = false;
}
els.disconnectBtn.disabled = !connected;
els.versionBtn.disabled = !connected;
els.uploadBtn.disabled = !idle;
els.autoFlashBtn.disabled = !idle;
els.abortBtn.disabled = !state.uploading;
if (els.otaState) {
if (!connected) {
els.otaState.textContent = '离线';
els.otaState.className = 'v bad';
} else if (state.uploading) {
els.otaState.textContent = '上传中';
els.otaState.className = 'v warn';
} else if (state.lastUploadStatus === 'success') {
els.otaState.textContent = '完成';
els.otaState.className = 'v ok';
} else if (state.lastUploadStatus === 'failed') {
els.otaState.textContent = '失败';
els.otaState.className = 'v bad';
} else {
els.otaState.textContent = '待机';
els.otaState.className = 'v accent';
}
}
if (!connected) {
setReadyHint('当前未连接设备。建议顺序:连接设备 → 选择固件 → 开始刷写。');
} else if (!state.notificationsEnabled) {
setReadyHint('设备已连接,正在自动准备刷写通道。若长时间无响应,请断开后重连。');
} else if (!state.selectedFile) {
setReadyHint('设备已连接。下一步请选择固件文件,并确认目标芯片与 Payload。');
} else {
setReadyHint('设备与文件都已就绪,可以直接开始刷写或执行一键刷写。');
}
}
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 (els.otaState) {
els.otaState.textContent = '离线';
els.otaState.className = 'v bad';
}
els.deviceName.textContent = '-';
els.deviceId.textContent = '-';
els.versionText.textContent = '未读取';
els.versionMirror.textContent = '未读取';
resetBleStages();
hideBleVerifyInfo();
setConnectionState(false);
}
function refreshTargetUi() {
const cfg = currentTarget();
els.ctrlHex.value = cfg.ctrlHex;
els.payloadSize.value = String(cfg.recommendedPayload);
els.protocolHint.innerHTML = `${cfg.hint} 推荐 payload:<b>${cfg.recommendedPayload}B</b>。协议格式:<b>cmd(1) + seq(2) + total_size(4) + payload</b>。<br><b>传输机制:</b>每 <b>${BLE_OTA_ACK_WINDOW}</b> 包等待一次 DATA_OUT ACK。${cfg.label === 'ESP32' ? ' <br><b>ESP32 模式:</b>最后一包到齐后由设备端自动完成 OTA 并重启。': '<br><b>GD32 模式:</b>数据写入后自动触发 SPI OTA 中继。'}`;
if (state.selectedFile) updateFileInfo(state.selectedFile);
setConnectionState(!!state.server);
log('CFG', `目标切换为 ${cfg.label}: packetCmd=0x${cfg.packetCmd.toString(16).padStart(2, '0').toUpperCase()}, payload=${cfg.recommendedPayload}`);
}
async function connectBle() {
if (!navigator.bluetooth) {
log('BLE', '当前浏览器不支持 Web Bluetooth', 'E');
alert('当前浏览器不支持 Web Bluetooth,请使用 Chrome / Edge。');
return;
}
try {
resetBleStages();
setBleStage(1, '扫描中...', 'active');
setBleStageText('正在打开设备选择器...');
log('BLE', '打开设备选择器...');
const device = await navigator.bluetooth.requestDevice({
filters: [{ namePrefix: 'ESP32-OTA' }],
optionalServices: [UUIDS.service],
});
state.device = device;
state.device.addEventListener('gattserverdisconnected', onDisconnected);
els.deviceName.textContent = device.name || '未知设备';
els.deviceId.textContent = device.id || '-';
setBleStage(1, '已选择', null);
setBleStage(2, '连接GATT...', 'active');
setBleStageText('正在连接 GATT 服务...');
log('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);
log('BLE', 'GATT 服务与特征获取完成');
await enableNotifications();
// Mark stages 1-2 as done
setBleStage(3, '', null);
setBleStageText('BLE 连接就绪,请选择固件文件');
} catch (err) {
log('BLE', `连接失败: ${err.message}`, 'E');
if (err.name !== 'NotFoundError') {
setBleStageError(err.message);
} else {
resetBleStages();
}
resetUiAfterDisconnect();
}
}
function onDisconnected() {
log('BLE', '设备已断开', 'W');
resetUiAfterDisconnect();
}
async function disconnectBle() {
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();
}
async function readVersion() {
try {
const value = await state.versionChar.readValue();
const text = new TextDecoder().decode(value.buffer).replace(/\0/g, '').trim() || '(空)';
els.versionText.textContent = text;
els.versionMirror.textContent = text;
log('BLE', `版本: ${text}`);
} catch (err) {
log('BLE', `读取版本失败: ${err.message}`, 'E');
}
}
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);
state.lastAckCmd = ackCmd;
state.lastAckSeq = ackSeq;
state.lastAckBytes = ackBytes >>> 0;
const ackText = `[ACK] cmd=0x${ackCmd.toString(16).padStart(2, '0').toUpperCase()} seq=${ackSeq} bytes=${state.lastAckBytes} (${formatBytes(state.lastAckBytes)}) raw=${hex}`;
log('ACK', ackText);
if (state.ackWaiter && state.ackWaiter.cmd === ackCmd && ackSeq >= state.ackWaiter.expectedSeq
&& 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
// 这可以防止重试时原始批次的过期 ACK(字节数低)错误匹配新的 waiter
// 例如:原始批次 ACK bytes=14880 不应匹配重试 waiter(期望 15360)
if (expectedBytes > 0 && state.lastAckBytes < expectedBytes) {
log('ACK', `ACK 字节数不足: ${state.lastAckBytes}/${expectedBytes},忽略并继续等待`, 'W');
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 = {
0x01: '[FINISH] 即将执行 esp_ota_end()',
0x02: `[FINISH] esp_ota_end 完成 (耗时=${value} ms)`,
0x03: '[FINISH] 即将执行 esp_ota_set_boot_partition()',
0x04: `[FINISH] esp_ota_set_boot_partition 完成 (耗时=${value} ms)`,
0x05: `[FINISH] 即将进入重启前等待 (${value} ms)`,
0x06: `[FINISH] 重启前等待结束 (实际耗时=${value} ms)`,
0x07: '[FINISH] ESP32 即将重启',
0x81: `[FINISH] esp_ota_end 失败: err=${value}`,
0x82: `[FINISH] esp_ota_set_boot_partition 失败: err=${value}`,
0x83: `[FINISH] ESP32 OTA 被其他通道占用,互斥拒绝`,
0x84: `[FINISH] GD32 固件超出分区大小: ${formatBytes(value)} (上限 216KB)`,
};
const finishText = finishMap[stage] || `[FINISH] 未知阶段 stage=0x${stage.toString(16).padStart(2, '0').toUpperCase()} value=${value}`;
log('FINISH', finishText);
// Update BLE stage flow based on FINISH notifications
if (stage >= 0x01 && stage <= 0x04) {
setBleStage(5, 'OTA 刷写中...', null);
setBleStageText(finishText.replace('[FINISH] ', ''));
} else if (stage >= 0x05 && stage <= 0x06) {
setBleStage(5, '等待重启...', null);
setBleStageText(finishText.replace('[FINISH] ', ''));
} else if (stage === 0x07) {
setBleStage(5, '即将重启', null);
setBleStage(6, '设备重启', null);
setBleStageText('ESP32 即将重启');
var bar = document.getElementById('bleStageBar');
if (bar) bar.className = 'stage-status-bar ok';
}
// OTA 结果 toast 提示(用户可见,不只是在日志里)
if (stage === 0x83) {
showToast('ESP32 OTA 被其他通道占用,请稍后重试', 'error');
setProgress(0, 'OTA 被其他通道占用');
state.lastUploadStatus = 'failed';
setBleStageError('OTA 被其他通道占用');
} else if (stage === 0x84) {
showToast(`GD32 固件超出分区大小 (${formatBytes(value)},上限 216KB)`, 'error');
setProgress(0, 'GD32 固件太大,无法刷写');
state.lastUploadStatus = 'failed';
setBleStageError('固件超出分区大小');
} else if (stage === 0x81 || stage === 0x82) {
showToast('ESP32 OTA 刷写失败', 'error');
setProgress(0, '刷写失败');
state.lastUploadStatus = 'failed';
setBleStageError('OTA 刷写失败');
showBleVerifyInfo(state.fileSha256 || '-', state.deviceSha256 || '设备未上报', { text: '刷写失败', err: true });
} else if (stage === 0x07) {
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 && state.ackWaiter) {
const waiter = state.ackWaiter;
state.ackWaiter = null;
clearTimeout(waiter.timer);
waiter.reject(new Error(`设备端 FINISH 错误: stage=0x${stage.toString(16).padStart(2, '0').toUpperCase()} value=${value}`));
}
return;
}
// SHA-256 verification from device (after transfer complete)
if (bytes[0] === BLE_OTA_NOTIFY_SHA256 && bytes.length >= 33) {
var devSha = Array.from(bytes.slice(1, 33)).map(function(b) {
return b.toString(16).padStart(2, '0');
}).join('').toUpperCase();
state.deviceSha256 = devSha;
log('VERIFY', '设备端 SHA-256: ' + devSha);
// Compare with front-end SHA-256
var frontSha = state.fileSha256;
if (frontSha && frontSha.toUpperCase() === devSha) {
log('VERIFY', 'SHA-256 校验通过,前端与设备端一致', 'I');
showBleVerifyInfo(frontSha, devSha, { text: '校验通过 — SHA-256 一致', ok: true });
setBleStage(6, 'SHA-256 ✓', null);
setBleStageText('校验通过 — 固件完整性已确认');
} else if (frontSha) {
log('VERIFY', 'SHA-256 校验失败!前端: ' + frontSha + ' 设备: ' + devSha, 'E');
showBleVerifyInfo(frontSha, devSha, { text: '校验失败 — SHA-256 不一致', err: true });
setBleStage(6, 'SHA-256 ✗', null);
setBleStageError('SHA-256 不一致');
} else {
showBleVerifyInfo(undefined, devSha, { text: '前端 SHA-256 不可用,无法比对' });
setBleStage(6, '无法比对', null);
}
return;
}
log('NOTIFY', hex);
}
function waitForAck(cmd, expectedSeq, timeoutMs = 8000, opts = {}) {
const gen = state.ackGeneration;
if (state.lastAckCmd === cmd && state.lastAckSeq >= expectedSeq && 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,
};
});
}
async function enableNotifications() {
try {
await state.dataOutChar.startNotifications();
state.dataOutChar.removeEventListener('characteristicvaluechanged', handleNotify);
state.dataOutChar.addEventListener('characteristicvaluechanged', handleNotify);
state.notificationsEnabled = true;
setConnectionState(!!state.server);
log('BLE', 'DATA_OUT 通知已自动开启');
} catch (err) {
state.notificationsEnabled = false;
setConnectionState(!!state.server);
log('BLE', `自动开启 DATA_OUT 通知失败: ${err.message}`, 'E');
}
}
function updateFileInfo(file) {
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 = Number(els.payloadSize.value) || 480;
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');
}
// SHA-256 computation and display
var shaElem = document.getElementById('fileSha256');
if (file && shaElem) {
shaElem.textContent = '计算中...';
file.arrayBuffer().then(function(buf) {
return crypto.subtle.digest('SHA-256', buf);
}).then(function(hashBuffer) {
var hashArray = Array.from(new Uint8Array(hashBuffer));
var hashHex = hashArray.map(function(b) { return b.toString(16).padStart(2, '0'); }).join('');
shaElem.textContent = hashHex.toUpperCase();
state.fileSha256 = hashHex.toUpperCase();
log('FILE', 'SHA-256: ' + hashHex.toUpperCase());
// If connected, advance to stage 3 done
if (state.server) {
setBleStage(3, 'SHA-256 已计算', null);
setBleStage(4, '', null);
setBleStageText('文件就绪,可以开始刷写');
}
}).catch(function(err) {
shaElem.textContent = '计算失败: ' + err.message;
state.fileSha256 = null;
});
} else if (shaElem) {
shaElem.textContent = '-';
state.fileSha256 = null;
}
setConnectionState(!!state.server);
if (file) {
setProgress(0, `已选择 ${file.name}`);
log('FILE', `已选择: ${file.name} (${formatBytes(file.size)}), 目标=${currentTarget().label}`);
// If connected, show stage 3 as active
if (state.server) {
setBleStage(3, '计算哈希...', 'active');
setBleStageText('正在计算文件 SHA-256...');
}
} else {
setProgress(0, '等待选择文件');
if (state.server) {
resetBleStages();
setBleStage(3, '', null);
setBleStageText('BLE 连接就绪,请选择固件文件');
}
}
}
function syncSelectedFileFromInput() {
// FSAPI 选择的文件以 selectedFileHandle 为准,不从 input 元素同步,避免切标签页回来误清空
if (state.selectedFileHandle) return;
const file = (els.firmwareFile && els.firmwareFile.files && els.firmwareFile.files[0]) || null;
updateFileInfo(file);
}
// 通过 File System Access API 选择文件(支持热重载)
async function pickFileViaFSAPI() {
if (!window.showOpenFilePicker) {
log('FILE', '当前浏览器不支持 File System Access API', 'W');
return false;
}
try {
const handles = await window.showOpenFilePicker({
types: [{ description: '固件文件', accept: { 'application/octet-stream': ['.bin', '.hex'] } }],
multiple: false
});
if (handles && handles.length > 0) {
const handle = handles[0];
const file = await handle.getFile();
state.selectedFileHandle = handle;
updateFileInfo(file);
log('FILE', '已通过 FSAPI 选择文件(支持热重载): ' + file.name);
return true;
}
} catch (e) {
if (e.name !== 'AbortError') {
log('FILE', 'FSAPI 选择失败: ' + e.message, 'W');
}
}
return false;
}
function clearSelectedFile() {
state.selectedFile = null;
state.selectedFileHandle = null;
state.lastUploadStatus = 'idle';
state.fileSha256 = null;
state.deviceSha256 = null;
if (els.firmwareFile) els.firmwareFile.value = '';
updateFileInfo(null);
hideBleVerifyInfo();
// Reset stages back to connection-ready state
if (state.server) {
resetBleStages();
setBleStage(3, '', null);
setBleStageText('BLE 连接就绪,请选择固件文件');
}
log('FILE', '文件已清除');
}
function buildPacket(seq, totalSize, payload, cmd) {
// 协议格式: cmd(1) + seq(2) + total_size(4) + payload
// 注: 已移除CRC占位(BLE链路层已有CRC校验)
const packet = new Uint8Array(1 + 2 + 4 + 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;
}
async function sleep(ms) {
return new Promise(r => setTimeout(r, ms));
}
async function uploadFirmware() {
// 每次刷写前尝试重新从本地读取文件
if (state.selectedFileHandle) {
try {
const freshFile = await state.selectedFileHandle.getFile();
updateFileInfo(freshFile);
log('FILE', '已重新读取本地文件: ' + freshFile.name + ' (' + formatBytes(freshFile.size) + ')');
} catch (e) {
log('FILE', '重新读取文件失败: ' + e.message + ',将使用缓存版本', 'W');
}
} else {
syncSelectedFileFromInput();
}
if (!state.selectedFile || !state.dataInChar) return false;
const cfg = currentTarget();
const payloadSize = Math.max(32, Math.min(498, Number(els.payloadSize.value) || 480));
const file = state.selectedFile;
state.abortRequested = false;
state.uploading = true;
state.lastUploadStatus = 'uploading';
if (els.otaState) {
els.otaState.textContent = '准备中';
els.otaState.className = 'v warn';
}
state.lastAckCmd = null;
state.lastAckSeq = -1;
state.lastAckBytes = 0;
state.ackGeneration = 0;
state.deviceSha256 = null;
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 模式');
}
log('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);
log('OTA', `固件已读取: totalSize=${totalSize}, packetTotal=${packetTotal}, file.size=${file.size}`);
if (totalSize !== file.size) {
log('OTA', `⚠️ file.size(${file.size}) ≠ arrayBuffer(${totalSize}),以 arrayBuffer 为准,请确认页面已 Ctrl+F5 刷新`, 'W');
}
// 计算文件的 SHA-256 哈希值用于完整性验证
setBleStage(4, '计算SHA-256...', null);
setBleStageText('正在计算文件哈希...');
var fileSha256;
if (state.fileSha256) {
// 复用已计算的 SHA-256
fileSha256 = state.fileSha256.toLowerCase();
log('OTA', `复用已计算的 SHA-256: ${fileSha256.toUpperCase()}`);
} else {
log('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();
}
log('OTA', `文件 SHA-256: ${fileSha256.toUpperCase()}`);
// Update SHA display in file info
var shaElem = document.getElementById('fileSha256');
if (shaElem) shaElem.textContent = fileSha256.toUpperCase();
setBleStage(4, '写入中...', null);
setBleStageText(`正在写入 ${packetTotal} 包数据...`);
log('OTA', `开始写入 DATA_IN: 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;
for (let seq = windowStart; seq <= endSeq; seq++) {
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);
await state.dataInChar.writeValueWithoutResponse(packet);
const percent = ((seq + 1) / packetTotal) * 100;
els.sentCount.textContent = String(seq + 1);
setProgress(percent, `[${cfg.label}] 已写 ${seq + 1}/${packetTotal} 包`);
// Update stage detail with progress
setBleStage(4, `${seq + 1}/${packetTotal} 包`, null);
setBleStageText(`数据写入 ${Math.round(percent)}%`);
}
state.ackGeneration++;
let ackOk = false;
try {
await waitForAck(cfg.packetCmd, endSeq, 3000, { expectedTotalSize: totalSize, payloadSize });
ackOk = true;
} catch (e) {
log('OTA', `ACK 等待失败: ${e.message},进入重试`, 'W');
}
let expectedBytes = Math.min((endSeq + 1) * payloadSize, totalSize);
let retryCount = 0;
while ((!ackOk || state.lastAckBytes < expectedBytes) && retryCount < MAX_WINDOW_RETRIES) {
const missing = expectedBytes - state.lastAckBytes;
const missingPackets = Math.ceil(missing / payloadSize);
retryCount++;
log('OTA', `丢包检测: seq=${endSeq} 丢失${missing}字节(${missingPackets}包),第${retryCount}次重发整个窗口`, 'W');
setBleStage(4, `重发窗口 #${retryCount}`, null);
setBleStageText(`丢包重发中 (${retryCount}/${MAX_WINDOW_RETRIES})...`);
await sleep(100);
for (let seq = windowStart; seq <= endSeq; seq++) {
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);
await state.dataInChar.writeValueWithoutResponse(packet);
}
state.ackGeneration++;
ackOk = false;
try {
await waitForAck(cfg.packetCmd, endSeq, 3000, { expectedTotalSize: totalSize, payloadSize });
ackOk = true;
} catch (e) {
log('OTA', `重试 ACK 等待失败: ${e.message}`, 'W');
}
expectedBytes = Math.min((endSeq + 1) * payloadSize, totalSize);
}
if (!ackOk || state.lastAckBytes < expectedBytes) {
const missing = expectedBytes - state.lastAckBytes;
throw new Error(`BLE 丢包检测: seq=${endSeq} 期望${expectedBytes}字节 实际${state.lastAckBytes}字节 丢失${missing}字节(${(missing / payloadSize).toFixed(1)}包) 已重试${MAX_WINDOW_RETRIES}次`);
}
}
// Stage 4 done
setBleStage(4, '完成', null);
setProgress(100, `[${cfg.label}] 固件数据已全部写入 DATA_IN`);
state.lastUploadStatus = 'success';
// Stage 5: Completion trigger
if (cfg.label === 'ESP32') {
setBleStage(5, '自动完成', null);
setBleStage(5, '完成', null);
setBleStage(6, '等待设备校验...', null);
setBleStageText('ESP32 OTA 数据写入完成,等待设备端处理与校验');
// Show verify info — wait for device SHA-256
state.deviceSha256 = null;
showBleVerifyInfo(fileSha256.toUpperCase(), '等待设备上报...', { text: '等待设备端处理并上报 SHA-256' });
return true;
}
// GD32: send trigger command
setBleStage(5, '发送触发...', 'active');
setBleStageText('正在发送触发命令...');
await sleep(30);
const cmdHex = els.ctrlHex.value || cfg.ctrlHex;
const cmd = hexToBytes(cmdHex);
try {
await state.ctrlChar.writeValue(cmd);
setBleStage(5, '已触发', null);
log('OTA', `触发命令已发送: ${toHex(cmd)}`);
} catch (_) {
state.lastUploadStatus = 'failed';
setProgress(0, `触发命令发送失败: ${toHex(cmd)}`);
log('OTA', `触发命令发送失败: ${toHex(cmd)}`, 'E');
setBleStageError('触发命令发送失败');
return false;
}
// Stage 6: SHA-256 verification (GD32 mode)
setBleStage(6, 'GD32 无回传', null);
setBleStageText('GD32 OTA 中继已触发');
state.deviceSha256 = null;
showBleVerifyInfo(fileSha256.toUpperCase(), 'GD32 中继不回传 SHA', { text: 'GD32 模式无设备端校验(仅前端 SHA-256)' });
log('OTA', `✅ 固件刷写完成!`);
log('OTA', `📊 文件 SHA-256: ${fileSha256.toUpperCase()}`);
// Mark all stages done
for (var si = 0; si < bleStageIds.length; si++) {
var el = document.getElementById(bleStageIds[si]);
if (el && el.className !== 'stage-node err') el.className = 'stage-node done';
}
var bar = document.getElementById('bleStageBar');
if (bar) bar.className = 'stage-status-bar ok';
setBleStageText('刷写完成');
return true;
} catch (err) {
state.lastUploadStatus = 'failed';
log('OTA', `写入失败: ${err.message}`, 'E');
setProgress(0, `写入失败: ${err.message}`);
setBleStageError(err.message);
return false;
} finally {
if (state.ackWaiter) {
clearTimeout(state.ackWaiter.timer);
state.ackWaiter = null;
}
state.uploading = false;
setConnectionState(!!state.server);
}
}
async function autoFlash() {
// 每次刷写前尝试重新从本地读取文件
if (state.selectedFileHandle) {
try {
const freshFile = await state.selectedFileHandle.getFile();
updateFileInfo(freshFile);
log('FILE', '已重新读取本地文件: ' + freshFile.name + ' (' + formatBytes(freshFile.size) + ')');
} catch (e) {
log('FILE', '重新读取文件失败: ' + e.message + ',将使用缓存版本', 'W');
}
} else {
syncSelectedFileFromInput();
}
if (!state.selectedFile || !state.server) return;
const cfg = currentTarget();
state.autoFlashing = true;
try {
log('AUTO', `开始一键刷写 -> ${cfg.label}`);
const ok = await uploadFirmware();
if (!ok || state.abortRequested) {
log('AUTO', '一键刷写中止', 'W');
return;
}
setProgress(100, `[${cfg.label}] 刷写完成`);
if (cfg.label === 'ESP32') {
log('AUTO', 'ESP32 OTA 完成,等待设备重启');
} else {
log('AUTO', 'GD32 OTA 中继已触发');
}
} catch (err) {
log('AUTO', `一键刷写异常: ${err && err.message ? err.message : err}`, 'E');
setProgress(0, `一键刷写异常: ${err && err.message ? err.message : err}`);
} finally {
state.autoFlashing = false;
setConnectionState(!!state.server);
}
}
function abortTask() {
state.abortRequested = true;
log('OTA', '已请求中止,等待当前分包返回');
}
els.connectBtn.addEventListener('click', connectBle);
els.disconnectBtn.addEventListener('click', disconnectBle);
els.versionBtn.addEventListener('click', readVersion);
els.uploadBtn.addEventListener('click', uploadFirmware);
els.autoFlashBtn.addEventListener('click', autoFlash);
els.abortBtn.addEventListener('click', abortTask);
els.clearLogBtn.addEventListener('click', clearLogs);
els.uploadZone.addEventListener('click', () => {
if (window.showOpenFilePicker) { pickFileViaFSAPI(); }
else { els.firmwareFile.click(); }
});
els.uploadZone.addEventListener('dragover', (e) => {
e.preventDefault();
e.currentTarget.classList.add('dragover');
});
els.uploadZone.addEventListener('dragleave', (e) => {
e.currentTarget.classList.remove('dragover');
});
els.uploadZone.addEventListener('drop', (e) => {
e.preventDefault();
e.currentTarget.classList.remove('dragover');
const file = e.dataTransfer && e.dataTransfer.files ? (e.dataTransfer.files[0] || null) : null;
if (file) updateFileInfo(file);
});
els.firmwareFile.addEventListener('change', syncSelectedFileFromInput);
els.firmwareFile.addEventListener('input', syncSelectedFileFromInput);
window.addEventListener('focus', () => setTimeout(syncSelectedFileFromInput, 0));
els.clearFileBtn.addEventListener('click', clearSelectedFile);
els.payloadSize.addEventListener('change', () => updateFileInfo(state.selectedFile));
els.targetChip.addEventListener('change', refreshTargetUi);
refreshTargetUi();
setConnectionState(false);
resetBleStages();
// ====== Toast 通知 ======
function showToast(message, type) {
type = type || 'success';
const toast = document.getElementById('toast');
const ico = document.getElementById('toastIco');
const msg = document.getElementById('toastMsg');
if (!toast) return;
toast.className = 'toast ' + (type === 'success' ? 'ok' : type === 'error' ? 'err' : 'warn') + ' show';
if (ico) ico.textContent = type === 'success' ? '\u2713' : type === 'error' ? '\u2715' : '\u26A0';
if (msg) msg.textContent = message;
setTimeout(function () { if (toast) toast.classList.remove('show'); }, 3000);
}
log('SYS', `Web Bluetooth 刷写控制台已就绪 (${UI_BUILD})`);
})();
</script>
<!-- Toast -->
<div class="toast" id="toast">
<span class="toast-ico" id="toastIco">&#10003;</span>
<span id="toastMsg">操作成功</span>
</div>
</body>
</html>