Files
ESP32_COMM/tools/ble_ota.html
T
小龙虾 6f4be9189c feat(relay): OTA可用版本,添加OTA中继阶段健康检查和APP存活验证功能
- 新增health_check_before_ota_relay函数,用于OTA中继转发阶段的健康检查,
  跳过WiFi相关检查,只检查温度等本地状态
- 添加APP存活验证阶段,OTA完成后等待GD32 APP启动并通过ping确认存活
- 实现更精确的响应帧类型匹配和轮询机制,提高协议可靠性
- 添加多个阶段的响应匹配器(is_handshake_echo_ack、is_challenge_payload_ack等)
- 优化poll_until_expected_ack_shape函数,处理残留ACK和超时情况
- 将调试日志级别从INFO降级为DBG,减少日志输出
- 修复handshake阶段重试机制,添加最大重试次数限制
- 优化Flash擦除期间的WATI状态处理,使用短超时避免长阻塞
- 添加完整的会话数据清理,防止状态污染
- 增加详细的注释说明各阶段的处理逻辑
2026-05-30 20:04:04 +08:00

1164 lines
42 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>
<style>
:root {
--bg-root: #090e15;
--bg-panel: #0f161f;
--bg-card: #141d28;
--bg-elevated: #1a2432;
--bg-input: #0d131b;
--border: #1d2a3a;
--border-focus: #22d3ee;
--text: #e2e8f0;
--text-dim: #7b8fa8;
--text-label: #5a6d82;
--accent: #22d3ee;
--accent-dim: rgba(34,211,238,.15);
--green: #22c55e;
--amber: #f59e0b;
--red: #ef4444;
--radius-sm: 6px;
--radius: 10px;
--radius-lg: 14px;
--font: "PingFang SC","Microsoft YaHei","Segoe UI",system-ui,sans-serif;
--font-mono: "SF Mono","Fira Code","Cascadia Code","Consolas",monospace;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
min-height: 100%;
background: var(--bg-root);
color: var(--text);
font-family: var(--font);
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
body::before {
content: "";
position: fixed; inset: 0; pointer-events: none; z-index: 0;
background-image:
linear-gradient(rgba(255,255,255,.015) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,.015) 1px, transparent 1px);
background-size: 40px 40px;
mask-image: radial-gradient(ellipse at 50% 0%, black 40%, transparent 70%);
}
.wrap {
position: relative; z-index: 1;
max-width: 1480px; margin: 0 auto; padding: 24px 32px 40px;
}
.header-bar {
display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
padding: 14px 20px; margin-bottom: 24px;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
}
.header-brand {
display: flex; align-items: center; gap: 10px;
padding-right: 18px; border-right: 1px solid var(--border);
}
.header-brand .mark {
width: 10px; height: 10px; border-radius: 50%; background: var(--accent);
box-shadow: 0 0 8px var(--accent-dim);
}
.header-brand strong { font-size: 15px; }
.header-pills { display: flex; gap: 8px; flex-wrap: wrap; }
.pill {
display: inline-flex; align-items: center; gap: 6px;
padding: 5px 12px; border-radius: 20px; font-size: 12px; font-weight: 500;
background: var(--bg-elevated); border: 1px solid var(--border);
}
.pill .dot { width: 7px; height: 7px; border-radius: 50%; }
.pill.online .dot { background: var(--green); }
.pill.offline .dot { background: var(--red); }
.pill.live .dot { background: var(--amber); animation: blink 1s infinite; }
@keyframes blink { 50% { opacity: .35; } }
.layout {
display: grid;
grid-template-columns: 420px 1fr;
gap: 16px;
align-items: start;
}
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.card + .card { margin-top: 16px; }
.card-head {
display: flex; align-items: center; justify-content: space-between;
gap: 8px;
padding: 14px 18px;
border-bottom: 1px solid var(--border);
font-size: 13px; font-weight: 600; color: var(--text-dim);
text-transform: uppercase; letter-spacing: .06em;
}
.card-body { padding: 16px 18px; }
.form-row { margin-bottom: 14px; }
.form-row:last-child { margin-bottom: 0; }
.label {
display: block; margin-bottom: 6px;
font-size: 11px; color: var(--text-label);
text-transform: uppercase; letter-spacing: .08em;
}
input[type="text"], input[type="number"], input[type="file"], select, textarea {
width: 100%; padding: 10px 12px;
background: var(--bg-input); color: var(--text);
border: 1px solid var(--border); border-radius: var(--radius-sm);
outline: none; font-size: 13px; font-family: inherit;
}
input:focus, select:focus, textarea:focus {
border-color: var(--border-focus);
box-shadow: 0 0 0 3px var(--accent-dim);
}
select option { background: var(--bg-panel); color: var(--text); }
.row-2, .row-3, .row-4 { display: grid; gap: 10px; }
.row-2 { grid-template-columns: 1fr 1fr; }
.row-3 { grid-template-columns: repeat(3, 1fr); }
.row-4 { grid-template-columns: repeat(4, 1fr); }
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
padding: 9px 16px; border: none; border-radius: var(--radius-sm);
font-size: 13px; font-weight: 600; cursor: pointer;
font-family: inherit; transition: opacity .15s, transform .15s;
text-decoration: none;
}
.btn:hover:not(:disabled) { transform: translateY(-1px); }
.btn:disabled { opacity: .4; cursor: not-allowed; transform: none; }
.btn-primary { background: var(--accent); color: #07121c; }
.btn-secondary { background: transparent; color: var(--text-dim); border: 1px solid var(--border); }
.btn-secondary:hover:not(:disabled) { background: var(--bg-elevated); color: var(--text); }
.btn-danger { background: var(--red); color: #fff; }
.btn-block { width: 100%; }
.panel-note {
padding: 12px 14px; border-radius: var(--radius-sm);
background: var(--bg-elevated); border: 1px solid var(--border);
font-size: 12px; color: var(--text-dim); line-height: 1.7;
}
.upload-zone {
border: 2px dashed var(--border);
border-radius: var(--radius);
padding: 24px 16px;
text-align: center;
cursor: pointer;
transition: border-color .2s, background .2s, transform .15s;
background: rgba(255,255,255,.01);
}
.upload-zone:hover,
.upload-zone.dragover {
border-color: var(--accent);
background: var(--accent-dim);
}
.upload-zone:hover { transform: translateY(-1px); }
.upload-zone .upload-ico {
font-size: 30px;
margin-bottom: 8px;
}
.upload-zone h4 {
font-size: 14px;
margin-bottom: 4px;
}
.upload-zone p {
font-size: 12px;
color: var(--text-dim);
}
.file-info {
display: none;
margin-top: 12px;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 12px 14px;
}
.file-info.show { display: block; }
.file-info-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.file-info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-top: 10px;
}
.file-meta-item {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px 10px;
font-size: 12px;
}
.file-meta-item span {
display: block;
font-size: 10px;
color: var(--text-label);
margin-bottom: 2px;
text-transform: uppercase;
letter-spacing: .08em;
}
.file-meta-item b {
font-weight: 700;
color: var(--text);
word-break: break-word;
}
.progress-shell {
margin-top: 12px; padding: 12px 14px;
border-radius: var(--radius-sm);
background: var(--bg-elevated);
border: 1px solid var(--border);
}
.progress-bar {
width: 100%; height: 8px; border-radius: 999px;
overflow: hidden; background: var(--bg-input);
}
.progress-fill {
width: 0%; height: 100%; border-radius: 999px;
background: var(--accent); transition: width .18s linear;
position: relative;
}
.progress-fill::after {
content: ""; position: absolute; inset: 0;
background: linear-gradient(90deg, transparent 0%, rgba(255,255,255,.25) 50%, transparent 100%);
animation: sweep 1.8s linear infinite;
}
@keyframes sweep { 0% { transform: translateX(-100%); } 100% { transform: translateX(100%); } }
.progress-meta {
display: flex; justify-content: space-between; gap: 10px;
margin-top: 8px; font-size: 12px; color: var(--text-dim);
}
.status-board {
display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px;
}
.status-box {
background: var(--bg-elevated); border: 1px solid var(--border);
border-radius: var(--radius-sm); padding: 12px 14px;
}
.status-box .k {
font-size: 10px; color: var(--text-label);
text-transform: uppercase; letter-spacing: .1em; margin-bottom: 4px;
}
.status-box .v {
font-size: 18px; font-weight: 700; word-break: break-word;
}
.ok { color: var(--green); }
.warn { color: var(--amber); }
.bad { color: var(--red); }
.accent { color: var(--accent); }
.metric-grid {
display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px;
}
.metric {
background: var(--bg-elevated); border: 1px solid var(--border);
border-radius: var(--radius-sm); padding: 12px 14px;
}
.metric .k {
font-size: 10px; color: var(--text-label);
text-transform: uppercase; letter-spacing: .1em; margin-bottom: 4px;
}
.metric .v {
font-size: 18px; font-weight: 700; word-break: break-word;
}
.notify-box, .log-box {
background: var(--bg-elevated); border: 1px solid var(--border);
border-radius: var(--radius-sm);
}
.notify-box {
min-height: 110px; padding: 12px 14px;
}
.noti-line {
font-family: var(--font-mono);
font-size: 12px; line-height: 1.6;
color: #c7f7ff;
background: rgba(34,211,238,.08);
padding: 8px 10px; border-radius: 6px;
margin-bottom: 8px; word-break: break-all;
}
.log-box {
height: 420px; overflow: auto; padding: 12px 14px;
font-family: var(--font-mono); font-size: 12px; line-height: 1.7;
}
.log-box::-webkit-scrollbar { width: 5px; }
.log-box::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
.log-line { margin-bottom: 8px; color: var(--text); }
.log-line .t { color: var(--text-label); }
.log-line .tag { color: #a78bfa; }
.sub-actions {
display: flex; gap: 10px; flex-wrap: wrap; margin-top: 12px;
}
.footer-note {
margin-top: 12px; font-size: 12px; line-height: 1.7; color: var(--text-dim);
}
code { color: #d3f9ff; font-family: var(--font-mono); }
@media (max-width: 1120px) {
.layout { grid-template-columns: 1fr; }
.metric-grid { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 720px) {
.wrap { padding: 16px; }
.header-bar { flex-direction: column; align-items: flex-start; }
.row-2, .row-3, .row-4, .status-board, .metric-grid, .file-info-grid { grid-template-columns: 1fr; }
.file-info-row { align-items: flex-start; flex-direction: column; }
}
</style>
</head>
<body>
<div class="wrap">
<header class="header-bar">
<div class="header-brand">
<span class="mark"></span>
<strong>ESP32 BLE OTA 控制台</strong>
</div>
<div class="header-pills">
<span class="pill offline"><span class="dot"></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="layout">
<div>
<div class="card">
<div class="card-head">🔗 蓝牙连接</div>
<div class="card-body">
<div class="panel-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-secondary" disabled>断开连接</button>
</div>
<div class="sub-actions">
<button id="versionBtn" class="btn btn-secondary" disabled>读取版本</button>
<a class="btn btn-secondary" href="index.html">回 WiFi 页面</a>
</div>
</div>
</div>
<div class="card">
<div class="card-head">🎯 升级目标</div>
<div class="card-body">
<div class="form-row">
<label class="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="label" for="payloadSize">每包 Payload</label>
<input id="payloadSize" type="number" min="32" max="498" value="480">
</div>
<div class="form-row" style="margin:0;">
<label class="label" for="ctrlHex">触发命令(HEX)</label>
<input id="ctrlHex" type="text" value="F7" maxlength="8">
</div>
</div>
<div class="panel-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 + crc占位4</b>。
</div>
</div>
</div>
<div class="card">
<div class="card-head">📁 固件文件</div>
<div class="card-body">
<div class="form-row">
<label class="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-secondary" 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>
</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="panel-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-secondary" type="button">清空日志</button>
</div>
</div>
</div>
</div>
<div>
<div class="card">
<div class="card-head">📡 设备状态</div>
<div class="card-body">
<div class="status-board">
<div class="status-box"><div class="k">连接状态</div><div class="v bad" id="connBadge">offline</div></div>
<div class="status-box"><div class="k">GATT / MTU</div><div class="v" id="gattState">未就绪</div></div>
<div class="status-box"><div class="k">版本</div><div class="v accent" id="versionMirror">未读取</div></div>
<div class="status-box"><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-shell" style="margin-top:0;">
<div class="progress-bar"><div class="progress-fill" id="progressFill"></div></div>
<div class="progress-meta">
<span id="progressText">等待选择文件</span>
<span id="progressPercent">0%</span>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-head">🖥️ 运行日志</div>
<div class="card-body">
<div class="log-box" 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_ACK_WINDOW = 8;
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,
selectedFileBytes: null,
selectedFileKey: '',
selectedFileHandle: null, // FileSystemFileHandle for hot reload
abortRequested: false,
uploading: false,
autoFlashing: false,
lastUploadStatus: 'idle',
lastNotificationHex: '',
lastAckCmd: null,
lastAckSeq: -1,
lastAckBytes: 0,
ackWaiter: 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]));
}
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;
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';
if (els.otaState) {
els.otaState.textContent = '离线';
els.otaState.className = 'v bad';
}
els.deviceName.textContent = '-';
els.deviceId.textContent = '-';
els.versionText.textContent = '未读取';
els.versionMirror.textContent = '未读取';
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 + crc占位4</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 {
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 || '-';
log('BLE', `已选择设备: ${device.name || 'unknown'}`);
const server = await device.gatt.connect();
state.server = server;
state.service = await server.getPrimaryService(UUIDS.service);
state.ctrlChar = await state.service.getCharacteristic(UUIDS.ctrl);
state.dataInChar = await state.service.getCharacteristic(UUIDS.dataIn);
state.dataOutChar = await state.service.getCharacteristic(UUIDS.dataOut);
state.versionChar = await state.service.getCharacteristic(UUIDS.version);
setConnectionState(true);
log('BLE', 'GATT 服务与特征获取完成');
await enableNotifications();
} catch (err) {
log('BLE', `连接失败: ${err.message}`, 'E');
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) {
const waiter = state.ackWaiter;
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}`,
};
const finishText = finishMap[stage] || `[FINISH] 未知阶段 stage=0x${stage.toString(16).padStart(2, '0').toUpperCase()} value=${value}`;
log('FINISH', finishText);
return;
}
log('NOTIFY', hex);
}
function waitForAck(cmd, expectedSeq, timeoutMs = 8000) {
if (state.lastAckCmd === cmd && state.lastAckSeq >= expectedSeq) {
return Promise.resolve({ seq: state.lastAckSeq, bytes: state.lastAckBytes });
}
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 };
});
}
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.selectedFileBytes = null;
state.selectedFileKey = file ? `${file.name}|${file.size}|${file.lastModified || 0}` : '';
state.selectedFile = file || null;
state.lastUploadStatus = 'idle';
if (els.fileInfo) {
if (file) els.fileInfo.classList.add('show');
else els.fileInfo.classList.remove('show');
}
setConnectionState(!!state.server);
if (file) {
setProgress(0, `已选择 ${file.name}`);
log('FILE', `已选择: ${file.name} (${formatBytes(file.size)}), 目标=${currentTarget().label}`);
preloadSelectedFileBytes().catch((err) => {
log('FILE', `预读文件失败: ${err.message}`, 'W');
});
} else {
setProgress(0, '等待选择文件');
}
}
function syncSelectedFileFromInput() {
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.selectedFileBytes = null;
state.selectedFileKey = '';
state.selectedFileHandle = null;
state.lastUploadStatus = 'idle';
if (els.firmwareFile) els.firmwareFile.value = '';
updateFileInfo(null);
log('FILE', '文件已清除');
}
async function preloadSelectedFileBytes() {
if (!state.selectedFile) {
throw new Error('未选择固件文件');
}
if (state.selectedFileBytes && state.selectedFileBytes.length === state.selectedFile.size) {
return state.selectedFileBytes;
}
const bytes = new Uint8Array(await state.selectedFile.arrayBuffer());
state.selectedFileBytes = bytes;
log('FILE', `固件已缓存到内存: ${bytes.length} bytes`);
return bytes;
}
function buildPacket(seq, totalSize, payload, cmd) {
const packet = new Uint8Array(1 + 2 + 4 + payload.length + 4);
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 drainGattQueue() {
await sleep(120);
}
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;
const totalSize = file.size;
const packetTotal = Math.ceil(totalSize / payloadSize);
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;
if (state.ackWaiter) {
clearTimeout(state.ackWaiter.timer);
state.ackWaiter = null;
}
setConnectionState(true);
try {
if (!state.notificationsEnabled) {
await enableNotifications();
}
if (!state.notificationsEnabled) {
throw new Error('DATA_OUT 通知未开启,无法使用 ACK 模式');
}
log('OTA', `开始准备固件: target=${cfg.label}, file=${file.name}, total=${totalSize}, packets=${packetTotal}, payload=${payloadSize}, cmd=0x${cfg.packetCmd.toString(16).padStart(2, '0').toUpperCase()}, ackWindow=${BLE_OTA_ACK_WINDOW}`);
let fileBytes = state.selectedFileBytes;
if (!fileBytes || fileBytes.length !== totalSize) {
setProgress(0, `[${cfg.label}] 正在读取固件文件到内存...`);
fileBytes = await preloadSelectedFileBytes();
} else {
setProgress(0, `[${cfg.label}] 使用已缓存的固件数据...`);
log('OTA', `使用缓存固件: ${fileBytes.length} bytes`);
}
log('OTA', `开始写入 DATA_IN: target=${cfg.label}, file=${file.name}, total=${totalSize}, packets=${packetTotal}, payload=${payloadSize}, windowAck=${BLE_OTA_ACK_WINDOW}`);
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} 包,等待窗口 ACK`);
}
const ack = await waitForAck(cfg.packetCmd, endSeq, 10000);
if (ack.seq + 1 === packetTotal || ((ack.seq + 1) % (BLE_OTA_ACK_WINDOW * 5)) === 0) {
log('OTA', `${cfg.label} 窗口 ACK 已确认: seq=${ack.seq}, bytes=${ack.bytes}`);
}
await sleep(0);
}
setProgress(100, `[${cfg.label}] 固件数据已全部写入 DATA_IN`);
state.lastUploadStatus = 'success';
if (cfg.label === 'ESP32') {
return true;
}
await drainGattQueue();
const cmdHex = els.ctrlHex.value || cfg.ctrlHex;
const cmd = hexToBytes(cmdHex);
try {
await state.ctrlChar.writeValue(cmd);
} catch (_) {
state.lastUploadStatus = 'failed';
setProgress(0, `触发命令发送失败: ${toHex(cmd)}`);
log('OTA', `触发命令发送失败: ${toHex(cmd)}`, 'E');
return false;
}
return true;
} catch (err) {
state.lastUploadStatus = 'failed';
log('OTA', `写入失败: ${err.message}`, 'E');
setProgress(0, `写入失败: ${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);
log('SYS', `Web Bluetooth 刷写控制台已就绪 (${UI_BUILD})`);
})();
</script>
</body>
</html>