refactor(app): 删除过时的app.js备份文件
移除 _assistant_backups 目录中的 app.js.bak_2026-05-05_0451 备份文件, 该文件包含完整的JavaScript应用程序代码,包括全局状态管理、工具函数、 日志系统、连接管理、CAN帧发送、文件处理、固件上传等功能模块。
This commit is contained in:
@@ -0,0 +1,831 @@
|
||||
<!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: #07111f;
|
||||
--panel: rgba(15, 27, 47, 0.84);
|
||||
--line: rgba(126, 231, 255, 0.14);
|
||||
--line-strong: rgba(126, 231, 255, 0.28);
|
||||
--text: #eaf7ff;
|
||||
--muted: #8fa8bb;
|
||||
--cyan: #76e7ff;
|
||||
--blue: #4f8cff;
|
||||
--violet: #9785ff;
|
||||
--green: #21d19f;
|
||||
--amber: #ffb84d;
|
||||
--red: #ff6b7a;
|
||||
--shadow: 0 20px 70px rgba(0,0,0,.45);
|
||||
--radius: 20px;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin:0; min-height:100%; }
|
||||
body {
|
||||
font-family: "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(118,231,255,.12), transparent 34%),
|
||||
radial-gradient(circle at 90% 14%, rgba(151,133,255,.16), transparent 26%),
|
||||
linear-gradient(160deg, var(--bg), #06101c 44%, #050b14 100%);
|
||||
color: var(--text);
|
||||
}
|
||||
body::before {
|
||||
content:"";
|
||||
position:fixed; inset:0;
|
||||
background-image:
|
||||
linear-gradient(rgba(255,255,255,.02) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(255,255,255,.02) 1px, transparent 1px);
|
||||
background-size: 24px 24px;
|
||||
mask-image: radial-gradient(circle at center, black, transparent 82%);
|
||||
pointer-events:none;
|
||||
}
|
||||
.wrap { max-width: 1380px; margin: 0 auto; padding: 28px; position: relative; }
|
||||
.hero { display:grid; grid-template-columns: 1.2fr .8fr; gap: 20px; margin-bottom: 20px; }
|
||||
.hero-card, .card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
.hero-card { padding: 28px; overflow:hidden; position:relative; }
|
||||
.hero-card::after {
|
||||
content:"";
|
||||
position:absolute; right:-60px; top:-60px; width:200px; height:200px;
|
||||
background: radial-gradient(circle, rgba(118,231,255,.26), transparent 64%);
|
||||
pointer-events:none;
|
||||
}
|
||||
.eyebrow {
|
||||
display:inline-flex; align-items:center; gap:8px;
|
||||
font-size:12px; letter-spacing:.12em; text-transform:uppercase;
|
||||
color: var(--cyan); padding:8px 12px; border:1px solid var(--line-strong);
|
||||
border-radius:999px; background:rgba(118,231,255,.07);
|
||||
}
|
||||
h1 { margin: 18px 0 10px; font-size: 42px; line-height: 1.05; }
|
||||
.hero p { margin: 0; color: var(--muted); line-height: 1.7; font-size: 15px; }
|
||||
.hero-points { display:grid; grid-template-columns: repeat(3, 1fr); gap: 12px; margin-top: 22px; }
|
||||
.mini { padding: 14px 16px; border-radius: 16px; border:1px solid var(--line); background: rgba(255,255,255,.03); }
|
||||
.mini b { display:block; font-size: 18px; margin-bottom: 4px; }
|
||||
.mini span { color: var(--muted); font-size: 12px; }
|
||||
.status-stack { display:grid; gap: 14px; padding: 20px; }
|
||||
.status-pill {
|
||||
display:flex; justify-content:space-between; align-items:center;
|
||||
border:1px solid var(--line); border-radius:16px; padding:14px 16px;
|
||||
background: rgba(255,255,255,.03);
|
||||
gap: 12px;
|
||||
}
|
||||
.status-pill .label { color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing:.12em; }
|
||||
.status-pill .value { font-weight:700; font-size: 15px; }
|
||||
.ok { color: var(--green); }
|
||||
.warn { color: var(--amber); }
|
||||
.bad { color: var(--red); }
|
||||
.grid { display:grid; grid-template-columns: 1.05fr .95fr; gap: 20px; }
|
||||
.card { padding: 22px; }
|
||||
.card h2 { margin: 0 0 18px; font-size: 18px; }
|
||||
.section { margin-bottom: 18px; }
|
||||
.section:last-child { margin-bottom: 0; }
|
||||
.label { display:block; margin-bottom: 8px; color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: .12em; }
|
||||
.row-2, .row-3, .row-4 { display:grid; gap: 12px; }
|
||||
.row-2 { grid-template-columns: 1fr 1fr; }
|
||||
.row-3 { grid-template-columns: repeat(3, 1fr); }
|
||||
.row-4 { grid-template-columns: repeat(4, 1fr); }
|
||||
input[type="text"], input[type="number"], input[type="file"], select, textarea {
|
||||
width:100%; border-radius: 14px; border:1px solid var(--line);
|
||||
background: rgba(255,255,255,.04); color: var(--text);
|
||||
padding: 13px 14px; outline: none; font-size: 14px;
|
||||
}
|
||||
select option { background:#0b1828; color:var(--text); }
|
||||
input:focus, textarea:focus, select:focus { border-color: var(--cyan); box-shadow: 0 0 0 4px rgba(118,231,255,.08); }
|
||||
textarea { min-height: 110px; resize: vertical; }
|
||||
button, .btn-link {
|
||||
border:0; border-radius: 14px; padding: 13px 16px; cursor:pointer;
|
||||
color:#03111d; font-weight:700; font-size:14px; text-decoration:none;
|
||||
background: linear-gradient(135deg, var(--cyan), #9df5ff);
|
||||
transition: transform .18s ease, box-shadow .18s ease, opacity .18s ease;
|
||||
box-shadow: 0 12px 28px rgba(118,231,255,.18);
|
||||
display:inline-flex; align-items:center; justify-content:center; gap:8px;
|
||||
}
|
||||
button:hover:not(:disabled), .btn-link:hover { transform: translateY(-1px); }
|
||||
button:disabled { opacity:.45; cursor:not-allowed; box-shadow:none; }
|
||||
.secondary { background: linear-gradient(135deg, #ccd7e2, #e9f0f6); }
|
||||
.ghost {
|
||||
color: var(--text); background: rgba(255,255,255,.05);
|
||||
border:1px solid var(--line); box-shadow:none;
|
||||
}
|
||||
.danger { background: linear-gradient(135deg, #ff8393, #ffb1bb); }
|
||||
.top-actions { display:flex; gap:10px; flex-wrap:wrap; margin-top:18px; }
|
||||
.progress-shell { margin-top: 12px; padding: 16px; border-radius: 16px; background: rgba(255,255,255,.03); border:1px solid var(--line); }
|
||||
.progress-bar { width:100%; height: 14px; border-radius:999px; overflow:hidden; background: rgba(255,255,255,.06); border:1px solid rgba(255,255,255,.06); }
|
||||
.progress-fill { width:0%; height:100%; border-radius:999px; background: linear-gradient(90deg, var(--cyan), var(--blue), var(--violet)); transition: width .18s linear; }
|
||||
.progress-meta { display:flex; justify-content:space-between; gap:12px; margin-top:10px; color: var(--muted); font-size: 12px; }
|
||||
.metric-grid { display:grid; grid-template-columns: repeat(4, 1fr); gap: 12px; }
|
||||
.metric { padding: 14px; border-radius: 16px; border:1px solid var(--line); background: rgba(255,255,255,.03); }
|
||||
.metric .k { color: var(--muted); font-size: 12px; margin-bottom: 8px; }
|
||||
.metric .v { font-size: 20px; font-weight: 700; word-break: break-word; }
|
||||
.hint { padding: 12px 14px; border-radius: 14px; border:1px solid rgba(255,184,77,.18); background: rgba(255,184,77,.08); color: #ffd7a3; font-size: 13px; line-height: 1.7; }
|
||||
.noti-box, .log-box { border-radius: 16px; border:1px solid var(--line); background: rgba(255,255,255,.03); }
|
||||
.noti-box { padding: 14px; min-height: 110px; }
|
||||
.noti-line {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 12px; color: #bfefff; padding: 8px 10px; border-radius: 10px;
|
||||
background: rgba(118,231,255,.06); margin-bottom: 8px; word-break: break-all;
|
||||
}
|
||||
.log-box {
|
||||
height: 420px; overflow:auto; padding: 14px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px;
|
||||
}
|
||||
.log-line { margin-bottom: 8px; line-height: 1.6; color: #cfe3f3; }
|
||||
.log-line .t { color: #7aa2bd; }
|
||||
.log-line .tag { color: var(--cyan); }
|
||||
.footer-note { margin-top: 16px; color: var(--muted); font-size: 12px; line-height: 1.7; }
|
||||
.accent { color: var(--cyan); font-weight: 700; }
|
||||
code { color: #ccf5ff; }
|
||||
@media (max-width: 1120px) {
|
||||
.hero, .grid { grid-template-columns: 1fr; }
|
||||
.metric-grid { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
@media (max-width: 720px) {
|
||||
.wrap { padding: 16px; }
|
||||
h1 { font-size: 30px; }
|
||||
.row-2, .row-3, .row-4, .hero-points, .metric-grid { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<section class="hero">
|
||||
<div class="hero-card">
|
||||
<div class="eyebrow">BLE • OTA • Dual Target</div>
|
||||
<h1>ESP32 Web Bluetooth 刷写台</h1>
|
||||
<p>这个页面专门走 BLE GATT,不依赖 WiFi。它能扫描并连接 <b>ESP32-OTA-RELAY</b>,读取版本,订阅通知,把固件按协议分包写入 <b>DATA_IN</b>,并在完成后自动向 <b>CTRL</b> 写入对应命令:<span class="accent">GD32 = 0xF7</span>,<span class="accent">ESP32 = 0xF8</span>。</p>
|
||||
<div class="top-actions">
|
||||
<button id="connectBtn">连接设备</button>
|
||||
<button id="disconnectBtn" class="secondary" disabled>断开</button>
|
||||
<button id="versionBtn" class="ghost" disabled>读取版本</button>
|
||||
<button id="subscribeBtn" class="ghost" disabled>开启通知</button>
|
||||
<a class="btn-link ghost" href="index.html">回 WiFi 页面</a>
|
||||
</div>
|
||||
<div class="hero-points">
|
||||
<div class="mini"><b>GD32 / ESP32</b><span>双目标 BLE 刷写</span></div>
|
||||
<div class="mini"><b>480B</b><span>默认 Payload</span></div>
|
||||
<div class="mini"><b>一键刷写</b><span>写入完成后自动触发</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hero-card status-stack">
|
||||
<div class="status-pill"><div><div class="label">连接状态</div><div class="value" id="connState">未连接</div></div><div class="value bad" id="connBadge">offline</div></div>
|
||||
<div class="status-pill"><div><div class="label">设备名称</div><div class="value" id="deviceName">-</div></div><div class="value" id="deviceId">-</div></div>
|
||||
<div class="status-pill"><div><div class="label">GATT/MTU 提示</div><div class="value" id="gattState">未就绪</div></div><div class="value warn" id="mtuHint">浏览器决定</div></div>
|
||||
<div class="status-pill"><div><div class="label">版本</div><div class="value" id="versionText">未读取</div></div><div class="value ok" id="notifyState">notify off</div></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="grid">
|
||||
<div class="card">
|
||||
<h2>刷写控制台</h2>
|
||||
<div class="section">
|
||||
<label class="label" for="firmwareFile">固件文件</label>
|
||||
<input id="firmwareFile" type="file" accept=".bin,.hex,application/octet-stream">
|
||||
</div>
|
||||
<div class="section row-3">
|
||||
<div>
|
||||
<label class="label" for="targetChip">目标芯片</label>
|
||||
<select id="targetChip">
|
||||
<option value="gd32">GD32(写入后触发中继)</option>
|
||||
<option value="esp32">ESP32(写入后重启升级)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="label" for="payloadSize">每包 Payload 大小</label>
|
||||
<input id="payloadSize" type="number" min="32" max="498" value="480">
|
||||
</div>
|
||||
<div>
|
||||
<label class="label" for="ctrlHex">触发命令(HEX)</label>
|
||||
<input id="ctrlHex" type="text" value="F7" maxlength="8">
|
||||
</div>
|
||||
</div>
|
||||
<div class="section hint" id="protocolHint">
|
||||
当前目标:<b>GD32</b>。页面会使用 <b>cmd=0x00</b> 发送数据,最后自动写 <b>CTRL=0xF7</b>。协议格式:<b>cmd(1) + seq(2) + total_size(4) + payload + crc占位4</b>。
|
||||
</div>
|
||||
<div class="section progress-shell">
|
||||
<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 class="section row-4" style="margin-top:16px;">
|
||||
<button id="uploadBtn" disabled>仅写入 DATA_IN</button>
|
||||
<button id="finishBtn" class="ghost" disabled>仅发送 CTRL</button>
|
||||
<button id="autoFlashBtn" disabled>一键刷写</button>
|
||||
<button id="abortBtn" class="danger" disabled>中止当前任务</button>
|
||||
</div>
|
||||
<div class="footer-note">
|
||||
浏览器要求:建议用 <b>Chrome / Edge / Android Chromium</b>,并通过 <b>http(s)</b> 页面打开。某些桌面浏览器的 <code>file://</code> 下 Web Bluetooth 会被拒绝。若目标为 <b>ESP32</b>,一键刷写最后一步会触发设备重启,BLE 断开属于正常现象。<br>
|
||||
默认 payload 已提高到 <b>480B</b>;如果首包就失败,说明当前系统实际协商到的 ATT MTU 较小,把 payload 手动降回 <b>240B</b> 或更低再试。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>文件与进度</h2>
|
||||
<div class="metric-grid">
|
||||
<div class="metric"><div class="k">文件名</div><div class="v" id="fileName">-</div></div>
|
||||
<div class="metric"><div class="k">文件大小</div><div class="v" id="fileSize">0 B</div></div>
|
||||
<div class="metric"><div class="k">总包数</div><div class="v" id="packetCount">0</div></div>
|
||||
<div class="metric"><div class="k">已写包</div><div class="v" id="sentCount">0</div></div>
|
||||
</div>
|
||||
<div class="section" style="margin-top:16px;">
|
||||
<label class="label">DATA_OUT 通知</label>
|
||||
<div class="noti-box" id="notifyBox"><div class="noti-line">暂无通知</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card" style="margin-top:20px;">
|
||||
<h2>运行日志</h2>
|
||||
<div class="log-box" id="logBox"></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
const UUIDS = {
|
||||
service: 0xFFFF,
|
||||
ctrl: 0x0001,
|
||||
dataIn: 0x0002,
|
||||
dataOut: 0x0003,
|
||||
version: 0x0004,
|
||||
};
|
||||
|
||||
const UI_BUILD = '2026-05-07-ble-finishnotify-v15';
|
||||
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: '发送 CTRL(F7)',
|
||||
hint: '当前目标:<b>GD32</b>。页面会使用 <b>cmd=0x00</b> 发送数据,最后自动写 <b>CTRL=0xF7</b>,由 ESP32 触发 SPI OTA 中继。'
|
||||
},
|
||||
esp32: {
|
||||
label: 'ESP32',
|
||||
packetCmd: 0x01,
|
||||
ctrlHex: 'F8',
|
||||
recommendedPayload: 480,
|
||||
finishText: '发送 CTRL(F8)',
|
||||
hint: '当前目标:<b>ESP32</b>。页面会使用 <b>cmd=0x01</b> 发送数据,最后自动写 <b>CTRL=0xF8</b>,由 ESP32 完成 OTA 并重启。'
|
||||
}
|
||||
};
|
||||
|
||||
const state = {
|
||||
device: null,
|
||||
server: null,
|
||||
service: null,
|
||||
ctrlChar: null,
|
||||
dataInChar: null,
|
||||
dataOutChar: null,
|
||||
versionChar: null,
|
||||
notificationsEnabled: false,
|
||||
selectedFile: null,
|
||||
selectedFileBytes: null,
|
||||
selectedFileKey: '',
|
||||
abortRequested: false,
|
||||
uploading: false,
|
||||
autoFlashing: false,
|
||||
lastNotificationHex: '',
|
||||
lastAckCmd: null,
|
||||
lastAckSeq: -1,
|
||||
lastAckBytes: 0,
|
||||
ackWaiter: null,
|
||||
};
|
||||
|
||||
const $ = (id) => document.getElementById(id);
|
||||
const els = {
|
||||
connectBtn: $('connectBtn'),
|
||||
disconnectBtn: $('disconnectBtn'),
|
||||
versionBtn: $('versionBtn'),
|
||||
subscribeBtn: $('subscribeBtn'),
|
||||
uploadBtn: $('uploadBtn'),
|
||||
finishBtn: $('finishBtn'),
|
||||
autoFlashBtn: $('autoFlashBtn'),
|
||||
abortBtn: $('abortBtn'),
|
||||
firmwareFile: $('firmwareFile'),
|
||||
payloadSize: $('payloadSize'),
|
||||
targetChip: $('targetChip'),
|
||||
ctrlHex: $('ctrlHex'),
|
||||
protocolHint: $('protocolHint'),
|
||||
connState: $('connState'),
|
||||
connBadge: $('connBadge'),
|
||||
deviceName: $('deviceName'),
|
||||
deviceId: $('deviceId'),
|
||||
gattState: $('gattState'),
|
||||
mtuHint: $('mtuHint'),
|
||||
versionText: $('versionText'),
|
||||
notifyState: $('notifyState'),
|
||||
progressFill: $('progressFill'),
|
||||
progressText: $('progressText'),
|
||||
progressPercent: $('progressPercent'),
|
||||
fileName: $('fileName'),
|
||||
fileSize: $('fileSize'),
|
||||
packetCount: $('packetCount'),
|
||||
sentCount: $('sentCount'),
|
||||
notifyBox: $('notifyBox'),
|
||||
logBox: $('logBox'),
|
||||
};
|
||||
|
||||
function currentTarget() {
|
||||
return TARGETS[els.targetChip.value] || TARGETS.gd32;
|
||||
}
|
||||
|
||||
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);
|
||||
els.logBox.scrollTop = els.logBox.scrollHeight;
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, (m) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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;
|
||||
}
|
||||
|
||||
function setConnectionState(connected) {
|
||||
const idle = connected && state.selectedFile && !state.uploading;
|
||||
const isEsp32Target = currentTarget().label === 'ESP32';
|
||||
els.connState.textContent = connected ? '已连接' : '未连接';
|
||||
els.connBadge.textContent = connected ? 'online' : 'offline';
|
||||
els.connBadge.className = `value ${connected ? 'ok' : 'bad'}`;
|
||||
els.gattState.textContent = connected ? 'GATT 已就绪' : '未就绪';
|
||||
els.disconnectBtn.disabled = !connected;
|
||||
els.versionBtn.disabled = !connected;
|
||||
els.subscribeBtn.disabled = !connected;
|
||||
els.uploadBtn.disabled = !idle;
|
||||
els.finishBtn.disabled = !connected || state.uploading || isEsp32Target;
|
||||
els.autoFlashBtn.disabled = !idle;
|
||||
els.abortBtn.disabled = !state.uploading;
|
||||
}
|
||||
|
||||
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;
|
||||
els.deviceName.textContent = '-';
|
||||
els.deviceId.textContent = '-';
|
||||
els.versionText.textContent = '未读取';
|
||||
els.notifyState.textContent = 'notify off';
|
||||
els.notifyState.className = 'value ok';
|
||||
setConnectionState(false);
|
||||
}
|
||||
|
||||
function refreshTargetUi() {
|
||||
const cfg = currentTarget();
|
||||
els.ctrlHex.value = cfg.ctrlHex;
|
||||
els.payloadSize.value = String(cfg.recommendedPayload);
|
||||
els.finishBtn.textContent = cfg.label === 'ESP32' ? 'ESP32 自动完成' : `仅发送 CTRL(${cfg.ctrlHex})`;
|
||||
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 并重启,不再发送 CTRL。': ''}`;
|
||||
if (state.selectedFile) updateFileInfo(state.selectedFile);
|
||||
setConnectionState(!!state.server);
|
||||
log('CFG', `目标切换为 ${cfg.label}: packetCmd=0x${cfg.packetCmd.toString(16).padStart(2, '0').toUpperCase()}, ctrl=${cfg.ctrlHex}, 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);
|
||||
|
||||
els.mtuHint.textContent = '实际 ATT MTU 由系统协商';
|
||||
setConnectionState(true);
|
||||
log('BLE', 'GATT 服务与特征获取完成');
|
||||
} 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;
|
||||
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;
|
||||
|
||||
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}`,
|
||||
};
|
||||
log('FINISH', finishMap[stage] || `[FINISH] 未知阶段 stage=0x${stage.toString(16).padStart(2, '0').toUpperCase()} value=${value}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (els.notifyBox.firstElementChild && els.notifyBox.firstElementChild.textContent === '暂无通知') {
|
||||
els.notifyBox.innerHTML = '';
|
||||
}
|
||||
const div = document.createElement('div');
|
||||
div.className = 'noti-line';
|
||||
div.textContent = hex;
|
||||
els.notifyBox.prepend(div);
|
||||
while (els.notifyBox.children.length > 10) els.notifyBox.removeChild(els.notifyBox.lastChild);
|
||||
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;
|
||||
els.notifyState.textContent = 'notify on';
|
||||
els.notifyState.className = 'value ok';
|
||||
log('BLE', '已开启 DATA_OUT 通知');
|
||||
} catch (err) {
|
||||
log('BLE', `开启通知失败: ${err.message}`, 'E');
|
||||
}
|
||||
}
|
||||
|
||||
function updateFileInfo(file) {
|
||||
els.fileName.textContent = file ? file.name : '-';
|
||||
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;
|
||||
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');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
// writeValueWithoutResponse 在浏览器/系统蓝牙栈里通常还有内部发送队列。
|
||||
// 方案 B 已对尾包做轻微收油,这里只保留较短的排空等待,避免切 CTRL 太急。
|
||||
await sleep(120);
|
||||
}
|
||||
|
||||
function getTailDelayMs(seq, packetTotal) {
|
||||
const tailPackets = Math.min(24, packetTotal);
|
||||
const tailStart = Math.max(0, packetTotal - tailPackets);
|
||||
if (seq < tailStart) return 0;
|
||||
|
||||
const remaining = packetTotal - (seq + 1);
|
||||
if (remaining >= 12) return 1;
|
||||
if (remaining >= 6) return 2;
|
||||
if (remaining >= 3) return 4;
|
||||
return 8;
|
||||
}
|
||||
|
||||
async function writeCtrlWithRetry(cmd, cfg) {
|
||||
const maxAttempts = 8;
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
await state.ctrlChar.writeValue(cmd);
|
||||
log('CTRL', `已写入 ${cfg.label} CTRL: ${toHex(cmd)}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
const msg = String(err && err.message ? err.message : err);
|
||||
if (/GATT operation already in progress/i.test(msg) && attempt < maxAttempts) {
|
||||
const waitMs = Math.min(1200, 120 * attempt);
|
||||
log('CTRL', `CTRL busy,${waitMs}ms 后重试 (${attempt}/${maxAttempts - 1})`, 'W');
|
||||
await sleep(waitMs);
|
||||
continue;
|
||||
}
|
||||
log('CTRL', `写 CTRL 失败: ${msg}`, 'E');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function uploadFirmware() {
|
||||
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.lastAckCmd = null;
|
||||
state.lastAckSeq = -1;
|
||||
state.lastAckBytes = 0;
|
||||
if (state.ackWaiter) {
|
||||
clearTimeout(state.ackWaiter.timer);
|
||||
state.ackWaiter = null;
|
||||
}
|
||||
setConnectionState(true);
|
||||
|
||||
try {
|
||||
if (!state.notificationsEnabled) {
|
||||
log('BLE', '上传前自动开启 DATA_OUT 通知...');
|
||||
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`);
|
||||
|
||||
if (seq === 0 || seq === endSeq || (seq + 1) % 40 === 0 || seq === packetTotal - 1) {
|
||||
log('OTA', `${cfg.label} 分包 ${seq + 1}/${packetTotal} 已发送`);
|
||||
}
|
||||
}
|
||||
|
||||
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`);
|
||||
if (cfg.label === 'ESP32') {
|
||||
log('OTA', 'ESP32 数据已写完,设备端将自动完成 OTA 并重启,无需再发 CTRL F8');
|
||||
return true;
|
||||
}
|
||||
log('OTA', `${cfg.label} 数据写入完成,等待 GATT 队列排空...`);
|
||||
await drainGattQueue();
|
||||
log('OTA', `${cfg.label} 数据写入完成,可发送 CTRL ${cfg.ctrlHex}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
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 sendFinish(explicitHex = '', force = false) {
|
||||
try {
|
||||
const cfg = currentTarget();
|
||||
if (cfg.label === 'ESP32') {
|
||||
log('CTRL', 'ESP32 模式已改为设备端自动完成 OTA,不再发送 CTRL');
|
||||
return true;
|
||||
}
|
||||
const cmdHex = explicitHex || els.ctrlHex.value || cfg.ctrlHex;
|
||||
const cmd = hexToBytes(cmdHex);
|
||||
await drainGattQueue();
|
||||
return await writeCtrlWithRetry(cmd, cfg);
|
||||
} catch (err) {
|
||||
log('CTRL', `写 CTRL 失败: ${err.message}`, 'E');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function autoFlash() {
|
||||
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;
|
||||
}
|
||||
if (cfg.label === 'ESP32') {
|
||||
setProgress(100, '[ESP32] 数据已写入,等待设备自动完成 OTA 并重启');
|
||||
log('AUTO', 'ESP32 只走新固件流程:设备端自动完成 OTA 并重启,不再回退发送 CTRL F8');
|
||||
return;
|
||||
}
|
||||
setProgress(100, `[${cfg.label}] 数据已写入,正在发送 CTRL ${cfg.ctrlHex}`);
|
||||
const finishOk = await sendFinish(cfg.ctrlHex);
|
||||
if (finishOk) {
|
||||
log('AUTO', 'GD32 CTRL 已发送,已触发 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.subscribeBtn.addEventListener('click', enableNotifications);
|
||||
els.uploadBtn.addEventListener('click', uploadFirmware);
|
||||
els.finishBtn.addEventListener('click', () => sendFinish());
|
||||
els.autoFlashBtn.addEventListener('click', autoFlash);
|
||||
els.abortBtn.addEventListener('click', abortTask);
|
||||
els.firmwareFile.addEventListener('change', (e) => updateFileInfo(e.target.files[0] || null));
|
||||
els.payloadSize.addEventListener('change', () => updateFileInfo(state.selectedFile));
|
||||
els.targetChip.addEventListener('change', refreshTargetUi);
|
||||
|
||||
refreshTargetUi();
|
||||
setConnectionState(false);
|
||||
log('SYS', `Web Bluetooth 刷写台已就绪 (${UI_BUILD})`);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -61,6 +61,9 @@
|
||||
<button class="btn btn-primary" style="width:100%; margin-top:8px;" onclick="restartEsp32()">
|
||||
🔄 重启 ESP32
|
||||
</button>
|
||||
<a class="btn btn-secondary" href="ble_ota.html" style="width:100%; margin-top:8px; text-decoration:none; display:flex; align-items:center; justify-content:center;">
|
||||
📶 打开 BLE 刷写页面
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- 文件上传 -->
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
@echo off
|
||||
:: ESP32 COMM Host Server Launcher (Windows)
|
||||
:: Double-click to start, then visit http://localhost:8080
|
||||
|
||||
set PORT=8080
|
||||
|
||||
echo ========================================
|
||||
echo ESP32 COMM Host Server
|
||||
echo PORT: %PORT%
|
||||
echo ========================================
|
||||
echo.
|
||||
echo WiFi OTA: http://localhost:%PORT%/index.html
|
||||
echo BLE OTA: http://localhost:%PORT%/ble_ota.html
|
||||
echo.
|
||||
echo Press Ctrl+C to stop
|
||||
echo ========================================
|
||||
echo.
|
||||
|
||||
cd /d "%~dp0"
|
||||
python -m http.server %PORT%
|
||||
pause
|
||||
Reference in New Issue
Block a user