- 新增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状态处理,使用短超时避免长阻塞 - 添加完整的会话数据清理,防止状态污染 - 增加详细的注释说明各阶段的处理逻辑
1164 lines
42 KiB
HTML
1164 lines
42 KiB
HTML
<!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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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>
|