2026-06-14 17:10:24 +08:00
|
|
|
|
<!DOCTYPE html>
|
2026-05-07 23:50:04 +08:00
|
|
|
|
<html lang="zh-CN">
|
|
|
|
|
|
<head>
|
|
|
|
|
|
<meta charset="UTF-8">
|
|
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
2026-05-26 02:44:57 +08:00
|
|
|
|
<title>ESP32 BLE OTA 控制台</title>
|
2026-06-14 17:10:24 +08:00
|
|
|
|
<link rel="stylesheet" href="style.css?v=20260614e">
|
2026-05-07 23:50:04 +08:00
|
|
|
|
</head>
|
|
|
|
|
|
<body>
|
2026-06-14 17:10:24 +08:00
|
|
|
|
<div class="container">
|
2026-05-26 02:44:57 +08:00
|
|
|
|
<header class="header-bar">
|
|
|
|
|
|
<div class="header-brand">
|
2026-06-14 17:10:24 +08:00
|
|
|
|
<span class="dot-mark"></span>
|
2026-05-26 02:44:57 +08:00
|
|
|
|
<strong>ESP32 BLE OTA 控制台</strong>
|
2026-05-07 23:50:04 +08:00
|
|
|
|
</div>
|
2026-06-14 17:10:24 +08:00
|
|
|
|
<div class="header-status">
|
|
|
|
|
|
<span class="pill offline"><span class="indicator"></span><span id="connState">未连接</span></span>
|
2026-05-26 02:44:57 +08:00
|
|
|
|
<span class="pill"><span id="deviceName">-</span></span>
|
|
|
|
|
|
<span class="pill"><span id="deviceId">-</span></span>
|
|
|
|
|
|
<span class="pill"><span id="versionText">未读取</span></span>
|
2026-05-07 23:50:04 +08:00
|
|
|
|
</div>
|
2026-05-26 02:44:57 +08:00
|
|
|
|
</header>
|
2026-05-07 23:50:04 +08:00
|
|
|
|
|
2026-06-14 17:10:24 +08:00
|
|
|
|
<div class="main-layout">
|
2026-05-26 02:44:57 +08:00
|
|
|
|
<div>
|
|
|
|
|
|
<div class="card">
|
|
|
|
|
|
<div class="card-head">🔗 蓝牙连接</div>
|
|
|
|
|
|
<div class="card-body">
|
2026-06-14 17:10:24 +08:00
|
|
|
|
<div class="helper-note" style="margin-bottom:12px;">
|
2026-05-26 02:44:57 +08:00
|
|
|
|
本页走 <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>
|
2026-06-14 17:10:24 +08:00
|
|
|
|
<button id="disconnectBtn" class="btn btn-outline" disabled>断开连接</button>
|
2026-05-26 02:44:57 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div class="sub-actions">
|
2026-06-14 17:10:24 +08:00
|
|
|
|
<button id="versionBtn" class="btn btn-outline" disabled>读取版本</button>
|
|
|
|
|
|
<a class="btn btn-outline" href="index.html" target="_blank">回 WiFi 页面 ↗</a>
|
2026-05-26 02:44:57 +08:00
|
|
|
|
</div>
|
2026-05-07 23:50:04 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-05-26 02:44:57 +08:00
|
|
|
|
|
|
|
|
|
|
<div class="card">
|
|
|
|
|
|
<div class="card-head">🎯 升级目标</div>
|
|
|
|
|
|
<div class="card-body">
|
|
|
|
|
|
<div class="form-row">
|
2026-06-14 17:10:24 +08:00
|
|
|
|
<label class="form-label" for="targetChip">目标芯片</label>
|
2026-05-26 02:44:57 +08:00
|
|
|
|
<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;">
|
2026-06-14 17:10:24 +08:00
|
|
|
|
<label class="form-label" for="payloadSize">每包 Payload</label>
|
|
|
|
|
|
<input id="payloadSize" class="input" type="number" min="32" max="498" value="480">
|
2026-05-26 02:44:57 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
<div class="form-row" style="margin:0;">
|
2026-06-14 17:10:24 +08:00
|
|
|
|
<label class="form-label" for="ctrlHex">触发命令(HEX)</label>
|
|
|
|
|
|
<input id="ctrlHex" class="input" type="text" value="F7" maxlength="8">
|
2026-05-26 02:44:57 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-06-14 17:10:24 +08:00
|
|
|
|
<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>。
|
2026-05-26 02:44:57 +08:00
|
|
|
|
</div>
|
2026-05-07 23:50:04 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-05-26 02:44:57 +08:00
|
|
|
|
|
|
|
|
|
|
<div class="card">
|
|
|
|
|
|
<div class="card-head">📁 固件文件</div>
|
|
|
|
|
|
<div class="card-body">
|
|
|
|
|
|
<div class="form-row">
|
2026-06-14 17:10:24 +08:00
|
|
|
|
<label class="form-label">固件文件</label>
|
2026-05-26 02:44:57 +08:00
|
|
|
|
<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>
|
2026-06-14 17:10:24 +08:00
|
|
|
|
<button id="clearFileBtn" class="btn btn-outline" type="button">清除</button>
|
2026-05-26 02:44:57 +08:00
|
|
|
|
</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>
|
2026-06-14 17:10:24 +08:00
|
|
|
|
<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>
|
2026-05-26 02:44:57 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="footer-note">
|
|
|
|
|
|
建议使用 <b>Chrome / Edge / Android Chromium</b>。若首包就失败,通常是当前设备协商到的 ATT MTU 偏小,可将 payload 从 <b>480B</b> 手动降回 <b>240B</b> 或更低再试。
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-05-07 23:50:04 +08:00
|
|
|
|
</div>
|
2026-05-26 02:44:57 +08:00
|
|
|
|
|
|
|
|
|
|
<div class="card">
|
|
|
|
|
|
<div class="card-head">⚡ 执行操作</div>
|
|
|
|
|
|
<div class="card-body">
|
2026-06-14 17:10:24 +08:00
|
|
|
|
<div class="helper-note" id="readyHint" style="margin-bottom:12px;">当前未连接设备。建议顺序:连接设备 → 选择固件 → 开始刷写。</div>
|
2026-05-26 02:44:57 +08:00
|
|
|
|
<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>
|
2026-06-14 17:10:24 +08:00
|
|
|
|
<button id="clearLogBtn" class="btn btn-outline" type="button">清空日志</button>
|
2026-05-26 02:44:57 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-05-07 23:50:04 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-05-26 02:44:57 +08:00
|
|
|
|
<div>
|
|
|
|
|
|
<div class="card">
|
|
|
|
|
|
<div class="card-head">📡 设备状态</div>
|
|
|
|
|
|
<div class="card-body">
|
2026-06-14 17:10:24 +08:00
|
|
|
|
<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>
|
2026-05-26 02:44:57 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-05-07 23:50:04 +08:00
|
|
|
|
</div>
|
2026-05-26 02:44:57 +08:00
|
|
|
|
|
|
|
|
|
|
<div class="card">
|
|
|
|
|
|
<div class="card-head">📊 OTA 进度</div>
|
|
|
|
|
|
<div class="card-body">
|
2026-06-14 17:10:24 +08:00
|
|
|
|
<div class="progress-wrap show">
|
|
|
|
|
|
<div class="progress-track"><div class="progress-fill" id="progressFill"></div></div>
|
|
|
|
|
|
<div class="progress-text">
|
2026-05-26 02:44:57 +08:00
|
|
|
|
<span id="progressText">等待选择文件</span>
|
|
|
|
|
|
<span id="progressPercent">0%</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-06-14 17:10:24 +08:00
|
|
|
|
<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>
|
|
|
|
|
|
|
2026-05-26 02:44:57 +08:00
|
|
|
|
<div class="card">
|
|
|
|
|
|
<div class="card-head">🖥️ 运行日志</div>
|
|
|
|
|
|
<div class="card-body">
|
2026-06-14 17:10:24 +08:00
|
|
|
|
<div class="log-scroll" id="logBox"></div>
|
2026-05-26 02:44:57 +08:00
|
|
|
|
</div>
|
2026-05-07 23:50:04 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2026-05-26 02:44:57 +08:00
|
|
|
|
</div>
|
2026-05-07 23:50:04 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<script>
|
|
|
|
|
|
(() => {
|
|
|
|
|
|
const UUIDS = {
|
|
|
|
|
|
service: 0xFFFF,
|
|
|
|
|
|
ctrl: 0x0001,
|
|
|
|
|
|
dataIn: 0x0002,
|
|
|
|
|
|
dataOut: 0x0003,
|
|
|
|
|
|
version: 0x0004,
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-05-26 02:44:57 +08:00
|
|
|
|
const UI_BUILD = '2026-05-08-ble-console-v1';
|
2026-05-07 23:50:04 +08:00
|
|
|
|
const BLE_OTA_NOTIFY_ACK = 0xA1;
|
|
|
|
|
|
const BLE_OTA_NOTIFY_FINISH = 0xB1;
|
2026-06-14 17:10:24 +08:00
|
|
|
|
const BLE_OTA_NOTIFY_SHA256 = 0xC1;
|
2026-06-13 01:24:11 +08:00
|
|
|
|
const BLE_OTA_ACK_WINDOW = 32;
|
2026-05-07 23:50:04 +08:00
|
|
|
|
|
|
|
|
|
|
const TARGETS = {
|
|
|
|
|
|
gd32: {
|
|
|
|
|
|
label: 'GD32',
|
|
|
|
|
|
packetCmd: 0x00,
|
|
|
|
|
|
ctrlHex: 'F7',
|
|
|
|
|
|
recommendedPayload: 480,
|
2026-05-26 02:44:57 +08:00
|
|
|
|
finishText: '自动触发中继',
|
|
|
|
|
|
hint: '当前目标:<b>GD32</b>。页面会使用 <b>cmd=0x00</b> 发送数据,写入完成后自动触发 SPI OTA 中继。'
|
2026-05-07 23:50:04 +08:00
|
|
|
|
},
|
|
|
|
|
|
esp32: {
|
|
|
|
|
|
label: 'ESP32',
|
|
|
|
|
|
packetCmd: 0x01,
|
|
|
|
|
|
ctrlHex: 'F8',
|
|
|
|
|
|
recommendedPayload: 480,
|
2026-05-26 02:44:57 +08:00
|
|
|
|
finishText: '自动完成 OTA',
|
|
|
|
|
|
hint: '当前目标:<b>ESP32</b>。页面会使用 <b>cmd=0x01</b> 发送数据,最后一包到齐后由设备端自动完成 OTA 并重启。'
|
2026-05-07 23:50:04 +08:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const state = {
|
|
|
|
|
|
device: null,
|
|
|
|
|
|
server: null,
|
|
|
|
|
|
service: null,
|
|
|
|
|
|
ctrlChar: null,
|
|
|
|
|
|
dataInChar: null,
|
|
|
|
|
|
dataOutChar: null,
|
|
|
|
|
|
versionChar: null,
|
|
|
|
|
|
notificationsEnabled: false,
|
|
|
|
|
|
selectedFile: null,
|
2026-05-30 20:04:04 +08:00
|
|
|
|
selectedFileHandle: null, // FileSystemFileHandle for hot reload
|
2026-05-07 23:50:04 +08:00
|
|
|
|
abortRequested: false,
|
|
|
|
|
|
uploading: false,
|
|
|
|
|
|
autoFlashing: false,
|
2026-05-26 02:44:57 +08:00
|
|
|
|
lastUploadStatus: 'idle',
|
2026-05-07 23:50:04 +08:00
|
|
|
|
lastNotificationHex: '',
|
|
|
|
|
|
lastAckCmd: null,
|
|
|
|
|
|
lastAckSeq: -1,
|
|
|
|
|
|
lastAckBytes: 0,
|
|
|
|
|
|
ackWaiter: null,
|
2026-06-14 17:10:24 +08:00
|
|
|
|
fileSha256: null,
|
|
|
|
|
|
deviceSha256: null,
|
2026-05-07 23:50:04 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const $ = (id) => document.getElementById(id);
|
|
|
|
|
|
const els = {
|
|
|
|
|
|
connectBtn: $('connectBtn'),
|
|
|
|
|
|
disconnectBtn: $('disconnectBtn'),
|
|
|
|
|
|
versionBtn: $('versionBtn'),
|
|
|
|
|
|
uploadBtn: $('uploadBtn'),
|
|
|
|
|
|
autoFlashBtn: $('autoFlashBtn'),
|
|
|
|
|
|
abortBtn: $('abortBtn'),
|
2026-05-26 02:44:57 +08:00
|
|
|
|
clearLogBtn: $('clearLogBtn'),
|
|
|
|
|
|
uploadZone: $('uploadZone'),
|
2026-05-07 23:50:04 +08:00
|
|
|
|
firmwareFile: $('firmwareFile'),
|
|
|
|
|
|
payloadSize: $('payloadSize'),
|
|
|
|
|
|
targetChip: $('targetChip'),
|
|
|
|
|
|
ctrlHex: $('ctrlHex'),
|
|
|
|
|
|
protocolHint: $('protocolHint'),
|
|
|
|
|
|
connState: $('connState'),
|
|
|
|
|
|
connBadge: $('connBadge'),
|
|
|
|
|
|
deviceName: $('deviceName'),
|
|
|
|
|
|
deviceId: $('deviceId'),
|
|
|
|
|
|
gattState: $('gattState'),
|
|
|
|
|
|
versionText: $('versionText'),
|
2026-05-26 02:44:57 +08:00
|
|
|
|
versionMirror: $('versionMirror'),
|
|
|
|
|
|
otaState: $('otaState'),
|
|
|
|
|
|
fileInfo: $('fileInfo'),
|
|
|
|
|
|
clearFileBtn: $('clearFileBtn'),
|
2026-05-07 23:50:04 +08:00
|
|
|
|
progressFill: $('progressFill'),
|
|
|
|
|
|
progressText: $('progressText'),
|
|
|
|
|
|
progressPercent: $('progressPercent'),
|
|
|
|
|
|
fileName: $('fileName'),
|
2026-05-26 02:44:57 +08:00
|
|
|
|
fileType: $('fileType'),
|
2026-05-07 23:50:04 +08:00
|
|
|
|
fileSize: $('fileSize'),
|
|
|
|
|
|
packetCount: $('packetCount'),
|
|
|
|
|
|
sentCount: $('sentCount'),
|
|
|
|
|
|
logBox: $('logBox'),
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
function currentTarget() {
|
|
|
|
|
|
return TARGETS[els.targetChip.value] || TARGETS.gd32;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-26 02:44:57 +08:00
|
|
|
|
const MAX_LOG_LINES = 1000;
|
|
|
|
|
|
|
2026-05-07 23:50:04 +08:00
|
|
|
|
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);
|
2026-05-26 02:44:57 +08:00
|
|
|
|
while (els.logBox.children.length > MAX_LOG_LINES) {
|
|
|
|
|
|
els.logBox.removeChild(els.logBox.firstChild);
|
|
|
|
|
|
}
|
2026-05-07 23:50:04 +08:00
|
|
|
|
els.logBox.scrollTop = els.logBox.scrollHeight;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-26 02:44:57 +08:00
|
|
|
|
function clearLogs() {
|
|
|
|
|
|
els.logBox.innerHTML = '';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-07 23:50:04 +08:00
|
|
|
|
function escapeHtml(s) {
|
|
|
|
|
|
return String(s).replace(/[&<>"']/g, (m) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[m]));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-14 17:10:24 +08:00
|
|
|
|
// ====== 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'; }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-07 23:50:04 +08:00
|
|
|
|
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;
|
2026-06-14 17:10:24 +08:00
|
|
|
|
// Toggle done class for green finish effect
|
|
|
|
|
|
if (p >= 100) {
|
|
|
|
|
|
els.progressFill.classList.add('done');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
els.progressFill.classList.remove('done');
|
|
|
|
|
|
}
|
2026-05-26 02:44:57 +08:00
|
|
|
|
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;
|
2026-05-07 23:50:04 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function setConnectionState(connected) {
|
|
|
|
|
|
const idle = connected && state.selectedFile && !state.uploading;
|
|
|
|
|
|
els.connState.textContent = connected ? '已连接' : '未连接';
|
2026-05-26 02:44:57 +08:00
|
|
|
|
if (els.connState.parentElement) {
|
|
|
|
|
|
els.connState.parentElement.className = connected ? 'pill online' : 'pill offline';
|
|
|
|
|
|
}
|
2026-05-07 23:50:04 +08:00
|
|
|
|
els.connBadge.textContent = connected ? 'online' : 'offline';
|
2026-05-26 02:44:57 +08:00
|
|
|
|
els.connBadge.className = `v ${connected ? 'ok' : 'bad'}`;
|
2026-05-07 23:50:04 +08:00
|
|
|
|
els.gattState.textContent = connected ? 'GATT 已就绪' : '未就绪';
|
2026-05-26 02:44:57 +08:00
|
|
|
|
if (connected) {
|
|
|
|
|
|
els.connectBtn.disabled = true;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
els.connectBtn.disabled = false;
|
|
|
|
|
|
}
|
2026-05-07 23:50:04 +08:00
|
|
|
|
els.disconnectBtn.disabled = !connected;
|
|
|
|
|
|
els.versionBtn.disabled = !connected;
|
|
|
|
|
|
els.uploadBtn.disabled = !idle;
|
|
|
|
|
|
els.autoFlashBtn.disabled = !idle;
|
|
|
|
|
|
els.abortBtn.disabled = !state.uploading;
|
2026-05-26 02:44:57 +08:00
|
|
|
|
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('设备与文件都已就绪,可以直接开始刷写或执行一键刷写。');
|
|
|
|
|
|
}
|
2026-05-07 23:50:04 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
2026-05-26 02:44:57 +08:00
|
|
|
|
state.lastUploadStatus = 'idle';
|
2026-06-14 17:10:24 +08:00
|
|
|
|
state.deviceSha256 = null;
|
2026-05-26 02:44:57 +08:00
|
|
|
|
if (els.otaState) {
|
|
|
|
|
|
els.otaState.textContent = '离线';
|
|
|
|
|
|
els.otaState.className = 'v bad';
|
|
|
|
|
|
}
|
2026-05-07 23:50:04 +08:00
|
|
|
|
els.deviceName.textContent = '-';
|
|
|
|
|
|
els.deviceId.textContent = '-';
|
|
|
|
|
|
els.versionText.textContent = '未读取';
|
2026-05-26 02:44:57 +08:00
|
|
|
|
els.versionMirror.textContent = '未读取';
|
2026-06-14 17:10:24 +08:00
|
|
|
|
resetBleStages();
|
|
|
|
|
|
hideBleVerifyInfo();
|
2026-05-07 23:50:04 +08:00
|
|
|
|
setConnectionState(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function refreshTargetUi() {
|
|
|
|
|
|
const cfg = currentTarget();
|
|
|
|
|
|
els.ctrlHex.value = cfg.ctrlHex;
|
|
|
|
|
|
els.payloadSize.value = String(cfg.recommendedPayload);
|
2026-06-14 17:10:24 +08:00
|
|
|
|
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 中继。'}`;
|
2026-05-07 23:50:04 +08:00
|
|
|
|
if (state.selectedFile) updateFileInfo(state.selectedFile);
|
|
|
|
|
|
setConnectionState(!!state.server);
|
2026-05-26 02:44:57 +08:00
|
|
|
|
log('CFG', `目标切换为 ${cfg.label}: packetCmd=0x${cfg.packetCmd.toString(16).padStart(2, '0').toUpperCase()}, payload=${cfg.recommendedPayload}`);
|
2026-05-07 23:50:04 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function connectBle() {
|
|
|
|
|
|
if (!navigator.bluetooth) {
|
|
|
|
|
|
log('BLE', '当前浏览器不支持 Web Bluetooth', 'E');
|
|
|
|
|
|
alert('当前浏览器不支持 Web Bluetooth,请使用 Chrome / Edge。');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
2026-06-14 17:10:24 +08:00
|
|
|
|
resetBleStages();
|
|
|
|
|
|
setBleStage(1, '扫描中...', 'active');
|
|
|
|
|
|
setBleStageText('正在打开设备选择器...');
|
2026-05-07 23:50:04 +08:00
|
|
|
|
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 || '-';
|
|
|
|
|
|
|
2026-06-14 17:10:24 +08:00
|
|
|
|
setBleStage(1, '已选择', null);
|
|
|
|
|
|
setBleStage(2, '连接GATT...', 'active');
|
|
|
|
|
|
setBleStageText('正在连接 GATT 服务...');
|
2026-05-07 23:50:04 +08:00
|
|
|
|
log('BLE', `已选择设备: ${device.name || 'unknown'}`);
|
|
|
|
|
|
const server = await device.gatt.connect();
|
|
|
|
|
|
state.server = server;
|
2026-06-14 17:10:24 +08:00
|
|
|
|
|
|
|
|
|
|
setBleStage(2, '获取服务...', null);
|
|
|
|
|
|
setBleStageText('获取 GATT 服务和特征...');
|
2026-05-07 23:50:04 +08:00
|
|
|
|
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);
|
|
|
|
|
|
|
2026-06-14 17:10:24 +08:00
|
|
|
|
setBleStage(2, '已就绪', null);
|
2026-05-07 23:50:04 +08:00
|
|
|
|
setConnectionState(true);
|
|
|
|
|
|
log('BLE', 'GATT 服务与特征获取完成');
|
2026-05-26 02:44:57 +08:00
|
|
|
|
await enableNotifications();
|
2026-06-14 17:10:24 +08:00
|
|
|
|
// Mark stages 1-2 as done
|
|
|
|
|
|
setBleStage(3, '', null);
|
|
|
|
|
|
setBleStageText('BLE 连接就绪,请选择固件文件');
|
2026-05-07 23:50:04 +08:00
|
|
|
|
} catch (err) {
|
|
|
|
|
|
log('BLE', `连接失败: ${err.message}`, 'E');
|
2026-06-14 17:10:24 +08:00
|
|
|
|
if (err.name !== 'NotFoundError') {
|
|
|
|
|
|
setBleStageError(err.message);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
resetBleStages();
|
|
|
|
|
|
}
|
2026-05-07 23:50:04 +08:00
|
|
|
|
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;
|
2026-05-26 02:44:57 +08:00
|
|
|
|
els.versionMirror.textContent = text;
|
2026-05-07 23:50:04 +08:00
|
|
|
|
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;
|
|
|
|
|
|
|
2026-05-26 02:44:57 +08:00
|
|
|
|
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);
|
|
|
|
|
|
|
2026-06-14 17:10:24 +08:00
|
|
|
|
if (state.ackWaiter && state.ackWaiter.cmd === ackCmd && ackSeq >= state.ackWaiter.expectedSeq
|
|
|
|
|
|
&& state.ackWaiter.generation === state.ackGeneration) {
|
2026-05-07 23:50:04 +08:00
|
|
|
|
const waiter = state.ackWaiter;
|
2026-06-14 17:10:24 +08:00
|
|
|
|
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
|
2026-05-07 23:50:04 +08:00
|
|
|
|
state.ackWaiter = null;
|
|
|
|
|
|
clearTimeout(waiter.timer);
|
|
|
|
|
|
waiter.resolve({ seq: ackSeq, bytes: state.lastAckBytes });
|
|
|
|
|
|
}
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-14 17:10:24 +08:00
|
|
|
|
|
2026-05-07 23:50:04 +08:00
|
|
|
|
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}`,
|
2026-06-14 17:10:24 +08:00
|
|
|
|
0x83: `[FINISH] ESP32 OTA 被其他通道占用,互斥拒绝`,
|
|
|
|
|
|
0x84: `[FINISH] GD32 固件超出分区大小: ${formatBytes(value)} (上限 216KB)`,
|
2026-05-07 23:50:04 +08:00
|
|
|
|
};
|
2026-05-26 02:44:57 +08:00
|
|
|
|
const finishText = finishMap[stage] || `[FINISH] 未知阶段 stage=0x${stage.toString(16).padStart(2, '0').toUpperCase()} value=${value}`;
|
|
|
|
|
|
log('FINISH', finishText);
|
2026-06-14 17:10:24 +08:00
|
|
|
|
|
|
|
|
|
|
// 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}`));
|
|
|
|
|
|
}
|
2026-05-07 23:50:04 +08:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-14 17:10:24 +08:00
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-07 23:50:04 +08:00
|
|
|
|
log('NOTIFY', hex);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-14 17:10:24 +08:00
|
|
|
|
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
|
2026-05-07 23:50:04 +08:00
|
|
|
|
}
|
|
|
|
|
|
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);
|
2026-06-14 17:10:24 +08:00
|
|
|
|
state.ackWaiter = {
|
|
|
|
|
|
cmd, expectedSeq, resolve, reject, timer, generation: gen,
|
|
|
|
|
|
expectedTotalSize: opts.expectedTotalSize,
|
|
|
|
|
|
payloadSize: opts.payloadSize,
|
|
|
|
|
|
};
|
2026-05-07 23:50:04 +08:00
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function enableNotifications() {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await state.dataOutChar.startNotifications();
|
|
|
|
|
|
state.dataOutChar.removeEventListener('characteristicvaluechanged', handleNotify);
|
|
|
|
|
|
state.dataOutChar.addEventListener('characteristicvaluechanged', handleNotify);
|
|
|
|
|
|
state.notificationsEnabled = true;
|
2026-05-26 02:44:57 +08:00
|
|
|
|
setConnectionState(!!state.server);
|
|
|
|
|
|
log('BLE', 'DATA_OUT 通知已自动开启');
|
2026-05-07 23:50:04 +08:00
|
|
|
|
} catch (err) {
|
2026-05-26 02:44:57 +08:00
|
|
|
|
state.notificationsEnabled = false;
|
|
|
|
|
|
setConnectionState(!!state.server);
|
|
|
|
|
|
log('BLE', `自动开启 DATA_OUT 通知失败: ${err.message}`, 'E');
|
2026-05-07 23:50:04 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function updateFileInfo(file) {
|
|
|
|
|
|
els.fileName.textContent = file ? file.name : '-';
|
2026-05-26 02:44:57 +08:00
|
|
|
|
els.fileType.textContent = file ? (file.name.toLowerCase().includes('.hex') ? 'Intel HEX' : 'Binary') : '-';
|
2026-05-07 23:50:04 +08:00
|
|
|
|
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;
|
2026-05-26 02:44:57 +08:00
|
|
|
|
state.lastUploadStatus = 'idle';
|
|
|
|
|
|
|
|
|
|
|
|
if (els.fileInfo) {
|
|
|
|
|
|
if (file) els.fileInfo.classList.add('show');
|
|
|
|
|
|
else els.fileInfo.classList.remove('show');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-14 17:10:24 +08:00
|
|
|
|
// 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-07 23:50:04 +08:00
|
|
|
|
setConnectionState(!!state.server);
|
|
|
|
|
|
if (file) {
|
|
|
|
|
|
setProgress(0, `已选择 ${file.name}`);
|
|
|
|
|
|
log('FILE', `已选择: ${file.name} (${formatBytes(file.size)}), 目标=${currentTarget().label}`);
|
2026-06-14 17:10:24 +08:00
|
|
|
|
// If connected, show stage 3 as active
|
|
|
|
|
|
if (state.server) {
|
|
|
|
|
|
setBleStage(3, '计算哈希...', 'active');
|
|
|
|
|
|
setBleStageText('正在计算文件 SHA-256...');
|
|
|
|
|
|
}
|
2026-05-26 02:44:57 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
setProgress(0, '等待选择文件');
|
2026-06-14 17:10:24 +08:00
|
|
|
|
if (state.server) {
|
|
|
|
|
|
resetBleStages();
|
|
|
|
|
|
setBleStage(3, '', null);
|
|
|
|
|
|
setBleStageText('BLE 连接就绪,请选择固件文件');
|
|
|
|
|
|
}
|
2026-05-07 23:50:04 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-26 02:44:57 +08:00
|
|
|
|
function syncSelectedFileFromInput() {
|
2026-06-13 01:24:11 +08:00
|
|
|
|
// FSAPI 选择的文件以 selectedFileHandle 为准,不从 input 元素同步,避免切标签页回来误清空
|
|
|
|
|
|
if (state.selectedFileHandle) return;
|
2026-05-26 02:44:57 +08:00
|
|
|
|
const file = (els.firmwareFile && els.firmwareFile.files && els.firmwareFile.files[0]) || null;
|
|
|
|
|
|
updateFileInfo(file);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-30 20:04:04 +08:00
|
|
|
|
// 通过 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-26 02:44:57 +08:00
|
|
|
|
function clearSelectedFile() {
|
|
|
|
|
|
state.selectedFile = null;
|
2026-05-30 20:04:04 +08:00
|
|
|
|
state.selectedFileHandle = null;
|
2026-05-26 02:44:57 +08:00
|
|
|
|
state.lastUploadStatus = 'idle';
|
2026-06-14 17:10:24 +08:00
|
|
|
|
state.fileSha256 = null;
|
|
|
|
|
|
state.deviceSha256 = null;
|
2026-05-26 02:44:57 +08:00
|
|
|
|
if (els.firmwareFile) els.firmwareFile.value = '';
|
|
|
|
|
|
updateFileInfo(null);
|
2026-06-14 17:10:24 +08:00
|
|
|
|
hideBleVerifyInfo();
|
|
|
|
|
|
// Reset stages back to connection-ready state
|
|
|
|
|
|
if (state.server) {
|
|
|
|
|
|
resetBleStages();
|
|
|
|
|
|
setBleStage(3, '', null);
|
|
|
|
|
|
setBleStageText('BLE 连接就绪,请选择固件文件');
|
|
|
|
|
|
}
|
2026-05-26 02:44:57 +08:00
|
|
|
|
log('FILE', '文件已清除');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-07 23:50:04 +08:00
|
|
|
|
function buildPacket(seq, totalSize, payload, cmd) {
|
2026-06-14 17:10:24 +08:00
|
|
|
|
// 协议格式: cmd(1) + seq(2) + total_size(4) + payload
|
|
|
|
|
|
// 注: 已移除CRC占位(BLE链路层已有CRC校验)
|
|
|
|
|
|
const packet = new Uint8Array(1 + 2 + 4 + payload.length);
|
2026-05-07 23:50:04 +08:00
|
|
|
|
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() {
|
2026-05-30 20:04:04 +08:00
|
|
|
|
// 每次刷写前尝试重新从本地读取文件
|
|
|
|
|
|
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();
|
|
|
|
|
|
}
|
2026-05-07 23:50:04 +08:00
|
|
|
|
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;
|
2026-05-26 02:44:57 +08:00
|
|
|
|
state.lastUploadStatus = 'uploading';
|
|
|
|
|
|
if (els.otaState) {
|
|
|
|
|
|
els.otaState.textContent = '准备中';
|
|
|
|
|
|
els.otaState.className = 'v warn';
|
|
|
|
|
|
}
|
2026-05-07 23:50:04 +08:00
|
|
|
|
state.lastAckCmd = null;
|
|
|
|
|
|
state.lastAckSeq = -1;
|
|
|
|
|
|
state.lastAckBytes = 0;
|
2026-06-14 17:10:24 +08:00
|
|
|
|
state.ackGeneration = 0;
|
|
|
|
|
|
state.deviceSha256 = null;
|
2026-05-07 23:50:04 +08:00
|
|
|
|
if (state.ackWaiter) {
|
|
|
|
|
|
clearTimeout(state.ackWaiter.timer);
|
|
|
|
|
|
state.ackWaiter = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
setConnectionState(true);
|
|
|
|
|
|
|
2026-06-14 17:10:24 +08:00
|
|
|
|
// Stage 4: Data writing
|
|
|
|
|
|
setBleStage(4, '准备中...', 'active');
|
|
|
|
|
|
setBleStageText('正在准备数据写入...');
|
|
|
|
|
|
hideBleVerifyInfo();
|
|
|
|
|
|
|
2026-05-07 23:50:04 +08:00
|
|
|
|
try {
|
|
|
|
|
|
if (!state.notificationsEnabled) {
|
|
|
|
|
|
await enableNotifications();
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!state.notificationsEnabled) {
|
|
|
|
|
|
throw new Error('DATA_OUT 通知未开启,无法使用 ACK 模式');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-13 01:24:11 +08:00
|
|
|
|
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}] 正在读取固件文件...`);
|
2026-06-14 17:10:24 +08:00
|
|
|
|
setBleStage(4, '读取固件...', null);
|
2026-06-13 01:24:11 +08:00
|
|
|
|
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');
|
2026-05-07 23:50:04 +08:00
|
|
|
|
}
|
2026-06-14 17:10:24 +08:00
|
|
|
|
|
|
|
|
|
|
// 计算文件的 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} 包数据...`);
|
2026-05-07 23:50:04 +08:00
|
|
|
|
log('OTA', `开始写入 DATA_IN: target=${cfg.label}, file=${file.name}, total=${totalSize}, packets=${packetTotal}, payload=${payloadSize}, windowAck=${BLE_OTA_ACK_WINDOW}`);
|
|
|
|
|
|
|
2026-06-14 17:10:24 +08:00
|
|
|
|
const MAX_WINDOW_RETRIES = 3;
|
2026-05-07 23:50:04 +08:00
|
|
|
|
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;
|
2026-06-14 17:10:24 +08:00
|
|
|
|
|
2026-05-07 23:50:04 +08:00
|
|
|
|
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);
|
2026-06-13 01:24:11 +08:00
|
|
|
|
setProgress(percent, `[${cfg.label}] 已写 ${seq + 1}/${packetTotal} 包`);
|
2026-06-14 17:10:24 +08:00
|
|
|
|
// Update stage detail with progress
|
|
|
|
|
|
setBleStage(4, `${seq + 1}/${packetTotal} 包`, null);
|
|
|
|
|
|
setBleStageText(`数据写入 ${Math.round(percent)}%`);
|
2026-05-07 23:50:04 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-14 17:10:24 +08:00
|
|
|
|
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');
|
|
|
|
|
|
}
|
2026-06-13 01:24:11 +08:00
|
|
|
|
|
2026-06-14 17:10:24 +08:00
|
|
|
|
let expectedBytes = Math.min((endSeq + 1) * payloadSize, totalSize);
|
|
|
|
|
|
let retryCount = 0;
|
|
|
|
|
|
while ((!ackOk || state.lastAckBytes < expectedBytes) && retryCount < MAX_WINDOW_RETRIES) {
|
2026-06-13 01:24:11 +08:00
|
|
|
|
const missing = expectedBytes - state.lastAckBytes;
|
2026-06-14 17:10:24 +08:00
|
|
|
|
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}次`);
|
2026-05-07 23:50:04 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-14 17:10:24 +08:00
|
|
|
|
// Stage 4 done
|
|
|
|
|
|
setBleStage(4, '完成', null);
|
2026-05-07 23:50:04 +08:00
|
|
|
|
setProgress(100, `[${cfg.label}] 固件数据已全部写入 DATA_IN`);
|
2026-05-26 02:44:57 +08:00
|
|
|
|
state.lastUploadStatus = 'success';
|
2026-06-14 17:10:24 +08:00
|
|
|
|
|
|
|
|
|
|
// Stage 5: Completion trigger
|
2026-05-07 23:50:04 +08:00
|
|
|
|
if (cfg.label === 'ESP32') {
|
2026-06-14 17:10:24 +08:00
|
|
|
|
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' });
|
2026-05-07 23:50:04 +08:00
|
|
|
|
return true;
|
|
|
|
|
|
}
|
2026-06-14 17:10:24 +08:00
|
|
|
|
|
|
|
|
|
|
// GD32: send trigger command
|
|
|
|
|
|
setBleStage(5, '发送触发...', 'active');
|
|
|
|
|
|
setBleStageText('正在发送触发命令...');
|
2026-06-13 01:24:11 +08:00
|
|
|
|
await sleep(30);
|
2026-05-26 02:44:57 +08:00
|
|
|
|
const cmdHex = els.ctrlHex.value || cfg.ctrlHex;
|
|
|
|
|
|
const cmd = hexToBytes(cmdHex);
|
|
|
|
|
|
try {
|
|
|
|
|
|
await state.ctrlChar.writeValue(cmd);
|
2026-06-14 17:10:24 +08:00
|
|
|
|
setBleStage(5, '已触发', null);
|
|
|
|
|
|
log('OTA', `触发命令已发送: ${toHex(cmd)}`);
|
2026-05-26 02:44:57 +08:00
|
|
|
|
} catch (_) {
|
|
|
|
|
|
state.lastUploadStatus = 'failed';
|
|
|
|
|
|
setProgress(0, `触发命令发送失败: ${toHex(cmd)}`);
|
|
|
|
|
|
log('OTA', `触发命令发送失败: ${toHex(cmd)}`, 'E');
|
2026-06-14 17:10:24 +08:00
|
|
|
|
setBleStageError('触发命令发送失败');
|
2026-05-26 02:44:57 +08:00
|
|
|
|
return false;
|
|
|
|
|
|
}
|
2026-06-14 17:10:24 +08:00
|
|
|
|
|
|
|
|
|
|
// 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('刷写完成');
|
|
|
|
|
|
|
2026-05-07 23:50:04 +08:00
|
|
|
|
return true;
|
|
|
|
|
|
} catch (err) {
|
2026-05-26 02:44:57 +08:00
|
|
|
|
state.lastUploadStatus = 'failed';
|
2026-05-07 23:50:04 +08:00
|
|
|
|
log('OTA', `写入失败: ${err.message}`, 'E');
|
|
|
|
|
|
setProgress(0, `写入失败: ${err.message}`);
|
2026-06-14 17:10:24 +08:00
|
|
|
|
setBleStageError(err.message);
|
2026-05-07 23:50:04 +08:00
|
|
|
|
return false;
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
if (state.ackWaiter) {
|
|
|
|
|
|
clearTimeout(state.ackWaiter.timer);
|
|
|
|
|
|
state.ackWaiter = null;
|
|
|
|
|
|
}
|
|
|
|
|
|
state.uploading = false;
|
|
|
|
|
|
setConnectionState(!!state.server);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function autoFlash() {
|
2026-05-30 20:04:04 +08:00
|
|
|
|
// 每次刷写前尝试重新从本地读取文件
|
|
|
|
|
|
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();
|
|
|
|
|
|
}
|
2026-05-07 23:50:04 +08:00
|
|
|
|
if (!state.selectedFile || !state.server) return;
|
2026-06-14 17:10:24 +08:00
|
|
|
|
|
2026-05-07 23:50:04 +08:00
|
|
|
|
const cfg = currentTarget();
|
|
|
|
|
|
state.autoFlashing = true;
|
|
|
|
|
|
try {
|
|
|
|
|
|
log('AUTO', `开始一键刷写 -> ${cfg.label}`);
|
|
|
|
|
|
const ok = await uploadFirmware();
|
|
|
|
|
|
if (!ok || state.abortRequested) {
|
|
|
|
|
|
log('AUTO', '一键刷写中止', 'W');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-05-26 02:44:57 +08:00
|
|
|
|
setProgress(100, `[${cfg.label}] 刷写完成`);
|
2026-05-07 23:50:04 +08:00
|
|
|
|
if (cfg.label === 'ESP32') {
|
2026-05-26 02:44:57 +08:00
|
|
|
|
log('AUTO', 'ESP32 OTA 完成,等待设备重启');
|
|
|
|
|
|
} else {
|
|
|
|
|
|
log('AUTO', 'GD32 OTA 中继已触发');
|
2026-05-07 23:50:04 +08:00
|
|
|
|
}
|
|
|
|
|
|
} 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);
|
2026-05-26 02:44:57 +08:00
|
|
|
|
els.clearLogBtn.addEventListener('click', clearLogs);
|
2026-05-30 20:04:04 +08:00
|
|
|
|
els.uploadZone.addEventListener('click', () => {
|
|
|
|
|
|
if (window.showOpenFilePicker) { pickFileViaFSAPI(); }
|
|
|
|
|
|
else { els.firmwareFile.click(); }
|
|
|
|
|
|
});
|
2026-05-26 02:44:57 +08:00
|
|
|
|
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);
|
2026-05-07 23:50:04 +08:00
|
|
|
|
els.payloadSize.addEventListener('change', () => updateFileInfo(state.selectedFile));
|
|
|
|
|
|
els.targetChip.addEventListener('change', refreshTargetUi);
|
|
|
|
|
|
|
|
|
|
|
|
refreshTargetUi();
|
|
|
|
|
|
setConnectionState(false);
|
2026-06-14 17:10:24 +08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-05-26 02:44:57 +08:00
|
|
|
|
log('SYS', `Web Bluetooth 刷写控制台已就绪 (${UI_BUILD})`);
|
2026-05-07 23:50:04 +08:00
|
|
|
|
})();
|
|
|
|
|
|
</script>
|
2026-06-14 17:10:24 +08:00
|
|
|
|
|
|
|
|
|
|
<!-- Toast -->
|
|
|
|
|
|
<div class="toast" id="toast">
|
|
|
|
|
|
<span class="toast-ico" id="toastIco">✓</span>
|
|
|
|
|
|
<span id="toastMsg">操作成功</span>
|
|
|
|
|
|
</div>
|
2026-05-07 23:50:04 +08:00
|
|
|
|
</body>
|
|
|
|
|
|
</html>
|