- 新增 ota_channel_t(WiFi/BLE) + m_otaEpoch 会话代次, abortCurrentIfEpoch 防止同类型重启后旧 handler 误清新会话 - 同通道再次发起 OTA 时自动中止旧会话并重开 - 统一 prepareGd32Storage(validate+begin+erase+meta), 消除 BLE/WiFi 两路径隐式差异 - OtaRelay 拆分子步骤(doRelayPackets/doFinishAndVerify), 状态成员私有化消除数据竞争 - Gd32FlashStore NVS 读写加 mutex,metaUpdate 原子化 - BLE OTA 新增 0xFB 中止命令(保持连接)+ 0x20 ABORTED 处理 - HTTP handler 改 per-request buffer 支持并发 - 修复 CRITICAL 状态 RSSI 恢复无法降级的缺陷 - main.cpp 关键任务失败触发看门狗重启 - style.css 增强动画 + prefers-reduced-motion 兼容
684 lines
34 KiB
HTML
684 lines
34 KiB
HTML
<!--
|
||
index.html - ESP32 OTA 中继控制台主页面
|
||
支持 WiFi OTA(ESP32 本机 + GD32 中继)和 BLE OTA 双通道固件升级。
|
||
配套脚本:app.js (WiFi OTA), ble_ota.js (BLE OTA), style.css (UI 样式)
|
||
-->
|
||
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>ESP32 OTA 中继控制台</title>
|
||
<link rel="stylesheet" href="style.css?v=20260625b">
|
||
</head>
|
||
<body>
|
||
<div class="container">
|
||
|
||
<!-- ====== HEADER: compact status bar ====== -->
|
||
<header class="header-bar">
|
||
<div class="header-brand">
|
||
<span class="dot-mark"></span>
|
||
<strong>ESP32 OTA 中继控制台</strong>
|
||
</div>
|
||
<div class="header-status">
|
||
<span class="pill offline" id="pillConn"><span class="indicator"></span><span id="pillConnTxt">未连接</span></span>
|
||
<span class="pill" id="pillVer">v--</span>
|
||
<span class="pill" id="pillIP">--</span>
|
||
<span class="pill" id="pillRSSI">--</span>
|
||
<span class="pill" id="pillUptime">运行 --</span>
|
||
</div>
|
||
<div class="header-sub">
|
||
<span class="pill">设备名: <b class="pill-val" id="deviceName">-</b></span>
|
||
<span class="pill">设备ID: <b class="pill-val" id="deviceId">-</b></span>
|
||
</div>
|
||
</header>
|
||
|
||
<!-- ====== MAIN LAYOUT ====== -->
|
||
<div class="main-layout">
|
||
|
||
<!-- ====== LEFT: workflow ====== -->
|
||
<div>
|
||
|
||
<!-- 1. Connection -->
|
||
<div class="card">
|
||
<div class="card-head"><span class="ico">🔗</span> 设备连接
|
||
<span id="bleHeadActions" style="display:none;margin-left:auto;">
|
||
<button id="connectBtn" class="btn btn-sm btn-primary">连接设备</button>
|
||
<button id="disconnectBtn" class="btn btn-sm btn-outline" disabled>断开连接</button>
|
||
</span>
|
||
</div>
|
||
<div class="card-body">
|
||
<div style="display:flex;gap:6px;margin-bottom:14px;">
|
||
<button id="modeWifiBtn" class="btn btn-primary" style="flex:1;font-size:12px;padding:7px 10px;" onclick="setConsoleMode('wifi')">WiFi OTA</button>
|
||
<button id="modeBleBtn" class="btn btn-outline" style="flex:1;font-size:12px;padding:7px 10px;" onclick="setConsoleMode('ble')">BLE 蓝牙</button>
|
||
</div>
|
||
<div id="wifiModeCards">
|
||
<div class="form-row">
|
||
<label class="form-label" for="espIp">ESP32 IP 地址</label>
|
||
<div class="input-group">
|
||
<input class="input" type="text" id="espIp" value="192.168.31.127" placeholder="输入 IP 地址">
|
||
<button class="btn btn-outline" onclick="testConnection()">测试</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="conn-indicator err" id="connStatus" style="margin-top:12px;">
|
||
<span class="light"></span>
|
||
<span id="connStatusTxt">等待连接</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- ====== Shared Target + Firmware + Actions ====== -->
|
||
<div class="card">
|
||
<div class="card-head"><span class="ico">🎯</span> 升级目标、固件文件与执行操作</div>
|
||
<div class="card-body">
|
||
<div style="display:flex;gap:6px;margin-bottom:14px;">
|
||
<button id="targetGd32Tab" class="btn btn-outline" style="flex:1;font-size:12px;padding:7px 10px;" onclick="selectFlashTarget('gd32')">GD32</button>
|
||
<button id="targetEsp32Tab" class="btn btn-primary" style="flex:1;font-size:12px;padding:7px 10px;" onclick="selectFlashTarget('esp32')">ESP32</button>
|
||
</div>
|
||
<div style="border-top:1px solid var(--border);margin:0 -16px 14px;padding-top:14px;padding-left:16px;padding-right:16px;">
|
||
<div id="wifiEsp32UploadPane">
|
||
<div class="upload-zone upload-zone-sm" id="uploadZoneEsp32" onclick="pickSharedFirmware('esp32')">
|
||
<div class="upload-ico">📤</div>
|
||
<h4>点击或拖拽 ESP32 固件</h4>
|
||
</div>
|
||
<input type="file" id="fileInputEsp32" accept=".bin" onchange="handleSharedFileSelect(event, 'esp32')" style="display:none;">
|
||
<div class="file-info" id="fileInfoEsp32">
|
||
<div class="file-info-row">
|
||
<span style="font-weight:600;">📄 <span id="fileNameEsp32">-</span></span>
|
||
<button class="btn btn-ghost btn-sm" onclick="clearSharedFirmware('esp32')">清除</button>
|
||
</div>
|
||
<div class="file-info-grid">
|
||
<div class="file-meta-item"><span>类型</span><b id="fileTypeEsp32">-</b></div>
|
||
<div class="file-meta-item"><span>大小</span><b id="fileSizeEsp32">-</b></div>
|
||
</div>
|
||
<div class="file-meta-item" style="margin-top:6px;">
|
||
<span>SHA-256</span>
|
||
<b id="fileSha256Esp32" style="font-size:10px;word-break:break-all;font-family:monospace;">-</b>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="wifiGd32UploadPane" style="display:none;">
|
||
<div class="upload-zone upload-zone-sm" id="uploadZoneGd32" onclick="pickSharedFirmware('gd32')">
|
||
<div class="upload-ico">📤</div>
|
||
<h4>点击或拖拽 GD32 固件</h4>
|
||
<p>支持 .bin 和 .hex(自动转换)</p>
|
||
</div>
|
||
<input type="file" id="fileInputGd32" accept=".hex,.bin" onchange="handleSharedFileSelect(event, 'gd32')" style="display:none;">
|
||
<div class="file-info" id="fileInfoGd32">
|
||
<div class="file-info-row">
|
||
<span style="font-weight:600;">📄 <span id="fileNameGd32">-</span></span>
|
||
<button class="btn btn-ghost btn-sm" onclick="clearSharedFirmware('gd32')">清除</button>
|
||
</div>
|
||
<div class="file-info-grid">
|
||
<div class="file-meta-item"><span>类型</span><b id="fileTypeGd32">-</b></div>
|
||
<div class="file-meta-item"><span>大小</span><b id="fileSizeGd32">-</b></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="bleUploadPane" style="display:none;">
|
||
<div class="upload-zone upload-zone-sm" id="uploadZone" style="margin-bottom:12px;"
|
||
onclick="document.getElementById('firmwareFile').click()"
|
||
ondragover="event.preventDefault();this.classList.add('dragover')"
|
||
ondragleave="this.classList.remove('dragover')"
|
||
ondrop="event.preventDefault();this.classList.remove('dragover');var f=event.dataTransfer.files[0];if(f&&window.bleOta)window.bleOta.notifyFileChange(f,null);">
|
||
<div class="upload-ico">📤</div>
|
||
<h4>点击或拖拽文件到此处</h4>
|
||
<p>支持 .bin 和 .hex 格式</p>
|
||
</div>
|
||
<input id="firmwareFile" type="file" accept=".bin,.hex,application/octet-stream" style="display:none;">
|
||
<div class="file-info" id="fileInfo">
|
||
<div class="file-info-row">
|
||
<span style="font-weight:600;">📄 <span id="fileName">-</span></span>
|
||
<button id="clearFileBtn" class="btn btn-outline btn-sm" onclick="if(window.bleOta)window.bleOta.clearFile()">清除</button>
|
||
</div>
|
||
<div class="file-info-grid">
|
||
<div class="file-meta-item"><span>类型</span><b id="fileType">-</b></div>
|
||
<div class="file-meta-item"><span>大小</span><b id="fileSize">0 B</b></div>
|
||
<div class="file-meta-item"><span>总包数</span><b id="packetCount">0</b></div>
|
||
<div class="file-meta-item"><span>已写包</span><b id="sentCount">0</b></div>
|
||
</div>
|
||
<div class="file-meta-item" style="margin-top:8px;">
|
||
<span>SHA-256</span>
|
||
<b id="fileSha256" style="font-size:10px;word-break:break-all;font-family:var(--font-mono);">-</b>
|
||
</div>
|
||
</div>
|
||
<div class="footer-note">建议使用 Chrome / Edge。每包 Payload 与触发命令由系统按目标自动设置。</div>
|
||
</div>
|
||
<div style="height:12px"></div>
|
||
<div id="wifiEsp32ActionPane">
|
||
<button class="btn btn-primary btn-block" id="uploadEsp32Btn" onclick="startUpload('esp32')" disabled>
|
||
上传并升级 ESP32
|
||
</button>
|
||
</div>
|
||
<div id="wifiGd32ActionPane" style="display:none;">
|
||
<button class="btn btn-success btn-block" id="uploadGd32Btn" onclick="startUpload('gd32')" disabled>
|
||
GD32 OTA
|
||
</button>
|
||
</div>
|
||
<div class="row-2" id="bleActionPane" style="display:none;">
|
||
<button id="autoFlashBtn" class="btn btn-primary" disabled>一键刷写</button>
|
||
<button id="abortBtn" class="btn btn-danger" disabled>中止任务</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 4. Flash Progress + Stage Flow -->
|
||
<div class="card">
|
||
<div class="card-head"><span class="ico">⚡</span> 刷写进度与流程阶段</div>
|
||
<div class="card-body">
|
||
<!-- Phase step indicators -->
|
||
<div class="progress-phase-row">
|
||
<div class="pp-step active" id="ppEsp32"><span class="pp-ico">📦</span><span class="pp-txt">ESP32 OTA</span></div>
|
||
<div class="pp-step" id="ppGd32Ota"><span class="pp-ico">📤</span><span class="pp-txt">GD32 OTA</span></div>
|
||
</div>
|
||
<!-- Unified progress bar -->
|
||
<div class="progress-wrap show" id="progressWrap">
|
||
<div class="progress-track"><div class="progress-fill" id="progressFill"></div></div>
|
||
<div class="progress-text">
|
||
<span id="progressTxt">等待操作...</span>
|
||
<span id="progressPct">0%</span>
|
||
</div>
|
||
</div>
|
||
<div style="height:14px"></div>
|
||
<!-- ESP32 OTA stages -->
|
||
<div class="stage-flow" id="esp32Stages" style="display:none;">
|
||
<div class="stage-node" id="es1"><div class="stage-dot">1</div><div class="stage-label">固件上传</div><div class="stage-detail" id="esd1"></div></div>
|
||
<div class="stage-node" id="es2"><div class="stage-dot">2</div><div class="stage-label">OTA 写入</div><div class="stage-detail" id="esd2"></div></div>
|
||
<div class="stage-node" id="es3"><div class="stage-dot">3</div><div class="stage-label">SHA-256 校验</div><div class="stage-detail" id="esd3"></div></div>
|
||
<div class="stage-node" id="es4"><div class="stage-dot">4</div><div class="stage-label">应用固件</div><div class="stage-detail" id="esd4"></div></div>
|
||
<div class="stage-node" id="es5"><div class="stage-dot">5</div><div class="stage-label">重启中</div><div class="stage-detail" id="esd5"></div></div>
|
||
<div class="stage-node" id="es6"><div class="stage-dot">6</div><div class="stage-label">启动完成</div><div class="stage-detail" id="esd6"></div></div>
|
||
</div>
|
||
<!-- BLE OTA stages -->
|
||
<div class="stage-flow" id="bleStages" style="display:none;">
|
||
<div class="stage-node" id="bs1"><div class="stage-dot">1</div><div class="stage-label">BLE 连接</div><div class="stage-detail" id="bsd1"></div></div>
|
||
<div class="stage-node" id="bs2"><div class="stage-dot">2</div><div class="stage-label">GATT 准备</div><div class="stage-detail" id="bsd2"></div></div>
|
||
<div class="stage-node" id="bs3"><div class="stage-dot">3</div><div class="stage-label">文件准备</div><div class="stage-detail" id="bsd3"></div></div>
|
||
<div class="stage-node" id="bs4"><div class="stage-dot">4</div><div class="stage-label">数据写入</div><div class="stage-detail" id="bsd4"></div></div>
|
||
<div class="stage-node" id="bs5"><div class="stage-dot">5</div><div class="stage-label" id="bsl5">校验比对</div><div class="stage-detail" id="bsd5"></div></div>
|
||
<div class="stage-node" id="bs6"><div class="stage-dot">6</div><div class="stage-label" id="bsl6">完成触发</div><div class="stage-detail" id="bsd6"></div></div>
|
||
</div>
|
||
<!-- GD32 OTA stages -->
|
||
<div class="stage-flow" id="gd32Stages">
|
||
<div class="stage-node" id="s1"><div class="stage-dot">1</div><div class="stage-label">认证握手</div><div class="stage-detail" id="sd1"></div></div>
|
||
<div class="stage-node" id="s2"><div class="stage-dot">2</div><div class="stage-label">传输协商</div><div class="stage-detail" id="sd2"></div></div>
|
||
<div class="stage-node" id="s3"><div class="stage-dot">3</div><div class="stage-label">数据转发</div><div class="stage-detail" id="sd3"></div></div>
|
||
<div class="stage-node" id="s4"><div class="stage-dot">4</div><div class="stage-label">传输完成</div><div class="stage-detail" id="sd4"></div></div>
|
||
<div class="stage-node" id="s5"><div class="stage-dot">5</div><div class="stage-label">OTA 刷写</div><div class="stage-detail" id="sd5"></div></div>
|
||
<div class="stage-node" id="s6"><div class="stage-dot">6</div><div class="stage-label">校验验证</div><div class="stage-detail" id="sd6"></div></div>
|
||
</div>
|
||
<div class="stage-status-bar" id="stageStatusBar">
|
||
<span class="ssb-dot" id="stageStatusDot"></span>
|
||
<span class="ssb-text" id="stageStatusText">待机</span>
|
||
</div>
|
||
<div class="stage-verify-info" id="stageVerifyInfo" style="display:none;">
|
||
<div class="svi-row"><span class="svi-key">前端 SHA-256</span><span class="svi-val" id="sviFrontSha">-</span></div>
|
||
<div class="svi-row"><span class="svi-key">设备 SHA-256</span><span class="svi-val" id="sviDeviceSha">-</span></div>
|
||
<div class="svi-row"><span class="svi-key">校验结果</span><span class="svi-val" id="sviResult">-</span></div>
|
||
</div>
|
||
<div style="margin-top:12px;">
|
||
<button class="btn btn-outline btn-block" onclick="restartEsp32()">重启 ESP32</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<!-- ====== RIGHT: status + tools ====== -->
|
||
<div>
|
||
|
||
<!-- Primary status -->
|
||
<div class="card">
|
||
<div class="card-head"><span class="ico">📡</span> 设备状态</div>
|
||
<div class="card-body">
|
||
<div class="badge-grid">
|
||
<div class="badge-stat">
|
||
<div class="v err" id="bConn">离线</div>
|
||
<div class="k">连接状态</div>
|
||
</div>
|
||
<div class="badge-stat">
|
||
<div class="v" id="mHealth">--</div>
|
||
<div class="k">健康状态</div>
|
||
</div>
|
||
<div class="badge-stat">
|
||
<div class="v err" id="connBadge">offline</div>
|
||
<div class="k">BLE 连接</div>
|
||
</div>
|
||
<div class="badge-stat">
|
||
<div class="v" id="gattState" style="font-size:16px;">未就绪</div>
|
||
<div class="k">GATT 状态</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Secondary metrics -->
|
||
<div class="card">
|
||
<div class="card-head"><span class="ico">📊</span> 运行指标</div>
|
||
<div class="card-body">
|
||
<div class="metric-grid">
|
||
<div class="metric"><div class="k">温度</div><div class="v" id="mTemp">--</div></div>
|
||
<div class="metric"><div class="k">GD32 APP</div><div class="v" id="mGd32App" style="font-size:14px;">--</div></div>
|
||
<div class="metric"><div class="k">GD32 BOOT</div><div class="v" id="mGd32Boot" style="font-size:14px;">--</div></div>
|
||
<div class="metric"><div class="k">OTA 缓存</div><div class="v" id="mOtaCache" style="font-size:13px;">--</div></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card" style="display:none">
|
||
<div class="card-head">
|
||
<span class="ico">🧾</span> 系统状态 (/system/status)
|
||
<button class="btn btn-ghost btn-sm card-head-btn" onclick="refreshSystemStatusNow(event)">刷新</button>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="status-kv-grid">
|
||
<div class="status-kv"><span class="k">OTA 状态</span><b id="sOtaState">--</b></div>
|
||
<div class="status-kv"><span class="k">Relay 状态</span><b id="sRelayState">--</b></div>
|
||
<div class="status-kv"><span class="k">缓存进度</span><b id="sOtaStored">--</b></div>
|
||
<div class="status-kv"><span class="k">刷新时间</span><b id="sStatusUpdated">--</b></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card collapsed">
|
||
<div class="card-head" onclick="toggleCollapse(this)">
|
||
<span class="ico">🧠</span> 堆与任务栈
|
||
<button class="btn btn-ghost btn-sm card-head-btn" onclick="refreshSystemStatusNow(event)">刷新</button>
|
||
<span class="collapse-arrow">▼</span>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="status-section-title">CPU 占用</div>
|
||
<div class="heap-group cpu-group">
|
||
<div class="heap-group-title">ESP32 Runtime <span style="font-weight:400;color:var(--text-dim);font-size:10px;">(FreeRTOS)</span></div>
|
||
<div class="heap-bar-row">
|
||
<span class="heap-bar-text" id="cpuUsageText">等待采样...</span>
|
||
<span class="heap-bar-track"><span class="heap-bar-fill cpu-fill" id="cpuUsageFill"></span></span>
|
||
<span class="heap-bar-pct" id="cpuUsagePct">--</span>
|
||
</div>
|
||
<div class="heap-detail-row">
|
||
<span>空闲: <b id="cpuIdlePct">--</b></span>
|
||
<span>窗口: <b id="cpuSampleMs">--</b></span>
|
||
<span>任务数: <b id="cpuTaskCount">--</b></span>
|
||
</div>
|
||
</div>
|
||
<div class="status-task-list cpu-task-list" id="cpuTaskList">
|
||
<div class="status-empty">等待下一次状态刷新...</div>
|
||
</div>
|
||
|
||
<div class="status-section-title">堆概况</div>
|
||
<div class="heap-group">
|
||
<div class="heap-group-title">内部 DRAM <span style="font-weight:400;color:var(--text-dim);font-size:10px;">(数据堆)</span></div>
|
||
<div class="heap-bar-row">
|
||
<span class="heap-bar-text" id="memInternalUsage">--</span>
|
||
<span class="heap-bar-track"><span class="heap-bar-fill" id="memInternalFill"></span></span>
|
||
<span class="heap-bar-pct" id="memInternalPct">--</span>
|
||
</div>
|
||
<div class="heap-detail-row">
|
||
<span>空闲: <b id="mInternalHeap">--</b></span>
|
||
<span>最低空闲: <b id="mInternalMinHeap">--</b></span>
|
||
<span>最大块: <b id="mInternalLargestBlock">--</b></span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="status-section-title">任务栈水位</div>
|
||
<div class="status-task-list" id="taskStackList">
|
||
<div class="status-empty">等待状态刷新...</div>
|
||
</div>
|
||
<div class="status-section-title">原始 JSON</div>
|
||
<pre class="status-json" id="statusJson">--</pre>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- CAN workspace entry -->
|
||
<div class="card collapsed">
|
||
<div class="card-head" onclick="toggleCollapse(this)">
|
||
<span class="ico">📨</span> CAN 通信页
|
||
<span class="collapse-arrow">▼</span>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="can-launch-card">
|
||
<div class="can-launch-copy">
|
||
<div class="can-launch-top">
|
||
<div>
|
||
<div class="can-launch-kicker">CAN Workspace</div>
|
||
<h3>独立 CAN 通信工作台</h3>
|
||
</div>
|
||
<span class="can-launch-badge">DBC / HEX</span>
|
||
</div>
|
||
<p class="can-launch-desc">导入 DBC 后自动读取报文和信号,按物理值实时组帧,并复用现有 <code>/can/send</code> 接口直接发送。</p>
|
||
<div class="can-launch-points">
|
||
<span class="can-launch-point">自动解析 DBC</span>
|
||
<span class="can-launch-point">信号实时组帧</span>
|
||
<span class="can-launch-point">保留原始 HEX 发送</span>
|
||
</div>
|
||
</div>
|
||
<div class="can-launch-actions">
|
||
<a class="btn btn-primary can-launch-primary" href="can.html">进入 CAN 页面</a>
|
||
<button class="btn btn-outline can-launch-secondary" type="button" onclick="window.open('can.html','_blank','noopener')">新窗口打开</button>
|
||
</div>
|
||
<div class="can-launch-foot">
|
||
<span class="can-launch-foot-label">说明</span>
|
||
<span>手工 CAN 表单已迁到独立页面,避免和 OTA 控制流混在一起;SPI 调试仍保留在当前页。</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- SPI panel -->
|
||
<div class="card collapsed">
|
||
<div class="card-head" onclick="toggleCollapse(this)">
|
||
<span class="ico">⚡</span> SPI 发送
|
||
<span class="collapse-arrow">▼</span>
|
||
</div>
|
||
<div class="card-body">
|
||
<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap;">
|
||
<input class="input" type="text" id="spiType" value="01" maxlength="2" placeholder="type" title="帧类型 (hex)" style="width:52px;flex:none;">
|
||
<input class="input" type="text" id="spiData" value="" placeholder="payload hex..." style="flex:1;min-width:100px;">
|
||
<input class="input" type="number" id="spiTimeout" value="1000" min="100" max="10000" title="超时 ms" style="width:68px;flex:none;">
|
||
<span style="font-size:11px;color:var(--text-dim);">ms</span>
|
||
<button class="btn btn-primary" onclick="sendSpiFrame()" style="padding:4px 14px;">发送</button>
|
||
<button class="btn btn-outline" onclick="clearSpiFields()" style="padding:4px 10px;">清空</button>
|
||
</div>
|
||
<div class="helper-note" id="spiHint">type(hex) + payload → 自动构建 SOF+LEN+CRC 帧</div>
|
||
<div id="spiResp" style="display:none;margin-top:8px;padding:8px 10px;background:var(--bg-elevated);border-radius:var(--radius-sm);font-family:var(--font-mono);font-size:12px;">
|
||
<span id="spiRespText" style="color:var(--accent);"></span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Log panel -->
|
||
<div class="card log-panel">
|
||
<div class="card-head" style="justify-content:space-between;">
|
||
<span><span class="ico">🖥️</span> 调试日志</span>
|
||
<span style="font-size:11px;font-weight:400;color:var(--text-label);text-transform:none;letter-spacing:0;">UDP :6666</span>
|
||
</div>
|
||
<div class="card-body">
|
||
<div class="log-toolbar">
|
||
<div class="log-filters">
|
||
<button class="log-filter active" onclick="setLogFilter('all', this)">全部</button>
|
||
<button class="log-filter" onclick="setLogFilter('E', this)">错误</button>
|
||
<button class="log-filter" onclick="setLogFilter('W', this)">警告</button>
|
||
<button class="log-filter" onclick="setLogFilter('I', this)">信息</button>
|
||
</div>
|
||
<button class="btn btn-ghost btn-sm" onclick="toggleAutoScroll()" id="autoScrollBtn" title="切换自动滚动">📌 自动滚动</button>
|
||
</div>
|
||
<div class="log-scroll" id="logScroll">
|
||
<div class="log-line"><span class="lt">[--:--:--]</span><span class="lv I">I</span><span class="lg">[系统]</span><span class="lm">等待上位机连接...</span></div>
|
||
</div>
|
||
<div class="log-foot">
|
||
<span>共 <span id="logCount">0</span> 条</span>
|
||
<button class="btn btn-ghost btn-sm" onclick="clearLogs()">清除日志</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Toast -->
|
||
<div class="toast" id="toast">
|
||
<span class="toast-ico" id="toastIco">✓</span>
|
||
<span id="toastMsg">操作成功</span>
|
||
</div>
|
||
|
||
<script src="app.js?v=20260626a"></script>
|
||
<script>
|
||
window.connectionMode = window.connectionMode || 'wifi';
|
||
window.flashTarget = window.flashTarget || 'esp32';
|
||
window.sharedFirmwareFiles = window.sharedFirmwareFiles || {
|
||
esp32: { file: null, handle: null },
|
||
gd32: { file: null, handle: null }
|
||
};
|
||
(function () {
|
||
var originalSwitchStageFlow = window.switchStageFlow;
|
||
window.switchStageFlow = function (target) {
|
||
if (target === 'ble') {
|
||
window.currentOtaTarget = 'ble';
|
||
var esp32El = document.getElementById('esp32Stages');
|
||
var gd32El = document.getElementById('gd32Stages');
|
||
var bleEl = document.getElementById('bleStages');
|
||
if (esp32El) esp32El.style.display = 'none';
|
||
if (gd32El) gd32El.style.display = 'none';
|
||
if (bleEl) bleEl.style.display = '';
|
||
return;
|
||
}
|
||
if (typeof originalSwitchStageFlow === 'function') {
|
||
originalSwitchStageFlow(target);
|
||
}
|
||
var bleEl = document.getElementById('bleStages');
|
||
if (bleEl) bleEl.style.display = 'none';
|
||
};
|
||
|
||
window.setConsoleMode = function (mode) {
|
||
var isBle = mode === 'ble';
|
||
window.connectionMode = isBle ? 'ble' : 'wifi';
|
||
var wifiCards = document.getElementById('wifiModeCards');
|
||
var bleCards = document.getElementById('bleModeCards');
|
||
var wifiBtn = document.getElementById('modeWifiBtn');
|
||
var bleBtn = document.getElementById('modeBleBtn');
|
||
if (wifiCards) wifiCards.style.display = isBle ? 'none' : '';
|
||
if (bleCards) bleCards.style.display = isBle ? '' : 'none';
|
||
var headBtns = document.getElementById('bleHeadActions');
|
||
if (headBtns) headBtns.style.display = isBle ? '' : 'none';
|
||
if (wifiBtn) wifiBtn.className = isBle ? 'btn btn-outline' : 'btn btn-primary';
|
||
if (bleBtn) bleBtn.className = isBle ? 'btn btn-primary' : 'btn btn-outline';
|
||
applySharedModeUi();
|
||
syncBleFileFromSelectedTarget();
|
||
if (isBle) {
|
||
window.switchStageFlow('ble');
|
||
if (typeof setActivePhase === 'function') {
|
||
setActivePhase(window.flashTarget === 'gd32' ? 'gd32Ota' : 'esp32Ota');
|
||
}
|
||
if (window.bleOta && typeof window.bleOta.refreshButtons === 'function') {
|
||
window.bleOta.refreshButtons();
|
||
}
|
||
} else {
|
||
window.switchStageFlow(window.flashTarget || (window.activePhase === 'esp32Ota' ? 'esp32' : 'gd32'));
|
||
}
|
||
};
|
||
|
||
window.selectFlashTarget = function (target) {
|
||
window.flashTarget = target === 'gd32' ? 'gd32' : 'esp32';
|
||
applySharedModeUi();
|
||
syncBleFileFromSelectedTarget();
|
||
if (window.connectionMode === 'ble') {
|
||
window.switchStageFlow('ble');
|
||
if (typeof setActivePhase === 'function') {
|
||
setActivePhase(target === 'gd32' ? 'gd32Ota' : 'esp32Ota');
|
||
}
|
||
} else {
|
||
window.switchStageFlow(window.flashTarget);
|
||
}
|
||
if (window.bleOta && typeof window.bleOta.refreshTargetUi === 'function') {
|
||
window.bleOta.refreshTargetUi();
|
||
}
|
||
};
|
||
|
||
window.handleSharedFileSelect = function (event, chip) {
|
||
var file = event && event.target && event.target.files && event.target.files[0] ? event.target.files[0] : null;
|
||
if (file) {
|
||
window.sharedFirmwareFiles[chip] = { file: file, handle: null };
|
||
}
|
||
if (typeof window.handleFileSelect === 'function') {
|
||
window.handleFileSelect(event, chip);
|
||
}
|
||
window.flashTarget = chip === 'gd32' ? 'gd32' : 'esp32';
|
||
applySharedModeUi();
|
||
setTimeout(syncBleFileFromSelectedTarget, 0);
|
||
};
|
||
|
||
window.pickSharedFirmware = function (chip) {
|
||
window.flashTarget = chip === 'gd32' ? 'gd32' : 'esp32';
|
||
applySharedModeUi();
|
||
if (window.showOpenFilePicker && typeof window.pickFileViaFSAPI === 'function') {
|
||
var nativePicker = window.showOpenFilePicker;
|
||
var capturedHandle = null;
|
||
window.showOpenFilePicker = function () {
|
||
return nativePicker.apply(this, arguments).then(function (handles) {
|
||
capturedHandle = handles && handles.length ? handles[0] : null;
|
||
return handles;
|
||
});
|
||
};
|
||
Promise.resolve(window.pickFileViaFSAPI(chip)).then(function () {
|
||
window.showOpenFilePicker = nativePicker;
|
||
if (!capturedHandle || typeof capturedHandle.getFile !== 'function') {
|
||
syncBleFileFromSelectedTarget();
|
||
return;
|
||
}
|
||
capturedHandle.getFile().then(function (file) {
|
||
window.sharedFirmwareFiles[chip] = { file: file, handle: capturedHandle };
|
||
syncBleFileFromSelectedTarget();
|
||
}).catch(function () {
|
||
syncBleFileFromSelectedTarget();
|
||
});
|
||
}).catch(function (err) {
|
||
window.showOpenFilePicker = nativePicker;
|
||
if (err && err.name !== 'AbortError' && typeof window.addLog === 'function') {
|
||
window.addLog('W', '文件', 'FSAPI 选择失败: ' + err.message);
|
||
}
|
||
});
|
||
} else {
|
||
var input = document.getElementById(chip === 'gd32' ? 'fileInputGd32' : 'fileInputEsp32');
|
||
if (input) input.click();
|
||
}
|
||
};
|
||
|
||
window.clearSharedFirmware = function (chip) {
|
||
window.sharedFirmwareFiles[chip] = { file: null, handle: null };
|
||
if (typeof window.clearFile === 'function') {
|
||
window.clearFile(chip);
|
||
}
|
||
if (window.connectionMode === 'ble' && window.flashTarget === chip && window.bleOta && typeof window.bleOta.clearFile === 'function') {
|
||
window.bleOta.clearFile();
|
||
}
|
||
};
|
||
|
||
window.setBleFirmwareInputFile = function (file) {
|
||
var input = document.getElementById('firmwareFile');
|
||
if (!input || !file || typeof window.DataTransfer !== 'function') {
|
||
return;
|
||
}
|
||
try {
|
||
var dt = new DataTransfer();
|
||
dt.items.add(file);
|
||
input.files = dt.files;
|
||
} catch (err) {
|
||
// Some browsers restrict assignment to file inputs; BLE state is still synced via notifyFileChange.
|
||
}
|
||
};
|
||
|
||
window.syncBleFileFromSelectedTarget = function () {
|
||
if (window.connectionMode !== 'ble' || !window.bleOta || typeof window.bleOta.notifyFileChange !== 'function') {
|
||
return;
|
||
}
|
||
var isGd32 = window.flashTarget === 'gd32';
|
||
var chip = isGd32 ? 'gd32' : 'esp32';
|
||
var suffix = isGd32 ? 'Gd32' : 'Esp32';
|
||
var stored = window.sharedFirmwareFiles[chip];
|
||
if (stored && stored.handle && typeof stored.handle.getFile === 'function') {
|
||
stored.handle.getFile().then(function (file) {
|
||
stored.file = file;
|
||
setBleFirmwareInputFile(file);
|
||
window.bleOta.notifyFileChange(file, stored.handle);
|
||
}).catch(function () {
|
||
if (stored.file) {
|
||
setBleFirmwareInputFile(stored.file);
|
||
window.bleOta.notifyFileChange(stored.file, stored.handle);
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
if (stored && stored.file) {
|
||
setBleFirmwareInputFile(stored.file);
|
||
window.bleOta.notifyFileChange(stored.file, stored.handle || null);
|
||
return;
|
||
}
|
||
var input = document.getElementById('fileInput' + suffix);
|
||
var info = document.getElementById('fileInfo' + suffix);
|
||
var hasInfo = info && info.classList.contains('show');
|
||
var file = input && input.files && input.files[0] ? input.files[0] : null;
|
||
if (file) {
|
||
window.sharedFirmwareFiles[chip] = { file: file, handle: null };
|
||
setBleFirmwareInputFile(file);
|
||
window.bleOta.notifyFileChange(file, null);
|
||
return;
|
||
}
|
||
if (!hasInfo) {
|
||
return;
|
||
}
|
||
var nameEl = document.getElementById('fileName' + suffix);
|
||
var typeEl = document.getElementById('fileType' + suffix);
|
||
var sizeEl = document.getElementById('fileSize' + suffix);
|
||
var bleInfo = document.getElementById('fileInfo');
|
||
var bleName = document.getElementById('fileName');
|
||
var bleType = document.getElementById('fileType');
|
||
var bleSize = document.getElementById('fileSize');
|
||
if (bleInfo) bleInfo.classList.add('show');
|
||
if (bleName && nameEl) bleName.textContent = nameEl.textContent;
|
||
if (bleType && typeEl) bleType.textContent = typeEl.textContent;
|
||
if (bleSize && sizeEl) bleSize.textContent = sizeEl.textContent;
|
||
var packetCount = document.getElementById('packetCount');
|
||
var sentCount = document.getElementById('sentCount');
|
||
if (packetCount) packetCount.textContent = '-';
|
||
if (sentCount) sentCount.textContent = '0';
|
||
};
|
||
|
||
window.attachSharedFirmwareDropBridge = function () {
|
||
['esp32', 'gd32'].forEach(function (chip) {
|
||
var zone = document.getElementById(chip === 'gd32' ? 'uploadZoneGd32' : 'uploadZoneEsp32');
|
||
if (!zone) return;
|
||
zone.addEventListener('drop', function (event) {
|
||
var file = event && event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files[0] ? event.dataTransfer.files[0] : null;
|
||
if (file) {
|
||
window.sharedFirmwareFiles[chip] = { file: file, handle: null };
|
||
}
|
||
window.flashTarget = chip;
|
||
applySharedModeUi();
|
||
setTimeout(syncBleFileFromSelectedTarget, 120);
|
||
}, true);
|
||
});
|
||
};
|
||
|
||
window.applySharedModeUi = function () {
|
||
var isBle = window.connectionMode === 'ble';
|
||
var isGd32 = window.flashTarget === 'gd32';
|
||
var targetEsp32 = document.getElementById('targetEsp32Tab');
|
||
var targetGd32 = document.getElementById('targetGd32Tab');
|
||
if (targetEsp32) targetEsp32.className = isGd32 ? 'btn btn-outline' : 'btn btn-primary';
|
||
if (targetGd32) targetGd32.className = isGd32 ? 'btn btn-primary' : 'btn btn-outline';
|
||
var ids = {
|
||
wifiEsp32UploadPane: !isGd32,
|
||
wifiGd32UploadPane: isGd32,
|
||
bleUploadPane: false,
|
||
wifiEsp32ActionPane: !isBle && !isGd32,
|
||
wifiGd32ActionPane: !isBle && isGd32,
|
||
bleActionPane: isBle
|
||
};
|
||
Object.keys(ids).forEach(function (id) {
|
||
var el = document.getElementById(id);
|
||
if (el) el.style.display = ids[id] ? '' : 'none';
|
||
});
|
||
var hint = document.getElementById('readyHint');
|
||
if (hint) {
|
||
if (isBle) {
|
||
hint.innerHTML = 'BLE 模式:连接设备 → 选择固件 → 一键刷写。';
|
||
} else if (isGd32) {
|
||
hint.innerHTML = 'WiFi 模式:上传 GD32 固件后自动执行 SPI OTA。';
|
||
} else {
|
||
hint.innerHTML = 'WiFi 模式:上传 ESP32 固件后自动执行 OTA 并重启。';
|
||
}
|
||
}
|
||
};
|
||
|
||
applySharedModeUi();
|
||
})();
|
||
</script>
|
||
<script src="ble_ota.js?v=20260626a"></script>
|
||
</body>
|
||
</html>
|