init: DCU ESP32 调试控制台前端与本地页面服务

纯前端上位机界面 + esp32_server.py 静态服务/桌面窗口外壳, 不含固件、
不直接访问硬件; 业务 API 由 ESP32_COMM 固件 80 端口跨域提供。

- tabs.html 单窗口多标签外壳, iframe 承载各功能页
- index.html OTA 中继(ESP32 WiFi / GD32 relay / BLE), js/ota + js/ble
- can.html / can_monitor.html CAN 通信与接收监控, js/can
- motor.html 电机 CAN FD 控制台, js/motor
- continuity_test.html LAN9252 通断测试, spi_test.html SPI 测速(临时, 自包含)
- 三入口: esp32_app.vbs 桌面窗口 / esp32_web.vbs 浏览器 / serve.bat 控制台调试
- 补 README(页面与目录约定、?v= 缓存版本等改动须知)、.gitignore、
  .gitattributes(* -text 保持 Windows 换行原样)
This commit is contained in:
cbb
2026-09-03 21:30:32 +08:00
commit d2c86c425a
54 changed files with 20643 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
# 该工具集在 Windows 下开发运行,保持文件换行符原样,避免 checkout/commit 时被改写
* -text
+23
View File
@@ -0,0 +1,23 @@
# Python
__pycache__/
*.py[cod]
*.egg-info/
.pytest_cache/
# 虚拟环境(本地生成,勿入库)
venv/
.venv/
env/
# 固件 / 日志产物(本目录只做前端与本地服务,固件由 ESP32_COMM 构建)
*.bin
*.elf
*.map
*.log
# 编辑器 / 系统
.vscode/
.idea/
*.swp
Thumbs.db
desktop.ini
+63
View File
@@ -0,0 +1,63 @@
# DCU ESP32 调试控制台(Web 前端 + 本地页面服务)
`G:\Robot_v2\DCU\tools\ESP32` —— ESP32-C3(`DCU/ESP32_COMM` 固件)的上位机调试界面。
本目录**不含固件代码,也不直接访问硬件**:HTML/CSS/JS 是纯前端,页面本体由
`esp32_server.py` 以静态文件方式提供(同源相对路径),而所有业务 API 由 ESP32
固件的 80 端口跨域提供。设备 IP 存在浏览器 `localStorage` 的 `dcu_esp_ip`
(默认 `192.168.31.127`,见 `js/common/base-url.js`)。
## 启动方式
| 入口 | 形态 | 说明 |
|------|------|------|
| `esp32_app.vbs` | 桌面窗口 | pywebview + WebView2,无控制台窗口;服务跑在独立子进程 |
| `esp32_web.vbs` | 默认浏览器标签页 | 无控制台窗口 |
| `serve.bat` | 控制台模式 | 端口 8080,窗口内可见服务器日志,用于调试 |
窗口模式为何拆两个进程:`pythonw` + `pythonnet`(pywebview 依赖)环境下 Python
后台线程会被静默杀死,把 HTTP 服务放进独立子进程可规避,与 `tools/ecat/ecat_server.py`
同款结构。pywebview 窗口没有地址栏与刷新按钮,因此服务器会向 HTML 响应注入
`_reload.html`(右下角 ⟳ 悬浮按钮 + F5/Ctrl+R 拦截)。
`build_mem.bat` 是从 Git Bash 调用 ESP-IDF 构建 `DCU/ESP32_COMM` 的辅助脚本
(会先清掉 MSYS 环境变量绕过 IDF 的 MSys 检测)。
## 页面
`tabs.html` 是单窗口多标签外壳,用 iframe 承载下列功能页,激活标签记忆在
`localStorage` 的 `dcu_tab`:
| 页面 | 用途 | 主要脚本 |
|------|------|----------|
| `index.html` | OTA 中继控制台(ESP32 WiFi OTA / GD32 relay / BLE OTA) | `js/app.js`、`js/ota/*`、`js/ble_ota.js`、`js/ble/*` |
| `can.html` | CAN 通信(DBC 解析、消息编辑与批量发送) | `js/can.js`、`js/can/*` |
| `can_monitor.html` | CAN 接收监控 | `js/can_monitor.js` |
| `motor.html` | 电机控制台(CAN FD) | `js/motor/main.js`、`js/motor/tick.worker.js` |
| `continuity_test.html` | LAN9252 通断测试(自包含,含 WebGL 液态玻璃效果) | 内联 |
| `spi_test.html` | 【临时功能 SPI-SPEEDTEST】SPI 链路测速,完全自包含,删文件即可移除 | 内联 |
## 目录约定
- `css/` —— `base`(变量/基础)、`layout`、`components`、`can`、`motor`、`responsive`。
- `js/common/` —— 跨页公用:`base-url`(设备 IP)、`connection`、`polling`、`log`、`toast`、`utils`、`heap-low-history`。
- `js/ota/`、`js/ble/`、`js/can/`、`js/motor/` —— 按功能域拆分的 ES module。
- 子系统配色:ESP32 = 青绿 · GD32 = 琥珀 · BLE = 蓝 · CAN = 绿 · 内存/调试 = 紫。
## 改动须知
- **改 CSS/JS 后必须 bump 引用处的 `?v=` 缓存版本**,否则浏览器会加载旧代码。
- OTA 流程以设备端为真相来源:WiFi OTA 由设备回读分区算 SHA-256 后自动重启;
BLE OTA 由前端发 `0xF8 + 32B SHA-256`、设备端比较并通过 FINISH 事件报结果
(前端不做 SHA 预比较);GD32 relay 状态以 `/system/status` 为准。
- 监控 UI 需区分 `valid`(通信是否成功)与 `count > 0`(是否有数据),
以便分辨「正常空闲」和「真实通信错误」。
- 响应式 grid 表隐藏列用 `0fr` 宽度实现,不要改变 grid track 数量。
- 新增 CAN 工作模式时,需在页面上显式补按钮与说明文字。
- DBC 文件常为 GBK/GB2312:先按 UTF-8(`{fatal: true}`)尝试,失败回退 GB18030,并去 BOM。
## 相关仓库
- `DCU/ESP32_COMM` —— ESP32-C3 固件(OTA、SPI Link v2、CAN FD bridge)。
- `DCU/DCU_APP`、`DCU/GD32E50X_BOOT` —— GD32 侧应用与 bootloader。
- `DCU/tools/ecat` —— LAN9252 EtherCAT 主站工具集(同类结构的姊妹工具)。
+23
View File
@@ -0,0 +1,23 @@
@echo off
REM Clear MSYS/MINGW env vars to bypass IDF MSys detection when called from Git Bash
set MSYSTEM=
set MSYSTEM_CHOST=
set MSYSTEM_PREFIX=
set MINGW_CHOST=
set MINGW_PREFIX=
set MINGW_PACKAGE_PREFIX=
set CHERE_INVOKING=
set MSYS2_PATH_TYPE=
REM Toolchain installed at C:\Espressif (see .vscode/settings.json idf.toolsPath)
set IDF_TOOLS_PATH=C:\Espressif
call "F:\software\.espressif\v6.0\esp-idf\export.bat"
if errorlevel 1 (
echo EXPORT_FAILED
exit /b 1
)
cd /d "G:\Robot_v2\DCU\ESP32_COMM"
idf.py build
exit /b %errorlevel%␍
+334
View File
@@ -0,0 +1,334 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ESP32 CAN 通信控制台</title>
<!-- 空 favicon:阻止浏览器自动请求 /favicon.ico(本地静态服务无此文件会 404) -->
<link rel="icon" href="data:,">
<link rel="stylesheet" href="css/base.css?v=857f83a5">
<link rel="stylesheet" href="css/layout.css?v=acd17001">
<link rel="stylesheet" href="css/components.css?v=7bc7d5db">
<link rel="stylesheet" href="css/can.css?v=c06f5688">
<link rel="stylesheet" href="css/responsive.css?v=46449483">
</head>
<body class="can-page">
<div class="container can-container">
<header class="header-bar can-header">
<div class="header-brand">
<span class="brand-logo" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<rect x="6" y="6" width="12" height="12" rx="2.5"/>
<rect x="9.5" y="9.5" width="5" height="5" rx="1" fill="currentColor" stroke="none" opacity=".85"/>
<path d="M9 2v3M15 2v3M9 19v3M15 19v3M2 9h3M2 15h3M19 9h3M19 15h3"/>
</svg>
</span>
<div class="brand-text">
<strong>ESP32 CAN 通信控制台</strong>
<span class="brand-sub">DCU · Robot_v2 · CAN FD</span>
</div>
</div>
<div class="header-status">
<span class="pill offline" id="canConnPill"><span class="indicator"></span><span id="canConnTxt">未连接</span></span>
<span class="pill" id="canIpPill">IP --</span>
<span class="pill" id="canRssiPill">RSSI --</span>
<span class="pill" id="canUptimePill">运行 --</span>
<span class="pill" id="canVersionPill">v--</span>
<span class="pill" id="canChannelPill">CH0</span>
<span class="pill" id="dbcSummaryPill">未导入 DBC</span>
<span class="pill" id="messageSummaryPill">0 报文</span>
<span class="pill" id="fbModePill">模式: --</span>
<span class="pill" id="fbPollStatus">轮询: --</span>
</div>
<div class="header-sub can-header-actions">
<a class="pill can-link-pill" href="index.html">返回 OTA 控制台</a>
<a class="pill can-link-pill" href="can_monitor.html" title="独立全屏监控页">监控全屏</a>
</div>
</header>
<!-- DBC 并入报文列表:无文件时列表内大拖放区;有文件时标题栏紧凑摘要 + 更换 -->
<input type="file" id="dbcFileInput" accept=".dbc,text/plain" multiple style="display:none;">
<div class="can-editor-layout">
<div class="can-main-col">
<section class="card can-table-card" id="dbcDropZone">
<div class="card-head can-msg-list-head">
<span class="ico">📚</span>
<span>报文列表</span>
<!-- 已导入:合计 + 文件 chip(可逐个移除) -->
<div class="dbc-summary-strip dbc-summary-inline" id="dbcFileInfo" hidden>
<span class="dbc-summary-meta">
<span><b id="dbcFileCount">0</b>文件</span>
<span><b id="dbcMessageCount">0</b>报文</span>
<span><b id="dbcSignalCount">0</b>信号</span>
<span><b id="dbcNodeCount">0</b>节点</span>
<span id="dbcFileSize">0 B</span>
</span>
<span class="dbc-file-chips" id="dbcFileChips"></span>
</div>
<div class="can-msg-head-actions">
<button class="btn btn-ghost btn-sm" id="clearDbcBtn" type="button" title="清除全部 DBC" hidden>全部清除</button>
<button class="btn btn-primary btn-sm" id="importDbcBtn" type="button">导入 DBC</button>
</div>
</div>
<!-- 通道 / 搜索 / 勾选 / 节点 / 批量 同一工具栏 -->
<div class="can-msg-toolbar" id="canMsgToolbar">
<label class="can-msg-channel" for="defaultChannel">
<span class="can-msg-channel-label">通道</span>
<select class="input can-msg-channel-select" id="defaultChannel" title="默认发送通道">
<option value="0">CH0</option>
<option value="1">CH1</option>
<option value="2">CH2</option>
</select>
</label>
<div class="can-msg-search-wrap">
<input class="input can-msg-search" type="text" id="dbcSearch"
placeholder="搜索报文名 / ID / 节点 / 信号" autocomplete="off">
</div>
<div class="can-batch-actions">
<span class="pill" id="selectedCountPill">已勾选 0</span>
<div class="can-node-filter" id="nodeFilterWrap">
<button class="btn btn-outline btn-sm can-node-filter-btn" id="nodeFilterBtn" type="button" aria-haspopup="true" aria-expanded="false">
<span class="can-node-filter-label" id="nodeFilterLabel">发送节点:全部</span>
<span class="can-node-filter-caret">▾</span>
</button>
<div class="can-node-filter-panel" id="nodeFilterPanel" hidden>
<div class="can-node-filter-head">
<input class="input can-node-filter-search" id="nodeFilterSearch" type="text" placeholder="筛选节点名…">
<div class="can-node-filter-actions">
<button class="btn btn-ghost btn-sm" id="nodeFilterAllBtn" type="button">全选</button>
<button class="btn btn-ghost btn-sm" id="nodeFilterNoneBtn" type="button">清空</button>
</div>
</div>
<div class="can-node-filter-list" id="nodeFilterList">
<div class="status-empty">先导入 DBC。</div>
</div>
</div>
</div>
<label class="checkbox-inline can-batch-all-label" title="全选/取消当前过滤的所有报文">
<input type="checkbox" id="selectAllBox">
<span>全选当前过滤</span>
</label>
<button class="btn btn-outline btn-sm" id="expandAllBtn" type="button">展开全部</button>
<button class="btn btn-outline btn-sm" id="collapseAllBtn" type="button">收起全部</button>
<button class="btn btn-primary btn-sm" id="sendCheckedBtn" type="button">批量发送勾选</button>
</div>
</div>
<div class="card-body can-table-card-body">
<div class="message-list" id="messageList">
<div class="dbc-empty-drop" id="dbcEmptyDrop">
<div class="upload-ico">📥</div>
<h4>导入 DBC 开始编辑报文</h4>
<p>点击此处或拖拽 <code>.dbc</code> 文件到列表区域</p>
</div>
</div>
<div class="helper-note" id="dbcHint" hidden></div>
</div>
</section>
<section class="card can-history-card">
<div class="card-head"><span class="ico">🕘</span> 最近发送</div>
<div class="card-body">
<div class="history-list history-list-compact" id="historyList">
<div class="status-empty">尚未发送任何 CAN 帧。</div>
</div>
</div>
</section>
</div>
<aside class="can-sidepanel">
<section class="card can-preview-card">
<div class="card-head"><span class="ico">🛰️</span> 帧预览</div>
<div class="card-body">
<div class="can-frame-panel can-frame-panel-compact">
<div class="can-frame-hex" id="generatedHex">--</div>
<div class="can-byte-strip" id="generatedBytes"></div>
<div class="helper-note" id="encodeHint">选择报文后,信号值会实时编码为 CAN 数据。</div>
<div class="btn-row can-frame-actions">
<button class="btn btn-outline btn-sm" id="loadPreviewToRawBtn" type="button">载入到原始发送区</button>
<button class="btn btn-primary btn-sm" id="sendSelectedBtn" type="button">发送当前预览帧</button>
</div>
<div class="can-response" id="dbcResp" style="display:none;">
<div class="can-response-label">设备响应</div>
<div id="dbcRespText"></div>
</div>
</div>
</div>
</section>
<section class="card can-raw-card">
<div class="card-head"><span class="ico">✍️</span> 原始帧发送</div>
<div class="card-body">
<div class="row-2 can-raw-grid">
<div class="form-row" style="margin:0;">
<label class="form-label" for="manualChannel">通道</label>
<select class="input" id="manualChannel">
<option value="0">CH0</option>
<option value="1">CH1</option>
<option value="2">CH2</option>
</select>
</div>
<div class="form-row" style="margin:0;">
<label class="form-label" for="manualId">CAN ID</label>
<input class="input" type="text" id="manualId" value="0x18DA00F1" placeholder="支持 0x 或十进制">
</div>
</div>
<div class="form-row" style="margin-top:10px;">
<label class="form-label" for="manualDlc">DLC</label>
<input class="input" type="number" id="manualDlc" value="8" min="0" max="64">
</div>
<div class="form-row" style="margin-top:10px;">
<label class="form-label" for="manualData">HEX 数据</label>
<input class="input" type="text" id="manualData" value="0102030405060708" placeholder="01020304...">
</div>
<div class="helper-note" id="manualHint">
原始帧模式适合绕过 DBC 直接发 HEX,或发送当前预览帧后二次微调。
</div>
<div class="btn-row">
<button class="btn btn-primary" id="sendManualBtn" type="button">发送原始帧</button>
<button class="btn btn-outline" id="resetManualBtn" type="button">重置</button>
</div>
<div class="can-response" id="manualResp" style="display:none;">
<div class="can-response-label">设备响应</div>
<div id="manualRespText"></div>
</div>
</div>
</section>
</aside>
</div>
</div>
<!-- 右侧滑出:CAN 接收监控(与 OTA 页「堆与任务栈」同样交互) -->
<aside class="sys-drawer can-monitor-drawer" id="canMonitorDrawer" aria-label="CAN 接收监控">
<div class="sys-drawer-rail" title="悬停展开 CAN 接收监控">
<span class="sys-drawer-rail-ico">📊</span>
<span class="sys-drawer-rail-text">CAN 接收监控</span>
</div>
<div class="sys-drawer-panel card">
<div class="card-head sys-drawer-head">
<span class="ico">📊</span> CAN 接收监控
</div>
<div class="card-body sys-drawer-body">
<div class="status-section-title">接收模式</div>
<div class="input-group" style="margin-bottom:12px;flex-wrap:wrap;">
<button class="btn btn-primary btn-sm" id="btnModeAllPass" type="button">全收模式</button>
<button class="btn btn-outline btn-sm" id="btnModeFilter" type="button">过滤模式</button>
<button class="btn btn-outline btn-sm" id="btnModeBridge" type="button" title="CAN0 收到的每一帧按原帧格式自动转发到 CAN1 与 CAN2">CAN0→1/2 桥接</button>
</div>
<div class="helper-note" id="monHelper" style="margin-bottom:14px;">
地址与顶栏一致;1Hz <code>/can/feedback</code>。悬停抽屉保持展开。
</div>
<div class="status-section-title">三路 CAN 通道(1Hz)</div>
<div class="feedback-grid feedback-grid-drawer">
<div class="feedback-channel collapsed" id="fbCh0">
<div class="feedback-ch-head" role="button" tabindex="0" title="点击展开/折叠详情">
<span class="ch-dot ch0"></span> CAN0
<span class="feedback-expand-caret">▾</span>
</div>
<div class="feedback-primary">
<div class="feedback-row"><span class="fb-label">状态</span><span class="fb-val" id="fb0State">--</span></div>
<div class="feedback-row"><span class="fb-label">接收数</span><span class="fb-val" id="fb0Rx">--</span></div>
<div class="feedback-row"><span class="fb-label">发送数</span><span class="fb-val" id="fb0Tx">--</span></div>
<div class="feedback-row"><span class="fb-label">丢帧</span><span class="fb-val" id="fb0Drop">--</span></div>
<div class="feedback-row"><span class="fb-label">错误</span><span class="fb-val" id="fb0Err">--</span></div>
</div>
<div class="feedback-detail">
<div class="feedback-row"><span class="fb-label">TEC</span><span class="fb-val" id="fb0Tec">--</span></div>
<div class="feedback-row"><span class="fb-label">REC</span><span class="fb-val" id="fb0Rec">--</span></div>
<div class="feedback-row"><span class="fb-label">总线状态</span><span class="fb-val" id="fb0Bus">--</span></div>
<div class="feedback-row"><span class="fb-label">最新ID</span><span class="fb-val mono" id="fb0LastId">--</span></div>
<div class="feedback-row"><span class="fb-label">DLC</span><span class="fb-val" id="fb0Dlc">--</span></div>
<div class="feedback-row"><span class="fb-label">FD/BRS/ESI</span><span class="fb-val" id="fb0Flags">--</span></div>
<div class="feedback-row"><span class="fb-label">Seq</span><span class="fb-val" id="fb0Seq">--</span></div>
<div class="feedback-row"><span class="fb-label">Δrx/s</span><span class="fb-val" id="fb0Delta">--</span></div>
<div class="feedback-row"><span class="fb-label">最新HEX</span><span class="fb-val mono fb-data-cell" id="fb0LastData">--</span></div>
</div>
</div>
<div class="feedback-channel collapsed" id="fbCh1">
<div class="feedback-ch-head" role="button" tabindex="0" title="点击展开/折叠详情">
<span class="ch-dot ch1"></span> CAN1
<span class="feedback-expand-caret">▾</span>
</div>
<div class="feedback-primary">
<div class="feedback-row"><span class="fb-label">状态</span><span class="fb-val" id="fb1State">--</span></div>
<div class="feedback-row"><span class="fb-label">接收数</span><span class="fb-val" id="fb1Rx">--</span></div>
<div class="feedback-row"><span class="fb-label">发送数</span><span class="fb-val" id="fb1Tx">--</span></div>
<div class="feedback-row"><span class="fb-label">丢帧</span><span class="fb-val" id="fb1Drop">--</span></div>
<div class="feedback-row"><span class="fb-label">错误</span><span class="fb-val" id="fb1Err">--</span></div>
</div>
<div class="feedback-detail">
<div class="feedback-row"><span class="fb-label">TEC</span><span class="fb-val" id="fb1Tec">--</span></div>
<div class="feedback-row"><span class="fb-label">REC</span><span class="fb-val" id="fb1Rec">--</span></div>
<div class="feedback-row"><span class="fb-label">总线状态</span><span class="fb-val" id="fb1Bus">--</span></div>
<div class="feedback-row"><span class="fb-label">最新ID</span><span class="fb-val mono" id="fb1LastId">--</span></div>
<div class="feedback-row"><span class="fb-label">DLC</span><span class="fb-val" id="fb1Dlc">--</span></div>
<div class="feedback-row"><span class="fb-label">FD/BRS/ESI</span><span class="fb-val" id="fb1Flags">--</span></div>
<div class="feedback-row"><span class="fb-label">Seq</span><span class="fb-val" id="fb1Seq">--</span></div>
<div class="feedback-row"><span class="fb-label">Δrx/s</span><span class="fb-val" id="fb1Delta">--</span></div>
<div class="feedback-row"><span class="fb-label">最新HEX</span><span class="fb-val mono fb-data-cell" id="fb1LastData">--</span></div>
</div>
</div>
<div class="feedback-channel collapsed" id="fbCh2">
<div class="feedback-ch-head" role="button" tabindex="0" title="点击展开/折叠详情">
<span class="ch-dot ch2"></span> CAN2
<span class="feedback-expand-caret">▾</span>
</div>
<div class="feedback-primary">
<div class="feedback-row"><span class="fb-label">状态</span><span class="fb-val" id="fb2State">--</span></div>
<div class="feedback-row"><span class="fb-label">接收数</span><span class="fb-val" id="fb2Rx">--</span></div>
<div class="feedback-row"><span class="fb-label">发送数</span><span class="fb-val" id="fb2Tx">--</span></div>
<div class="feedback-row"><span class="fb-label">丢帧</span><span class="fb-val" id="fb2Drop">--</span></div>
<div class="feedback-row"><span class="fb-label">错误</span><span class="fb-val" id="fb2Err">--</span></div>
</div>
<div class="feedback-detail">
<div class="feedback-row"><span class="fb-label">TEC</span><span class="fb-val" id="fb2Tec">--</span></div>
<div class="feedback-row"><span class="fb-label">REC</span><span class="fb-val" id="fb2Rec">--</span></div>
<div class="feedback-row"><span class="fb-label">总线状态</span><span class="fb-val" id="fb2Bus">--</span></div>
<div class="feedback-row"><span class="fb-label">最新ID</span><span class="fb-val mono" id="fb2LastId">--</span></div>
<div class="feedback-row"><span class="fb-label">DLC</span><span class="fb-val" id="fb2Dlc">--</span></div>
<div class="feedback-row"><span class="fb-label">FD/BRS/ESI</span><span class="fb-val" id="fb2Flags">--</span></div>
<div class="feedback-row"><span class="fb-label">Seq</span><span class="fb-val" id="fb2Seq">--</span></div>
<div class="feedback-row"><span class="fb-label">Δrx/s</span><span class="fb-val" id="fb2Delta">--</span></div>
<div class="feedback-row"><span class="fb-label">最新HEX</span><span class="fb-val mono fb-data-cell" id="fb2LastData">--</span></div>
</div>
</div>
</div>
<div class="status-section-title" style="margin-top:16px;display:flex;align-items:center;gap:10px;flex-wrap:wrap;">
<span>每 ID 快照表</span>
<select class="input" id="idsnapChannel" style="width:100px;text-transform:none;">
<option value="0">CAN0</option>
<option value="1">CAN1</option>
<option value="2">CAN2</option>
</select>
<span class="pill" id="idsnapStatus">--</span>
</div>
<div class="idsnap-table-wrap">
<table class="idsnap-table" id="idsnapTable">
<thead>
<tr>
<th>#</th><th>CAN ID</th><th>DLC</th>
<th>FD</th><th>BRS</th><th>ESI</th><th>数据 (HEX)</th>
</tr>
</thead>
<tbody id="idsnapBody">
<tr><td colspan="7" class="status-empty">等待数据...</td></tr>
</tbody>
</table>
</div>
</div>
</div>
</aside>
<div class="toast" id="toast">
<span class="toast-ico" id="toastIco">✓</span>
<span id="toastMsg">操作成功</span>
</div>
<script type="module" src="js/can.js?v=20260801f"></script>
</body>
</html>
+189
View File
@@ -0,0 +1,189 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ESP32 CAN 接收监控</title>
<!-- 空 favicon:阻止浏览器自动请求 /favicon.ico(本地静态服务无此文件会 404) -->
<link rel="icon" href="data:,">
<link rel="stylesheet" href="css/base.css?v=857f83a5">
<link rel="stylesheet" href="css/layout.css?v=acd17001">
<link rel="stylesheet" href="css/components.css?v=7bc7d5db">
<link rel="stylesheet" href="css/can.css?v=c06f5688">
<link rel="stylesheet" href="css/responsive.css?v=46449483">
</head>
<body class="can-page">
<div class="container can-container">
<header class="header-bar can-header">
<div class="header-brand">
<span class="dot-mark"></span>
<strong>ESP32 CAN 接收监控</strong>
</div>
<div class="header-status">
<span class="pill offline" id="monConnPill"><span class="indicator"></span><span id="monConnTxt">未连接</span></span>
<span class="pill" id="fbModePill">模式: --</span>
<span class="pill" id="fbPollStatus">轮询: 待连接</span>
</div>
<div class="header-sub can-header-actions">
<a class="pill can-link-pill" href="can.html">CAN 发送控制台</a>
<a class="pill can-link-pill" href="index.html">返回 OTA 控制台</a>
</div>
</header>
<!-- ═══ 工作台 ═══ -->
<section class="card can-toolbar-card">
<div class="card-head"><span class="ico">🧰</span> 连接设置</div>
<div class="card-body">
<div class="can-toolbar-grid" style="grid-template-columns: minmax(260px, 1.2fr) 1fr;">
<div class="can-toolbar-block">
<label class="form-label" for="monIp">ESP32 IP</label>
<div class="input-group">
<input class="input" type="text" id="monIp" value="192.168.31.127" placeholder="输入 IP 地址">
<button class="btn btn-outline" id="monTestBtn" type="button">测试</button>
</div>
</div>
<div class="can-toolbar-block">
<label class="form-label">接收模式</label>
<div class="input-group">
<button class="btn btn-primary btn-sm" id="btnModeAllPass" type="button">全收模式</button>
<button class="btn btn-outline btn-sm" id="btnModeFilter" type="button">过滤模式</button>
<button class="btn btn-outline btn-sm" id="btnModeBridge" type="button" title="CAN0 收到的每一帧按原帧格式自动转发到 CAN1 与 CAN2">CAN0→1/2 桥接</button>
</div>
</div>
</div>
<div class="can-toolbar-foot">
<div class="conn-indicator err" id="monConnIndicator">
<span class="light"></span>
<span id="monConnIndicatorTxt">等待连接测试</span>
</div>
<div class="helper-note" id="monHelper">
页面每 1 秒通过 <code>/can/feedback</code> 拉取三路 CAN 通道快照。无 RX 的通道显示 <b>空(无 RX)</b>。<br><b>桥接模式</b>:CAN0 收到的每一帧(11/29 位、FD、BRS)自动由 CAN1 与 CAN2 转发出去。
</div>
</div>
</div>
</section>
<!-- ═══ 三路反馈 ═══ -->
<section class="card can-feedback-card">
<div class="card-head"><span class="ico">📊</span> 三路 CAN 通道反馈(1Hz)</div>
<div class="card-body">
<div class="feedback-grid">
<div class="feedback-channel collapsed" id="fbCh0">
<div class="feedback-ch-head" role="button" tabindex="0" title="点击展开/折叠详情">
<span class="ch-dot ch0"></span> CAN0
<span class="feedback-expand-caret">▾</span>
</div>
<div class="feedback-primary">
<div class="feedback-row"><span class="fb-label">状态</span><span class="fb-val" id="fb0State">--</span></div>
<div class="feedback-row"><span class="fb-label">接收数</span><span class="fb-val" id="fb0Rx">--</span></div>
<div class="feedback-row"><span class="fb-label">发送数</span><span class="fb-val" id="fb0Tx">--</span></div>
<div class="feedback-row"><span class="fb-label">丢帧</span><span class="fb-val" id="fb0Drop">--</span></div>
<div class="feedback-row"><span class="fb-label">错误</span><span class="fb-val" id="fb0Err">--</span></div>
</div>
<div class="feedback-detail">
<div class="feedback-row"><span class="fb-label">TEC</span><span class="fb-val" id="fb0Tec">--</span></div>
<div class="feedback-row"><span class="fb-label">REC</span><span class="fb-val" id="fb0Rec">--</span></div>
<div class="feedback-row"><span class="fb-label">总线状态</span><span class="fb-val" id="fb0Bus">--</span></div>
<div class="feedback-row"><span class="fb-label">最新ID</span><span class="fb-val mono" id="fb0LastId">--</span></div>
<div class="feedback-row"><span class="fb-label">DLC</span><span class="fb-val" id="fb0Dlc">--</span></div>
<div class="feedback-row"><span class="fb-label">FD/BRS/ESI</span><span class="fb-val" id="fb0Flags">--</span></div>
<div class="feedback-row"><span class="fb-label">Seq</span><span class="fb-val" id="fb0Seq">--</span></div>
<div class="feedback-row"><span class="fb-label">Δrx/s</span><span class="fb-val" id="fb0Delta">--</span></div>
<div class="feedback-row"><span class="fb-label">最新HEX</span><span class="fb-val mono fb-data-cell" id="fb0LastData">--</span></div>
</div>
</div>
<div class="feedback-channel collapsed" id="fbCh1">
<div class="feedback-ch-head" role="button" tabindex="0" title="点击展开/折叠详情">
<span class="ch-dot ch1"></span> CAN1
<span class="feedback-expand-caret">▾</span>
</div>
<div class="feedback-primary">
<div class="feedback-row"><span class="fb-label">状态</span><span class="fb-val" id="fb1State">--</span></div>
<div class="feedback-row"><span class="fb-label">接收数</span><span class="fb-val" id="fb1Rx">--</span></div>
<div class="feedback-row"><span class="fb-label">发送数</span><span class="fb-val" id="fb1Tx">--</span></div>
<div class="feedback-row"><span class="fb-label">丢帧</span><span class="fb-val" id="fb1Drop">--</span></div>
<div class="feedback-row"><span class="fb-label">错误</span><span class="fb-val" id="fb1Err">--</span></div>
</div>
<div class="feedback-detail">
<div class="feedback-row"><span class="fb-label">TEC</span><span class="fb-val" id="fb1Tec">--</span></div>
<div class="feedback-row"><span class="fb-label">REC</span><span class="fb-val" id="fb1Rec">--</span></div>
<div class="feedback-row"><span class="fb-label">总线状态</span><span class="fb-val" id="fb1Bus">--</span></div>
<div class="feedback-row"><span class="fb-label">最新ID</span><span class="fb-val mono" id="fb1LastId">--</span></div>
<div class="feedback-row"><span class="fb-label">DLC</span><span class="fb-val" id="fb1Dlc">--</span></div>
<div class="feedback-row"><span class="fb-label">FD/BRS/ESI</span><span class="fb-val" id="fb1Flags">--</span></div>
<div class="feedback-row"><span class="fb-label">Seq</span><span class="fb-val" id="fb1Seq">--</span></div>
<div class="feedback-row"><span class="fb-label">Δrx/s</span><span class="fb-val" id="fb1Delta">--</span></div>
<div class="feedback-row"><span class="fb-label">最新HEX</span><span class="fb-val mono fb-data-cell" id="fb1LastData">--</span></div>
</div>
</div>
<div class="feedback-channel collapsed" id="fbCh2">
<div class="feedback-ch-head" role="button" tabindex="0" title="点击展开/折叠详情">
<span class="ch-dot ch2"></span> CAN2
<span class="feedback-expand-caret">▾</span>
</div>
<div class="feedback-primary">
<div class="feedback-row"><span class="fb-label">状态</span><span class="fb-val" id="fb2State">--</span></div>
<div class="feedback-row"><span class="fb-label">接收数</span><span class="fb-val" id="fb2Rx">--</span></div>
<div class="feedback-row"><span class="fb-label">发送数</span><span class="fb-val" id="fb2Tx">--</span></div>
<div class="feedback-row"><span class="fb-label">丢帧</span><span class="fb-val" id="fb2Drop">--</span></div>
<div class="feedback-row"><span class="fb-label">错误</span><span class="fb-val" id="fb2Err">--</span></div>
</div>
<div class="feedback-detail">
<div class="feedback-row"><span class="fb-label">TEC</span><span class="fb-val" id="fb2Tec">--</span></div>
<div class="feedback-row"><span class="fb-label">REC</span><span class="fb-val" id="fb2Rec">--</span></div>
<div class="feedback-row"><span class="fb-label">总线状态</span><span class="fb-val" id="fb2Bus">--</span></div>
<div class="feedback-row"><span class="fb-label">最新ID</span><span class="fb-val mono" id="fb2LastId">--</span></div>
<div class="feedback-row"><span class="fb-label">DLC</span><span class="fb-val" id="fb2Dlc">--</span></div>
<div class="feedback-row"><span class="fb-label">FD/BRS/ESI</span><span class="fb-val" id="fb2Flags">--</span></div>
<div class="feedback-row"><span class="fb-label">Seq</span><span class="fb-val" id="fb2Seq">--</span></div>
<div class="feedback-row"><span class="fb-label">Δrx/s</span><span class="fb-val" id="fb2Delta">--</span></div>
<div class="feedback-row"><span class="fb-label">最新HEX</span><span class="fb-val mono fb-data-cell" id="fb2LastData">--</span></div>
</div>
</div>
</div>
</div>
</section>
<!-- ═══ 每 ID 快照表 ═══ -->
<section class="card" id="idSnapshotSection" style="margin-top:18px;">
<div class="card-head"><span class="ico">🔬</span> 每 ID 快照表(1Hz 刷新)
<div class="feedback-controls">
<select class="input" id="idsnapChannel" style="width:100px;">
<option value="0">CAN0</option>
<option value="1">CAN1</option>
<option value="2">CAN2</option>
</select>
<span class="pill" id="idsnapStatus">--</span>
</div>
</div>
<div class="card-body">
<div class="idsnap-table-wrap">
<table class="idsnap-table" id="idsnapTable">
<thead>
<tr>
<th>#</th><th>CAN ID</th><th>DLC</th>
<th>FD</th><th>BRS</th><th>ESI</th><th>数据 (HEX)</th>
</tr>
</thead>
<tbody id="idsnapBody">
<tr><td colspan="7" class="status-empty">等待数据...</td></tr>
</tbody>
</table>
</div>
</div>
</section>
</div>
<div class="toast" id="toast">
<span class="toast-ico" id="toastIco">✓</span>
<span id="toastMsg">操作成功</span>
</div>
<script type="module" src="js/can_monitor.js?v=20260727a"></script>
</body>
</html>
+754
View File
@@ -0,0 +1,754 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>LAN9252 通断测试</title>
<style>
/* === WebGL 液态玻璃 (参考 yzrt/html-liquid-glass-effect-webgl) ===
参数:IOR=1.5 / Thickness=50 / Normal=10 / Blur=0 / Highlight=4 / Radius=26
实现:SVG feDisplacementMap 做真实背景像素折射位移 + saturate/contrast 色散
+ 边缘 4px 白色高光带 (Highlight Width=4)
+ 边缘 1px 暗描边 (Normal Strength=10)
所有组件统一用此风格,旧 yzrt .box 多层 inset 阴影已舍弃
*/
:root{
--grid:#E1E1E1;
--text:#f0f0f5;
--text-2:#c8c8d0;
--text-3:#8a8a95;
--separator:rgba(255,255,255,.08);
--blue:#5b9eff;
--green:#30d158;
--red:#ff453a;
--orange:#ff9f0a;
--purple:#bf5af2;
}
*{box-sizing:border-box;margin:0;padding:0;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;transition:.25s;}
html,body{height:100%;}
body{
font-family:"Microsoft YaHei","SF Pro Display","Segoe UI",sans-serif;
color:var(--text);font-size:15px;line-height:1.5;
min-height:100vh;padding:32px 20px 80px;
position:relative;overflow-x:hidden;
/* 彩色 blur 球 + 细点阵 — 让液态玻璃折射位移清晰可见
色球提供颜色参照(折射色散),点阵提供位置参照(位移扭曲) */
background:
/* 细点阵:让折射位移有参照物 */
radial-gradient(circle at 50% 50%, rgba(255,255,255,.12) 1px, transparent 1.5px) 0 0/24px 24px,
/* 彩色 blur 球:中饱和度,不要太鲜艳以免干扰文字 */
radial-gradient(circle at 15% 20%, #4a90e2 0%, transparent 35%),
radial-gradient(circle at 85% 15%, #b06ab3 0%, transparent 38%),
radial-gradient(circle at 75% 80%, #e27d60 0%, transparent 40%),
radial-gradient(circle at 20% 75%, #41d3a3 0%, transparent 38%),
radial-gradient(circle at 50% 50%, #f0a500 0%, transparent 45%),
/* 底色渐变 */
linear-gradient(135deg, #2d3561 0%, #2d4a6b 50%, #1a2a45 100%);
background-attachment:fixed;
}
/* 大色球:增强折射参照(放固定层,不会被玻璃遮挡) */
body::before{
content:"";position:fixed;inset:0;z-index:-1;pointer-events:none;
background:
radial-gradient(circle at 30% 40%, rgba(255,200,100,.18) 0%, transparent 20%),
radial-gradient(circle at 70% 60%, rgba(100,200,255,.15) 0%, transparent 25%);
}
.wrap{max-width:1100px;margin:0 auto;position:relative;z-index:1;}
h1{
font-size:36px;font-weight:700;letter-spacing:-.03em;margin-bottom:6px;color:#fff;
text-shadow:0 2px 8px rgba(0,0,0,.4);
}
.sub{color:rgba(255,255,255,.85);font-size:15px;margin-bottom:24px;font-weight:500;text-shadow:0 1px 4px rgba(0,0,0,.3);}
/* === 玻璃基础类 — 纯 WebGL 液态玻璃(无辅助效果) ===
IOR1.5/Thick50 → SVG feDisplacementMap 背景折射位移 + saturate/contrast 色散
Blur0 → 不模糊
Highlight4 → 边缘 8px 白色高光带(略扩散)
Normal10 → 边缘 2px 暗影
Radius26 → 圆角 */
.glass,.glass-strong{
position:relative;z-index:0;isolation:isolate;
border-radius:26px;
border:none;
background:transparent;
backdrop-filter:url(#glassRefract) saturate(1.4) contrast(1.15);
-webkit-backdrop-filter:url(#glassRefract) saturate(1.4) contrast(1.15);
box-shadow:
inset 0 0 8px rgba(255,255,255,.6), /* Highlight Width=4 → 略向中间扩散 */
inset 0 0 2px rgba(0,0,0,.6), /* Normal Strength=10 */
0 4px 12px rgba(0,0,0,.18);
}
.glass-strong{
box-shadow:
inset 0 0 10px rgba(255,255,255,.7),
inset 0 0 2px rgba(0,0,0,.6),
0 6px 16px rgba(0,0,0,.2);
}
.glass-subtle{
position:relative;z-index:0;isolation:isolate;
border-radius:18px;
border:none;
background:transparent;
backdrop-filter:url(#glassRefract) saturate(1.3) contrast(1.1);
-webkit-backdrop-filter:url(#glassRefract) saturate(1.3) contrast(1.1);
box-shadow:
inset 0 0 6px rgba(255,255,255,.5),
inset 0 0 2px rgba(0,0,0,.5),
0 2px 8px rgba(0,0,0,.14);
}
.card{padding:24px;margin-bottom:16px;}
.card > *{position:relative;z-index:1;}
.card h2{
font-size:22px;font-weight:700;letter-spacing:-.02em;margin-bottom:6px;color:#fff;
text-shadow:0 1px 6px rgba(0,0,0,.3);
}
.card-desc{font-size:13px;color:var(--text-2);margin-bottom:16px;}
/* 警告条 — 纯 WebGL 玻璃 + 橙红染色 */
.warn{
color:#fff;font-size:13px;margin-bottom:24px;padding:14px 18px;
position:relative;z-index:0;isolation:isolate;
background:linear-gradient(135deg,rgba(255,159,10,.35),rgba(255,69,58,.3));
backdrop-filter:url(#glassRefract) saturate(1.4) contrast(1.15);
-webkit-backdrop-filter:url(#glassRefract) saturate(1.4) contrast(1.15);
border:none;
border-radius:26px;
box-shadow:
inset 0 0 8px rgba(255,255,255,.5),
inset 0 0 2px rgba(0,0,0,.5),
0 4px 12px rgba(0,0,0,.16);
display:flex;align-items:flex-start;gap:10px;font-weight:500;
text-shadow:0 1px 3px rgba(0,0,0,.4);
}
.warn > *{position:relative;z-index:1;}
.warn-icon{font-size:16px;flex-shrink:0;}
/* === 按钮 — 纯 WebGL 液态玻璃(无辅助效果) === */
button{
--btn-glow:rgba(0,0,0,.3);
position:relative;z-index:0;isolation:isolate;
background:transparent;
backdrop-filter:url(#glassRefract) saturate(1.4) contrast(1.15);
-webkit-backdrop-filter:url(#glassRefract) saturate(1.4) contrast(1.15);
color:var(--text);
border:none;
border-radius:26px;
padding:11px 22px;
font-size:14px;font-weight:600;font-family:inherit;
cursor:pointer;letter-spacing:-.01em;
box-shadow:
inset 0 0 8px rgba(255,255,255,.6),
inset 0 0 2px rgba(0,0,0,.6),
0 2px 6px rgba(0,0,0,.16);
}
button:hover{
transform:translateY(-1px);
backdrop-filter:url(#glassRefract) saturate(1.5) contrast(1.2);
-webkit-backdrop-filter:url(#glassRefract) saturate(1.5) contrast(1.2);
box-shadow:
inset 0 0 10px rgba(255,255,255,.7),
inset 0 0 2px rgba(0,0,0,.6),
0 4px 10px rgba(0,0,0,.2);
}
button:active{transform:translateY(0);}
/* 液体弹动:点击时像液态玻璃被按压后弹性回弹
注:用 transform-origin:center 保证对称变形;
background 用 background-size:200% 避免渐变被拉伸变形;
box-shadow 跟随收缩,保持形状一致 */
button.pressed{
animation:liquidPress .6s cubic-bezier(.25,1.5,.5,1) 1;
background-size:200% 200% !important;
background-position:center !important;
}
@keyframes liquidPress{
0%{transform:scale(1,1);box-shadow:inset 0 0 8px rgba(255,255,255,.5),inset 0 0 2px rgba(0,0,0,.6),0 3px 10px var(--btn-glow,rgba(0,0,0,.3));}
25%{transform:scale(.90,.86);border-radius:32px;box-shadow:inset 0 0 6px rgba(255,255,255,.4),inset 0 0 2px rgba(0,0,0,.6),0 1px 4px var(--btn-glow,rgba(0,0,0,.3));}
45%{transform:scale(1.06,.94);border-radius:22px;box-shadow:inset 0 0 10px rgba(255,255,255,.6),inset 0 0 2px rgba(0,0,0,.6),0 4px 12px var(--btn-glow,rgba(0,0,0,.3));}
65%{transform:scale(.97,1.03);border-radius:28px;box-shadow:inset 0 0 9px rgba(255,255,255,.55),inset 0 0 2px rgba(0,0,0,.6),0 3px 9px var(--btn-glow,rgba(0,0,0,.3));}
82%{transform:scale(1.01,.99);border-radius:25px;box-shadow:inset 0 0 8px rgba(255,255,255,.5),inset 0 0 2px rgba(0,0,0,.6),0 3px 10px var(--btn-glow,rgba(0,0,0,.3));}
100%{transform:scale(1,1);border-radius:26px;box-shadow:inset 0 0 8px rgba(255,255,255,.5),inset 0 0 2px rgba(0,0,0,.6),0 3px 10px var(--btn-glow,rgba(0,0,0,.3));}
}
button.selected{
--btn-glow:rgba(37,99,235,.45);
background:linear-gradient(135deg,rgba(37,99,235,.82),rgba(29,78,216,.82));
background-size:200% 200%;
color:#fff;
box-shadow:
inset 0 0 8px rgba(255,255,255,.5),
inset 0 0 2px rgba(0,0,0,.6),
0 3px 10px rgba(37,99,235,.45);
}
button.selected:hover{background:linear-gradient(135deg,rgba(29,78,216,.88),rgba(37,99,235,.88));}
button.confirm-btn{
--btn-glow:rgba(22,163,74,.4);
background:linear-gradient(135deg,rgba(22,163,74,.8),rgba(21,128,61,.8));
background-size:200% 200%;
color:#fff;
box-shadow:
inset 0 0 8px rgba(255,255,255,.5),
inset 0 0 2px rgba(0,0,0,.6),
0 3px 10px rgba(22,163,74,.4);
}
button.confirm-btn:hover{background:linear-gradient(135deg,rgba(21,128,61,.88),rgba(22,163,74,.88));}
button.confirm-btn:disabled{
--btn-glow:rgba(0,0,0,.2);
background:rgba(140,140,145,.3);color:rgba(255,255,255,.8);
cursor:not-allowed;filter:brightness(.85);
box-shadow:inset 0 0 2px rgba(0,0,0,.4);transform:none;
}
button.loop-btn{
--btn-glow:rgba(124,58,237,.4);
background:linear-gradient(135deg,rgba(124,58,237,.8),rgba(109,40,217,.8));
background-size:200% 200%;
color:#fff;
box-shadow:
inset 0 0 8px rgba(255,255,255,.5),
inset 0 0 2px rgba(0,0,0,.6),
0 3px 10px rgba(124,58,237,.4);
}
button.loop-btn:hover{background:linear-gradient(135deg,rgba(109,40,217,.92),rgba(124,58,237,.92));}
button.loop-btn.active{
--btn-glow:rgba(234,88,12,.5);
background:linear-gradient(135deg,rgba(234,88,12,.92),rgba(194,65,12,.92));
background-size:200% 200%;
animation:pulse 2s infinite;
}
button.stop-btn{
--btn-glow:rgba(220,38,38,.4);
background:linear-gradient(135deg,rgba(220,38,38,.8),rgba(185,28,28,.8));
background-size:200% 200%;
color:#fff;
box-shadow:
inset 0 0 8px rgba(255,255,255,.5),
inset 0 0 2px rgba(0,0,0,.6),
0 3px 10px rgba(220,38,38,.4);
}
button.stop-btn:hover{background:linear-gradient(135deg,rgba(185,28,28,.88),rgba(220,38,38,.88));}
@keyframes pulse{
0%,100%{box-shadow:inset 0 0 2px rgba(0,0,0,.7),0 0 0 0 rgba(234,88,12,.5);}
50%{box-shadow:inset 0 0 2px rgba(0,0,0,.7),0 0 0 12px rgba(234,88,12,0);}
}
.btn-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(170px,1fr));gap:10px;}
.ctrl-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:10px;}
.section-label{
font-size:11px;color:var(--text-3);text-transform:uppercase;letter-spacing:.08em;
margin:18px 0 10px;font-weight:700;
}
.section-label:first-child{margin-top:0;}
/* SVG 引脚图容器 — 纯 WebGL 玻璃 */
.pin-svg-wrap{
display:flex;justify-content:center;margin:12px 0;padding:20px;
position:relative;z-index:0;isolation:isolate;
background:transparent;
backdrop-filter:url(#glassRefract) saturate(1.4) contrast(1.15);
-webkit-backdrop-filter:url(#glassRefract) saturate(1.4) contrast(1.15);
border-radius:18px;
border:none;
box-shadow:
inset 0 0 6px rgba(255,255,255,.5),
inset 0 0 2px rgba(0,0,0,.5),
0 2px 8px rgba(0,0,0,.14);
}
.pin-svg-wrap svg{max-width:100%;height:auto;}
.pin-rect{stroke:rgba(255,255,255,.3);stroke-width:.5;transition:fill .3s ease;}
.pin-num{fill:rgba(255,255,255,.7);font-size:12px;font-family:ui-monospace,"SF Mono",monospace;}
.pin-name{fill:rgba(255,255,255,.9);font-size:11px;font-family:ui-monospace,"SF Mono",monospace;}
.pin-name-data{fill:#7fd3ff;font-weight:600;}
.pin-name-ctrl{fill:#ffb74d;font-weight:600;}
.chip-body{fill:url(#chipGrad);stroke:rgba(255,255,255,.25);stroke-width:1;}
.chip-label{fill:#fff;font-size:22px;font-weight:700;text-anchor:middle;}
.chip-sub{fill:rgba(255,255,255,.7);font-size:10px;text-anchor:middle;}
.pin1-dot{fill:var(--blue);}
.pin-legend{
display:flex;gap:18px;justify-content:center;flex-wrap:wrap;margin-top:16px;
font-size:12px;color:var(--text-2);padding-top:16px;
border-top:1px solid var(--separator);font-weight:500;
position:relative;z-index:1;
}
.pin-legend span{display:flex;align-items:center;gap:6px;}
.pin-legend .swatch{
width:14px;height:14px;border-radius:4px;display:inline-block;
box-shadow:0 1px 2px rgba(0,0,0,.2),inset 0 1px 0 rgba(255,255,255,.5);
}
/* 连接 / 操作 */
.connect-row{display:flex;align-items:center;gap:12px;flex-wrap:wrap;font-size:14px;position:relative;z-index:1;}
.connect-label{color:var(--text-2);font-weight:500;}
.connect-ip{
color:var(--blue);font-weight:700;
font-family:ui-monospace,"SF Mono",monospace;font-size:15px;
background:rgba(37,99,235,.15);padding:5px 14px;border-radius:26px;
border:none;
box-shadow:inset 0 0 6px rgba(255,255,255,.4),inset 0 0 2px rgba(0,0,0,.3);
}
.connect-hint{color:var(--text-3);font-size:12px;}
.action-row{display:flex;gap:10px;flex-wrap:wrap;position:relative;z-index:1;}
.action-desc{
font-size:13px;color:var(--text-2);margin-top:16px;padding-top:14px;
border-top:1px solid var(--separator);line-height:1.7;position:relative;z-index:1;
}
.action-desc strong{color:var(--text);font-weight:600;}
/* 日志面板 — 纯 WebGL 玻璃 */
.log-panel{padding:0;overflow:hidden;}
.log-panel-header{
padding:18px 24px 14px;border-bottom:1px solid var(--separator);
display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap;
background:transparent;position:relative;z-index:1;
}
.log-panel-title{font-size:17px;font-weight:700;letter-spacing:-.01em;}
.log-filters{display:flex;gap:6px;}
.log-filter{
position:relative;z-index:0;isolation:isolate;
padding:5px 14px;border-radius:26px;font-size:12px;font-weight:600;
border:none;background:transparent;color:var(--text-2);
cursor:pointer;font-family:inherit;
backdrop-filter:url(#glassRefract) saturate(1.3) contrast(1.1);
-webkit-backdrop-filter:url(#glassRefract) saturate(1.3) contrast(1.1);
box-shadow:
inset 0 0 5px rgba(255,255,255,.4),
inset 0 0 2px rgba(0,0,0,.4),
0 1px 3px rgba(0,0,0,.1);
}
.log-filter.active{
background:linear-gradient(135deg,rgba(37,99,235,.82),rgba(29,78,216,.82));
color:#fff;
box-shadow:
inset 0 0 8px rgba(255,255,255,.5),
inset 0 0 2px rgba(0,0,0,.6),
0 2px 6px rgba(37,99,235,.35);
}
.log-filter:hover:not(.active){color:var(--text);}
.log-scroll{
padding:14px 24px;font-family:ui-monospace,"SF Mono","Cascadia Code",monospace;
font-size:12px;line-height:1.8;max-height:380px;overflow-y:auto;
background:transparent;position:relative;z-index:1;
}
.log-scroll::-webkit-scrollbar{width:6px;}
.log-scroll::-webkit-scrollbar-track{background:transparent;}
.log-scroll::-webkit-scrollbar-thumb{background:rgba(0,0,0,.2);border-radius:3px;}
.log-scroll::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.35);}
.log-line{display:flex;gap:8px;padding:3px 0;border-bottom:1px solid rgba(255,255,255,.05);}
.log-line .lt{color:var(--text-3);flex-shrink:0;}
.log-line .lv{font-weight:700;min-width:18px;flex-shrink:0;}
.log-line .lv.E{color:var(--red);}
.log-line .lv.I{color:var(--blue);}
.log-line .lg{color:var(--purple);flex-shrink:0;}
.log-line .lm{color:#fff;word-break:break-all;}
.log-foot{
display:flex;justify-content:space-between;align-items:center;
padding:12px 24px;border-top:1px solid var(--separator);
font-size:12px;color:var(--text-3);font-weight:500;
background:transparent;position:relative;z-index:1;
}
</style>
</head>
<body>
<!-- SVG 折射滤镜:模拟 WebGL IOR=1.5 Thickness=50 的背景位移折射 -->
<svg style="position:absolute;width:0;height:0;overflow:hidden" aria-hidden="true">
<defs>
<filter id="glassRefract" x="-20%" y="-20%" width="140%" height="140%">
<feTurbulence type="fractalNoise" baseFrequency="0.012 0.012" numOctaves="2" seed="7" result="noise"/>
<feGaussianBlur in="noise" stdDeviation="1.5" result="softNoise"/>
<feDisplacementMap in="SourceGraphic" in2="softNoise" scale="14" xChannelSelector="R" yChannelSelector="G"/>
</filter>
</defs>
</svg>
<div class="wrap">
<h1>LAN9252 通断测试</h1>
<div class="sub">通过 ESP32 /spi/send → SPI 0xA2 → GD32 驱动 EXMC 引脚,万用表测 LAN9252 端电压</div>
<div class="warn">
<span class="warn-icon">⚠️</span>
<span>不点按钮时 GD32 引脚为高阻输入态(安全)。点击模式后先预览引脚图,点"确认应用"才实际驱动引脚。测量完毕请点"停止/恢复"释放。</span>
</div>
<!-- 连接 -->
<div class="glass card">
<div class="connect-row">
<span class="connect-label">设备 IP</span>
<span id="ipDisplay" class="connect-ip">读取中...</span>
<span class="connect-hint">共用主页"设备连接"配置的 IP</span>
</div>
</div>
<!-- 引脚图 -->
<div class="glass-strong card">
<h2>LAN9252 64-QFN 引脚图</h2>
<p class="card-desc">俯视,Pin1 右上角。点击下方模式按钮预览引脚电压,点"确认应用"发送命令。绿色=3.3V,红色=0V,橙色=控制线测试中。</p>
<div class="pin-svg-wrap">
<svg id="pinSvg" width="720" height="720" viewBox="0 0 720 720">
<defs>
<linearGradient id="chipGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="rgba(60,60,80,.6)"/>
<stop offset="100%" stop-color="rgba(40,40,60,.5)"/>
</linearGradient>
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="3.5" result="blur"/>
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
</svg>
</div>
<div class="pin-legend">
<span><span class="swatch" style="background:#16a34a;"></span> HIGH 3.3V</span>
<span><span class="swatch" style="background:#dc2626;"></span> LOW 0V</span>
<span><span class="swatch" style="background:#ea580c;"></span> 控制线测试</span>
<span><span class="swatch" style="background:rgba(232,232,237,.8);"></span> 数据线空闲</span>
<span><span class="swatch" style="background:rgba(255,245,230,.8);"></span> 控制线空闲</span>
<span><span class="swatch" style="background:rgba(240,240,243,.8);"></span> 电源/其他</span>
<span><span class="swatch" style="background:#2563eb;border-radius:50%;"></span> Pin1</span>
</div>
</div>
<!-- 模式选择 -->
<div class="glass card">
<h2>模式选择</h2>
<p class="card-desc">点击预览引脚图,再点"确认应用"发送命令</p>
<div class="section-label">数据线 pattern · D0-D15</div>
<div class="btn-grid">
<button class="mode-btn" data-action="01">0xFFFF 全高</button>
<button class="mode-btn" data-action="02">0x0000 全低</button>
<button class="mode-btn" data-action="03">0xAAAA 奇数高</button>
<button class="mode-btn" data-action="04">0x5555 偶数高</button>
</div>
<div class="section-label">控制线测试</div>
<div class="ctrl-grid">
<button class="mode-btn" data-action="05">CS 拉低 · PD7→Pin28</button>
<button class="mode-btn" data-action="06">RD 拉低 · PD4→Pin31</button>
<button class="mode-btn" data-action="07">WR 拉低 · PD5→Pin30</button>
<button class="mode-btn" data-action="08">A1 拉高 · PD11→Pin25</button>
<button class="mode-btn" data-action="09">A2 拉高 · PD12→Pin29</button>
</div>
<div class="section-label">读周期保持 · 判定芯片是否驱动总线</div>
<div class="ctrl-grid">
<button class="mode-btn" data-action="10">读保持 · CS#+RD# 持续低</button>
</div>
<p class="card-desc" style="margin-top:10px;">
数据线切为高阻输入、CS#+RD# 持续拉低(读 IDX_0)。用万用表在 <strong>LAN9252 芯片引脚</strong> 上量数据线:
芯片活着 → D2(Pin12)、D4(Pin49)、D5(Pin50)、D9(Pin19)、D12(Pin21) ≈ 3.3V,其余数据脚 ≈ 0V(全部确定电平,即 0x1234);
全部中电平/漂移 → 芯片未驱动总线。图上预览色 = 芯片应答的期望值。
</p>
</div>
<!-- 操作 -->
<div class="glass card">
<h2>操作</h2>
<div class="action-row">
<button class="confirm-btn" id="confirmBtn" disabled>✓ 确认应用</button>
<button class="loop-btn" id="loopBtn">🔄 循环切换</button>
<button class="stop-btn" id="stopBtn">⏹ 停止 / 恢复</button>
</div>
<p class="action-desc">
<strong>确认应用</strong> · 发送当前选中的模式到 GD32<br>
<strong>循环切换</strong> · 每5s在 0x0000 ↔ 当前选中模式 之间切换(需先选数据线 pattern)<br>
<strong>停止 / 恢复</strong> · 恢复 EXMC 正常模式
</p>
</div>
<!-- 日志 -->
<div class="glass log-panel">
<div class="log-panel-header">
<span class="log-panel-title">操作日志</span>
<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('I',this)">信息</button>
<button class="log-filter" onclick="clearLogs()">清除</button>
</div>
</div>
<div class="log-scroll" id="logScroll"></div>
<div class="log-foot">
<span>共 <span id="logCount">0</span> 条</span>
<span style="font-family:ui-monospace,monospace;">SPI 0xA2 · UDP 6666</span>
</div>
</div>
</div><!-- /.wrap -->
<script>
/* ===== LAN9252 64-QFN 引脚表 ===== */
var pinData=[
{n:1,name:"OSCI",type:"other"},{n:2,name:"OSCO",type:"other"},
{n:3,name:"VDD12",type:"power"},{n:4,name:"VSS",type:"gnd"},
{n:5,name:"VDD33",type:"power"},{n:6,name:"VDDCR",type:"power"},
{n:7,name:"REG_EN",type:"other"},{n:8,name:"FXLOSEN",type:"other"},
{n:9,name:"FXSDA",type:"other"},{n:10,name:"FXSDB",type:"other"},
{n:11,name:"RST#",type:"other"},{n:12,name:"D2",type:"data",d:2},
{n:13,name:"D1",type:"data",d:1},{n:14,name:"VDDIO",type:"power"},
{n:15,name:"D14",type:"data",d:14},{n:16,name:"D13",type:"data",d:13},
{n:17,name:"D0",type:"data",d:0},{n:18,name:"SYNC1",type:"other"},
{n:19,name:"D9",type:"data",d:9},{n:20,name:"VDDIO",type:"power"},
{n:21,name:"D12",type:"data",d:12},{n:22,name:"D11",type:"data",d:11},
{n:23,name:"D10",type:"data",d:10},{n:24,name:"VDDCR",type:"power"},
{n:25,name:"A1",type:"ctrl",ctrl:"A1"},{n:26,name:"A3",type:"other"},
{n:27,name:"A4",type:"other"},{n:28,name:"CS",type:"ctrl",ctrl:"CS"},
{n:29,name:"A2",type:"ctrl",ctrl:"A2"},{n:30,name:"WR",type:"ctrl",ctrl:"WR"},
{n:31,name:"RD",type:"ctrl",ctrl:"RD"},{n:32,name:"VDDIO",type:"power"},
{n:33,name:"D15",type:"data",d:15},{n:34,name:"SYNC0",type:"other"},
{n:35,name:"D3",type:"data",d:3},{n:36,name:"D6",type:"data",d:6},
{n:37,name:"VDDIO",type:"power"},{n:38,name:"VDDCR",type:"power"},
{n:39,name:"D7",type:"data",d:7},{n:40,name:"D8",type:"data",d:8},
{n:41,name:"TESTMODE",type:"other"},{n:42,name:"EESDA",type:"other"},
{n:43,name:"EESCL",type:"other"},{n:44,name:"IRQ",type:"other"},
{n:45,name:"RUNLED",type:"other"},{n:46,name:"MODE1",type:"other"},
{n:47,name:"VDDIO",type:"power"},{n:48,name:"MODE0",type:"other"},
{n:49,name:"D4",type:"data",d:4},{n:50,name:"D5",type:"data",d:5},
{n:51,name:"TXRX1",type:"power"},{n:52,name:"TXNA",type:"other"},
{n:53,name:"TXPA",type:"other"},{n:54,name:"RXNA",type:"other"},
{n:55,name:"RXPA",type:"other"},{n:56,name:"VDD12",type:"power"},
{n:57,name:"RBIAS",type:"other"},{n:58,name:"VDD33B",type:"power"},
{n:59,name:"VDD12",type:"power"},{n:60,name:"RXPB",type:"other"},
{n:61,name:"RXNB",type:"other"},{n:62,name:"TXPB",type:"other"},
{n:63,name:"TXNB",type:"other"},{n:64,name:"TXRX2",type:"power"}
];
var actionMap={
"01":{t:"data",val:0xFFFF,label:"0xFFFF 全高"},"02":{t:"data",val:0x0000,label:"0x0000 全低"},
"03":{t:"data",val:0xAAAA,label:"0xAAAA 奇数高"},"04":{t:"data",val:0x5555,label:"0x5555 偶数高"},
"05":{t:"ctrl",val:"CS",label:"CS 拉低"},"06":{t:"ctrl",val:"RD",label:"RD 拉低"},
"07":{t:"ctrl",val:"WR",label:"WR 拉低"},"08":{t:"ctrl",val:"A1",label:"A1 拉高"},
"09":{t:"ctrl",val:"A2",label:"A2 拉高"},
"10":{t:"readhold",val:0x1234,label:"读周期保持(CS#+RD# 持续低)"},
"00":{t:"stop",label:"停止/恢复"}
};
/* ===== SVG ===== */
var SVG_NS="http://www.w3.org/2000/svg";
var CHIP_X1=120,CHIP_Y1=120,CHIP_X2=600,CHIP_Y2=600;
var CHIP_W=CHIP_X2-CHIP_X1,PIN_GAP=CHIP_W/16,PIN_OFF=PIN_GAP/2;
var DEFAULT_COLORS={data:"rgba(200,200,210,.5)",ctrl:"rgba(255,200,150,.5)",power:"rgba(220,220,230,.5)",gnd:"rgba(180,180,190,.5)",other:"rgba(210,210,220,.5)"};
function el(tag,attrs,text){
var e=document.createElementNS(SVG_NS,tag);
for(var k in attrs)e.setAttribute(k,attrs[k]);
if(text!==undefined)e.textContent=text;
return e;
}
function getPinPos(n){
if(n>=1&&n<=16){
/* 顶部:引脚上方,名称在上、编号在下 */
var x=CHIP_X2-PIN_OFF-(n-1)*PIN_GAP;
return{rx:x-7,ry:CHIP_Y1-14,rw:14,rh:14,tx:x,ty:CHIP_Y1-16,anchor:"middle",ny:CHIP_Y1-32};
}else if(n>=17&&n<=32){
/* 左侧:引脚左方,编号在上、名称在下(竖向堆叠) */
var y=CHIP_Y1+PIN_OFF+(n-17)*PIN_GAP;
return{rx:CHIP_X1-14,ry:y-7,rw:14,rh:14,tx:CHIP_X1-18,ty:y-1,anchor:"end",ny:y+13};
}else if(n>=33&&n<=48){
/* 底部:引脚下方,编号在上、名称在下 */
var x=CHIP_X1+PIN_OFF+(n-33)*PIN_GAP;
return{rx:x-7,ry:CHIP_Y2,rw:14,rh:14,tx:x,ty:CHIP_Y2+22,anchor:"middle",ny:CHIP_Y2+38};
}else{
/* 右侧:引脚右方,编号在上、名称在下(竖向堆叠) */
var y=CHIP_Y2-PIN_OFF-(n-49)*PIN_GAP;
return{rx:CHIP_X2,ry:y-7,rw:14,rh:14,tx:CHIP_X2+18,ty:y-1,anchor:"start",ny:y+13};
}
}
var pinRects={};
function buildSvg(){
var svg=document.getElementById("pinSvg");
svg.appendChild(el("rect",{class:"chip-body",x:CHIP_X1,y:CHIP_Y1,width:CHIP_W,height:CHIP_W,rx:14}));
svg.appendChild(el("text",{class:"chip-label",x:360,y:345},"LAN9252"));
svg.appendChild(el("text",{class:"chip-sub",x:360,y:368},"64-QFN"));
svg.appendChild(el("text",{class:"chip-sub",x:360,y:386},"Pin1 → 右上角"));
svg.appendChild(el("circle",{class:"pin1-dot",cx:CHIP_X2-14,cy:CHIP_Y1+14,r:5}));
for(var i=0;i<pinData.length;i++){
var p=pinData[i],pos=getPinPos(p.n);
var rect=el("rect",{class:"pin-rect",x:pos.rx,y:pos.ry,width:pos.rw,height:pos.rh,rx:3,id:"pin-"+p.n});
rect.setAttribute("fill",DEFAULT_COLORS[p.type]||DEFAULT_COLORS.other);
svg.appendChild(rect);
pinRects[p.n]=rect;
svg.appendChild(el("text",{class:"pin-num",x:pos.tx,y:pos.ty,"text-anchor":pos.anchor},String(p.n)));
var nc="pin-name";
if(p.type==="data")nc="pin-name pin-name-data";
else if(p.type==="ctrl")nc="pin-name pin-name-ctrl";
svg.appendChild(el("text",{class:nc,x:pos.tx,y:pos.ny||pos.ty,"text-anchor":pos.anchor},p.name));
}
}
function updatePinColors(actionHex){
var act=actionMap[actionHex];if(!act)return;
for(var i=0;i<pinData.length;i++){
var p=pinData[i],color=DEFAULT_COLORS[p.type]||DEFAULT_COLORS.other;
var glow=false;
if(act.t==="data"&&p.type==="data"){
color=(act.val&(1<<p.d))?"#30d158":"#ff453a";glow=true;
}else if(act.t==="ctrl"&&p.type==="ctrl"&&p.ctrl===act.val){
color="#ff9f0a";glow=true;
}else if(act.t==="readhold"){
/* CS/RD 由 GD32 拉低(橙), 数据线 = 芯片应答期望值 0x1234 */
if(p.type==="ctrl"&&(p.ctrl==="CS"||p.ctrl==="RD")){color="#ff9f0a";glow=true;}
else if(p.type==="data"){color=(act.val&(1<<p.d))?"#30d158":"#ff453a";glow=true;}
}
pinRects[p.n].setAttribute("fill",color);
if(glow)pinRects[p.n].setAttribute("filter","url(#glow)");
else pinRects[p.n].removeAttribute("filter");
}
}
function resetPinColors(){
for(var i=0;i<pinData.length;i++){
var p=pinData[i];
pinRects[p.n].setAttribute("fill",DEFAULT_COLORS[p.type]||DEFAULT_COLORS.other);
pinRects[p.n].removeAttribute("filter");
}
}
/* ===== IP ===== */
var LS_ESP_IP="dcu_esp_ip",DEFAULT_IP="192.168.31.127";
function getIp(){try{var s=localStorage.getItem(LS_ESP_IP);if(s&&s.trim())return s.trim();}catch(e){}return DEFAULT_IP;}
document.getElementById("ipDisplay").textContent=getIp();
window.addEventListener("storage",function(e){if(e.key===LS_ESP_IP)document.getElementById("ipDisplay").textContent=getIp();});
/* ===== 日志 ===== */
var logScroll=document.getElementById("logScroll"),logCountEl=document.getElementById("logCount"),logCount=0,logFilter="all";
function esc(s){return String(s==null?"":s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#39;");}
function addLog(level,tag,msg){
var line=document.createElement("div");
line.className="log-line";
if(logFilter!=="all"&&level!==logFilter){line.style.display="none";}
/* msg/tag 含设备响应内容(如 /spi/send JSON),拼 innerHTML 前必须转义 */
line.innerHTML='<span class="lt">['+new Date().toLocaleTimeString()+']</span>'+
'<span class="lv '+esc(level)+'">'+esc(level)+'</span>'+
'<span class="lg">['+esc(tag)+']</span>'+
'<span class="lm">'+esc(msg)+'</span>';
logScroll.insertBefore(line,logScroll.firstChild);
logCount++;logCountEl.textContent=logCount;
while(logScroll.children.length>200)logScroll.removeChild(logScroll.lastChild);
}
function setLogFilter(f,btn){
logFilter=f;
document.querySelectorAll(".log-filter").forEach(function(b){b.classList.remove("active");});
btn.classList.add("active");
logScroll.querySelectorAll(".log-line").forEach(function(l){
var lv=l.querySelector(".lv");
if(f==="all"||(lv&&lv.classList.contains(f)))l.style.display="";
else l.style.display="none";
});
}
function clearLogs(){logScroll.innerHTML="";logCount=0;logCountEl.textContent=0;}
/* ===== 按钮:液体弹动效果 ===== */
document.addEventListener("click",function(e){
var btn=e.target.closest("button");
if(!btn)return;
btn.classList.remove("pressed");
void btn.offsetWidth; /* 强制 reflow,允许重复触发 */
btn.classList.add("pressed");
var done=function(){
btn.classList.remove("pressed");
btn.removeEventListener("animationend",done);
};
btn.addEventListener("animationend",done);
},true);
/* ===== 模式选择 ===== */
var selectedAction=null,lastModeBtn=null;
document.querySelectorAll(".mode-btn").forEach(function(btn){
btn.addEventListener("click",function(){
var action=btn.getAttribute("data-action");
if(lastModeBtn)lastModeBtn.classList.remove("selected");
btn.classList.add("selected");
lastModeBtn=btn;
selectedAction=action;
updatePinColors(action);
document.getElementById("confirmBtn").disabled=false;
addLog("I","预览","选中: "+actionMap[action].label);
});
});
/* ===== 发送 ===== */
var busy=false;
function sendAction(actionHex){
if(busy)return;
var ip=getIp();if(!ip){addLog("E","错误","请先在主页配置 ESP32 IP");return;}
busy=true;
var label=actionMap[actionHex]?actionMap[actionHex].label:actionHex;
addLog("I","发送","action=0x"+actionHex+" ("+label+")");
fetch("http://"+ip+"/spi/send",{
method:"POST",headers:{"Content-Type":"application/json"},
body:JSON.stringify({type:"A2",data:actionHex,timeout_ms:2000}),
signal:AbortSignal.timeout(3000)
})
.then(function(r){if(!r.ok)throw new Error("HTTP "+r.status);return r.json();})
.then(function(j){
if(j.status==="ok"&&j.resp_type==="80")
addLog("I","成功",label+" → ACK"+(j.recv?" 0x"+j.recv:""));
else if(j.status==="ok"&&j.resp_type==="82")
addLog("E","失败",label+" → GD32 ERROR");
else
addLog("E","异常",label+" → "+JSON.stringify(j));
})
.catch(function(err){addLog("E","网络",label+" → "+err.message);})
.then(function(){busy=false;});
}
document.getElementById("confirmBtn").addEventListener("click",function(){
if(selectedAction)sendAction(selectedAction);
});
document.getElementById("stopBtn").addEventListener("click",function(){
stopLoop();
sendAction("00");
resetPinColors();
if(lastModeBtn)lastModeBtn.classList.remove("selected");
lastModeBtn=null;selectedAction=null;
document.getElementById("confirmBtn").disabled=true;
addLog("I","恢复","EXMC 恢复正常模式");
});
/* ===== 循环: 0x0000 ↔ 当前模式 ===== */
var loopTimer=null,loopToggle=false;
var loopBtn=document.getElementById("loopBtn");
loopBtn.addEventListener("click",function(){
if(loopTimer)stopLoop();else startLoop();
});
function startLoop(){
if(!selectedAction||actionMap[selectedAction].t!=="data"){
addLog("E","循环","请先选择一个数据线 pattern 模式");
return;
}
stopLoop();
var baseAction=selectedAction;
loopBtn.textContent="⏸ 停止循环";
loopBtn.classList.add("active");
addLog("I","循环","开始:每5s在 0x0000 ↔ "+actionMap[baseAction].label+" 切换");
loopToggle=false;
loopBtn.setAttribute("data-base",baseAction);
doLoopStep();
loopTimer=setInterval(doLoopStep,5000);
}
function stopLoop(){
if(loopTimer){clearInterval(loopTimer);loopTimer=null;}
loopBtn.textContent="🔄 循环切换";
loopBtn.classList.remove("active");
loopBtn.removeAttribute("data-base");
}
function doLoopStep(){
var baseAction=loopBtn.getAttribute("data-base");if(!baseAction)return;
loopToggle=!loopToggle;
var action=loopToggle?baseAction:"02";
if(lastModeBtn)lastModeBtn.classList.remove("selected");
var btn=document.querySelector('[data-action="'+action+'"]');
if(btn){btn.classList.add("selected");lastModeBtn=btn;}
selectedAction=action;
updatePinColors(action);
sendAction(action);
}
buildSvg();
addLog("I","系统","通断测试工具就绪");
</script>
</body>
</html>
+135
View File
@@ -0,0 +1,135 @@
/* ============================================================
base.css — DCU Mission Control · 设计令牌与全局基底
风格:深色测控台(deep blue-slate),多色子系统语义配色
子系统色:ESP32=青绿 · GD32=琥珀 · BLE=蓝 · CAN=绿 · 内存/调试=紫
============================================================ */
:root {
/* ---- 底色层次:深蓝灰,层层抬升,拒绝纯黑 ---- */
--bg-root: #0a0f1a;
--bg-panel: #0e1624;
--bg-card: #131d2e;
--bg-elevated: #1a2740;
--bg-input: #0b1220;
--bg-hover: #20304c;
/* ---- 边框 ---- */
--border: #21304a;
--border-strong: #2e4262;
--border-subtle: #1a2740;
/* ---- 文字 ---- */
--text: #e9f0fa;
--text-dim: #93a8c4;
--text-label: #617a99;
/* ---- 子系统语义色 ---- */
--esp32: #2fd8c1; /* 青绿:ESP32 / 主操作 / WiFi OTA */
--esp32-hi: #6ff2e0;
--esp32-dim: rgba(47,216,193,.14);
--gd32: #fcbf3f; /* 琥珀:GD32 / 中继 OTA */
--gd32-hi: #ffd97a;
--gd32-dim: rgba(252,191,63,.14);
--ble: #5fa8ff; /* 蓝:BLE 蓝牙通道 */
--ble-hi: #93c6ff;
--ble-dim: rgba(95,168,255,.14);
--can: #43d97b; /* 绿:CAN 总线 / 健康 */
--can-hi: #7deda6;
--can-dim: rgba(67,217,123,.14);
--motor: #22d3ee; /* 青:电机控制台 */
--motor-hi: #67e8f9;
--motor-dim: rgba(34,211,238,.14);
--mem: #a88bfa; /* 紫:内存 / 调试 / 日志 */
--mem-hi: #c7b3ff;
--mem-dim: rgba(168,139,250,.14);
/* ---- 状态语义色 ---- */
--green: #3ddc8e;
--amber: #fcbf3f;
--red: #ff6b6b;
--red-dim: rgba(255,107,107,.14);
/* ---- 兼容旧令牌(大量旧样式引用,勿删)---- */
--accent: var(--esp32);
--accent-dim: var(--esp32-dim);
--border-focus: var(--esp32);
/* ---- 圆角 ---- */
--radius-sm: 8px;
--radius: 12px;
--radius-lg: 16px;
/* ---- 字体 ----
沿用原始系统字体方案:中文优先苹方/雅黑,西文 Segoe UI;
mono 用于 SHA / HEX / 日志 / 数值(SF Mono → Fira Code → Cascadia → Consolas)。
--font-display 保留变量名以兼容既有引用,指向同一系统栈。 */
--font-display: "PingFang SC","Microsoft YaHei","Segoe UI",system-ui,sans-serif;
--font: "PingFang SC","Microsoft YaHei","Segoe UI",system-ui,sans-serif;
--font-mono: "SF Mono","Fira Code","Cascadia Code","Consolas",monospace;
/* ---- 间距 / 阴影 ---- */
--gap: 18px;
--shadow-card: 0 10px 30px rgba(3,7,16,.45), 0 1px 0 rgba(255,255,255,.03) inset;
--shadow-pop: 0 18px 48px rgba(3,7,16,.6);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
height: 100%;
background: var(--bg-root);
color: var(--text);
font-family: var(--font);
font-size: 14px;
line-height: 1.55;
-webkit-font-smoothing: antialiased;
overflow-x: hidden;
}
/* ---- 环境光背景:细网格 + 双色氛围辉光 + 顶部扫光 ----
网格提供"工程图纸"质感;左上青绿、右下琥珀的两团极淡辉光
暗示 ESP32 / GD32 双核子系统,随视口固定,营造纵深。 */
body::before {
content: "";
position: fixed; inset: 0; z-index: 0; pointer-events: none;
background-image:
linear-gradient(rgba(148,180,226,.035) 1px, transparent 1px),
linear-gradient(90deg, rgba(148,180,226,.035) 1px, transparent 1px);
background-size: 44px 44px;
mask-image: radial-gradient(ellipse 120% 90% at 50% -8%, black 30%, transparent 78%);
}
body::after {
content: "";
position: fixed; inset: 0; z-index: 0; pointer-events: none;
background:
radial-gradient(900px 520px at 8% -10%, rgba(47,216,193,.10), transparent 60%),
radial-gradient(820px 560px at 100% 112%, rgba(252,191,63,.07), transparent 62%),
radial-gradient(700px 480px at 88% -6%, rgba(95,168,255,.06), transparent 60%);
}
.container {
position: relative; z-index: 1;
max-width: 1480px; margin: 0 auto; padding: 26px 32px 44px;
}
/* ---- 全局滚动条 ---- */
::-webkit-scrollbar { width: 9px; height: 9px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb {
background: var(--border-strong); border-radius: 99px;
border: 2px solid var(--bg-root);
}
::-webkit-scrollbar-thumb:hover { background: #3a5378; }
/* ---- 选区 ---- */
::selection { background: rgba(47,216,193,.28); color: #eafffb; }
/* ---- 通用等宽数字(让指标/百分比对齐、不跳动)---- */
.tabular { font-variant-numeric: tabular-nums; }
/* ---- focus 可见性(键盘可达性)---- */
:focus-visible {
outline: 2px solid var(--esp32);
outline-offset: 2px;
border-radius: 4px;
}
+1712
View File
File diff suppressed because it is too large Load Diff
+944
View File
@@ -0,0 +1,944 @@
/* ============================================================
components.css — 上传 / 文件 / 进度 / 指标 / 徽章 / 阶段流 /
堆概况 / 任务栈 / 日志 / Toast / Tooltip 等组件
============================================================ */
/* ================= 上传区 ================= */
.upload-zone {
border: 2px dashed var(--border-strong); border-radius: var(--radius);
padding: 30px 16px; text-align: center; cursor: pointer;
background: rgba(255,255,255,.015);
transition: border-color .22s, background .22s, transform .22s, box-shadow .22s;
}
.upload-zone:hover, .upload-zone.dragover {
border-color: var(--esp32);
background: var(--esp32-dim);
box-shadow: 0 0 0 4px rgba(47,216,193,.07);
}
.upload-zone .upload-ico { font-size: 32px; margin-bottom: 10px; }
.upload-zone h4 {
font-family: var(--font-display);
font-size: 14px; font-weight: 600; margin-bottom: 4px; letter-spacing: .02em;
}
.upload-zone p { font-size: 12px; color: var(--text-dim); }
.upload-zone-sm { padding: 18px 12px; }
.upload-zone-sm .upload-ico { font-size: 24px; margin-bottom: 6px; }
.upload-zone-sm h4 { font-size: 13px; }
/* ================= 文件信息 ================= */
.file-info {
display: none; margin-top: 12px;
background: var(--bg-elevated); border: 1px solid var(--border);
border-radius: var(--radius-sm); padding: 13px 15px;
}
.file-info.show { display: block; }
.file-info-row { display: flex; justify-content: space-between; align-items: center; }
.file-info-row + .file-info-row { margin-top: 10px; }
.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-subtle);
padding: 8px 11px; border-radius: 7px; font-size: 12px;
}
.file-meta-item span {
color: var(--text-label); display: block;
font-family: var(--font-display);
font-size: 10px; letter-spacing: .1em; text-transform: uppercase; margin-bottom: 3px;
}
.file-meta-item b { font-weight: 600; font-family: var(--font-mono); }
/* ================= 阶段切换胶囊(ESP32 OTA / GD32 OTA)================= */
.progress-phase-row {
display: flex; align-items: center; gap: 4px; margin-bottom: 15px;
background: var(--bg-input); border: 1px solid var(--border);
border-radius: 999px; padding: 4px;
}
.pp-step {
flex: 1;
display: flex; align-items: center; justify-content: center; gap: 6px;
padding: 8px 10px; border-radius: 999px;
font-family: var(--font-display);
font-size: 12px; font-weight: 600; letter-spacing: .03em;
color: var(--text-dim); background: transparent;
transition: all .25s ease; white-space: nowrap; cursor: default;
}
.pp-ico { font-size: 13px; opacity: .45; transition: opacity .25s ease; }
.pp-step.active {
color: #04231e; background: linear-gradient(135deg, var(--esp32), #14b8a6);
box-shadow: 0 2px 12px rgba(47,216,193,.35);
}
.pp-step.active .pp-ico { opacity: 1; }
.pp-step.done { color: var(--can-hi); background: rgba(61,220,142,.13); }
.pp-step.done .pp-ico { opacity: 1; }
.pp-step.done .pp-txt::after { content: ' ✓'; font-size: 10px; }
/* ================= 进度条 ================= */
.progress-wrap { margin-top: 0; display: none; }
.progress-wrap.show { display: block; }
.progress-track {
width: 100%; height: 11px; border-radius: 999px;
background: var(--bg-input); overflow: hidden;
border: 1px solid var(--border-subtle);
box-shadow: inset 0 1px 3px rgba(0,0,0,.4);
}
.progress-fill {
height: 100%; border-radius: 999px;
background: linear-gradient(90deg, var(--esp32), #14b8a6);
width: 0%; transition: width .3s ease;
position: relative;
box-shadow: 0 0 10px rgba(47,216,193,.4);
}
.progress-fill::after {
content: ""; position: absolute; inset: 0;
background: linear-gradient(90deg, transparent 0%, rgba(255,255,255,.35) 50%, transparent 100%);
animation: sweep 1.6s linear infinite;
}
.progress-fill.done {
background: linear-gradient(90deg, var(--green), #16a374);
box-shadow: 0 0 10px rgba(61,220,142,.4);
}
@keyframes sweep { 0% { transform: translateX(-100%);} 100% { transform: translateX(100%);} }
.progress-text {
display: flex; justify-content: space-between; margin-top: 8px;
font-size: 12px; color: var(--text-dim);
}
.progress-text #progressPct {
font-family: var(--font-display); font-weight: 700;
color: var(--esp32-hi); font-variant-numeric: tabular-nums;
}
/* ================= 按钮行 ================= */
.btn-row { display: flex; gap: 10px; margin-top: 16px; }
.btn-row .btn { flex: 1; }
/* ================= 运行指标 ================= */
.metric-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; }
.metric {
position: relative;
background: var(--bg-elevated); border: 1px solid var(--border);
border-radius: var(--radius-sm); padding: 13px 15px;
overflow: hidden;
transition: border-color .22s, transform .22s, box-shadow .22s;
}
.metric::before {
content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px;
background: var(--metric-accent, var(--esp32));
opacity: .55;
}
.metric:nth-child(1) { --metric-accent: var(--esp32); } /* ESP32 温度 */
.metric:nth-child(2) { --metric-accent: var(--gd32); } /* GD32 温度 */
.metric:nth-child(3) { --metric-accent: var(--ble); } /* 输入 VCC */
.metric:nth-child(4) { --metric-accent: var(--mem); } /* VDDA */
.metric:nth-child(5) { --metric-accent: var(--gd32); } /* GD32 APP */
.metric:nth-child(6) { --metric-accent: var(--can); } /* GD32 BOOT */
.metric:hover {
border-color: color-mix(in srgb, var(--metric-accent, var(--esp32)) 45%, var(--border));
transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(3,7,16,.4);
}
.metric .k {
font-family: var(--font);
font-size: 11px; font-weight: 500; color: var(--text-label);
letter-spacing: .04em; margin-bottom: 6px;
}
.metric .v {
font-family: var(--font);
font-size: 20px; font-weight: 700; line-height: 1.2;
word-break: break-word;
font-variant-numeric: tabular-nums;
}
.metric .v.highlight, .metric .v.accent { color: var(--esp32-hi); }
.metric .v.ok { color: var(--green); }
.metric .v.warn { color: var(--amber); }
.metric .v.err { color: var(--red); }
.status-kv-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; }
.status-kv {
background: var(--bg-elevated); border: 1px solid var(--border);
border-radius: var(--radius-sm); padding: 10px 13px;
}
.status-kv .k {
display: block; font-family: var(--font-display);
font-size: 10px; color: var(--text-label);
text-transform: uppercase; letter-spacing: .1em; margin-bottom: 4px;
}
.status-kv b {
display: block; color: var(--text); font-size: 13px; font-weight: 700;
word-break: break-word; font-family: var(--font-mono);
}
/* ================= 主状态徽章 ================= */
.badge-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; }
.badge-stat {
background: var(--bg-elevated); border: 1px solid var(--border);
border-radius: var(--radius); padding: 16px 16px 13px; text-align: center;
transition: border-color .22s, transform .22s, box-shadow .22s;
}
.badge-stat:hover {
border-color: var(--border-strong); transform: translateY(-2px);
box-shadow: 0 8px 20px rgba(3,7,16,.4);
}
.badge-stat .v {
font-family: var(--font-display);
font-size: 23px; font-weight: 700; letter-spacing: .01em;
font-variant-numeric: tabular-nums;
}
.badge-stat .v.accent { color: var(--esp32-hi); }
.badge-stat .v.ok { color: var(--green); text-shadow: 0 0 14px rgba(61,220,142,.35); }
.badge-stat .v.warn { color: var(--amber); }
.badge-stat .v.err { color: var(--red); }
.badge-stat .v.bad { color: var(--red); }
.badge-stat .k {
font-family: var(--font-display);
font-size: 10px; color: var(--text-label);
text-transform: uppercase; letter-spacing: .13em; margin-top: 4px;
}
/* ---- 健康状态 → 启动自检 ---- */
.badge-stat-click { cursor: pointer; user-select: none; }
.badge-stat-click:hover, .badge-stat-click:focus-visible {
outline: none;
border-color: color-mix(in srgb, var(--esp32) 45%, var(--border));
background: color-mix(in srgb, var(--esp32) 7%, var(--bg-elevated));
}
.badge-stat-click.is-open {
border-color: color-mix(in srgb, var(--esp32) 50%, var(--border));
}
.health-boot-hint {
font-size: 9px; letter-spacing: 0; text-transform: none;
color: var(--text-dim); font-weight: 500; margin-left: 3px;
}
.badge-stat-click.is-open .health-boot-hint { color: var(--esp32-hi); }
.health-boot-panel {
margin-top: 12px; padding: 13px 15px;
border-radius: var(--radius-sm);
border: 1px solid var(--border); background: var(--bg-elevated);
}
.health-boot-panel.collapsed, .health-boot-panel[hidden] { display: none; }
.health-boot-head {
display: flex; align-items: baseline; justify-content: space-between;
gap: 10px; margin-bottom: 7px;
}
.health-boot-title {
font-family: var(--font-display);
font-size: 11px; color: var(--text-label);
text-transform: uppercase; letter-spacing: .1em; font-weight: 600;
}
.health-boot-summary { font-family: var(--font-display); font-size: 14px; font-weight: 700; color: var(--text); text-align: right; }
.health-boot-summary.ok { color: var(--green); }
.health-boot-summary.warn{ color: var(--amber); }
.health-boot-summary.err { color: var(--red); }
.health-boot-failed { font-size: 12px; color: var(--text-dim); margin-bottom: 9px; }
.health-boot-failed b { color: var(--text); font-weight: 600; }
.health-boot-failed b.ok { color: var(--green); }
.health-boot-failed b.warn{ color: var(--amber); }
.health-boot-failed b.err { color: var(--red); }
/* ---- 启动模块芯片 ---- */
.boot-module-list { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 4px; min-height: 8px; }
.boot-mod {
display: inline-flex; align-items: center; gap: 6px;
padding: 4px 9px; border-radius: 999px;
border: 1px solid var(--border); background: var(--bg-root);
font-family: var(--font-mono);
font-size: 11px; font-weight: 600; color: var(--text-dim); line-height: 1.2;
}
.boot-mod .boot-mod-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--text-dim); flex-shrink: 0; }
.boot-mod.ok { color: var(--can-hi); border-color: rgba(61,220,142,.4); background: rgba(61,220,142,.08); }
.boot-mod.ok .boot-mod-dot { background: var(--green); }
.boot-mod.fail { color: #ff9d9d; border-color: rgba(255,107,107,.4); background: rgba(255,107,107,.08); }
.boot-mod.fail .boot-mod-dot { background: var(--red); }
.boot-mod.fail.optional { color: var(--gd32-hi); border-color: rgba(252,191,63,.4); background: rgba(252,191,63,.08); }
.boot-mod.fail.optional .boot-mod-dot { background: var(--amber); }
.status-section-title {
margin-top: 15px; margin-bottom: 9px;
font-family: var(--font-display);
font-size: 11px; color: var(--text-label);
text-transform: uppercase; letter-spacing: .12em; font-weight: 600;
}
/* ================= 堆概况 / CPU / 任务栈 ================= */
.heap-group {
background: rgba(255,255,255,.02);
border: 1px solid var(--border); border-radius: 9px;
padding: 13px 15px; margin-bottom: 10px;
}
.heap-group:last-child { margin-bottom: 0; }
.cpu-group { margin-bottom: 8px; }
.cpu-task-list { margin-bottom: 12px; }
.cpu-task-list .status-task { background: rgba(255,255,255,.025); }
.cpu-fill { background: linear-gradient(90deg, var(--ble), var(--esp32)); }
.heap-group-title {
font-family: var(--font-display);
font-size: 11px; font-weight: 600; color: var(--text-label);
text-transform: uppercase; letter-spacing: .08em; margin-bottom: 10px;
}
.heap-bar-row { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; }
.heap-bar-text {
order: 1; flex: 1 1 auto; min-width: 0;
font-family: var(--font-display);
font-size: 13px; font-weight: 600; white-space: nowrap; color: var(--text);
}
.heap-bar-pct {
order: 2; flex: 0 0 auto;
font-family: var(--font-mono);
font-size: 12px; font-weight: 600; min-width: 44px; text-align: right;
font-variant-numeric: tabular-nums; color: var(--text-dim);
}
.heap-bar-track {
order: 3; flex: 1 1 100%; width: 100%; height: 14px;
background: rgba(255,255,255,.06); border-radius: 7px;
overflow: hidden; position: relative;
}
.heap-bar-fill {
display: block; height: 100%; border-radius: 7px;
background: linear-gradient(90deg, var(--esp32), #5eead4);
transition: width .5s cubic-bezier(.4,0,.2,1);
}
.heap-bar-fill.warn { background: linear-gradient(90deg, var(--amber), #d97706); }
.heap-bar-fill.danger { background: linear-gradient(90deg, var(--red), #dc2626); }
.heap-detail-row {
display: flex; gap: 16px; flex-wrap: wrap;
font-size: 11px; color: var(--text-dim);
margin-top: 7px; padding-top: 7px;
border-top: 1px solid rgba(255,255,255,.05);
}
.heap-detail-row span { display: inline-flex; align-items: center; gap: 4px; }
.heap-detail-row b { color: var(--text); font-weight: 500; font-family: var(--font-mono); font-size: 11px; }
/* ---- SRAM 分段条 ---- */
.mem-sram-track { display: flex; overflow: hidden; gap: 0; background: rgba(255,255,255,.04); }
.mem-seg { display: block; height: 100%; min-width: 0; transition: width .5s ease; }
.mem-seg-iram { background: linear-gradient(90deg, var(--mem), #7c5ce0); }
.mem-seg-data { background: linear-gradient(90deg, #fb923c, #ea580c); }
.mem-seg-bss { background: linear-gradient(90deg, #94a3b8, #64748b); }
.mem-seg-static { background: linear-gradient(90deg, #64748b, #475569); }
.mem-seg-used { background: linear-gradient(90deg, var(--esp32), #5eead4); }
.mem-seg-free { background: rgba(255,255,255,.08); }
.heap-legend { display: flex; flex-wrap: wrap; gap: 8px 14px; margin-top: 9px; font-size: 11px; color: var(--text-dim); }
.heap-legend-item { display: inline-flex; align-items: center; gap: 5px; }
.heap-legend-item b { color: var(--text); font-weight: 500; font-family: var(--font-mono); font-size: 11px; }
.mem-dot { display: inline-block; width: 8px; height: 8px; border-radius: 2px; flex: 0 0 auto; }
.mem-dot-iram { background: var(--mem); }
.mem-dot-data { background: #f97316; }
.mem-dot-bss { background: #64748b; }
.mem-dot-static { background: #64748b; }
.mem-dot-used { background: var(--esp32); }
.mem-dot-free { background: rgba(255,255,255,.18); border: 1px solid rgba(255,255,255,.22); box-sizing: border-box; }
.mem-static-list { margin-top: 8px; display: flex; flex-direction: column; gap: 4px; font-size: 11px; color: var(--text-dim); }
.mem-static-row {
display: grid; grid-template-columns: 1fr auto auto; gap: 8px; align-items: center;
padding: 3px 0; border-bottom: 1px solid rgba(255,255,255,.04);
}
.mem-static-row:last-child { border-bottom: 0; }
.mem-static-name { color: var(--text-label); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.mem-static-size { font-family: var(--font-mono); color: var(--text); font-variant-numeric: tabular-nums; }
.mem-static-pct { font-family: var(--font-mono); min-width: 42px; text-align: right; color: var(--text-dim); }
.heap-hint {
margin-top: 8px; padding-top: 6px;
border-top: 1px solid rgba(255,255,255,.05);
font-size: 10px; line-height: 1.5; color: var(--text-dim); opacity: .9;
}
/* ---- 任务列表 ---- */
.status-task-list { display: grid; gap: 8px; }
.status-task-list.list-scroll-top3 {
--status-task-row-est: 6.9rem;
max-height: calc(3 * var(--status-task-row-est) + 2 * 8px);
overflow-y: auto; overflow-x: hidden;
padding-right: 4px; scrollbar-gutter: stable; overscroll-behavior: contain;
}
.status-task-list.list-scroll-top3::-webkit-scrollbar { width: 6px; }
.status-task-list.list-scroll-top3::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 999px; }
.status-task-list.list-scroll-top3::-webkit-scrollbar-track { background: transparent; }
.status-task {
background: var(--bg-elevated); border: 1px solid var(--border);
border-radius: var(--radius-sm); padding: 11px 13px;
}
.status-task-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 8px; }
.status-task-name { font-size: 12px; font-weight: 700; color: var(--text); font-family: var(--font-mono); }
.status-task-ratio { font-size: 11px; color: var(--text-dim); font-family: var(--font-mono); }
.status-task-bar { width: 100%; height: 8px; border-radius: 999px; background: var(--bg-input); overflow: hidden; margin-bottom: 8px; }
.status-task-bar-fill { height: 100%; border-radius: 999px; background: linear-gradient(90deg, var(--esp32), #14b8a6); }
.status-task-bar-fill.warn { background: linear-gradient(90deg, var(--amber), #d97706); }
.status-task-bar-fill.err { background: linear-gradient(90deg, var(--red), #dc2626); }
.status-task-meta { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
.status-task-meta span {
display: block; font-family: var(--font-display);
font-size: 10px; color: var(--text-label);
text-transform: uppercase; letter-spacing: .07em;
}
.status-task-meta b { display: block; margin-top: 2px; color: var(--text); font-size: 11px; font-family: var(--font-mono); }
/* ---- 二级折叠 + 原始 JSON ---- */
.status-subfold { margin-top: 15px; }
.status-subfold-head {
display: flex; align-items: center; justify-content: space-between; gap: 8px;
cursor: pointer; user-select: none; padding: 6px 0; border-radius: var(--radius-sm);
}
.status-subfold-head:hover { color: var(--text); }
.status-subfold-title { margin: 0 !important; flex: 1; }
.status-subfold-head .collapse-arrow { flex: none; font-size: 10px; color: var(--text-dim); transition: transform .15s ease; }
.status-subfold.collapsed .collapse-arrow { transform: rotate(-90deg); }
.status-subfold.collapsed .status-subfold-body { display: none; }
.status-subfold-body { margin-top: 6px; }
.status-json {
margin-top: 0; padding: 12px;
border-radius: var(--radius-sm); border: 1px solid var(--border);
background: var(--bg-input); color: var(--text-dim);
font-size: 11px; line-height: 1.65;
max-height: 280px; overflow: auto;
white-space: pre-wrap; word-break: break-word;
font-family: var(--font-mono); resize: vertical; min-height: 80px;
}
.status-empty {
padding: 13px; border: 1px dashed var(--border-strong);
border-radius: var(--radius-sm); color: var(--text-dim); font-size: 12px;
}
/* 抽屉内强制等宽 */
.sys-drawer-body .heap-bar-pct,
.sys-drawer-body .heap-detail-row b,
.sys-drawer-body .status-task-name,
.sys-drawer-body .status-task-ratio,
.sys-drawer-body .status-task-meta b,
.sys-drawer-body .status-json { font-family: var(--font-mono); }
.sys-drawer-body .status-section-title,
.sys-drawer-body .heap-group-title,
.sys-drawer-body .heap-bar-text { font-family: var(--font-display); color: inherit; }
.sys-drawer-body .status-section-title { color: var(--text-label); font-size: 11px; font-weight: 600; }
.sys-drawer-body .heap-bar-text { color: var(--text); font-size: 13px; font-weight: 600; }
.sys-drawer-body .status-json { color: var(--text-dim); font-size: 11px; line-height: 1.65; white-space: pre-wrap; word-break: break-word; }
/* ================= OTA 阶段流 ================= */
.stage-flow { display: flex; align-items: flex-start; gap: 0; padding: 10px 0 6px; }
.stage-node { flex: 1; display: flex; flex-direction: column; align-items: center; gap: 7px; position: relative; }
.stage-node:not(:last-child)::after {
content: ""; position: absolute; top: 19px; left: calc(50% + 20px);
width: calc(100% - 40px); height: 2px;
background: var(--border); transition: background .3s;
}
.stage-node.done:not(:last-child)::after,
.stage-node.live:not(:last-child)::after { background: var(--esp32); }
.stage-dot {
width: 38px; height: 38px; border-radius: 50%;
background: var(--bg-input); border: 2px solid var(--border-strong);
display: flex; align-items: center; justify-content: center;
font-family: var(--font-display); font-size: 16px; font-weight: 700;
color: var(--text-label);
z-index: 1; transition: all .3s;
}
.stage-node.live .stage-dot {
background: linear-gradient(135deg, var(--esp32), #14b8a6);
border-color: var(--esp32); color: #04231e;
box-shadow: 0 0 16px rgba(47,216,193,.45);
}
.stage-node.done .stage-dot {
background: linear-gradient(135deg, var(--green), #16a374);
border-color: var(--green); color: #03231a;
box-shadow: 0 0 12px rgba(61,220,142,.35);
}
.stage-node.err .stage-dot {
background: linear-gradient(135deg, var(--red), #dc4c4c);
border-color: var(--red); color: #fff;
box-shadow: 0 0 12px rgba(255,107,107,.4);
}
.stage-label {
font-family: var(--font-display);
font-size: 11px; color: var(--text-label); text-align: center; line-height: 1.3;
letter-spacing: .02em;
}
.stage-detail {
font-size: 10px; color: var(--esp32-hi); text-align: center; line-height: 1.2;
margin-top: 2px; min-height: 14px;
font-family: var(--font-mono); transition: color .3s;
}
.stage-node.live .stage-label,
.stage-node.done .stage-label,
.stage-node.err .stage-label { color: var(--text); }
.stage-node.live .stage-detail { color: var(--esp32-hi); }
.stage-node.done .stage-detail { color: var(--green); }
.stage-node.err .stage-detail { color: var(--red); }
/* ---- 阶段状态条 ---- */
.stage-status-bar {
display: flex; align-items: center; gap: 10px;
margin-top: 15px; padding: 11px 17px;
border-radius: 999px;
background: var(--bg-elevated); border: 1px solid var(--border);
transition: all .3s;
}
.stage-status-bar .ssb-dot {
width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
background: var(--text-dim); animation: dotPulse 2.5s ease-in-out infinite;
}
.stage-status-bar .ssb-text {
font-family: var(--font-display);
color: var(--text-dim); font-size: 13px; font-weight: 600; letter-spacing: .02em;
}
@keyframes dotPulse { 0%,100% { opacity: .45;} 50% { opacity: .85;} }
.stage-status-bar.active {
border-color: rgba(47,216,193,.35);
background: linear-gradient(135deg, rgba(47,216,193,.09), rgba(47,216,193,.02));
}
.stage-status-bar.active .ssb-dot {
background: var(--esp32); box-shadow: 0 0 8px var(--esp32-dim);
animation: dotPulseLive 1.2s ease-in-out infinite;
}
.stage-status-bar.active .ssb-text { color: var(--esp32-hi); }
@keyframes dotPulseLive { 0%,100% { opacity: .7; transform: scale(1);} 50% { opacity: 1; transform: scale(1.3);} }
.stage-status-bar.ok {
border-color: rgba(61,220,142,.35);
background: linear-gradient(135deg, rgba(61,220,142,.09), rgba(61,220,142,.02));
}
.stage-status-bar.ok .ssb-dot { background: var(--green); box-shadow: 0 0 8px rgba(61,220,142,.4); animation: none; }
.stage-status-bar.ok .ssb-text { color: var(--green); }
.stage-status-bar.err {
border-color: rgba(255,107,107,.35);
background: linear-gradient(135deg, rgba(255,107,107,.09), rgba(255,107,107,.02));
}
.stage-status-bar.err .ssb-dot { background: var(--red); box-shadow: 0 0 8px rgba(255,107,107,.4); animation: none; }
.stage-status-bar.err .ssb-text { color: var(--red); }
/* ---- 校验信息面板 ---- */
.stage-verify-info {
margin-top: 12px; padding: 13px 15px;
border-radius: var(--radius-sm);
background: var(--bg-elevated); border: 1px solid var(--border);
}
.svi-row {
display: flex; align-items: flex-start; gap: 12px;
padding: 7px 0; font-size: 12px;
border-bottom: 1px solid rgba(255,255,255,.04);
}
.svi-row:last-child { border-bottom: none; }
.svi-key {
color: var(--text-label); font-weight: 600;
font-family: var(--font-display);
text-transform: uppercase; letter-spacing: .07em; font-size: 10px;
min-width: 104px; flex-shrink: 0; padding-top: 2px;
}
.svi-val { color: var(--text-dim); font-family: var(--font-mono); font-size: 11px; word-break: break-all; }
.svi-val.ok { color: var(--green); font-weight: 600; }
.svi-val.err { color: var(--red); font-weight: 600; }
/* ---- 阶段结果面板 ---- */
.stage-result {
margin-top: 12px; padding: 15px 17px;
border-radius: var(--radius-sm);
background: var(--bg-elevated); border: 1px solid var(--border);
display: flex; flex-direction: column; gap: 10px;
}
.stage-result.ok { border-color: rgba(61,220,142,.5); background: rgba(61,220,142,.08); }
.stage-result.err { border-color: rgba(255,107,107,.5); background: rgba(255,107,107,.08); }
.stage-result .sr-head { display: flex; align-items: center; gap: 10px; }
.stage-result .sr-ico {
width: 32px; height: 32px; border-radius: 50%;
display: inline-flex; align-items: center; justify-content: center;
font-size: 18px; font-weight: 700; color: #04231e;
}
.stage-result.ok .sr-ico { background: var(--green); }
.stage-result.err .sr-ico { background: var(--red); color: #fff; }
.stage-result .sr-title { font-family: var(--font-display); font-size: 16px; font-weight: 700; }
.stage-result.ok .sr-title { color: var(--green); }
.stage-result.err .sr-title { color: var(--red); }
.stage-result .sr-detail { font-size: 13px; color: var(--text); line-height: 1.55; }
.stage-result .sr-actions { display: flex; justify-content: flex-end; gap: 8px; }
/* ================= 日志面板 ================= */
.log-panel { display: flex; flex-direction: column; min-height: 380px; }
.log-panel .card-body { flex: 1; display: flex; flex-direction: column; padding: 0; }
.log-toolbar {
display: flex; align-items: center; justify-content: space-between;
padding: 11px 16px; border-bottom: 1px solid var(--border-subtle);
gap: 10px; flex-wrap: wrap;
}
.log-filters { display: flex; gap: 6px; }
.log-filter {
padding: 5px 12px; border-radius: 999px;
font-family: var(--font-display);
font-size: 11px; font-weight: 600; letter-spacing: .03em;
border: 1px solid var(--border); background: transparent; color: var(--text-dim);
cursor: pointer; transition: all .18s;
}
.log-filter.active {
background: linear-gradient(135deg, var(--esp32), #14b8a6);
color: #04231e; border-color: var(--esp32);
box-shadow: 0 2px 10px rgba(47,216,193,.3);
}
.log-filter:hover:not(.active) { border-color: var(--border-strong); color: var(--text); }
.log-scroll {
flex: 1; overflow-y: auto; padding: 12px 16px;
font-family: var(--font-mono); font-size: 12px; line-height: 1.7;
max-height: 420px; background: rgba(4,8,16,.35);
}
.log-scroll::-webkit-scrollbar { width: 5px; }
.log-scroll::-webkit-scrollbar-track { background: transparent; }
.log-scroll::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 3px; }
.log-line {
display: flex; gap: 9px; padding: 3px 0;
border-bottom: 1px solid rgba(255,255,255,.025);
}
.log-line .lt { color: var(--text-label); flex-shrink: 0; }
.log-line .lv { font-weight: 700; min-width: 20px; flex-shrink: 0; }
.log-line .lv.E { color: var(--red); }
.log-line .lv.W { color: var(--amber); }
.log-line .lv.I { color: var(--esp32); }
.log-line .lv.D { color: var(--text-label); }
.log-line .lg, .log-line .tag { color: var(--mem-hi); flex-shrink: 0; }
.log-line .lm { color: var(--text); word-break: break-all; }
.log-line .t { color: var(--text-label); flex-shrink: 0; }
.log-foot {
display: flex; justify-content: space-between; align-items: center;
padding: 9px 16px; border-top: 1px solid var(--border-subtle);
font-size: 11px; color: var(--text-label);
}
/* ================= Toast ================= */
.toast {
position: fixed; top: 45%; left: 50%;
transform: translateX(-50%) translateY(30px);
z-index: 9999;
display: flex; align-items: center; gap: 11px;
padding: 14px 26px; border-radius: 12px;
font-family: var(--font-display);
font-size: 14px; font-weight: 600; letter-spacing: .02em;
color: #fff; pointer-events: none; opacity: 0;
transition: all .35s cubic-bezier(.4,0,.2,1);
box-shadow: 0 12px 40px rgba(2,5,12,.55);
border: 1px solid rgba(255,255,255,.12);
}
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); pointer-events: auto; }
.toast.ok { background: linear-gradient(135deg, #16a374, #0e8a5f); }
.toast.err { background: linear-gradient(135deg, #dc4c4c, #c03535); }
.toast.warn { background: linear-gradient(135deg, #d98a06, #b87405); }
.toast .toast-ico { font-size: 18px; }
/* ================= 杂项 ================= */
.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); }
.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: var(--esp32-hi); font-family: var(--font-mono); }
/* ================= 自定义 Tooltip(pill)================= */
.pill[data-tooltip] { position: relative; cursor: help; }
.pill[data-tooltip]:hover::after {
content: attr(data-tooltip);
position: absolute; top: calc(100% + 10px); left: 50%;
transform: translateX(-50%);
background: var(--bg-elevated); color: var(--text);
font-size: 11px; font-weight: 400; font-family: var(--font-mono);
padding: 6px 12px; border-radius: 7px;
border: 1px solid var(--border-strong);
box-shadow: 0 6px 20px rgba(2,5,12,.5);
white-space: nowrap; z-index: 1000; pointer-events: none;
animation: tooltipFadeIn .15s ease-out;
}
.pill[data-tooltip]:hover::before {
content: ''; position: absolute; top: calc(100% + 4px); left: 50%;
transform: translateX(-50%);
border: 6px solid transparent; border-bottom-color: var(--border-strong);
z-index: 1001; pointer-events: none;
animation: tooltipFadeIn .15s ease-out;
}
@keyframes tooltipFadeIn {
from { opacity: 0; transform: translateX(-50%) translateY(3px); }
to { opacity: 1; transform: translateX(-50%) translateY(0); }
}
/* ================= 堆低水位 Tooltip ================= */
.heap-low-tooltip {
position: fixed; z-index: 9999;
min-width: 480px; max-width: 640px;
padding: 11px 13px 9px;
background: var(--bg-elevated);
border: 1px solid var(--border-strong);
border-radius: 9px;
box-shadow: 0 8px 28px rgba(2,5,12,.6);
font-size: 11px; color: var(--text);
pointer-events: auto; visibility: hidden;
}
.heap-low-title { font-weight: 600; margin-bottom: 6px; color: var(--text); display: flex; align-items: center; }
.heap-low-sub { font-weight: 400; color: var(--text-dim); margin-left: 6px; }
.heap-low-clear {
margin-left: auto; margin-right: 6px; height: 22px; padding: 0 9px;
border: 1px solid rgba(255,107,107,.45); border-radius: 5px;
background: rgba(255,107,107,.12); color: #ff9d9d;
font-size: 11px; line-height: 20px; cursor: pointer;
transition: background .12s, color .12s, border-color .12s;
}
.heap-low-clear:hover:not(:disabled) { background: rgba(255,107,107,.22); border-color: rgba(255,107,107,.7); color: #ffcdd2; }
.heap-low-clear:disabled { opacity: .55; cursor: wait; }
.heap-low-close {
width: 22px; height: 22px; padding: 0; border: none; border-radius: 5px;
background: transparent; color: var(--text-dim);
font-size: 18px; line-height: 1; cursor: pointer;
transition: background .12s, color .12s;
}
.heap-low-close:hover { background: rgba(255,255,255,.08); color: var(--text); }
.heap-low-table-wrap { max-height: 360px; overflow-y: auto; }
.heap-low-table { width: 100%; border-collapse: collapse; font-family: var(--font-mono); font-size: 10.5px; }
.heap-low-section { margin-bottom: 6px; }
.heap-low-section + .heap-low-section { margin-top: 8px; padding-top: 6px; border-top: 1px dashed rgba(255,255,255,.15); }
.heap-low-section-title {
font-size: 11px; font-weight: 600; color: var(--text-dim);
padding: 4px 6px 2px; background: rgba(255,255,255,.03); border-radius: 3px 3px 0 0;
}
.heap-low-section-hist .heap-low-section-title { color: var(--gd32-hi); }
.heap-low-section-hist .heap-low-table { opacity: .92; }
.heap-low-table th, .heap-low-table td {
padding: 3px 6px; text-align: right; white-space: nowrap;
border-bottom: 1px solid rgba(255,255,255,.04);
}
.heap-low-table th { color: var(--text-dim); font-weight: 500; position: sticky; top: 0; background: var(--bg-elevated); text-align: right; }
.ble-low-table td:nth-child(5), .ble-low-table td:nth-child(6),
.ble-low-table td:nth-child(7), .ble-low-table td:nth-child(9) { text-align: left; }
.ble-low-table td:first-child { color: var(--text-dim); }
/* ================= BLE 连接按钮(launch 风格) =================
圆形物理按键:未连接=暗淡待机;勾选=已连接,蓝光环绕 + 文字波浪动画。
checkbox 隐藏,由 JS setConnectionState 驱动 checked,避免异步连接失败导致状态错位。 */
.ble-launch-wrap {
display: flex; flex-direction: column; align-items: center;
gap: 12px; padding: 6px 0 2px;
}
.ble-launch-btn {
cursor: pointer; position: relative;
display: flex; flex-direction: column; align-items: center;
margin: 0;
}
.ble-launch-btn input {
opacity: 0; width: 0; height: 0; display: none;
}
/* —— 三态:idle 待机 / connecting 连接中转圈 / connected 已连接变亮 —— */
/* 默认 text3 隐藏 */
.ble-launch-btn .back .mid .ble-dial .center .start .text3 {
opacity: 0;
}
/* connecting:仅光晕转圈(loading),按钮整体保持暗淡 */
.ble-launch-btn[data-ble-state="connecting"] .back .spot-shadow,
.ble-launch-btn[data-ble-state="connecting"] .back .mid .spot-main,
.ble-launch-btn[data-ble-state="connecting"] .back .mid .ble-dial .spot-light {
opacity: 0.7;
animation: ble-spot-rot 1.4s linear infinite;
}
.ble-launch-btn[data-ble-state="connecting"] .back .mid .ble-dial .center .start .text1 {
opacity: 0;
}
.ble-launch-btn[data-ble-state="connecting"] .back .mid .ble-dial .center .start .text3 {
opacity: 1;
color: #9bb8d8;
animation: ble-text-pulse 1.2s ease-in-out infinite;
}
/* connected:整个按钮变亮 + 静态整圈蓝光晕(不转圈)+ 文字波浪 */
/* spot 元素默认是横向条状(为转圈动画设计),connected 时改成整圈圆环 */
.ble-launch-btn[data-ble-state="connected"] .back .spot-shadow {
transition: opacity 1.2s ease;
opacity: 0.9;
width: 106px; height: 106px;
border-radius: 50%;
filter: blur(12px) brightness(1.2);
}
.ble-launch-btn[data-ble-state="connected"] .back .mid .spot-main {
transition: opacity 1.2s ease;
opacity: 0.95;
width: 100px; height: 100px;
border-radius: 50%;
filter: blur(9px) brightness(1.6);
}
.ble-launch-btn[data-ble-state="connected"] .back .mid .ble-dial .spot-light {
transition: opacity 1.2s ease;
opacity: 0.9;
width: 92px; height: 92px;
border-radius: 50%;
filter: blur(8px) brightness(1.2);
}
.ble-launch-btn[data-ble-state="connected"] .back .mid .ble-dial .center .icon svg {
filter: brightness(1.15);
}
.ble-launch-btn[data-ble-state="connected"] .back .mid .ble-dial .center .launch {
color: #e9f0fa;
}
.ble-launch-btn[data-ble-state="connected"] .back .mid .ble-dial .center .launch > div {
animation: ble-wave-text 1.8s calc(var(--i) * -0.13s) infinite;
}
.ble-launch-btn[data-ble-state="connected"] .back .mid .ble-dial .center .start .text1 {
transition: transform 0.5s ease, opacity 0.5s ease;
transform: translateX(10px);
opacity: 0;
}
.ble-launch-btn[data-ble-state="connected"] .back .mid .ble-dial .center .start .text2 {
opacity: 1;
animation: ble-text2 0.5s forwards;
}
.ble-launch-btn:active .back .mid .ble-dial {
transform: scale(0.94);
box-shadow:
0 -1px 2px rgba(255,255,255,0.3),
inset 0 0 10px rgba(0,0,0,0.24),
inset 0 -13px 26px rgba(0,0,0,0.8);
}
/* —— 外圈金属环 —— */
.ble-launch-btn .back {
position: relative; z-index: 200;
width: 128px; height: 128px;
display: flex; justify-content: center; align-items: center;
border-radius: 50%;
background-image: linear-gradient(0, #5e5e61 20%, #212123);
box-shadow:
inset 0 1px 1px rgba(255,255,255,0.3),
inset 0 3px 5px rgba(255,255,255,0.6),
inset 0 -4px 2px rgba(255,255,255,0.05),
inset 0 -2px 0 rgba(0,0,0,0.15),
inset -2px 4px 0 rgba(0,0,0,0.3),
inset 2px 4px 0 rgba(0,0,0,0.3),
0 -7px 16px 2px rgba(255,255,255,0.18),
0 4px 16px 2px rgba(0,0,0,0.5),
0 2px 5px #7c7c7c;
cursor: pointer;
}
.ble-launch-btn .back .spot-shadow {
position: absolute;
width: 96px; height: 27px;
background: rgba(95,168,255,0.9);
opacity: 0;
filter: blur(14px) brightness(1.1);
}
.ble-launch-btn .back .mid {
overflow: hidden; position: relative;
width: 106px; height: 106px;
display: flex; justify-content: center; align-items: center;
border-radius: 50%;
background: #000;
box-shadow: 0 0 4px #000, 0 1px 2px rgba(255,255,255,0.8);
}
.ble-launch-btn .back .mid::before {
content: ''; position: absolute; z-index: 500;
width: 100%; height: 100%; border-radius: 50%;
background: transparent;
box-shadow: inset 0 -12px 18px rgba(0,0,0,0.8);
pointer-events: none;
}
.ble-launch-btn .back .mid .spot-main {
position: absolute;
width: 110px; height: 20px;
background: #5fa8ff;
opacity: 0;
filter: blur(11px) brightness(1.5);
}
.ble-launch-btn .back .mid .ble-dial {
position: relative; z-index: 90;
display: flex; justify-content: center; align-items: center;
width: 100px; height: 100px; border-radius: 50%;
background: #424242;
box-shadow:
0 -1px 2px rgba(255,255,255,0.5),
inset 0 -9px 18px rgba(0,0,0,0.8);
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.ble-launch-btn .back .mid .ble-dial .spot-light {
position: absolute;
width: 110px; height: 20px;
opacity: 0;
background: rgba(95,168,255,0.6);
filter: blur(14px) brightness(1.1);
}
.ble-launch-btn .back .mid .ble-dial .center {
position: relative; z-index: 100;
width: 86px; height: 86px;
display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: 2px;
border-radius: 50%;
background: #232323;
cursor: pointer;
}
.ble-launch-btn .back .mid .ble-dial .center .icon svg {
margin-top: -3px;
width: 18px;
filter: brightness(1) grayscale(1);
transition: filter 1.5s ease;
}
.ble-launch-btn .back .mid .ble-dial .center .launch {
display: flex; margin-top: -3px;
font-size: 13px; color: #595959;
font-family: var(--font-display);
letter-spacing: 0.04em;
}
.ble-launch-btn .back .mid .ble-dial .center .start {
position: relative;
width: 100%; height: 11px;
font-size: 9px; color: #737373;
text-align: center;
font-family: var(--font-display);
}
.ble-launch-btn .back .mid .ble-dial .center .start > div {
position: absolute; left: 50%; top: 0;
transform: translateX(-50%);
white-space: nowrap;
}
.ble-launch-btn .back .mid .ble-dial .center .start .text2 {
opacity: 0;
display: inline-flex;
}
.ble-launch-btn .back .mid .ble-dial .center .start .text2 > span {
animation: ble-wave-text 1.8s calc(var(--n) * -0.13s) infinite;
}
.ble-launch-hint {
font-size: 11px; color: var(--text-dim);
letter-spacing: 0.04em;
}
@keyframes ble-wave-text {
0% { opacity: 0.7; }
50% { color: #c6e0ff; opacity: 1; }
100% { opacity: 0.7; }
}
@keyframes ble-spot-rot {
0% { transform: rotate(0); }
100% { transform: rotate(360deg); }
}
@keyframes ble-text2 {
0% { transform: translateX(-80%); }
100% { transform: translateX(-50%); }
}
@keyframes ble-text-pulse {
0%, 100% { opacity: 0.5; }
50% { opacity: 1; }
}
.heap-low-table tbody tr:hover { background: rgba(255,255,255,.03); }
.heap-low-table td.has-suspect { color: var(--red); font-weight: 500; }
.heap-low-table td.idle-suspect { color: var(--gd32-hi); }
.heap-low-detail-meta { display: flex; gap: 18px; font-size: 10px; color: var(--text-dim); margin-bottom: 6px; }
.heap-low-detail-meta b { color: var(--text); font-family: var(--font-mono); }
.heap-low-hook-meta {
margin: 8px 0 6px; padding: 6px 9px;
background: rgba(255,107,107,.08); border-left: 2px solid var(--red);
display: flex; flex-wrap: wrap; gap: 12px;
font-size: 10px; color: var(--text-dim);
}
.heap-low-hook-title { width: 100%; color: var(--red); font-weight: 600; margin-bottom: 2px; }
.heap-low-hook-meta b { color: var(--text); font-family: var(--font-mono); }
.heap-low-caller { color: var(--red); }
.heap-low-caller-hint { color: var(--text-dim); font-size: 9px; }
.heap-low-main-row { cursor: pointer; }
.heap-low-expand-hint { font-size: 9px; color: var(--text-dim); margin-left: 4px; }
.heap-low-stack-detail > td { padding: 6px 8px 8px; background: rgba(0,0,0,.2); }
.heap-low-stack-table { width: 100%; border-collapse: collapse; font-family: var(--font-mono); font-size: 10px; }
.heap-low-stack-table th, .heap-low-stack-table td { padding: 2px 6px; text-align: left; border-bottom: 1px solid rgba(255,255,255,.03); }
.heap-low-stack-table th { color: var(--text-dim); font-weight: 500; }
.heap-low-stack-table td:last-child { text-align: right; }
.heap-low-stack-table tr.crit td { color: var(--red); }
.heap-low-stack-table tr.warn td { color: var(--gd32-hi); }
.heap-low-empty { padding: 14px 6px; color: var(--text-dim); text-align: center; }
.heap-low-tip { margin-top: 6px; font-size: 10px; color: var(--text-dim); }
+337
View File
@@ -0,0 +1,337 @@
/* ============================================================
layout.css — 命令栏头部 / 主布局 / 卡片 / 表单 / 按钮 / 侧滑抽屉
============================================================ */
/* ================= HEADER — 命令栏 ================= */
.header-bar {
position: relative;
z-index: 20; /* 高于下方卡片,保证 pill tooltip 不被遮挡 */
display: flex; align-items: center; gap: 16px;
padding: 16px 22px; margin-bottom: 26px;
background:
radial-gradient(620px 240px at 88% -40%, rgba(47,216,193,.12), transparent 60%),
linear-gradient(160deg, var(--bg-panel) 0%, var(--bg-card) 70%);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
flex-wrap: wrap;
}
/* 顶部三色霓虹描边:ESP32 青绿 → BLE 蓝 → GD32 琥珀,一条细线点出子系统谱系 */
.header-bar::before {
content: "";
position: absolute; top: 0; left: 0; right: 0; height: 2px;
background: linear-gradient(90deg,
var(--esp32) 0%, var(--ble) 38%, var(--gd32) 78%, transparent 100%);
opacity: .85;
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
}
.header-brand {
display: flex; align-items: center; gap: 12px;
padding-right: 20px; border-right: 1px solid var(--border);
white-space: nowrap;
}
.brand-logo {
width: 40px; height: 40px; flex-shrink: 0;
display: inline-flex; align-items: center; justify-content: center;
border-radius: 11px;
background: linear-gradient(145deg, rgba(47,216,193,.16), rgba(95,168,255,.10));
border: 1px solid rgba(47,216,193,.3);
box-shadow: 0 0 18px rgba(47,216,193,.18), inset 0 0 12px rgba(47,216,193,.08);
color: var(--esp32);
}
.brand-logo svg { width: 24px; height: 24px; display: block; }
.brand-text { display: flex; flex-direction: column; line-height: 1.15; }
.brand-text strong {
font-family: var(--font-display);
font-size: 17px; font-weight: 700; letter-spacing: .02em;
background: linear-gradient(92deg, var(--text) 30%, var(--esp32-hi) 100%);
-webkit-background-clip: text; background-clip: text;
-webkit-text-fill-color: transparent;
}
.brand-sub {
font-family: var(--font-mono);
font-size: 10px; letter-spacing: .22em; text-transform: uppercase;
color: var(--text-label); margin-top: 2px;
}
/* 兼容旧结构:独立 dot-mark(无 brand-logo 时)仍显示为一颗呼吸 LED */
.header-brand .dot-mark {
width: 10px; height: 10px; border-radius: 50%;
background: var(--esp32);
box-shadow: 0 0 10px var(--esp32-dim);
flex-shrink: 0;
}
.header-brand strong { font-size: 16px; font-weight: 700; }
.header-status {
display: flex; align-items: center; gap: 8px;
flex-wrap: wrap;
}
.header-sub {
display: flex; gap: 10px; margin-left: auto;
flex-wrap: wrap;
}
/* ---- 状态胶囊 ---- */
.pill {
display: inline-flex; align-items: center; gap: 7px;
padding: 6px 13px; border-radius: 999px;
font-size: 12px; font-weight: 500;
font-family: var(--font-mono);
background: var(--bg-elevated); border: 1px solid var(--border);
color: var(--text-dim);
white-space: nowrap;
transition: border-color .2s, background .2s, transform .2s;
}
.pill .indicator {
width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0;
background: var(--text-label);
}
.pill.online .indicator { background: var(--green); box-shadow: 0 0 8px rgba(61,220,142,.6); }
.pill.offline .indicator { background: var(--red); box-shadow: 0 0 8px rgba(255,107,107,.5); }
.pill.processing .indicator,
.pill.live .indicator { background: var(--amber); animation: blink 1s infinite; }
.pill.idle .indicator { background: var(--text-dim); }
.pill.online { color: var(--can-hi); border-color: rgba(61,220,142,.35); }
.pill.offline { color: #ffb3b3; border-color: rgba(255,107,107,.3); }
.pill.processing,
.pill.live { color: var(--gd32-hi); border-color: rgba(252,191,63,.35); }
@keyframes blink { 50% { opacity: .3; } }
.pill-val {
font-weight: 700; color: var(--text);
font-family: var(--font-mono);
}
/* ================= 主布局 ================= */
.main-layout {
display: grid;
grid-template-columns: 400px 1fr;
gap: var(--gap);
align-items: start;
}
/* ================= 卡片 ================= */
.card {
position: relative;
background: linear-gradient(170deg, var(--bg-card) 0%, rgba(15,23,38,.92) 100%);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
box-shadow: var(--shadow-card);
}
.card + .card { margin-top: var(--gap); }
/* 子系统色带:卡片顶部 2px 渐变,由修饰类决定颜色 */
.card::before {
content: "";
position: absolute; top: 0; left: 0; right: 0; height: 2px;
background: linear-gradient(90deg, var(--card-accent, var(--esp32)), transparent 85%);
opacity: .7; pointer-events: none;
}
.card--esp32 { --card-accent: var(--esp32); --card-accent-dim: var(--esp32-dim); }
.card--gd32 { --card-accent: var(--gd32); --card-accent-dim: var(--gd32-dim); }
.card--ble { --card-accent: var(--ble); --card-accent-dim: var(--ble-dim); }
.card--can { --card-accent: var(--can); --card-accent-dim: var(--can-dim); }
.card--motor { --card-accent: var(--motor); --card-accent-dim: var(--motor-dim); }
.card--mem { --card-accent: var(--mem); --card-accent-dim: var(--mem-dim); }
.card--status { --card-accent: var(--green); --card-accent-dim: var(--can-dim); }
.card-head {
display: flex; align-items: center; gap: 10px;
padding: 14px 18px;
border-bottom: 1px solid var(--border-subtle);
font-family: var(--font-display);
font-size: 13px; font-weight: 600;
color: var(--text);
letter-spacing: .05em;
cursor: pointer;
user-select: none;
}
/* 图标芯片:圆角方块底色取自卡片子系统色 */
.card-head .ico {
width: 28px; height: 28px; flex-shrink: 0;
display: inline-flex; align-items: center; justify-content: center;
font-size: 15px;
border-radius: 8px;
background: var(--card-accent-dim, var(--esp32-dim));
border: 1px solid color-mix(in srgb, var(--card-accent, var(--esp32)) 30%, transparent);
}
.card-head-btn { margin-left: auto; padding: 4px 10px; position: relative; z-index: 2; }
.card-body { padding: 17px 18px; }
.card.collapsed .card-body { display: none; }
.card-head .collapse-arrow {
margin-left: auto; font-size: 10px; color: var(--text-label);
transition: transform .2s;
}
.card.collapsed .collapse-arrow { transform: rotate(-90deg); }
/* ================= 表单 ================= */
.form-row { margin-bottom: 14px; }
.form-row:last-child { margin-bottom: 0; }
.form-label {
display: block;
font-family: var(--font-display);
font-size: 11px; color: var(--text-label);
text-transform: uppercase; letter-spacing: .12em;
margin-bottom: 7px;
}
.input, select, textarea {
width: 100%; padding: 10px 13px;
font-size: 13px; font-family: var(--font-mono);
background: var(--bg-input); color: var(--text);
border: 1px solid var(--border); border-radius: var(--radius-sm);
outline: none;
transition: border-color .18s, box-shadow .18s, background .18s;
}
.input::placeholder { color: var(--text-label); }
.input:focus, select:focus, textarea:focus {
border-color: var(--esp32);
background: color-mix(in srgb, var(--esp32) 5%, var(--bg-input));
box-shadow: 0 0 0 3px var(--esp32-dim);
}
select { cursor: pointer; }
select option { background: var(--bg-panel); color: var(--text); }
.input-group { display: flex; gap: 8px; }
.input-group .input { flex: 1; }
/* ================= 按钮 ================= */
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: 7px;
padding: 9px 17px; border: none; border-radius: var(--radius-sm);
font-family: var(--font-display);
font-size: 13px; font-weight: 600; letter-spacing: .03em;
cursor: pointer; white-space: nowrap;
transition: transform .16s, box-shadow .2s, background .2s, border-color .2s, color .2s, opacity .2s;
}
.btn:hover:not(:disabled) { transform: translateY(-1px); }
.btn:active:not(:disabled) { transform: translateY(0) scale(.97); }
.btn:disabled { opacity: .38; cursor: not-allowed; transform: none; }
/* 主操作 = ESP32 青绿 */
.btn-primary {
background: linear-gradient(135deg, var(--esp32) 0%, #14b8a6 100%);
color: #04231e;
box-shadow: 0 4px 16px rgba(47,216,193,.28), inset 0 1px 0 rgba(255,255,255,.25);
}
.btn-primary:hover:not(:disabled) {
box-shadow: 0 6px 22px rgba(47,216,193,.4), inset 0 1px 0 rgba(255,255,255,.3);
}
.btn-success {
background: linear-gradient(135deg, var(--green) 0%, #16a374 100%);
color: #03231a;
box-shadow: 0 4px 16px rgba(61,220,142,.25), inset 0 1px 0 rgba(255,255,255,.22);
}
.btn-danger {
background: linear-gradient(135deg, var(--red) 0%, #dc4c4c 100%);
color: #fff;
box-shadow: 0 4px 16px rgba(255,107,107,.25), inset 0 1px 0 rgba(255,255,255,.2);
}
.btn-outline {
background: rgba(255,255,255,.02); color: var(--text-dim);
border: 1px solid var(--border-strong);
}
.btn-outline:hover:not(:disabled) {
background: var(--bg-hover); color: var(--text);
border-color: #3d5a84;
}
.btn-ghost { background: transparent; color: var(--text-dim); }
.btn-ghost:hover:not(:disabled) { background: var(--bg-hover); color: var(--text); }
.btn-block { width: 100%; justify-content: center; }
.btn-sm { padding: 6px 12px; font-size: 12px; }
/* ---- 多色子系统页签(关键)----
console-mode.js 用 btn-primary / btn-outline 表达"选中/未选中",
这里按按钮 id 把"选中态"染成各自子系统色:
WiFi=青绿 · BLE=蓝 · ESP32=青绿 · GD32=琥珀。 */
#modeBleBtn.btn-primary {
background: linear-gradient(135deg, var(--ble) 0%, #3b82f6 100%);
color: #04142b;
box-shadow: 0 4px 16px rgba(95,168,255,.3), inset 0 1px 0 rgba(255,255,255,.25);
}
#targetGd32Tab.btn-primary {
background: linear-gradient(135deg, var(--gd32) 0%, #f59e0b 100%);
color: #2b1d02;
box-shadow: 0 4px 16px rgba(252,191,63,.3), inset 0 1px 0 rgba(255,255,255,.3);
}
#targetEsp32Tab.btn-primary {
background: linear-gradient(135deg, var(--esp32) 0%, #14b8a6 100%);
color: #04231e;
}
/* GD32 OTA 大按钮 = 琥珀(区别于 ESP32 青绿) */
#uploadGd32Btn {
background: linear-gradient(135deg, var(--gd32) 0%, #f59e0b 100%);
color: #2b1d02;
box-shadow: 0 6px 20px rgba(252,191,63,.3), inset 0 1px 0 rgba(255,255,255,.3);
}
#uploadEsp32Btn {
box-shadow: 0 6px 20px rgba(47,216,193,.32), inset 0 1px 0 rgba(255,255,255,.28);
}
/* ================= 连接状态条 / 提示 ================= */
.conn-indicator {
display: flex; align-items: center; gap: 10px;
padding: 12px 16px; border-radius: var(--radius-sm);
background: var(--bg-elevated); border: 1px solid var(--border);
font-size: 13px; font-weight: 500;
}
.conn-indicator .light { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
.conn-indicator.ok .light { background: var(--green); box-shadow: 0 0 8px rgba(61,220,142,.6); }
.conn-indicator.err .light { background: var(--red); box-shadow: 0 0 8px rgba(255,107,107,.5); }
.conn-indicator.warn .light { background: var(--amber); }
.helper-note {
margin-top: 10px; padding: 10px 13px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--bg-elevated);
font-size: 12px; line-height: 1.65; color: var(--text-dim);
}
.helper-note.ok { border-color: rgba(61,220,142,.3); background: rgba(61,220,142,.07); color: #a9f0c6; }
.helper-note.warn { border-color: rgba(252,191,63,.3); background: rgba(252,191,63,.07); color: #fbe3ae; }
.helper-note.err { border-color: rgba(255,107,107,.3); background: rgba(255,107,107,.07); color: #ffc4c4; }
.helper-note code { color: var(--esp32-hi); font-family: var(--font-mono); }
/* ================= 右侧滑出抽屉:堆与任务栈 ================= */
.sys-drawer {
--sys-drawer-panel-w: min(390px, calc(100vw - 48px));
--sys-drawer-peek: 38px;
position: fixed; top: 76px; right: 0; z-index: 80;
width: calc(var(--sys-drawer-panel-w) + var(--sys-drawer-peek));
height: calc(100vh - 92px); max-height: calc(100vh - 92px);
display: flex; flex-direction: row; align-items: stretch;
transform: translateX(var(--sys-drawer-panel-w));
transition: transform .3s cubic-bezier(.22, 1, .36, 1);
filter: drop-shadow(-8px 0 24px rgba(2,5,12,.5));
}
.sys-drawer:hover, .sys-drawer:focus-within { transform: translateX(0); }
.sys-drawer-rail {
flex: 0 0 var(--sys-drawer-peek); width: var(--sys-drawer-peek);
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 12px; padding: 14px 4px;
background: linear-gradient(180deg, var(--bg-elevated) 0%, var(--bg-card) 100%);
border: 1px solid var(--border); border-right: none;
border-radius: var(--radius) 0 0 var(--radius);
cursor: default;
}
.sys-drawer-rail-ico { font-size: 16px; line-height: 1; }
.sys-drawer-rail-text {
writing-mode: vertical-rl; text-orientation: mixed;
font-family: var(--font-display);
font-size: 11px; font-weight: 600; letter-spacing: .18em;
color: var(--text-dim);
}
.sys-drawer:hover .sys-drawer-rail-text,
.sys-drawer:focus-within .sys-drawer-rail-text { color: var(--mem-hi); }
.sys-drawer-panel {
flex: 1 1 var(--sys-drawer-panel-w);
width: var(--sys-drawer-panel-w); min-width: 0; height: 100%;
margin: 0 !important;
display: flex; flex-direction: column;
border-radius: 0; overflow: hidden;
background: var(--bg-card);
border: 1px solid var(--border); border-left: none; border-right: none;
}
.sys-drawer-head { cursor: default; flex-shrink: 0; }
.sys-drawer-head .card-head-btn { margin-left: auto; }
.sys-drawer-body { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: contain; }
+797
View File
@@ -0,0 +1,797 @@
/* motor.css - 电机控制台页面样式
* 由 motor.html 内联 <style>(原 L7-L675)机械外提,内容未改。
* 独立主题(seed tokens),不依赖 base.css/components.css。 */
/* ═══════════════════════ SEED TOKENS ═══════════════════════ */
:root {
--seed-bg: #090e15;
--seed-surface: #0f161f;
--seed-fg: #e2e9f2;
--seed-primary: #22d3ee;
--seed-accent: #f59e0b;
--seed-radius: 10px;
/* derived tokens */
--bg-root: var(--seed-bg);
--bg-panel: var(--seed-surface);
--bg-raised: color-mix(in srgb, var(--seed-surface) 82%, #ffffff 4%);
--fg: var(--seed-fg);
--fg-muted: color-mix(in srgb, var(--seed-fg) 55%, transparent);
--fg-dim: color-mix(in srgb, var(--seed-fg) 35%, transparent);
--border: color-mix(in srgb, var(--seed-fg) 10%, transparent);
--border-strong: color-mix(in srgb, var(--seed-fg) 18%, transparent);
--primary: var(--seed-primary);
--primary-dim: color-mix(in srgb, var(--seed-primary) 18%, transparent);
--primary-glow: color-mix(in srgb, var(--seed-primary) 30%, transparent);
--warn: var(--seed-accent);
--warn-dim: color-mix(in srgb, var(--seed-accent) 15%, transparent);
--ok: #34d399;
--ok-dim: color-mix(in srgb, #34d399 14%, transparent);
--danger: #f87171;
--danger-dim: color-mix(in srgb, #f87171 14%, transparent);
--radius: var(--seed-radius);
--radius-sm: calc(var(--seed-radius) * 0.6);
--radius-lg: calc(var(--seed-radius) * 1.4);
--font-body: "PingFang SC", "Microsoft YaHei", "Noto Sans SC", system-ui, sans-serif;
--font-mono: "Cascadia Code", "Fira Code", "SF Mono", "JetBrains Mono", Consolas, monospace;
--sp-1: 4px; --sp-2: 8px; --sp-3: 12px; --sp-4: 16px; --sp-5: 20px; --sp-6: 24px;
--toolbar-h: 54px;
--statusbar-h: 42px;
}
/* ═══════════════════════ RESET & BASE ═══════════════════════ */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { font-size: 15px; }
body {
font-family: var(--font-body);
background: var(--bg-root);
color: var(--fg);
line-height: 1.5;
height: 100vh;
overflow: hidden;
display: grid;
grid-template-rows: var(--toolbar-h) 1fr var(--statusbar-h);
}
/* ambient grid texture */
body::before {
content: "";
position: fixed; inset: 0; z-index: 0; pointer-events: none;
background-image:
linear-gradient(color-mix(in srgb, var(--seed-primary) 3%, transparent) 1px, transparent 1px),
linear-gradient(90deg, color-mix(in srgb, var(--seed-primary) 3%, transparent) 1px, transparent 1px);
background-size: 48px 48px;
mask-image: radial-gradient(ellipse 90% 70% at 50% 30%, black 20%, transparent 80%);
}
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 3px; }
::-webkit-scrollbar-thumb:hover { background: var(--fg-dim); }
button { font: inherit; cursor: pointer; border: none; background: none; color: inherit; }
input, select { font: inherit; color: inherit; }
label { display: block; }
/* ═══════════════════════ TOOLBAR ═══════════════════════ */
.toolbar {
grid-row: 1;
grid-column: 1;
position: relative; z-index: 10;
display: flex; align-items: center; gap: var(--sp-4);
padding: 0 var(--sp-5);
background: linear-gradient(90deg, color-mix(in srgb, var(--seed-primary) 7%, var(--bg-panel)), var(--bg-panel) 42%);
border-bottom: 1px solid var(--border);
box-shadow: 0 8px 24px rgba(0, 0, 0, .15);
}
.toolbar-brand {
display: flex; align-items: center; gap: var(--sp-3);
font-weight: 600; font-size: 1.05rem; letter-spacing: -0.01em;
white-space: nowrap;
}
.toolbar-brand svg { flex-shrink: 0; }
.toolbar-brand .sub {
font-size: 0.72rem; font-weight: 400; color: var(--fg-muted);
letter-spacing: 0.08em; text-transform: uppercase;
font-family: var(--font-mono);
}
.toolbar-sep { width: 1px; height: 26px; background: var(--border-strong); flex-shrink: 0; }
.conn-group { display: flex; align-items: center; gap: var(--sp-2); }
.conn-ip-label {
font-family: var(--font-mono); font-size: 0.82rem;
color: var(--fg-muted); padding: 4px 8px;
background: var(--bg-root); border: 1px solid var(--border);
border-radius: var(--radius-sm); letter-spacing: 0.02em;
min-width: 7.5em;
}
.toolbar-right a.btn {
text-decoration: none; color: var(--fg-muted);
border-color: var(--border-strong);
}
.toolbar-right a.btn:hover { color: var(--primary); border-color: color-mix(in srgb, var(--seed-primary) 40%, transparent); }
.btn {
display: inline-flex; align-items: center; gap: 6px;
padding: 6px 14px; border-radius: var(--radius-sm);
font-size: 0.85rem; font-weight: 550; letter-spacing: 0.02em;
border: 1px solid var(--border-strong);
background: var(--bg-raised);
transition: background .15s, border-color .15s, transform .1s;
white-space: nowrap;
}
.btn:hover { background: color-mix(in srgb, var(--seed-surface) 60%, #ffffff 8%); }
.btn:active { transform: translateY(1px); }
.btn-primary {
background: color-mix(in srgb, var(--seed-primary) 16%, var(--seed-surface));
border-color: color-mix(in srgb, var(--seed-primary) 40%, transparent);
color: var(--primary);
}
.btn-primary:hover { background: color-mix(in srgb, var(--seed-primary) 24%, var(--seed-surface)); }
.btn-danger {
background: var(--danger-dim); border-color: color-mix(in srgb, var(--danger) 40%, transparent);
color: var(--danger);
}
.btn-sm { padding: 4px 10px; font-size: 0.78rem; }
.btn:disabled { opacity: .4; pointer-events: none; }
.led {
width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0;
background: var(--fg-dim); transition: background .3s, box-shadow .3s;
}
.led-on { background: var(--ok); box-shadow: 0 0 6px var(--ok); }
.led-warn { background: var(--warn); box-shadow: 0 0 6px var(--warn); }
.led-err { background: var(--danger); box-shadow: 0 0 6px var(--danger); }
.led-pulse { animation: ledPulse 1.2s ease-in-out infinite; }
@keyframes ledPulse { 0%,100% { opacity: 1; } 50% { opacity: .35; } }
.ch-select {
padding: 5px 8px; background: var(--bg-root);
border: 1px solid var(--border-strong); border-radius: var(--radius-sm);
font-family: var(--font-mono); font-size: 0.82rem; outline: none;
}
.ch-select:focus { border-color: var(--primary); }
/* 轮询卡顿:通道下拉框变黄并呼吸发光(fixed-tick 下某拍未按时完成) */
.ch-select.stuck {
border-color: color-mix(in srgb, var(--warn) 65%, transparent);
animation: stuckPulse 1.2s ease-in-out infinite;
}
@keyframes stuckPulse {
0%, 100% { box-shadow: 0 0 2px color-mix(in srgb, var(--warn) 15%, transparent); }
50% { box-shadow: 0 0 10px color-mix(in srgb, var(--warn) 40%, transparent); }
}
/* 刷新速度输入框:与 .ch-select 同风格,宽度收窄 */
.refresh-input {
width: 64px; padding: 5px 6px; background: var(--bg-root);
border: 1px solid var(--border-strong); border-radius: var(--radius-sm);
font-family: var(--font-mono); font-size: 0.82rem; outline: none;
text-align: center;
}
.refresh-input:focus { border-color: var(--primary); }
.refresh-input::-webkit-inner-spin-button { opacity: .5; }
.refresh-hint { font-size: .68rem; color: var(--fg-dim); font-family: var(--font-mono); letter-spacing: .04em; }
.toolbar-right { margin-left: auto; display: flex; align-items: center; gap: var(--sp-3); }
.node-badge {
font-family: var(--font-mono); font-size: 0.8rem;
padding: 4px 12px; border-radius: 99px;
background: var(--primary-dim); color: var(--primary);
border: 1px solid color-mix(in srgb, var(--seed-primary) 30%, transparent);
letter-spacing: 0.02em;
}
/* ═══════════════════════ MAIN LAYOUT ═══════════════════════ */
.main-grid {
grid-row: 2;
grid-column: 1;
position: relative; z-index: 1;
display: grid;
grid-template-columns: minmax(240px, 290px) minmax(0, 0.9fr) minmax(420px, 1.1fr);
gap: var(--sp-3);
padding: var(--sp-3);
overflow: hidden;
min-height: 0;
}
.panel {
background: linear-gradient(145deg, color-mix(in srgb, var(--bg-panel) 94%, #ffffff 3%), var(--bg-panel) 52%);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
display: flex; flex-direction: column;
min-height: 0; overflow: hidden;
box-shadow: 0 10px 28px rgba(0, 0, 0, .16), inset 0 1px 0 rgba(255, 255, 255, .025);
}
.panel-head {
display: flex; align-items: center; gap: var(--sp-2);
padding: var(--sp-3) var(--sp-4);
background: linear-gradient(90deg, var(--primary-dim), transparent 48%);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
}
.panel-title {
font-size: 0.78rem; font-weight: 600;
letter-spacing: 0.08em; text-transform: uppercase;
color: var(--fg-muted); font-family: var(--font-mono);
}
.panel-body { flex: 1; overflow-y: auto; min-height: 0; }
/* ═══════════════════════ LEFT: NODE LIST ═══════════════════════ */
.node-list { padding: var(--sp-2); display: flex; flex-direction: column; gap: var(--sp-2); }
.node-empty { padding: var(--sp-5); text-align: center; color: var(--fg-dim); font-size: .8rem; line-height: 1.7; }
.node-card {
position: relative;
padding: var(--sp-3) var(--sp-4);
border-radius: var(--radius);
border: 1px solid var(--border);
background: var(--bg-root);
cursor: pointer;
transition: border-color .2s, background .2s, transform .12s;
}
.node-card:hover { border-color: var(--border-strong); transform: translateX(2px); }
.node-card.active {
border-color: color-mix(in srgb, var(--seed-primary) 55%, transparent);
background: color-mix(in srgb, var(--seed-primary) 6%, var(--bg-root));
}
.node-card.fault { border-color: color-mix(in srgb, var(--danger) 55%, transparent); }
.node-card.fault.active { border-color: var(--danger); }
.node-card.offline { opacity: .5; }
/* 独立 BOOT 徽章:总线上有电机在 Bootloader(0x103 心跳),不归属任何 Nx
* 注意:不要用内联 style="display:none",否则 .show 的 display:flex 盖不过。 */
.boot-badge {
display: none; align-items: center; gap: var(--sp-2);
margin-bottom: var(--sp-2); padding: var(--sp-2) var(--sp-3);
border: 1px solid color-mix(in srgb, var(--warn) 55%, transparent);
border-radius: var(--radius); background: color-mix(in srgb, var(--warn) 12%, transparent);
font-family: var(--font-mono); font-size: .72rem; color: var(--warn);
font-weight: 600; letter-spacing: 0.02em;
}
.boot-badge.show { display: flex; }
.boot-badge .boot-dot {
width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0;
background: var(--warn); box-shadow: 0 0 6px var(--warn);
animation: bootpulse 1s ease-in-out infinite;
}
.boot-badge .boot-sub { opacity: .75; font-weight: 400; margin-left: auto; }
@keyframes bootpulse { 0%,100% { opacity: 1; } 50% { opacity: .35; } }
.nc-top { display: flex; align-items: center; gap: var(--sp-2); margin-bottom: var(--sp-2); }
.nc-id {
font-family: var(--font-mono); font-weight: 700; font-size: 1.05rem;
letter-spacing: -0.02em; min-width: 2.2em;
}
.nc-mode {
font-size: 0.7rem; padding: 2px 8px; border-radius: 99px;
background: var(--primary-dim); color: var(--primary);
font-weight: 550; letter-spacing: 0.02em;
}
.nc-mode.mode-off { background: color-mix(in srgb, var(--fg) 8%, transparent); color: var(--fg-muted); }
.nc-mode.mode-fault { background: var(--danger-dim); color: var(--danger); }
/* 节点在其他通道在线:橙色通道标签,提示切换顶部通道查看 */
.nc-mode.mode-other-ch { background: color-mix(in srgb, var(--warn) 18%, transparent); color: var(--warn); }
.nc-status-text {
margin-left: auto; font-size: 0.68rem; color: var(--fg-muted);
font-family: var(--font-mono); max-width: 7.5em;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
letter-spacing: 0.01em;
}
.node-card.fault .nc-status-text { color: var(--danger); font-weight: 600; }
.nc-readings {
display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--sp-1);
font-family: var(--font-mono); font-size: 0.78rem;
}
.nc-reading { display: flex; flex-direction: column; gap: 1px; }
.nc-reading .lbl { font-size: 0.62rem; color: var(--fg-dim); letter-spacing: 0.06em; text-transform: uppercase; }
.nc-reading .val { color: var(--fg); transition: color .3s; }
.nc-faults {
position: absolute; top: var(--sp-2); right: var(--sp-2);
display: flex; gap: 3px;
}
.fault-dot {
width: 7px; height: 7px; border-radius: 2px;
background: var(--danger); opacity: .9;
animation: ledPulse 1s ease-in-out infinite;
}
.fault-dot:nth-child(2) { animation-delay: .2s; }
.fault-dot:nth-child(3) { animation-delay: .4s; }
/* ═══════════════════════ CENTER: CONTROL ═══════════════════════ */
.center-panel { display: flex; flex-direction: column; min-height: 0; }
.cmd-section { padding: var(--sp-4); flex-shrink: 0; }
.cmd-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: var(--sp-4);
}
.field { display: flex; flex-direction: column; gap: var(--sp-1); }
.field-label {
font-size: 0.72rem; font-weight: 550; color: var(--fg-muted);
letter-spacing: 0.06em; text-transform: uppercase; font-family: var(--font-mono);
}
.field-unit { color: var(--fg-dim); text-transform: none; letter-spacing: 0; }
.field select, .field input[type="number"], .field input[type="text"] {
padding: 7px 10px; background: var(--bg-root);
border: 1px solid var(--border-strong); border-radius: var(--radius-sm);
font-family: var(--font-mono); font-size: 0.88rem; outline: none;
transition: border-color .2s;
width: 100%;
}
.field select:focus, .field input:focus { border-color: var(--primary); }
.mode-desc {
font-size: 0.75rem; color: var(--fg-dim); margin-top: 2px;
min-height: 1.2em;
}
.pid-group {
display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--sp-3);
}
/* 快捷运行:一键写目标值并立即发送(目标字段随控制模式联动) */
.quick-run {
display: flex; align-items: center; gap: var(--sp-3);
margin-top: var(--sp-4); flex-wrap: wrap;
}
.qr-label {
font-size: 0.72rem; font-weight: 550; color: var(--fg-muted);
letter-spacing: 0.06em; text-transform: uppercase; font-family: var(--font-mono);
user-select: none;
}
.qr-target { color: var(--fg-dim); text-transform: none; letter-spacing: 0; }
.qr-btns { display: inline-flex; gap: 6px; flex-wrap: wrap; }
.qr-btn {
min-width: 52px; padding: 6px 12px;
background: var(--bg-root); border: 1px solid var(--border-strong);
border-radius: var(--radius-sm); color: var(--fg);
font-family: var(--font-mono); font-size: 0.88rem; font-weight: 600;
cursor: pointer; transition: border-color .15s, color .15s, background .15s;
}
.qr-btn:hover { border-color: var(--primary); color: var(--primary); }
.qr-btn:active { transform: translateY(1px); }
.qr-btn:focus-visible { outline: none; border-color: var(--primary); }
.cmd-actions {
display: flex; gap: var(--sp-3); margin-top: var(--sp-4);
padding-top: var(--sp-3); border-top: 1px solid var(--border);
align-items: center; flex-wrap: wrap;
}
.send-hint { font-size: 0.72rem; color: var(--fg-dim); margin-left: auto; font-family: var(--font-mono); }
.target-hidden { display: none !important; }
.repeat-period {
display: inline-flex; align-items: center; gap: 4px;
font-family: var(--font-mono); font-size: 0.75rem; color: var(--fg-muted);
user-select: none;
}
.repeat-period input {
width: 4.6em; padding: 5px 6px; background: var(--bg-root);
border: 1px solid var(--border-strong); border-radius: var(--radius-sm);
font-family: var(--font-mono); font-size: 0.8rem; color: var(--fg);
outline: none; text-align: right; transition: border-color .2s;
}
.repeat-period input:focus { border-color: var(--primary); }
/* tabs */
.tabs-bar {
display: flex; gap: 2px; padding: 0 var(--sp-4);
border-top: 1px solid var(--border); flex-shrink: 0;
background: color-mix(in srgb, var(--bg-root) 50%, var(--bg-panel));
}
.tab-btn {
padding: 9px 16px; font-size: 0.8rem; font-weight: 550;
color: var(--fg-muted); border-bottom: 2px solid transparent;
transition: color .2s, border-color .2s; letter-spacing: 0.02em;
}
.tab-btn:hover { color: var(--fg); }
.tab-btn.active { color: var(--primary); border-bottom-color: var(--primary); }
.tab-content { flex: 1; overflow-y: auto; min-height: 0; padding: var(--sp-4); }
.tab-page { display: none; }
.tab-page.active { display: block; }
/* config tab */
.config-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: var(--sp-3); }
.config-btn {
display: flex; flex-direction: column; align-items: center; gap: var(--sp-2);
padding: var(--sp-4); border-radius: var(--radius);
border: 1px solid var(--border-strong); background: var(--bg-root);
transition: border-color .2s, background .2s, transform .1s;
}
.config-btn:hover { border-color: var(--fg-dim); transform: translateY(-1px); }
.config-btn:active { transform: translateY(1px); }
.config-btn svg { opacity: .8; }
.config-btn .cb-name { font-size: 0.82rem; font-weight: 550; }
.config-btn .cb-sig { font-size: 0.68rem; color: var(--fg-dim); font-family: var(--font-mono); }
.config-btn.danger-action:hover { border-color: color-mix(in srgb, var(--danger) 50%, transparent); }
/* version tab */
.version-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--sp-3); }
.ver-card {
padding: var(--sp-4); border-radius: var(--radius);
border: 1px solid var(--border); background: var(--bg-root); text-align: center;
}
.ver-card .ver-type { font-size: 0.68rem; color: var(--fg-muted); letter-spacing: 0.08em; text-transform: uppercase; font-family: var(--font-mono); margin-bottom: var(--sp-2); }
.ver-card .ver-date { font-family: var(--font-mono); font-size: 1.05rem; font-weight: 600; letter-spacing: -0.01em; }
.ver-empty { color: var(--fg-dim); font-size: 0.85rem; }
/* node id tab */
.nodeid-row { display: flex; gap: var(--sp-3); align-items: flex-end; flex-wrap: wrap; }
.nodeid-result {
margin-top: var(--sp-3); padding: var(--sp-3) var(--sp-4);
border-radius: var(--radius-sm); background: var(--bg-root);
border: 1px solid var(--border); font-family: var(--font-mono); font-size: 0.85rem;
color: var(--fg-muted); min-height: 2.6em;
}
/* OTA tab */
.ota-stages {
display: flex; gap: 0; margin-bottom: var(--sp-4); overflow-x: auto;
padding-bottom: var(--sp-1);
}
.ota-stage {
display: flex; align-items: center; gap: var(--sp-2);
padding: var(--sp-2) var(--sp-3);
font-size: 0.72rem; color: var(--fg-dim); white-space: nowrap;
position: relative;
}
.ota-stage::after {
content: ""; width: 20px; height: 1px; background: var(--border-strong);
margin-left: var(--sp-2); flex-shrink: 0;
}
.ota-stage:last-child::after { display: none; }
.ota-stage .st-dot {
width: 20px; height: 20px; border-radius: 50%; flex-shrink: 0;
border: 1.5px solid var(--border-strong);
display: flex; align-items: center; justify-content: center;
font-family: var(--font-mono); font-size: 0.62rem;
transition: border-color .3s, background .3s, color .3s;
}
.ota-stage.done .st-dot { border-color: var(--ok); background: var(--ok-dim); color: var(--ok); }
.ota-stage.current .st-dot { border-color: var(--primary); background: var(--primary-dim); color: var(--primary); animation: ledPulse 1.4s infinite; }
.ota-stage.current { color: var(--fg); }
.ota-stage.done { color: var(--fg-muted); }
.ota-progress-wrap { margin-bottom: var(--sp-4); }
.ota-progress-bar {
height: 8px; border-radius: 4px; background: var(--bg-root);
border: 1px solid var(--border); overflow: hidden;
}
.ota-progress-fill {
height: 100%; width: 0%; border-radius: 4px;
background: linear-gradient(90deg, color-mix(in srgb, var(--seed-primary) 70%, var(--bg-root)), var(--primary));
transition: width .3s ease;
}
.ota-stats {
display: flex; gap: var(--sp-5); margin-top: var(--sp-2);
font-family: var(--font-mono); font-size: 0.78rem; color: var(--fg-muted);
flex-wrap: wrap;
}
.ota-stats .hl { color: var(--primary); }
.ota-file-row { display: flex; gap: var(--sp-3); align-items: center; margin-bottom: var(--sp-4); flex-wrap: wrap; }
.ota-file-name { font-family: var(--font-mono); font-size: 0.8rem; color: var(--fg-muted); }
.ota-log {
margin-top: var(--sp-3); padding: var(--sp-3);
background: var(--bg-root); border: 1px solid var(--border);
border-radius: var(--radius-sm); font-family: var(--font-mono);
font-size: 0.72rem; line-height: 1.7; color: var(--fg-muted);
max-height: 160px; overflow-y: auto;
}
.ota-log .log-ok { color: var(--ok); }
.ota-log .log-err { color: var(--danger); }
.ota-log .log-info { color: var(--primary); }
.ota-log-toolbar {
display: flex; justify-content: flex-end; gap: 6px;
margin-top: var(--sp-3); margin-bottom: -2px;
}
.ota-log-toolbar button {
font-size: 0.68rem; padding: 2px 8px;
border-radius: var(--radius-sm); border: 1px solid var(--border);
background: var(--bg-card); color: var(--fg-muted);
cursor: pointer; transition: background .15s, color .15s;
}
.ota-log-toolbar button:hover { background: rgba(255,255,255,.08); color: var(--fg); }
.ota-log-toolbar button.copied { background: rgba(34,197,94,.18); color: #86efac; }
/* ═══════════════════════ RIGHT: STATUS PANEL ═══════════════════════ */
.status-panel .panel-body { padding: var(--sp-4); display: flex; flex-direction: column; gap: var(--sp-4); }
.big-readings { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--sp-3); }
.big-reading {
padding: var(--sp-3) var(--sp-3); text-align: center;
background: var(--bg-root); border: 1px solid var(--border);
border-radius: var(--radius);
transition: border-color .3s, background .2s;
cursor: pointer; position: relative;
}
.big-reading:hover { border-color: var(--border-strong); background: color-mix(in srgb, var(--seed-primary) 4%, var(--bg-root)); }
.big-reading.open { border-color: color-mix(in srgb, var(--seed-primary) 50%, transparent); }
.br-toggle {
font-size: 0.55rem; color: var(--fg-dim); letter-spacing: 0.06em;
opacity: 0; transition: opacity .2s, color .2s; margin-left: 4px;
}
.big-reading:hover .br-toggle { opacity: .8; }
.big-reading.open .br-toggle { opacity: 1; color: var(--primary); }
.br-scope {
max-height: 0; overflow: hidden; margin-top: 0;
transition: max-height .35s cubic-bezier(.4,0,.2,1), margin-top .35s;
}
.big-reading.open .br-scope { max-height: 96px; margin-top: var(--sp-2); }
.br-scope canvas {
width: 100%; height: 84px; display: block;
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--seed-bg) 78%, #000000 14%);
border: 1px solid var(--border);
}
.big-reading .br-val {
font-family: var(--font-mono); font-size: clamp(1.5rem, 2vw, 2.4rem);
font-weight: 700; letter-spacing: -0.02em; line-height: 1.2;
transition: color .25s;
}
.big-reading .br-val.flash { color: var(--primary); }
.big-reading .br-unit { font-size: 0.65rem; color: var(--fg-dim); font-family: var(--font-mono); }
.big-reading .br-lbl {
font-size: 0.62rem; color: var(--fg-muted); margin-top: 2px;
letter-spacing: 0.08em; text-transform: uppercase; font-family: var(--font-mono);
}
.big-reading .br-target {
font-size: 0.65rem; color: var(--fg-dim); font-family: var(--font-mono); margin-top: 1px;
}
.big-reading .br-target span { color: var(--primary); font-weight: 600; }
.big-reading .br-target span:empty::before { content: "--"; color: var(--fg-dim); }
.status-row { display: grid; grid-template-columns: 1fr 1fr; gap: var(--sp-3); }
.temp-block {
padding: var(--sp-3); background: var(--bg-root); border: 1px solid var(--border);
border-radius: var(--radius);
transition: border-color .3s, background .2s; cursor: pointer;
}
.temp-block:hover { border-color: var(--border-strong); background: color-mix(in srgb, var(--seed-primary) 4%, var(--bg-root)); }
.temp-block.open { border-color: color-mix(in srgb, var(--seed-primary) 50%, transparent); }
.temp-head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: var(--sp-2); }
.temp-name { font-size: 0.68rem; color: var(--fg-muted); letter-spacing: 0.06em; text-transform: uppercase; font-family: var(--font-mono); }
.temp-toggle {
font-size: 0.55rem; color: var(--fg-dim); letter-spacing: 0.06em;
opacity: 0; transition: opacity .2s, color .2s; margin-left: 6px;
}
.temp-block:hover .temp-toggle { opacity: .8; }
.temp-block.open .temp-toggle { opacity: 1; color: var(--primary); }
.temp-val { font-family: var(--font-mono); font-size: 0.95rem; font-weight: 600; }
.temp-bar { height: 5px; border-radius: 3px; background: var(--border); overflow: hidden; }
.temp-block .br-scope { max-height: 0; overflow: hidden; margin-top: 0; transition: max-height .35s cubic-bezier(.4,0,.2,1), margin-top .35s; }
.temp-block.open .br-scope { max-height: 80px; margin-top: var(--sp-2); }
.temp-block .br-scope canvas { height: 68px; }
.temp-fill {
height: 100%; border-radius: 3px; transition: width .6s ease, background .6s;
background: var(--ok);
}
.temp-fill.warm { background: var(--warn); }
.temp-fill.hot { background: var(--danger); }
.phase-currents { padding: var(--sp-3); background: var(--bg-root); border: 1px solid var(--border); border-radius: var(--radius); }
.pc-head { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2); margin-bottom: var(--sp-2); flex-wrap: wrap; }
.pc-title { font-size: 0.68rem; color: var(--fg-muted); letter-spacing: 0.06em; text-transform: uppercase; font-family: var(--font-mono); }
.pc-live-vals { display: flex; gap: var(--sp-2); flex-wrap: wrap; }
.pc-chip {
display: inline-flex; align-items: center; gap: 4px;
font-family: var(--font-mono); font-size: 0.72rem; color: var(--fg-muted);
padding: 2px 8px; border-radius: 99px;
background: color-mix(in srgb, var(--ch-color) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--ch-color) 30%, transparent);
}
.pc-chip b { color: var(--ch-color); font-weight: 600; min-width: 4.2em; text-align: right; }
.scope-wrap { position: relative; height: 172px; border-radius: var(--radius-sm); overflow: hidden; background: color-mix(in srgb, var(--seed-bg) 80%, #000000 12%); border: 1px solid var(--border); }
.scope-wrap canvas { width: 100%; height: 100%; display: block; }
.scope-grid-labels {
position: absolute; left: 6px; top: 0; bottom: 0;
display: flex; flex-direction: column; justify-content: space-between;
font-family: var(--font-mono); font-size: 0.58rem; color: var(--fg-dim);
padding: 4px 0; pointer-events: none;
}
.misc-grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--sp-3); }
.misc-item {
padding: var(--sp-3); background: var(--bg-root);
border: 1px solid var(--border); border-radius: var(--radius);
display: flex; flex-direction: column; gap: 2px;
}
.misc-item .mi-lbl { font-size: 0.62rem; color: var(--fg-muted); letter-spacing: 0.08em; text-transform: uppercase; font-family: var(--font-mono); }
.misc-item .mi-val { font-family: var(--font-mono); font-size: 1rem; font-weight: 600; }
.flag-lights { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: var(--sp-2); padding: var(--sp-3); background: var(--bg-root); border: 1px solid var(--border); border-radius: var(--radius); }
.flag-item {
display: flex; align-items: center; gap: var(--sp-2);
font-size: 0.75rem; font-family: var(--font-mono); color: var(--fg-muted);
padding: var(--sp-2) var(--sp-3); border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: color-mix(in srgb, var(--seed-surface) 60%, transparent);
transition: background .3s, border-color .3s, color .3s, box-shadow .3s;
}
.flag-item .flag-state { margin-left: auto; font-size: 0.62rem; letter-spacing: 0.08em; text-transform: uppercase; opacity: .7; }
.flag-item.is-ok { border-color: color-mix(in srgb, var(--ok) 25%, transparent); }
.flag-item.is-ok .flag-state { color: var(--ok); }
.flag-item.is-fault {
background: color-mix(in srgb, var(--danger) 16%, var(--seed-bg));
border-color: color-mix(in srgb, var(--danger) 65%, transparent);
color: var(--danger); font-weight: 700;
box-shadow: 0 0 12px color-mix(in srgb, var(--danger) 25%, transparent), inset 0 0 8px color-mix(in srgb, var(--danger) 8%, transparent);
animation: faultPulse 1.1s ease-in-out infinite;
}
.flag-item.is-fault .flag-state { color: var(--danger); opacity: 1; }
@keyframes faultPulse {
0%, 100% { box-shadow: 0 0 12px color-mix(in srgb, var(--danger) 30%, transparent), inset 0 0 8px color-mix(in srgb, var(--danger) 10%, transparent); }
50% { box-shadow: 0 0 22px color-mix(in srgb, var(--danger) 50%, transparent), inset 0 0 12px color-mix(in srgb, var(--danger) 16%, transparent); }
}
.flag-led { width: 9px; height: 9px; border-radius: 50%; background: var(--border-strong); transition: background .3s, box-shadow .3s; flex-shrink: 0; }
.flag-led.on-ok { background: var(--ok); box-shadow: 0 0 6px var(--ok); }
.flag-led.on-err { background: var(--danger); box-shadow: 0 0 8px var(--danger); animation: ledPulse .8s infinite; }
.cpu-ring-wrap { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-3); background: var(--bg-root); border: 1px solid var(--border); border-radius: var(--radius); }
.cpu-ring { position: relative; width: 52px; height: 52px; flex-shrink: 0; }
.cpu-ring svg { transform: rotate(-90deg); }
.cpu-ring .ring-bg { fill: none; stroke: var(--border); stroke-width: 5; }
.cpu-ring .ring-fg { fill: none; stroke: var(--primary); stroke-width: 5; stroke-linecap: round; transition: stroke-dashoffset .6s ease; }
.cpu-pct {
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
font-family: var(--font-mono); font-size: 0.72rem; font-weight: 700;
}
.cpu-info { display: flex; flex-direction: column; gap: 2px; }
.cpu-info .mi-lbl { font-size: 0.62rem; color: var(--fg-muted); letter-spacing: 0.08em; text-transform: uppercase; font-family: var(--font-mono); }
.cpu-info .mi-sub { font-size: 0.72rem; color: var(--fg-dim); font-family: var(--font-mono); }
.offline-overlay {
display: none; flex-direction: column; align-items: center; justify-content: center;
gap: var(--sp-3); padding: var(--sp-6); color: var(--fg-dim); text-align: center;
}
.offline-overlay.show { display: flex; }
.offline-overlay .off-icon { opacity: .4; }
.status-live { display: flex; flex-direction: column; gap: var(--sp-4); }
.status-live.hide { display: none; }
/* ═══════════════════════ STATUS BAR ═══════════════════════ */
.statusbar {
grid-row: 3;
grid-column: 1;
position: relative; z-index: 10;
display: flex; align-items: center; gap: var(--sp-4);
padding: 0 var(--sp-5);
background: color-mix(in srgb, var(--bg-panel) 92%, #000000 8%);
border-top: 1px solid var(--border);
font-family: var(--font-mono); font-size: 0.72rem; color: var(--fg-muted);
overflow: hidden;
}
.sb-item { display: flex; align-items: center; gap: var(--sp-2); white-space: nowrap; }
.sb-item .sb-val { color: var(--fg); }
.sb-sep { width: 1px; height: 16px; background: var(--border); flex-shrink: 0; }
.sb-last-send { margin-left: auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 40%; color: var(--fg-dim); }
/* ═══════════════════════ TOAST & MODAL ═══════════════════════ */
.toast-container {
position: fixed; bottom: 60px; left: 50%; transform: translateX(-50%);
display: flex; flex-direction: column; gap: var(--sp-2); z-index: 1000;
pointer-events: none;
}
.toast {
padding: 10px 20px; border-radius: var(--radius);
background: var(--bg-raised); border: 1px solid var(--border-strong);
font-size: 0.82rem; box-shadow: 0 8px 32px rgba(0,0,0,.5);
animation: toastIn .25s ease;
display: flex; align-items: center; gap: var(--sp-2);
max-width: 80vw;
}
.toast.ok { border-color: color-mix(in srgb, var(--ok) 40%, transparent); }
.toast.err { border-color: color-mix(in srgb, var(--danger) 40%, transparent); }
.toast.warn { border-color: color-mix(in srgb, var(--warn) 40%, transparent); }
.toast.out { animation: toastOut .2s ease forwards; }
@keyframes toastIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
@keyframes toastOut { to { opacity: 0; transform: translateY(6px); } }
.modal-mask {
position: fixed; inset: 0; z-index: 900;
background: rgba(4, 8, 14, .75);
display: none; align-items: center; justify-content: center;
}
.modal-mask.show { display: flex; }
.modal-box {
background: var(--bg-panel); border: 1px solid var(--border-strong);
border-radius: var(--radius-lg); padding: var(--sp-6);
min-width: 320px; max-width: 420px;
box-shadow: 0 24px 64px rgba(0,0,0,.6);
animation: toastIn .2s ease;
}
.modal-title { font-size: 1rem; font-weight: 600; margin-bottom: var(--sp-2); }
.modal-desc { font-size: 0.82rem; color: var(--fg-muted); margin-bottom: var(--sp-5); line-height: 1.6; }
.modal-actions { display: flex; gap: var(--sp-3); justify-content: flex-end; }
/* disconnected banner */
.net-banner {
display: none; grid-row: 1; grid-column: 1;
align-self: end; transform: translateY(100%); z-index: 20;
align-items: center; gap: var(--sp-2);
padding: 4px var(--sp-5); font-size: .72rem; font-family: var(--font-mono);
background: linear-gradient(90deg, color-mix(in srgb, var(--danger) 20%, var(--bg-panel)), var(--danger-dim)); color: var(--danger);
border-bottom: 1px solid color-mix(in srgb, var(--danger) 30%, transparent);
box-shadow: 0 8px 20px rgba(0, 0, 0, .24);
}
.net-banner.show { display: flex; }
body:has(.net-banner.show) .main-grid { padding-top: calc(var(--sp-3) + 29px); }
/* ═══════════════════════ 命令序列 tab ═══════════════════════ */
.seq-toolbar { display: flex; align-items: center; gap: var(--sp-3); flex-wrap: wrap; margin-bottom: var(--sp-4); }
.seq-status { font-family: var(--font-mono); font-size: 0.78rem; color: var(--fg-dim); margin-left: auto; white-space: nowrap; }
.seq-status.on { color: var(--primary); }
.seq-list { display: flex; flex-direction: column; gap: var(--sp-3); }
.seq-empty {
padding: var(--sp-5); text-align: center; color: var(--fg-dim); font-size: 0.8rem;
border: 1px dashed var(--border-strong); border-radius: var(--radius); line-height: 1.7;
}
.seq-step {
padding: var(--sp-3); border-radius: var(--radius);
border: 1px solid var(--border); background: var(--bg-root);
transition: border-color .2s, background .2s;
}
.seq-step:hover { border-color: var(--border-strong); }
.seq-step.current {
border-color: color-mix(in srgb, var(--seed-primary) 55%, transparent);
background: color-mix(in srgb, var(--seed-primary) 5%, var(--bg-root));
}
.seq-locked .seq-step { opacity: .55; pointer-events: none; }
.seq-step-head { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; margin-bottom: var(--sp-2); }
.seq-idx { font-family: var(--font-mono); font-size: 0.78rem; font-weight: 600; color: var(--primary); min-width: 4.2em; }
.seq-num {
padding: 4px 8px; background: var(--bg-root);
border: 1px solid var(--border-strong); border-radius: var(--radius-sm);
font-family: var(--font-mono); font-size: 0.78rem; outline: none; color: var(--fg);
transition: border-color .2s;
}
.seq-num:focus { border-color: var(--primary); }
/* 隐藏原生上下箭头(样式丑且尺寸不可控):数值直接键入,聚焦后可用键盘 ↑↓ 微调 */
.seq-num { -moz-appearance: textfield; appearance: textfield; }
.seq-num::-webkit-inner-spin-button,
.seq-num::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; }
.seq-step-head .seq-num { width: 6.8em; text-align: right; }
.seq-step-head select {
padding: 4px 8px; background: var(--bg-root);
border: 1px solid var(--border-strong); border-radius: var(--radius-sm);
font-family: var(--font-mono); font-size: 0.78rem; outline: none; color: var(--fg);
}
.seq-step-head select:focus { border-color: var(--primary); }
.seq-hold-label, .seq-hold-unit { font-size: 0.68rem; color: var(--fg-dim); font-family: var(--font-mono); }
.seq-ops { margin-left: auto; display: flex; gap: 2px; }
.seq-ops button {
width: 26px; height: 26px; border-radius: var(--radius-sm);
display: inline-flex; align-items: center; justify-content: center;
color: var(--fg-dim); background: transparent; border: none;
transition: background .15s, color .15s;
}
.seq-ops button svg { flex-shrink: 0; }
.seq-ops button:hover:not(:disabled) { background: var(--primary-dim); color: var(--primary); }
.seq-ops button.seq-del:hover:not(:disabled) { background: var(--danger-dim); color: var(--danger); }
.seq-ops button:disabled { opacity: .25; cursor: default; }
.seq-step-fields { display: grid; grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); gap: var(--sp-2); }
.seq-step-fields .field-label { font-size: 0.62rem; }
.seq-step-fields .seq-num { width: 100%; }
/* 动作组:一步内多个电机动作(近同时下发),动作行嵌套在步骤卡片内 */
.seq-count {
font-family: var(--font-mono); font-size: 0.68rem; color: var(--fg-dim);
padding: 2px 8px; border: 1px solid var(--border); border-radius: 999px; white-space: nowrap;
}
.seq-actions { display: flex; flex-direction: column; gap: var(--sp-2); }
.seq-action {
padding: var(--sp-2) var(--sp-3); border-radius: var(--radius-sm);
border: 1px solid var(--border); background: var(--bg-panel);
}
.seq-action-head { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; margin-bottom: var(--sp-2); }
.seq-action-tag { font-family: var(--font-mono); font-size: 0.7rem; font-weight: 600; color: var(--fg-muted); min-width: 4.2em; }
.seq-action-head select {
padding: 4px 8px; background: var(--bg-root);
border: 1px solid var(--border-strong); border-radius: var(--radius-sm);
font-family: var(--font-mono); font-size: 0.78rem; outline: none; color: var(--fg);
}
.seq-action-head select:focus { border-color: var(--primary); }
.seq-hint { margin-top: var(--sp-4); font-size: 0.72rem; color: var(--fg-dim); line-height: 1.6; }
/* ═══════════════════════ RESPONSIVE ═══════════════════════ */
@media (max-width: 1365px) {
.main-grid {
grid-template-columns: 1fr minmax(380px, 1.1fr);
grid-template-rows: auto 1fr;
}
.nodes-panel {
grid-column: 1 / -1; grid-row: 1;
}
.nodes-panel .panel-body { overflow-x: auto; overflow-y: hidden; }
.node-list { flex-direction: row; min-width: max-content; }
.node-card { min-width: 200px; }
.center-panel { grid-column: 1; grid-row: 2; }
.status-panel { grid-column: 2; grid-row: 2; }
}
@media (max-width: 900px) {
.main-grid { grid-template-columns: 1fr; grid-template-rows: auto 1fr auto; }
.center-panel { grid-column: 1; grid-row: 2; }
.status-panel { grid-column: 1; grid-row: 3; max-height: 380px; }
.toolbar { flex-wrap: wrap; height: auto; padding: var(--sp-2) var(--sp-4); }
body { grid-template-rows: auto 1fr var(--statusbar-h); overflow: auto; }
}
+248
View File
@@ -0,0 +1,248 @@
/* ============================================================
responsive.css — 响应式断点 + 动效(对齐新设计令牌)
============================================================ */
/* ================= 断点 ================= */
@media (max-width: 1100px) {
.main-layout { grid-template-columns: 1fr; }
.sys-drawer {
--sys-drawer-panel-w: min(340px, calc(100vw - 40px));
--sys-drawer-peek: 32px;
top: 64px; height: calc(100vh - 72px); max-height: calc(100vh - 72px);
}
.can-monitor-drawer { --sys-drawer-panel-w: min(400px, calc(100vw - 40px)); }
.badge-grid { grid-template-columns: repeat(2, 1fr); }
.can-toolbar-grid, .can-toolbar-foot, .can-editor-layout { grid-template-columns: 1fr; }
.message-list { max-height: 420px; }
.can-meta-grid { grid-template-columns: 1fr 1fr; }
}
@media (max-width: 640px) {
.container { padding: 16px; }
.sys-drawer {
--sys-drawer-panel-w: calc(100vw - 36px);
--sys-drawer-peek: 28px;
top: 56px; height: calc(100vh - 60px); max-height: calc(100vh - 60px);
}
.sys-drawer-rail-text { font-size: 11px; }
.header-bar { flex-direction: column; align-items: flex-start; }
.header-sub { margin-left: 0; }
.badge-grid, .metric-grid { grid-template-columns: 1fr; }
.status-kv-grid, .status-task-meta { grid-template-columns: 1fr; }
.can-grid { grid-template-columns: 1fr 1fr; }
.btn-row { flex-direction: column; }
.can-meta-grid { grid-template-columns: 1fr; }
.can-launch-top, .can-launch-actions, .can-launch-foot {
grid-template-columns: 1fr; flex-direction: column; align-items: flex-start;
}
.can-selection-bar, .can-selection-actions { flex-direction: column; align-items: flex-start; }
.can-msg-toolbar { flex-wrap: wrap; }
.history-item-row {
grid-template-columns: auto 56px minmax(0, 1fr);
grid-template-rows: auto auto;
}
.history-item-hex, .history-item-foot { grid-column: 1 / -1; text-align: left; }
.can-title { font-size: 22px; }
.can-frame-hex { font-size: 15px; }
.message-table, .signal-table { min-width: 0; max-width: 100%; }
}
/* ================= 动画关键帧 =================
* 性能约定:常驻(infinite)动画只动 transform/opacity(合成器直通,
* 不走主线程重绘);box-shadow/background-position 关键帧仅限短暂、
* 临时态(如 dragover / OTA 阶段活跃)。 */
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
@keyframes popIn {
0% { opacity: 0; transform: scale(.85); }
60% { opacity: 1; transform: scale(1.04); }
100% { opacity: 1; transform: scale(1); }
}
/* 光泽扫过:由 background-position 改为伪元素自身平移(合成器友好),
* 扫动节奏与原版一致:扫过 + 停预周期。 */
@keyframes shimmer {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
@keyframes ringPulse {
0% { box-shadow: 0 0 0 0 rgba(47,216,193,.5); }
70% { box-shadow: 0 0 0 10px rgba(47,216,193,0); }
100% { box-shadow: 0 0 0 0 rgba(47,216,193,0); }
}
/* 常驻波纹/呼吸辉光的合成器友好版:叠加层只动 transform/opacity。 */
@keyframes ringRipple {
0% { transform: scale(1); opacity: .5; }
70%,100% { transform: scale(3); opacity: 0; }
}
@keyframes glowBreathFade {
0%,100% { opacity: 0; }
50% { opacity: 1; }
}
@keyframes shake {
0%,100% { transform: translateX(0); }
20% { transform: translateX(-4px); } 40% { transform: translateX(4px); }
60% { transform: translateX(-3px); } 80% { transform: translateX(3px); }
}
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes floatY { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-4px); } }
@keyframes flowDash { to { background-position: 200% 0; } }
@keyframes logoPulse {
0%,100% { opacity: 0; }
50% { opacity: 1; }
}
/* ================= 入场:头部 + 卡片错落浮现 ================= */
.header-bar { animation: fadeInUp .5s ease both; }
.main-layout > div > .card { animation: fadeInUp .55s ease both; }
.main-layout > div:nth-child(1) > .card:nth-child(1) { animation-delay: .06s; }
.main-layout > div:nth-child(1) > .card:nth-child(2) { animation-delay: .13s; }
.main-layout > div:nth-child(1) > .card:nth-child(3) { animation-delay: .20s; }
.main-layout > div:nth-child(1) > .card:nth-child(4) { animation-delay: .27s; }
.main-layout > div:nth-child(2) > .card:nth-child(1) { animation-delay: .09s; }
.main-layout > div:nth-child(2) > .card:nth-child(2) { animation-delay: .16s; }
.main-layout > div:nth-child(2) > .card:nth-child(3) { animation-delay: .23s; }
.main-layout > div:nth-child(2) > .card:nth-child(4) { animation-delay: .30s; }
.main-layout > div:nth-child(2) > .card:nth-child(5) { animation-delay: .37s; }
/* ================= 卡片悬停:抬升 + 子系统色描边 ================= */
.card { transition: transform .25s ease, border-color .25s ease, box-shadow .25s ease; }
.card:hover {
transform: translateY(-2px);
border-color: color-mix(in srgb, var(--card-accent, var(--esp32)) 32%, var(--border));
box-shadow: 0 14px 34px rgba(2,5,12,.5),
0 0 0 1px color-mix(in srgb, var(--card-accent, var(--esp32)) 12%, transparent);
}
/* ================= 品牌 Logo:呼吸辉光 =================
* box-shadow 关键帧每帧走主线程重绘;改为叠加静态辉光的伪元素
* 只动 opacity(合成器直通),基础辉光由 layout.css 静态 box-shadow 提供,
* 呼吸节奏与原 logoPulse/ringPulse/glowBreath 一致。 */
.brand-logo { position: relative; }
.brand-logo::before {
content: "";
position: absolute; inset: -1px; border-radius: inherit;
box-shadow: 0 0 26px rgba(47,216,193,.4), inset 0 0 16px rgba(47,216,193,.14);
opacity: 0; pointer-events: none;
animation: logoPulse 3.2s ease-in-out infinite;
will-change: opacity;
}
.header-brand .dot-mark { position: relative; }
.header-brand .dot-mark::after {
content: "";
position: absolute; inset: 0; border-radius: 50%;
background: rgba(47,216,193,.5); pointer-events: none;
animation: ringRipple 2.4s ease-out infinite;
will-change: transform, opacity;
}
.header-brand .dot-mark::before {
content: "";
position: absolute; inset: 0; border-radius: 50%;
box-shadow: 0 0 20px rgba(47,216,193,.45);
opacity: 0; pointer-events: none;
animation: glowBreathFade 3.6s ease-in-out infinite;
will-change: opacity;
}
/* ================= 胶囊 / 按钮微交互 ================= */
.pill:hover { transform: translateY(-1px); border-color: var(--border-strong); }
.btn { position: relative; overflow: hidden; }
.btn-primary::before, .btn-success::before, .btn-danger::before {
content: "";
position: absolute; top: 0; left: -120%;
width: 60%; height: 100%;
background: linear-gradient(90deg, transparent, rgba(255,255,255,.4), transparent);
transform: skewX(-20deg);
transition: left .55s ease;
pointer-events: none;
}
.btn-primary:hover:not(:disabled)::before,
.btn-success:hover:not(:disabled)::before,
.btn-danger:hover:not(:disabled)::before { left: 130%; }
/* ================= 上传区:拖拽脉冲 + 图标浮动 ================= */
.upload-zone:hover .upload-ico { animation: floatY 1.6s ease-in-out infinite; }
.upload-zone.dragover { animation: ringPulse 1.2s ease-out infinite; }
/* ================= 文件信息浮现 ================= */
.file-info.show { animation: fadeInUp .3s ease both; }
/* ================= 进度条:流动斜纹 ================= */
.progress-fill {
background-image:
linear-gradient(90deg, var(--esp32), #14b8a6),
repeating-linear-gradient(45deg, rgba(255,255,255,.2) 0 10px, rgba(255,255,255,0) 10px 20px);
background-size: 100% 100%, 200% 100%;
background-blend-mode: normal, overlay;
animation: flowDash 1.4s linear infinite;
}
.progress-fill.done { animation: none; }
/* ================= 阶段胶囊:激活弹入 ================= */
.pp-step.active, .pp-step.done { animation: popIn .35s cubic-bezier(.4,1.4,.5,1) both; }
/* ================= 阶段流:连接线生长 + 圆点动效 ================= */
.stage-node:not(:last-child)::after {
background: linear-gradient(90deg, var(--esp32) 0%, var(--esp32) 50%, var(--border) 50%, var(--border) 100%);
background-size: 200% 100%; background-position: 100% 0;
transition: background-position .5s ease;
}
.stage-node.done:not(:last-child)::after,
.stage-node.live:not(:last-child)::after { background-position: 0% 0; }
.stage-dot { transition: transform .3s ease, background .3s, border-color .3s, color .3s, box-shadow .3s; }
.stage-node.live .stage-dot { animation: ringPulse 1.6s ease-out infinite; }
.stage-node.done .stage-dot { animation: popIn .35s cubic-bezier(.4,1.4,.5,1) both; }
.stage-node.err .stage-dot { animation: shake .4s ease both; }
/* ================= 状态条切换浮现 ================= */
.stage-status-bar.active, .stage-status-bar.ok, .stage-status-bar.err { animation: fadeInUp .3s ease both; }
.stage-result { animation: popIn .4s cubic-bezier(.4,1.4,.5,1) both; }
/* ================= 指标数值过渡 ================= */
.metric .v, .badge-stat .v { transition: color .3s ease, transform .3s ease; }
/* ================= 连接指示 ================= */
.conn-indicator { transition: background .25s ease, border-color .25s ease; }
.conn-indicator.warn .light { animation: ringPulse 1.4s ease-out infinite; }
/* 在线呼吸辉光:常驻动画改伪元素 opacity(合成器直通),节奏同原 glowBreath */
.conn-indicator.ok .light { position: relative; }
.conn-indicator.ok .light::after {
content: "";
position: absolute; inset: 0; border-radius: 50%;
box-shadow: 0 0 20px rgba(47,216,193,.45);
opacity: 0; pointer-events: none;
animation: glowBreathFade 3s ease-in-out infinite;
will-change: opacity;
}
/* ================= 堆 / 任务条:光泽扫过 =================
* shimmer 改动 transform(见上方关键帧注释):十几根条常驻扫光
* 不再每帧触发主线程重绘,视觉效果不变。 */
.heap-bar-track, .status-task-bar { position: relative; overflow: hidden; }
.heap-bar-track::after, .status-task-bar::after {
content: "";
position: absolute; inset: 0;
background: linear-gradient(90deg, transparent 0%, rgba(255,255,255,.12) 50%, transparent 100%);
transform: translateX(-100%);
animation: shimmer 2.8s linear infinite;
will-change: transform;
pointer-events: none;
}
.heap-bar-fill, .status-task-bar-fill { transition: width .6s cubic-bezier(.4,0,.2,1); }
/* ================= 日志新行淡入 ================= */
.log-line { animation: fadeIn .25s ease both; }
/* ================= Toast 弹出 ================= */
.toast.show { animation: popIn .35s cubic-bezier(.4,1.4,.5,1) both; }
/* ================= 降低动态偏好 ================= */
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: .001ms !important;
animation-iteration-count: 1 !important;
transition-duration: .001ms !important;
}
}
+11
View File
@@ -0,0 +1,11 @@
' DCU ESP32 debug console - desktop window (pywebview/WebView2). No console window.
' Browser-tab launcher: esp32_web.vbs Console-mode debug launcher: serve.bat
Set sh = CreateObject("WScript.Shell")
Set fso = CreateObject("Scripting.FileSystemObject")
base = fso.GetParentFolderName(WScript.ScriptFullName)
sh.CurrentDirectory = base
sh.Environment("PROCESS")("PYTHONUTF8") = "1"
' WebView2 hardware acceleration is ON by default (no --disable-gpu).
pyw = sh.ExpandEnvironmentStrings("%LOCALAPPDATA%\Programs\Python\Python312\pythonw.exe")
If Not fso.FileExists(pyw) Then pyw = "pythonw.exe"
sh.Run """" & pyw & """ """ & base & "\esp32_server.py"" --window", 0, False
+539
View File
@@ -0,0 +1,539 @@
#!/usr/bin/env python3
"""esp32_server.py — ESP32 调试控制台的本地页面服务器 + 桌面窗口外壳。
本目录的 HTML/CSS/JS 是纯前端: 页面本体由本服务器提供(同源相对路径),
而所有业务 API 由 ESP32 固件的 80 端口跨域提供(js/common/base-url.js 里的
设备 IP, 存 localStorage 的 dcu_esp_ip)。因此本脚本不碰任何硬件, 只做静态服务。
GET / → index.html (OTA 控制台; 标签外壳为 tabs.html)
GET /<file> → 本目录静态文件(html/css/js)
POST /api/shutdown → 结束本服务进程(窗口模式父进程用它通知子进程退出)
启动模式:
--window pywebview 桌面窗口(esp32_app.vbs) —— GUI 进程 + 独立服务子进程
默认 浏览器标签页(esp32_web.vbs / serve.bat)
窗口模式为何要分两个进程: 实测 pythonw + pythonnet(pywebview 依赖) 环境下,
Python 后台线程会被静默杀死(py-spy 可见 serve 线程凭空消失, 页面随即失联)。
把 HTTP 服务放进独立子进程可彻底规避, 见 tools/ecat/ecat_server.py 同款结构。
pywebview 窗口没有浏览器刷新按钮/地址栏, 因此本服务器给 HTML 响应注入
_reload.html: 右下角 ⟳ 悬浮按钮 + F5/Ctrl+R 键盘拦截(WebView2 默认不响应)。
"""
import argparse
import collections
import ctypes
import os
import re
import socket
import subprocess
import sys
import threading
import time
import urllib.request
import webbrowser
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
# 单实例互斥: 已有实例时, 新双击不创建第二个窗口(会隐形/端口漂移),
# 而是通过 /api/focus 让旧实例的窗口到前台。
g_mutex = None
# pywebview 窗口内的刷新能力: 悬浮按钮 + F5/Ctrl+R。浏览器 tab 打开时同样无害。
# __HOME__ 由模块加载时替换为工具主页路径(tabs.html 标签外壳)。
# 注入脚本对三种上下文自适应:
# 1) iframe 内的功能页(标签外壳承载): 移除悬浮按钮, 但仍覆写 window.open/_blank
# 让页面里的"新窗口打开"在 iframe 内跳转, 不弹系统浏览器;
# 2) 顶层标签外壳(tabs.html, 含 #view iframe): ⟳ 刷新当前 iframe, 不显示 ⌂;
# 3) 顶层独立页(浏览器直开某 html): ⟳ 刷新自身, 非主页时显示 ⌂ 回到标签外壳。
_RELOAD_SNIPPET = """
<style>#__rl,#__home{position:fixed;right:14px;width:34px;height:34px;z-index:99999;
border-radius:50%;border:1px solid rgba(255,255,255,.25);background:rgba(20,22,28,.72);
color:#9fb0c3;font-size:17px;line-height:32px;text-align:center;cursor:pointer;
user-select:none;opacity:.55;transition:opacity .15s}
#__rl{bottom:14px}#__home{bottom:56px;font-size:19px}
#__rl:hover,#__home:hover{opacity:1}#__rl:active{transform:rotate(90deg)}</style>
<div id="__rl" title="刷新当前页 (F5)">&#x21bb;</div>
<div id="__home" title="返回标签主页" style="display:none">&#x2302;</div>
<script>(function(){
var TOP=(window.top===window.self);
if(!TOP){var _r=document.getElementById('__rl'),_h=document.getElementById('__home');
if(_r)_r.remove(); if(_h)_h.remove();}
window.open=function(u){if(u)location.href=u;return null;};
document.addEventListener('click',function(e){
var a=e.target&&e.target.closest?e.target.closest('a[target=_blank]'):null;
if(a&&a.href){e.preventDefault();location.href=a.href;}
},true);
function reloadCur(){
if(TOP){var f=document.getElementById('view');
if(f){try{f.contentWindow.location.reload();}catch(_){f.src=f.src;} return;}}
location.reload();
}
window.addEventListener('keydown',function(e){
if(e.key==='F5'||(e.ctrlKey&&(e.key==='r'||e.key==='R'))){e.preventDefault();reloadCur();}
});
if(!TOP) return;
document.getElementById('__rl').addEventListener('click',reloadCur);
var home=document.getElementById('__home');
var p=location.pathname;
var isShell=!!document.getElementById('view');
if(!isShell&&!/\\/$/.test(p)&&!/\\/index\\.html$/.test(p)){
home.style.display='block';
home.addEventListener('click',function(){location.href='__HOME__';});
}
function ready(){try{window.pywebview&&window.pywebview.api&&
window.pywebview.api.page_loaded()}catch(e){}}
document.addEventListener('DOMContentLoaded',ready);
window.addEventListener('load',ready);
})();</script>
"""
_RELOAD_SNIPPET = _RELOAD_SNIPPET.replace("__HOME__", "/tabs.html")
_RELOAD_MARK = "</body>"
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
g_httpd = None
g_child = None
g_port = None
g_win = None
g_job = None # Job Object 句柄: 父进程一死内核连坐杀服务子进程
class _GuiApi:
"""暴露给窗口内 JS (window.pywebview.api) 的 GUI 控制接口。"""
def page_loaded(self):
"""页面每次加载完成后由注入脚本调用: 把窗口带到前台。"""
win = g_win
if win is not None:
try:
win.show()
except Exception:
pass
h = _native_handle(win) if win is not None else None
if h is not None:
_win32_foreground(h)
def reload_page(self):
win = g_win
if win is not None:
try:
win.load_url(win.url) # pywebview 无 reload, 用重载当前 URL 代替
except Exception:
pass
def _focus_existing(port):
"""让已有实例的窗口到前台。返回 True 表示确实存在旧实例。"""
try:
urllib.request.urlopen(urllib.request.Request(
f"http://127.0.0.1:{port}/api/focus", data=b"{}",
headers={"Content-Type": "application/json"}, method="POST"),
timeout=2)
return True
except Exception:
return False
class Handler(SimpleHTTPRequestHandler):
server_version = "Esp32Tools/1.0"
def __init__(self, *a, **kw):
super().__init__(*a, directory=BASE_DIR, **kw)
def log_message(self, fmt, *args):
pass # 静默访问日志
def end_headers(self):
# 本地调试: 禁缓存, 避免改了 css/js 还得手工 bump ?v= 才生效
self.send_header("Cache-Control", "no-store, must-revalidate")
super().end_headers()
def do_GET(self):
path = self.path.split("?")[0].lower()
if path.endswith(".html") or path == "/" or not os.path.splitext(path)[1]:
# HTML(含无扩展名的 SPA 路由): 注入刷新悬浮按钮/快捷键后返回
fp = self.translate_path(self.path)
if os.path.isdir(fp):
fp = os.path.join(fp, "index.html")
try:
with open(fp, "rb") as f:
html = f.read().decode("utf-8", "replace")
except OSError:
self.send_error(404, "File not found")
return
if _RELOAD_MARK in html:
html = html.replace(_RELOAD_MARK, _RELOAD_SNIPPET + _RELOAD_MARK, 1)
body = html.encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
else:
super().do_GET()
def do_POST(self):
path = self.path.split("?")[0]
if path == "/api/focus":
# 已有实例响应: 新实例应当退场
body = b'{"ok":true,"focused":true}'
self._post_json(body)
threading.Thread(target=lambda: print("FOCUS=1", flush=True), daemon=True).start()
return
if path == "/api/shutdown":
body = b'{"ok":true}'
self._post_json(body)
threading.Thread(target=_shutdown_self, daemon=True).start()
return
self.send_error(404)
def _post_json(self, body):
self.send_response(200)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _shutdown_self():
if g_httpd is not None:
try:
g_httpd.shutdown()
except Exception:
pass
def _win32_foreground(native_handle):
"""SetForegroundWindow: pywebview Window 没有 focus(), 要走 Win32。"""
try:
hwnd = int(native_handle)
ctypes.windll.user32.ShowWindow(hwnd, 9) # SW_RESTORE
ctypes.windll.user32.SetForegroundWindow(hwnd)
except Exception:
pass
def _native_handle(win):
try:
return win.native.Handle # WinForms Form
except Exception:
try:
return win.native
except Exception:
return None
def _port_taken(port):
"""Windows 的 SO_REUSEADDR 允许同端口重复 bind(实测会双监听、连接随机落),
故先用一次真实连接探测占用, 不能只靠 bind 是否抛错。"""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.settimeout(0.3)
return s.connect_ex(("127.0.0.1", port)) == 0
class QuietServer(ThreadingHTTPServer):
# 客户端中途断开(刷新/关窗)会触发 handle_error 打 traceback 到 stderr;
# 窗口模式下 stderr 是父进程的 PIPE, 4KB 缓冲一满子进程写日志即永久阻塞
# → 整个服务冻死(实测"窗口经常卡死"的根因)。此处静音, 父侧另有排空线程双保险。
def handle_error(self, request, client_address):
pass
def _bind_server(port):
for _ in range(6):
if _port_taken(port):
print(f"端口 {port} 已被占用, 尝试 {port + 1}")
port += 1
continue
try:
return QuietServer(("127.0.0.1", port), Handler), port
except OSError:
print(f"端口 {port} 绑定失败, 尝试 {port + 1}")
port += 1
return None, port
def _watch_parent(ppid):
"""兜底看门狗(Job Object 万一不可用时): 父 GUI 进程消失就自我了断,
避免窗口关了服务还在后台占端口。"""
def _loop():
k32 = ctypes.windll.kernel32
h = k32.OpenProcess(0x00100000, False, ppid) # SYNCHRONIZE
if not h:
return
k32.WaitForSingleObject(h, 0xFFFFFFFF) # 父进程退出即返回
os._exit(0)
threading.Thread(target=_loop, daemon=True).start()
def _run_browser(args):
"""浏览器模式: 服务跑在本进程主线程。"""
global g_httpd
if args.parent_pid:
_watch_parent(args.parent_pid)
httpd, port = _bind_server(args.port)
if httpd is None:
raise SystemExit("无法绑定端口")
g_httpd = httpd
url = f"http://127.0.0.1:{port}/tabs.html"
print(f"PORT={port}", flush=True) # 窗口模式父进程据此得知端口
print(f"ESP32 调试控制台: {url} (Ctrl+C 退出)")
print("页面里的设备 IP 决定 API 目标(默认 192.168.31.127), 本服务只提供页面")
if not args.no_browser:
threading.Timer(0.5, lambda: webbrowser.open(url)).start()
try:
httpd.serve_forever()
except KeyboardInterrupt:
pass
httpd.server_close()
print("\n已退出")
def _bind_child_lifetime(child):
"""把服务子进程放进 Job Object, 父进程句柄一关(正常退出/被任务管理器强杀/崩溃)
由内核连坐结束子进程, 杜绝"窗口关了端口还占着"的孤儿服务。
句柄故意不关闭: 生命周期就是靠父进程存活维系的。"""
k32 = ctypes.windll.kernel32
class _JOBLIM(ctypes.Structure):
_fields_ = [("PerProcessUserTimeLimit", ctypes.c_int64),
("PerJobUserTimeLimit", ctypes.c_int64),
("LimitFlags", ctypes.c_uint32),
("MinimumWorkingSetSize", ctypes.c_size_t),
("MaximumWorkingSetSize", ctypes.c_size_t),
("ActiveProcessLimit", ctypes.c_uint32),
("Affinity", ctypes.POINTER(ctypes.c_ulong)),
("PriorityClass", ctypes.c_uint32),
("SchedulingClass", ctypes.c_uint32)]
class _IOC(ctypes.Structure):
_fields_ = [("ReadOperationCount", ctypes.c_uint64),
("WriteOperationCount", ctypes.c_uint64),
("OtherOperationCount", ctypes.c_uint64),
("ReadTransferCount", ctypes.c_uint64),
("WriteTransferCount", ctypes.c_uint64),
("OtherTransferCount", ctypes.c_uint64)]
class _EXLIM(ctypes.Structure):
_fields_ = [("BasicLimitInformation", _JOBLIM),
("IoInfo", _IOC),
("ProcessMemoryLimit", ctypes.c_size_t),
("JobMemoryLimit", ctypes.c_size_t),
("PeakProcessMemoryUsed", ctypes.c_size_t),
("PeakJobMemoryUsed", ctypes.c_size_t)]
try:
job = k32.CreateJobObjectW(None, None)
if not job:
return None
info = _EXLIM()
info.BasicLimitInformation.LimitFlags = 0x2000 # KILL_ON_JOB_CLOSE
if not k32.SetInformationJobObject(job, 9, ctypes.byref(info),
ctypes.sizeof(info)): # ExtendedLimitInformation
return None
h = k32.OpenProcess(0x1F0FFF, False, child.pid) # PROCESS_ALL_ACCESS
if not h:
return None
k32.AssignProcessToJobObject(job, h)
k32.CloseHandle(h)
return job
except Exception:
return None
def _stop_child(child, port):
"""通知服务子进程优雅退出, 超时则强杀。"""
if child is None or child.poll() is not None:
return
try:
urllib.request.urlopen(urllib.request.Request(
f"http://127.0.0.1:{port}/api/shutdown", data=b"{}",
headers={"Content-Type": "application/json"}, method="POST"),
timeout=3)
except Exception:
pass
try:
child.wait(timeout=3)
except Exception:
pass
if child.poll() is None:
child.kill()
try:
child.wait(timeout=3)
except Exception:
pass
def _fail_box(title, msg):
"""GUI 进程启动失败时弹原生对话框, 避免无声无息。"""
try:
ctypes.windll.user32.MessageBoxW(None, msg, title, 0x10) # MB_ICONERROR
except Exception:
pass
def _acquire_single_instance():
"""命名 mutex 单实例守卫。返回 False=已有实例在跑。"""
global g_mutex
try:
g_mutex = ctypes.windll.kernel32.CreateMutexW(None, False, "DCU_Esp32Tools_SingleInstance")
return ctypes.windll.kernel32.GetLastError() != 183 # ERROR_ALREADY_EXISTS
except Exception:
return True # mutex 不可用时退化为允许多开
def _spawn_service_child(extra_args, fail_title):
"""启动服务子进程并持续排空其 stdout。
必须一直读: PIPE 缓冲 ~4KB, 读停则子进程任何 print/traceback 写满即永久阻塞。"""
script = os.path.abspath(__file__)
child = subprocess.Popen(
[sys.executable, script] + extra_args,
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, cwd=BASE_DIR)
global g_job
g_job = _bind_child_lifetime(child) # 父进程消失 → 内核连坐杀子进程
holder = {"port": None, "focus": False}
tail = collections.deque(maxlen=40)
focus_evt = threading.Event()
def _drain():
for raw in child.stdout:
s = raw.decode("utf-8", "replace").rstrip()
if s:
tail.append(s)
if s.startswith("PORT=") and holder["port"] is None:
try:
holder["port"] = int(s[5:])
except ValueError:
pass
elif s.startswith("FOCUS="):
holder["focus"] = True
focus_evt.set()
threading.Thread(target=_drain, daemon=True).start()
deadline = time.time() + 30
while time.time() < deadline:
if holder["port"] is not None or child.poll() is not None:
break
time.sleep(0.1)
port = holder["port"]
if port is None:
child.terminate()
_fail_box(fail_title,
"\n".join(tail) or "服务子进程未在 30 秒内报告端口(无输出)")
os._exit(1)
return child, port, focus_evt
def _run_window(webview, args):
"""桌面窗口模式: 本进程只做 GUI, HTTP 服务跑在子进程。"""
global g_child, g_port, g_win
if not _acquire_single_instance():
# 已有实例: 尝试把它的窗口顶到前台后退出。旧实例是修过 focus 钩子的
# 新版本才能响应; 旧版无响应时提示用户用旧窗口。
probed = False
for p in (args.port, args.port + 1, args.port + 2):
if _focus_existing(p):
probed = True
break
if probed:
os._exit(0)
_fail_box("ESP32 调试控制台",
"已有一个实例在运行, 但无法唤醒其窗口。\n"
"请到任务管理器结束所有 pythonw.exe 后重新打开。")
os._exit(0)
child, port, focus_evt = _spawn_service_child(
["--no-browser", "--port", str(args.port),
"--parent-pid", str(os.getpid())], "ESP32 调试控制台启动失败")
g_child = child
g_port = port
url = f"http://127.0.0.1:{port}/tabs.html"
for _ in range(50): # 等 HTTP 就绪(最多 ~10s)
try:
urllib.request.urlopen(url, timeout=1)
break
except Exception:
if g_child.poll() is not None:
_fail_box("ESP32 调试控制台启动失败", "服务子进程意外退出")
os._exit(1)
time.sleep(0.2)
def _monitor():
while True:
if focus_evt.is_set():
_gui_bringToFront() # 新双击 → 旧窗口到前台
focus_evt.clear()
if g_child.poll() is not None:
os._exit(0) # 子进程没了 → GUI 一起退
time.sleep(0.5)
threading.Thread(target=_monitor, daemon=True).start()
g_win = webview.create_window("ESP32 调试控制台 — DCU", url,
width=1600, height=1000, min_size=(1100, 720),
js_api=_GuiApi(), hidden=True, text_select=True)
print(f"桌面窗口模式: {url} (关窗即退出)")
def _show_watchdog():
# 页面加载后由 JS 调 page_loaded → show; 若 20s 仍未显示则强制 show
deadline = time.time() + 20
while time.time() < deadline:
if g_win.events.loaded.is_set():
time.sleep(1)
break
time.sleep(0.5)
try:
g_win.show()
except Exception:
pass
h = _native_handle(g_win)
if h is not None:
_win32_foreground(h)
threading.Thread(target=_show_watchdog, daemon=True).start()
webview.start()
_stop_child(g_child, g_port)
os._exit(0)
def _gui_bringToFront():
"""把 GUI 窗口恢复/置顶(从子进程 monitor 线程调用)。"""
win = g_win
if win is None:
return
try:
win.show()
except Exception:
pass
h = _native_handle(win)
if h is not None:
_win32_foreground(h)
def main():
ap = argparse.ArgumentParser(description="ESP32 调试控制台页面服务器")
ap.add_argument("--port", type=int, default=8080)
ap.add_argument("--no-browser", action="store_true")
ap.add_argument("--window", action="store_true",
help="pywebview 桌面窗口模式")
ap.add_argument("--parent-pid", type=int, default=0,
help="内部用: GUI 父进程 pid, 父进程消失时本服务自动退出")
args = ap.parse_args()
if args.window:
try:
import webview
except ImportError:
print("pywebview 未安装: pip install pywebview —— 回退浏览器模式")
webview = None
if webview is not None:
_run_window(webview, args)
return
_run_browser(args)
if __name__ == "__main__":
main()
+10
View File
@@ -0,0 +1,10 @@
' DCU ESP32 debug console - hidden launcher, opens in default browser (no console).
' Desktop-window launcher: esp32_app.vbs Console-mode debug launcher: serve.bat
Set sh = CreateObject("WScript.Shell")
Set fso = CreateObject("Scripting.FileSystemObject")
base = fso.GetParentFolderName(WScript.ScriptFullName)
sh.CurrentDirectory = base
sh.Environment("PROCESS")("PYTHONUTF8") = "1"
pyw = sh.ExpandEnvironmentStrings("%LOCALAPPDATA%\Programs\Python\Python312\pythonw.exe")
If Not fso.FileExists(pyw) Then pyw = "pythonw.exe"
sh.Run """" & pyw & """ """ & base & "\esp32_server.py""", 0, False
+521
View File
@@ -0,0 +1,521 @@
<!--
index.html - ESP32 OTA 中继控制台主页面(DCU Mission Control · 美化版)
支持 WiFi OTA(ESP32 本机 + GD32 中继)和 BLE OTA 双通道固件升级。
配套脚本:app.js (WiFi OTA), ble_ota.js (BLE OTA)
子系统配色:ESP32=青绿 · GD32=琥珀 · BLE=蓝 · CAN=绿 · 内存/调试=紫
-->
<!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>
<!-- 空 favicon:阻止浏览器自动请求 /favicon.ico(本地静态服务无此文件会 404) -->
<link rel="icon" href="data:,">
<link rel="stylesheet" href="css/base.css?v=857f83a5">
<link rel="stylesheet" href="css/layout.css?v=acd17001">
<link rel="stylesheet" href="css/components.css?v=a2c3d4e5">
<link rel="stylesheet" href="css/can.css?v=c06f5688">
<link rel="stylesheet" href="css/responsive.css?v=46449483">
</head>
<body>
<div class="container">
<!-- ====== HEADER: 命令栏 ====== -->
<header class="header-bar">
<div class="header-brand">
<span class="brand-logo" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<rect x="6" y="6" width="12" height="12" rx="2.5"/>
<rect x="9.5" y="9.5" width="5" height="5" rx="1" fill="currentColor" stroke="none" opacity=".85"/>
<path d="M9 2v3M15 2v3M9 19v3M15 19v3M2 9h3M2 15h3M19 9h3M19 15h3"/>
</svg>
</span>
<div class="brand-text">
<strong>ESP32 OTA 中继控制台</strong>
<span class="brand-sub">DCU · Robot_v2 · Relay</span>
</div>
</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 card--ble">
<div class="card-head"><span class="ico">🔗</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;" data-action="set-console-mode" data-arg="wifi">WiFi OTA</button>
<button id="modeBleBtn" class="btn btn-outline" style="flex:1;font-size:12px;padding:7px 10px;" data-action="set-console-mode" data-arg="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 id="testConnBtn" class="btn btn-outline" data-action="test-connection">测试</button>
</div>
</div>
</div>
<!-- BLE 模式:launch 风格连接按钮(checkbox 驱动:勾选=已连接) -->
<div id="bleModeCards" style="display:none;">
<div class="ble-launch-wrap">
<label class="ble-launch-btn" for="bleConnectToggle" data-ble-state="idle">
<input id="bleConnectToggle" type="checkbox">
<div class="back">
<div class="spot-shadow"></div>
<div class="mid">
<div class="spot-main"></div>
<div class="ble-dial">
<div class="spot-light"></div>
<div class="center">
<div class="icon">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#bleIconGlow)">
<path d="M7 7 L17 17 L12 22 V2 L17 7 L7 17"
stroke="#BBE7E9" stroke-width="3"
stroke-linecap="round" stroke-linejoin="round" fill="none"
shape-rendering="geometricPrecision"/>
</g>
<defs>
<filter id="bleIconGlow" x="-50%" y="-50%" width="200%" height="200%"
filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="bg"/>
<feColorMatrix in="SourceAlpha" type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="a"/>
<feOffset/>
<feGaussianBlur stdDeviation="2"/>
<feComposite in2="a" operator="out"/>
<feColorMatrix type="matrix"
values="0 0 0 0 0.37 0 0 0 0 0.66 0 0 0 0 1 0 0 0 0.88 0"/>
<feBlend mode="normal" in2="bg" result="g"/>
<feBlend mode="normal" in="SourceGraphic" in2="g" result="shape"/>
</filter>
</defs>
</svg>
</div>
<div class="launch">
<div style="--i:1">蓝</div>
<div style="--i:2">牙</div>
<div style="--i:3">连</div>
<div style="--i:4">接</div>
</div>
<div class="start">
<div class="text1">等待连接</div>
<div class="text2">
<span style="--n:0">已</span>
<span style="--n:1">连</span>
<span style="--n:2">接</span>
<span style="--n:3">.</span>
<span style="--n:4">.</span>
<span style="--n:5">.</span>
</div>
<div class="text3">连接中...</div>
</div>
</div>
</div>
</div>
</div>
</label>
<div class="ble-launch-hint">点击按钮连接 / 断开蓝牙设备</div>
</div>
</div>
<!-- 连接文案只在顶栏 #pillConnTxt 显示,此处不再重复 -->
</div>
</div>
<!-- ====== Shared Target + Firmware + Actions ====== -->
<div class="card card--gd32">
<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;" data-action="select-flash-target" data-arg="gd32">GD32</button>
<button id="targetEsp32Tab" class="btn btn-primary" style="flex:1;font-size:12px;padding:7px 10px;" data-action="select-flash-target" data-arg="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" data-action="pick-shared-firmware" data-arg="esp32">
<div class="upload-ico">📤</div>
<h4>点击或拖拽 ESP32 固件</h4>
</div>
<input type="file" id="fileInputEsp32" accept=".bin" 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" data-action="clear-shared-firmware" data-arg="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" data-action="pick-shared-firmware" data-arg="gd32">
<div class="upload-ico">📤</div>
<h4>点击或拖拽 GD32 固件</h4>
<p>支持 .bin 和 .hex(自动转换)</p>
</div>
<input type="file" id="fileInputGd32" accept=".hex,.bin" 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" data-action="clear-shared-firmware" data-arg="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 class="file-meta-item" style="margin-top:6px;">
<span>SHA-256</span>
<b id="fileSha256Gd32" style="font-size:10px;word-break:break-all;font-family:monospace;">-</b>
</div>
</div>
</div>
<div id="bleUploadPane" style="display:none;">
<div class="upload-zone upload-zone-sm" id="uploadZone" style="margin-bottom:12px;" data-action="ble-pick-file">
<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" data-action="ble-clear-file">清除</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" data-action="start-upload" data-arg="esp32" disabled>
上传并升级 ESP32
</button>
</div>
<div id="wifiGd32ActionPane" style="display:none;">
<button class="btn btn-success btn-block" id="uploadGd32Btn" data-action="start-upload" data-arg="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 card--esp32">
<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">APP 启动</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">OTA完成</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" data-action="restart-esp32">重启 ESP32</button>
</div>
</div>
</div>
</div>
<!-- ====== RIGHT: status + tools ====== -->
<div>
<!-- Primary status -->
<div class="card card--status">
<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 badge-stat-click" id="healthBadge"
role="button" tabindex="0" title="点击查看启动自检"
aria-expanded="false" aria-controls="healthBootPanel">
<div class="v" id="mHealth">--</div>
<div class="k">健康状态 <span class="health-boot-hint">▾ 自检</span></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 class="health-boot-panel collapsed" id="healthBootPanel" hidden>
<div class="health-boot-head">
<span class="health-boot-title">启动自检</span>
<span class="health-boot-summary" id="mBootResult">--</span>
</div>
<div class="health-boot-failed">失败/跳过:<b id="mBootFailed">--</b></div>
<div class="boot-module-list" id="bootModuleList" aria-label="启动模块列表"></div>
</div>
</div>
</div>
<!-- Secondary metrics -->
<div class="card card--mem">
<div class="card-head"><span class="ico">📊</span> 运行指标</div>
<div class="card-body">
<div class="metric-grid">
<div class="metric"><div class="k">ESP32 温度</div><div class="v" id="mTemp">--</div></div>
<div class="metric"><div class="k">GD32 温度</div><div class="v" id="mGd32Temp" title="SPI 0xC4;片内温度">--</div></div>
<div class="metric"><div class="k">输入 VCC</div><div class="v" id="mVcc" title="PA6 分压反推:VCC—47k—ADC—4.7k—GND">--</div></div>
<div class="metric"><div class="k">VDDA</div><div class="v" id="mVdda" title="片内 VREFINT 反推的 MCU 模拟供电">--</div></div>
<div class="metric"><div class="k">GD32 APP</div><div class="v" id="mGd32App">--</div></div>
<div class="metric"><div class="k">GD32 BOOT</div><div class="v" id="mGd32Boot">--</div></div>
<div class="metric"><div class="k">GD32 自检</div><div class="v" id="mGd32Init" title="GD32 APP 上电初始化状态(SPI 0xA3;悬停看失败模块/复位源)">--</div></div>
</div>
</div>
</div>
<div class="card card--mem" style="display:none">
<div class="card-head">
<span class="ico">🧾</span> 系统状态 (/system/status)
<button class="btn btn-ghost btn-sm card-head-btn" data-action="refresh-system-status">刷新</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="sStatusUpdated">--</b></div>
</div>
</div>
</div>
<!-- Log panel -->
<div class="card card--mem 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;font-family:var(--font-mono);">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>
<!-- 右侧滑出:堆与任务栈(默认露出一条,悬停展开) -->
<aside class="sys-drawer" id="sysDrawer" aria-label="堆与任务栈">
<div class="sys-drawer-rail" title="悬停展开堆与任务栈">
<span class="sys-drawer-rail-ico">🧠</span>
<span class="sys-drawer-rail-text">堆与任务栈</span>
</div>
<div class="sys-drawer-panel card card--mem">
<div class="card-head sys-drawer-head">
<span class="ico">🧠</span> 堆与任务栈
<button class="btn btn-ghost btn-sm card-head-btn" type="button" onclick="refreshSystemStatusNow(event)">刷新</button>
</div>
<div class="card-body sys-drawer-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 list-scroll-top3" id="cpuTaskList" title="按占用降序,默认显示前 3 项,下滑查看其余">
<div class="status-empty">等待下一次状态刷新...</div>
</div>
<div class="status-section-title">内存概况</div>
<div class="heap-group">
<div class="heap-group-title">芯片 SRAM 总览 <span style="font-weight:400;color:var(--text-dim);font-size:10px;">(ESP32-C3 · 静态段可拆分)</span></div>
<div class="heap-bar-row">
<span class="heap-bar-text" id="memSramUsage">--</span>
<span class="heap-bar-track mem-sram-track" id="memSramTrack" title="紫=IRAM · 橙=.data · 灰=.bss · 青=堆已用 · 暗=堆空闲">
<span class="mem-seg mem-seg-iram" id="memSegIram"></span>
<span class="mem-seg mem-seg-data" id="memSegData"></span>
<span class="mem-seg mem-seg-bss" id="memSegBss"></span>
<span class="mem-seg mem-seg-used" id="memSegUsed"></span>
<span class="mem-seg mem-seg-free" id="memSegFree"></span>
</span>
<span class="heap-bar-pct" id="memSramPct">--</span>
</div>
<div class="heap-legend">
<span class="heap-legend-item"><i class="mem-dot mem-dot-iram"></i>IRAM <b id="mIram">--</b></span>
<span class="heap-legend-item"><i class="mem-dot mem-dot-data"></i>.data <b id="mData">--</b></span>
<span class="heap-legend-item"><i class="mem-dot mem-dot-bss"></i>.bss <b id="mBss">--</b></span>
<span class="heap-legend-item"><i class="mem-dot mem-dot-used"></i>堆已用 <b id="mHeapUsed">--</b></span>
<span class="heap-legend-item"><i class="mem-dot mem-dot-free"></i>堆空闲 <b id="mHeapFree">--</b></span>
</div>
<div class="heap-detail-row">
<span>SOC 窗口: <b id="mSramSoc">--</b></span>
<span>尾部保留: <b id="mReservedTail">--</b></span>
<span>任务栈合计: <b id="mTaskStackTotal">--</b></span>
</div>
</div>
<div class="heap-group">
<div class="heap-group-title">内部 DRAM 堆 <span style="font-weight:400;color:var(--text-dim);font-size:10px;">(malloc 数据堆)</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 class="heap-detail-row">
<span>DMA 空闲: <b id="mDmaFree">--</b></span>
<span>RTC 堆空闲: <b id="mRtcFree">--</b></span>
<span>分配块数: <b id="mAllocBlocks">--</b></span>
</div>
</div>
<div class="status-section-title">任务栈水位 <span style="font-weight:400;text-transform:none;letter-spacing:0;color:var(--text-dim);">(峰值占用 Top,下滑查看)</span></div>
<div class="status-task-list list-scroll-top3" id="taskStackList" title="按峰值占用降序,默认显示前 3 项,下滑查看其余">
<div class="status-empty">等待状态刷新...</div>
</div>
<div class="status-subfold collapsed" id="statusJsonFold">
<div class="status-subfold-head" role="button" tabindex="0"
onclick="this.parentElement.classList.toggle('collapsed')"
onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();this.parentElement.classList.toggle('collapsed');}">
<span class="status-section-title status-subfold-title">原始 JSON</span>
<span class="collapse-arrow">▼</span>
</div>
<div class="status-subfold-body">
<pre class="status-json" id="statusJson">--</pre>
</div>
</div>
</div>
</div>
</aside>
<!-- Toast -->
<div class="toast" id="toast">
<span class="toast-ico" id="toastIco">✓</span>
<span id="toastMsg">操作成功</span>
</div>
<script type="module" src="js/app.js?v=20260901d"></script>
<script type="module" src="js/ota/console-mode.js?v=20260901d"></script>
<script type="module" src="js/ble_ota.js?v=20260901d"></script>
<!-- 内联兜底:同步 BLE 按钮 data-ble-state 与 checkbox.checked
不依赖 ES 模块(子模块可能被浏览器缓存为旧版,不含 data-ble-state 逻辑)。
connecting 态由 session.js click handler 设置,此处跳过不覆盖。 -->
<script type="module">
setInterval(function() {
var btn = document.querySelector('.ble-launch-btn');
if (!btn || !btn.offsetParent) return;
var st = btn.getAttribute('data-ble-state');
if (st === 'connecting') return;
var toggle = document.getElementById('bleConnectToggle');
if (!toggle) return;
var expected = toggle.checked ? 'connected' : 'idle';
if (st !== expected) btn.setAttribute('data-ble-state', expected);
}, 500);
</script>
</body>
</html>
+293
View File
@@ -0,0 +1,293 @@
/**
* app.js - ESP32 OTA 中继控制台入口(WiFi OTA 通道)
*
* 薄入口:import 所有 ota/* + common/* 模块,将 HTML onclick 引用的函数
* 显式挂载到 window 对象,保留原有内联事件处理器兼容。
*
* 模块通过 window.* 共享状态,模块间依赖通过 import 解耦。
* 此入口只负责挂载 + 顶层副作用(拖拽区绑定、SPI 输入监听、启动轮询)。
*/
import { formatBytes, formatTime, formatUptime, escapeHtml } from './common/utils.js';
import { showToast } from './common/toast.js';
import { addLog, clearLogs, setLogFilter, toggleAutoScroll } from './common/log.js';
import {
updateConnectionUi, updateHealthUi, updateHeapAndTaskStatusUi,
testConnection, refreshSystemStatusNow, diagnoseNetwork, systemStatusUrl
} from './common/connection.js';
import {
scheduleStatusPoll, startPolling, pausePollingForUpload,
resumePollingAfterUpload, pollStatus, pollLogs, setStatusPollIntervalMs
} from './common/polling.js';
import {
OtaSessionState, otaSessionState, otaSessionTarget, otaFailReason,
otaSessionActive, otaSessionTerminated, otaSessionTransition,
otaSessionReset, clearOtaSessionResiduals,
stopUploadWaitHeartbeat, startUploadWaitHeartbeat
} from './ota/session.js';
import {
toggleCollapse, switchStageFlow, updateEsp32StagesFromPhase,
updateEsp32StagesError, cleanupAfterOtaFailure, setActivePhase,
markPhaseDone, setProgress, resetProgress, esp32PhaseLabels
} from './ota/progress.js';
import {
updateStagesFromRelayState, updateSystemStatusUi
} from './ota/stages.js';
import {
handleFileSelect, pickFileViaFSAPI, setupDropZone,
parseIntelHex, isIntelHexText, convertHexFileToBin,
processFile, clearFile, resumePersistedFirmwareWithGesture
} from './ota/file-handle.js';
import { startUpload, verifyEsp32Firmware, restartEsp32 } from './ota/upload-esp32.js';
import {
triggerGd32Relay, markGd32OtaUiComplete, stopRelayProgressWatch,
startRelayProgressWatch, emitRelayProgress, ensureRelayProgressComplete
} from './ota/upload-gd32.js';
// ==================== 工具函数 ====================
function formatPercent(value) {
if (!Number.isFinite(value)) return '--';
return value.toFixed(1).replace(/\.0$/, '') + '%';
}
function getEspIp() {
var el = document.getElementById('espIp');
var ip = el && el.value ? el.value.trim() : '';
if (!ip) {
try { ip = (localStorage.getItem('dcu_esp_ip') || '').trim(); } catch (_) {}
}
if (!ip) ip = '192.168.31.127';
try { localStorage.setItem('dcu_esp_ip', ip); } catch (_) {}
return ip;
}
function getBaseUrl() {
return 'http://' + getEspIp();
}
function updateUploadButtonLabel() {
var esp32Btn = document.getElementById('uploadEsp32Btn');
var gd32Btn = document.getElementById('uploadGd32Btn');
/* 仅 XHR 上传期禁用(isUploading)。校验/中继/重启期允许按钮恢复可点,
* startUpload 用 otaSessionActive() 拒绝并发会话。 */
var xhrBusy = !!window.isUploading;
if (esp32Btn) esp32Btn.disabled = !window.esp32File || xhrBusy;
if (gd32Btn) gd32Btn.disabled = !window.gd32File || xhrBusy;
}
function resetStageFlow() {
['s1', 's2', 's3', 's4', 's5', 's6'].forEach(function (id) {
var el = document.getElementById(id);
if (el) el.className = 'stage-node';
});
['sd1', 'sd2', 'sd3', 'sd4', 'sd5', 'sd6'].forEach(function (id) {
var el = document.getElementById(id);
if (el) el.textContent = '';
});
var statusBar = document.getElementById('stageStatusBar');
if (statusBar) statusBar.className = 'stage-status-bar';
var statusText = document.getElementById('stageStatusText');
if (statusText) statusText.textContent = '待机';
}
function applyDisconnectedRuntimeState() {
document.getElementById('pillIP').textContent = '--';
document.getElementById('pillRSSI').textContent = '--';
// ★ 状态机守卫:会话活跃时不重置进度/阶段,避免覆盖进行中的 OTA 进度
if (otaSessionActive()) return;
// FAILED 态:保留失败进度显示,只清连接胶囊(让用户看到失败原因)
if (otaSessionState === OtaSessionState.FAILED) return;
resetProgress();
resetStageFlow();
}
function applyRebootingRuntimeState() {
var pc = document.getElementById('pillConn');
var pct = document.getElementById('pillConnTxt');
if (pc) pc.className = 'pill processing';
if (pct) {
var elapsed = Math.round((Date.now() - window.rebootStartTime) / 1000);
pct.textContent = '重启中 ' + elapsed + 's';
}
var bc = document.getElementById('bConn');
if (bc) bc.textContent = '重启中';
if (bc) bc.className = 'v warn';
}
function applyUploadingRuntimeState() {
// ★ 状态机守卫:会话已失败/完成时,绝不重新应用"上传中"运行时态,
// 防止网络抖动后的轮询错误分支把 UI 拉回"上传中"。
if (otaSessionState === OtaSessionState.FAILED ||
otaSessionState === OtaSessionState.DONE) {
return;
}
var pc = document.getElementById('pillConn');
var pct = document.getElementById('pillConnTxt');
if (pc) pc.className = 'pill processing';
if (pct) pct.textContent = '上传中';
var bc = document.getElementById('bConn');
if (bc) bc.textContent = '上传中';
if (bc) bc.className = 'v warn';
}
/** 设备忙:只更新顶栏连接胶囊 + 系统状态徽章,不写下方重复文案。 */
function applyDeviceBusyRuntimeState(pillText, statusText, badgeText) {
var pc = document.getElementById('pillConn');
var pct = document.getElementById('pillConnTxt');
if (pc) pc.className = 'pill processing';
if (pct) pct.textContent = pillText || '设备忙';
var bc = document.getElementById('bConn');
if (bc) bc.textContent = badgeText || '设备忙';
if (bc) bc.className = 'v warn';
}
// ==================== 挂载 ota/* 函数到 window(供其他模块通过 window.* 调用) ====================
window.OtaSessionState = OtaSessionState;
window.otaSessionActive = otaSessionActive;
window.otaSessionTerminated = otaSessionTerminated;
window.otaSessionTransition = otaSessionTransition;
window.otaSessionReset = otaSessionReset;
window.clearOtaSessionResiduals = clearOtaSessionResiduals;
window.stopUploadWaitHeartbeat = stopUploadWaitHeartbeat;
window.startUploadWaitHeartbeat = startUploadWaitHeartbeat;
window.toggleCollapse = toggleCollapse;
window.switchStageFlow = switchStageFlow;
window.updateEsp32StagesFromPhase = updateEsp32StagesFromPhase;
window.updateEsp32StagesError = updateEsp32StagesError;
window.cleanupAfterOtaFailure = cleanupAfterOtaFailure;
window.setActivePhase = setActivePhase;
window.markPhaseDone = markPhaseDone;
window.setProgress = setProgress;
window.resetProgress = resetProgress;
window.updateStagesFromRelayState = updateStagesFromRelayState;
window.updateSystemStatusUi = updateSystemStatusUi;
window.handleFileSelect = handleFileSelect;
window.pickFileViaFSAPI = pickFileViaFSAPI;
window.resumePersistedFirmwareWithGesture = resumePersistedFirmwareWithGesture;
window.parseIntelHex = parseIntelHex;
window.isIntelHexText = isIntelHexText;
window.convertHexFileToBin = convertHexFileToBin;
window.processFile = processFile;
window.clearFile = clearFile;
window.startUpload = startUpload;
window.verifyEsp32Firmware = verifyEsp32Firmware;
window.restartEsp32 = restartEsp32;
window.triggerGd32Relay = triggerGd32Relay;
window.markGd32OtaUiComplete = markGd32OtaUiComplete;
window.stopRelayProgressWatch = stopRelayProgressWatch;
window.startRelayProgressWatch = startRelayProgressWatch;
window.emitRelayProgress = emitRelayProgress;
window.ensureRelayProgressComplete = ensureRelayProgressComplete;
// ==================== 挂载 common/* 函数到 window(供 polling.js 等通过 window.* 调用) ====================
window.formatBytes = formatBytes;
window.formatTime = formatTime;
window.formatUptime = formatUptime;
window.escapeHtml = escapeHtml;
window.formatPercent = formatPercent;
window.showToast = showToast;
window.addLog = addLog;
window.clearLogs = clearLogs;
window.setLogFilter = setLogFilter;
window.toggleAutoScroll = toggleAutoScroll;
window.updateConnectionUi = updateConnectionUi;
window.updateHealthUi = updateHealthUi;
window.updateHeapAndTaskStatusUi = updateHeapAndTaskStatusUi;
window.testConnection = testConnection;
window.refreshSystemStatusNow = refreshSystemStatusNow;
window.systemStatusUrl = systemStatusUrl;
window.scheduleStatusPoll = scheduleStatusPoll;
window.setStatusPollIntervalMs = setStatusPollIntervalMs;
window.startPolling = startPolling;
window.pausePollingForUpload = pausePollingForUpload;
window.resumePollingAfterUpload = resumePollingAfterUpload;
window.pollStatus = pollStatus;
window.pollLogs = pollLogs;
window.diagnoseNetwork = diagnoseNetwork;
// ==================== 挂载本入口的辅助函数到 window ====================
window.getEspIp = getEspIp;
window.getBaseUrl = getBaseUrl;
window.updateUploadButtonLabel = updateUploadButtonLabel;
window.resetStageFlow = resetStageFlow;
window.applyDisconnectedRuntimeState = applyDisconnectedRuntimeState;
window.applyRebootingRuntimeState = applyRebootingRuntimeState;
window.applyUploadingRuntimeState = applyUploadingRuntimeState;
window.applyDeviceBusyRuntimeState = applyDeviceBusyRuntimeState;
// ==================== data-action 事件委托(替代 HTML 内联 onclick) ====================
// 处理函数分散在 app.js / ota/console-mode.js / ble_ota.js 三个入口,均挂载在
// window 上;委托在点击时才解引用,天然规避模块加载顺序问题。
document.addEventListener('click', function (ev) {
var target = ev.target.closest('[data-action]');
if (!target) return;
var arg = target.getAttribute('data-arg');
switch (target.getAttribute('data-action')) {
case 'set-console-mode': if (window.setConsoleMode) window.setConsoleMode(arg); break;
case 'test-connection': testConnection(); break;
case 'select-flash-target': if (window.selectFlashTarget) window.selectFlashTarget(arg); break;
case 'pick-shared-firmware': if (window.pickSharedFirmware) window.pickSharedFirmware(arg); break;
case 'clear-shared-firmware': if (window.clearSharedFirmware) window.clearSharedFirmware(arg); break;
case 'ble-pick-file': var fi = document.getElementById('firmwareFile'); if (fi) fi.click(); break;
case 'ble-clear-file': if (window.bleOta) window.bleOta.clearFile(); break;
case 'start-upload': startUpload(arg); break;
case 'restart-esp32': restartEsp32(); break;
case 'refresh-system-status': refreshSystemStatusNow(ev); break;
}
});
// 共享固件文件选择(原内联 onchange="handleSharedFileSelect(event, chip)")
['esp32', 'gd32'].forEach(function (chip) {
var input = document.getElementById(chip === 'esp32' ? 'fileInputEsp32' : 'fileInputGd32');
if (input) {
input.addEventListener('change', function (ev) {
if (window.handleSharedFileSelect) window.handleSharedFileSelect(ev, chip);
});
}
});
// BLE 上传区拖拽(原内联 ondragover/ondragleave/ondrop)
var bleZone = document.getElementById('uploadZone');
if (bleZone) {
bleZone.addEventListener('dragover', function (ev) {
ev.preventDefault();
bleZone.classList.add('dragover');
});
bleZone.addEventListener('dragleave', function () {
bleZone.classList.remove('dragover');
});
bleZone.addEventListener('drop', function (ev) {
ev.preventDefault();
bleZone.classList.remove('dragover');
var f = ev.dataTransfer.files[0];
if (f && window.bleOta) window.bleOta.notifyFileChange(f, null);
});
}
// ==================== 顶层副作用(原 app.js 末尾的初始化) ====================
setupDropZone('uploadZoneEsp32', 'esp32');
setupDropZone('uploadZoneGd32', 'gd32');
// 启动轮询(必须在所有模块加载完成、window.OtaSessionState 已挂载后调用,
// 否则 polling.js 的 scheduleStatusPoll 会因 window.OtaSessionState 未定义而抛 TypeError)
startPolling();
// IP 输入变化时清零失败计数并立即刷新状态
var ipInput = document.getElementById('espIp');
if (ipInput) {
try {
var savedIp = localStorage.getItem('dcu_esp_ip');
if (savedIp && savedIp.trim()) ipInput.value = savedIp.trim();
} catch (_) {}
ipInput.addEventListener('change', function () {
try { localStorage.setItem('dcu_esp_ip', ipInput.value.trim() || '192.168.31.127'); } catch (_) {}
window.consecutivePollFailures = 0;
addLog('I', '系统', 'IP 已修改,重新启动轮询...');
if (!window.statusPollInFlight) {
scheduleStatusPoll(0);
}
});
}
// 初始化按钮状态与提示
setTimeout(function () {
updateUploadButtonLabel();
addLog('I', '系统', '控制台初始化完成,等待固件文件...');
}, 500);
+60
View File
@@ -0,0 +1,60 @@
// ==================== BLE OTA 协议常量 ====================
// 与 WiFi OTA 共享界面元素:进度条、阶段流程、日志面板、连接状态、文件选择。
// 仅传输层不同 (GATT vs HTTP)。通过 connectionMode 变量判断当前模式。
export const UUIDS = {
service: 0xFFFF,
ctrl: 0x0001,
dataIn: 0x0002,
dataOut: 0x0003,
version: 0x0004,
};
export const UI_BUILD = '2026-07-18-ble-log-trim';
export const BLE_OTA_NOTIFY_ACK = 0xA1;
export const BLE_OTA_NOTIFY_FINISH = 0xB1;
export const BLE_OTA_NOTIFY_SHA256 = 0xC1;
// ★ 须与后端 main/include/app_config.h BLE_OTA_ACK_WINDOW 保持一致
// 64:实测 Windows 瓶颈在每窗口 ACK 往返,窗口 32→64 提速 ~10%,再试 96 无收益。
export const BLE_OTA_ACK_WINDOW = 64;
export const BLE_OTA_FINISH_ESP32_DONE = 0x02;
export const BLE_OTA_FINISH_REBOOT_NOW = 0x07;
export const BLE_OTA_FINISH_GD32_RELAY_START = 0x10;
export const BLE_OTA_FINISH_GD32_RELAY_DONE = 0x11;
export const BLE_OTA_FINISH_ABORTED = 0x20; // 用户主动中止(BLE 不断开)
export const BLE_OTA_CTRL_ABORT_OTA = 0xFB; // 中止当前 OTA(不断开 BLE)
export const BLE_OTA_FINISH_ERR_FINALIZE = 0x81;
export const BLE_OTA_FINISH_ERR_MUTEX = 0x83;
export const BLE_OTA_FINISH_ERR_GD32_SIZE = 0x84;
export const BLE_OTA_FINISH_ERR_SHA_MISMATCH = 0x85;
export const BLE_OTA_FINISH_ERR_GD32_RELAY = 0x86;
export const BLE_OTA_FINISH_ERR_FW_IDENTITY = 0x87; // 包身份不符(疑似 Bootloader/非 APP 包),拒绝中继
export const TARGETS = {
gd32: {
label: 'GD32',
packetCmd: 0x00,
ctrlHex: 'F7',
recommendedPayload: 480,
finishText: '自动触发中继',
hint: '当前目标:<b>GD32</b>。页面会使用 <b>cmd=0x00</b> 发送数据,写入完成后自动触发 SPI OTA 中继。',
verifyStage: 6,
finalizeStage: 5,
stage5Label: '完成触发',
stage6Label: '校验比对'
},
esp32: {
label: 'ESP32',
packetCmd: 0x01,
ctrlHex: 'F8',
recommendedPayload: 480,
finishText: '自动完成 OTA',
hint: '当前目标:ESP32。页面会使用 <b>cmd=0x01</b> 发送数据,写入完成后发 <b>0xF8</b> + 32 字节 SHA-256 触发设备端收尾:设备端读 OTA 分区算 SHA 并比对,一致则 end+set_boot+reboot;不一致上报 0x85,不重启。',
verifyStage: 5,
finalizeStage: 6,
stage5Label: '校验比对',
stage6Label: '完成触发'
}
};
export const MAX_LOG_LINES = 1000;
+253
View File
@@ -0,0 +1,253 @@
// ==================== BLE OTA 文件准备与分包 ====================
//
// ★ 上传中拦截策略(全文件统一):file.js 是 BLE 通道文件操作的唯一入口,
// transfer.js / state.js / session.js / 外部 bleOta.notifyFileChange 都汇聚到这里。
// isBleUploading()(state.js)= state.uploading || state.autoFlashing;
// window.isUploading = WiFi 通道 OTA 会话是否处于 UPLOADING(ota/session.js 维护)。
// 两者任一为真即视为上传中,禁止重置进度条/阶段/清空文件,避免覆盖进行中的 OTA。
import { state, els, bleLog, currentTarget, currentPayloadSize, setConnectionState, isBleUploading } from './state.js';
import { setBleStage, setBleStageText, resetBleStages } from './stages.js';
import { setProgress } from './transfer.js';
import { hideBleVerifyInfo } from './verify.js';
import { formatBytes } from '../common/utils.js';
import { showToast } from '../common/toast.js';
import { computeFileSha, checkGd32ImageFile } from '../ota/file-handle.js';
/** 任一通道上传中(BLE 或 WiFi)。focus/change 等不分通道的事件用此判定。 */
function isAnyChannelUploading() {
return isBleUploading() || (typeof window.isUploading !== 'undefined' && !!window.isUploading);
}
export function updateFileInfo(file) {
// ★ 上传进行中禁止重置进度/阶段
if (isBleUploading() && file && state.selectedFile) {
bleLog('FILE', '上传进行中,跳过 updateFileInfo 进度重置', 'D');
return Promise.resolve();
}
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 = currentPayloadSize();
const packets = file ? Math.ceil(file.size / payloadSize) : 0;
els.packetCount.textContent = String(packets);
els.sentCount.textContent = '0';
state.selectedFile = file || null;
state.lastUploadStatus = 'idle';
if (els.fileInfo) {
if (file) els.fileInfo.classList.add('show');
else els.fileInfo.classList.remove('show');
}
// ★ HEX → BIN:必须用 window.convertHexFileToBin(本模块未 import,裸名会 ReferenceError)
if (file && file.name.toLowerCase().endsWith('.hex') && typeof window.convertHexFileToBin === 'function') {
return window.convertHexFileToBin(file).then(function(result) {
if (!result.converted) {
bleLog('FILE', file.name + ' 扩展名为 .hex 但内容不像 HEX 格式,按原始文件处理', 'W');
finalizeAndHash(file);
return;
}
state.selectedFile = result.file;
var baseHex = '0x' + result.baseAddr.toString(16).toUpperCase().padStart(8, '0');
bleLog('FILE', 'HEX → BIN 转换完成: ' + file.name + ' → ' + result.file.name + ' (基地址 ' + baseHex + ', ' + formatBytes(result.file.size) + ')');
showToast('HEX 已自动转换为 BIN', 'success');
els.fileName.textContent = result.file.name + ' (由 ' + file.name + ' 转换)';
els.fileType.textContent = 'Binary (HEX转换)';
els.fileSize.textContent = formatBytes(result.file.size);
var newPackets = Math.ceil(result.file.size / payloadSize);
els.packetCount.textContent = String(newPackets);
finalizeAndHash(result.file);
}).catch(function(err) {
bleLog('FILE', 'HEX 解析失败: ' + err.message, 'E');
showToast('HEX 文件解析失败: ' + err.message, 'error');
state.selectedFile = null;
state.fileSha256 = null;
if (els.fileInfo) els.fileInfo.classList.remove('show');
setConnectionState(!!state.server);
});
}
finalizeAndHash(file);
return Promise.resolve();
}
function finalizeAndHash(file) {
/* GD32 目标:选文件时即判定包身份,BOOT 包不进上传队列(设备端还有兜底)
file 为 null 表示"没有选文件"(清空 / focus 时 input 为空),不是身份不符,不得报错 */
var chip = (window.flashTarget === 'gd32') ? 'gd32' : 'esp32';
if (chip === 'gd32' && file) {
return checkGd32ImageFile(file).then(function (id) {
if (!id.ok) {
var msg = id.kind === 'bootloader'
? '检测到 Bootloader 固件,不能刷入 APP 区,请选择 APP 固件'
: id.kind === 'none'
? '未选择固件文件'
: '文件不是合法的 GD32 APP 固件(向量表非法),已拒绝';
bleLog('FILE', msg, 'E');
showToast(msg, 'error');
state.selectedFile = null;
state.fileSha256 = null;
els.fileName.textContent = '-';
if (els.fileInfo) els.fileInfo.classList.remove('show');
setConnectionState(!!state.server);
return;
}
finalizeFileInfo(file);
computeBleFileSha(file);
});
}
finalizeFileInfo(file);
computeBleFileSha(file);
return Promise.resolve();
}
function finalizeFileInfo(file) {
if (isBleUploading()) {
bleLog('FILE', '上传进行中,跳过 finalizeFileInfo 进度重置', 'D');
return;
}
setConnectionState(!!state.server);
if (file) {
setProgress(0, `已选择 ${file.name}`);
bleLog('FILE', `已选择: ${file.name} (${formatBytes(file.size)}), 目标=${currentTarget().label}`);
} else {
setProgress(0, '等待选择文件');
if (state.server) {
resetBleStages();
setBleStage(3, '', null);
setBleStageText('BLE 连接就绪,请选择固件文件');
}
}
}
/**
* BLE 侧:读对应芯片槽;槽里已有则直接用,没有才算一次。
* 不跨芯片、不互切。
*/
function computeBleFileSha(file) {
var shaElem = document.getElementById('fileSha256');
if (!file) {
if (shaElem) shaElem.textContent = '-';
state.fileSha256 = null;
return;
}
var chip = (window.flashTarget === 'gd32') ? 'gd32' : 'esp32';
var existing = chip === 'gd32' ? window._gd32FileSha256 : window._esp32FileSha256;
if (existing) {
state.fileSha256 = existing;
if (shaElem) shaElem.textContent = existing;
if (state.server) {
setBleStage(3, 'SHA-256 已计算', null);
setBleStage(4, '', null);
setBleStageText('文件就绪,可以开始刷写');
}
return;
}
if (state.server) {
setBleStage(3, '计算哈希...', 'active');
setBleStageText('正在计算文件 SHA-256...');
}
computeFileSha(file, chip).then(function(hashHex) {
if (!hashHex) {
state.fileSha256 = null;
return;
}
state.fileSha256 = hashHex;
if (state.server) {
setBleStage(3, 'SHA-256 已计算', null);
setBleStage(4, '', null);
setBleStageText('文件就绪,可以开始刷写');
}
});
}
export async function syncSelectedFileFromInput() {
// ★ 上传进行中禁止同步:window focus / file input change 事件会在上传期间
// 触发本函数,若不拦截会通过 updateFileInfo 把进度条重置为"等待选择文件 0%"。
// ★ 必须同时拦截 WiFi 通道上传:focus 事件不分通道,WiFi 刷写时点击网页/切标签
// 回来会触发本函数 → finalizeFileInfo → setProgress(0),把进度"闪一下"归零,
// 直到下一次 XHR progress 事件才恢复。
if (isAnyChannelUploading()) return;
// FSAPI 选择的文件以 selectedFileHandle 为准,不从 input 元素同步,避免切标签页回来误清空
if (state.selectedFileHandle) return;
const file = (els.firmwareFile && els.firmwareFile.files && els.firmwareFile.files[0]) || null;
// ★ 拖拽 / notifyFileChange 送进来的文件不在 input 元素里(handle 也为 null),
// focus 时 input 为空不代表用户取消了选择,不能用 null 覆盖已有选择。
if (!file && state.selectedFile) return;
await updateFileInfo(file);
}
// 通过 File System Access API 选择文件(支持热重载)
export async function pickFileViaFSAPI() {
if (!window.showOpenFilePicker) {
bleLog('FILE', '当前浏览器不支持 File System Access API', 'W');
return false;
}
try {
const pickerOpts = {
id: 'dcu-fw-ble',
types: [{ description: '固件文件', accept: { 'application/octet-stream': ['.bin', '.hex'] } }],
multiple: false
};
/* 再次选择时定位到刷新/上次已选文件所在目录 */
if (state.selectedFileHandle) {
pickerOpts.startIn = state.selectedFileHandle;
}
const handles = await window.showOpenFilePicker(pickerOpts);
if (handles && handles.length > 0) {
const handle = handles[0];
const file = await handle.getFile();
state.selectedFileHandle = handle;
await updateFileInfo(file);
bleLog('FILE', '已通过 FSAPI 选择文件(支持热重载): ' + file.name);
return true;
}
} catch (e) {
if (e.name !== 'AbortError') {
bleLog('FILE', 'FSAPI 选择失败: ' + e.message, 'W');
}
}
return false;
}
export function clearSelectedFile() {
// ★ 上传进行中禁止清除文件:防止目标切换/模式切换在上传期间清空文件引用,
// 导致进度条被重置为"等待选择文件 0%"并中断上传数据源。
if (isBleUploading()) {
bleLog('FILE', '上传进行中,拒绝清除文件', 'W');
return;
}
state.selectedFile = null;
state.selectedFileHandle = null;
state.lastUploadStatus = 'idle';
state.fileSha256 = null;
state.deviceSha256 = null;
if (els.firmwareFile) els.firmwareFile.value = '';
updateFileInfo(null); // 内部 finalizeFileInfo(null) 已重置进度条并恢复「连接就绪」stage
hideBleVerifyInfo();
bleLog('FILE', '文件已清除');
}
// 别名导出,与 window.bleOta.clearFile 对齐
export { clearSelectedFile as clearFile };
/**
* 外部文件变更通知(index.html 拖拽 / console-mode 切换等)。
* ★ 守卫集中在此:file.js 是 BLE 文件操作唯一入口,外部包装层(ble_ota.js)不再重复守卫。
*/
export async function notifyFileChange(file, handle) {
if (isAnyChannelUploading()) {
bleLog('FILE', '上传进行中,忽略外部 notifyFileChange', 'D');
return;
}
if (file) {
/* 始终更新 handle(含 null),避免切目标后仍热重载到上一芯片的文件 */
state.selectedFileHandle = handle || null;
await updateFileInfo(file);
} else {
state.selectedFileHandle = null;
await updateFileInfo(null);
}
}
+564
View File
@@ -0,0 +1,564 @@
// ==================== BLE OTA 连接管理 ====================
import {
UUIDS,
BLE_OTA_NOTIFY_ACK,
BLE_OTA_NOTIFY_FINISH,
BLE_OTA_NOTIFY_SHA256,
BLE_OTA_ACK_WINDOW,
BLE_OTA_FINISH_ESP32_DONE,
BLE_OTA_FINISH_REBOOT_NOW,
BLE_OTA_FINISH_GD32_RELAY_START,
BLE_OTA_FINISH_GD32_RELAY_DONE,
BLE_OTA_FINISH_ABORTED,
BLE_OTA_FINISH_ERR_FINALIZE,
BLE_OTA_FINISH_ERR_MUTEX,
BLE_OTA_FINISH_ERR_GD32_SIZE,
BLE_OTA_FINISH_ERR_SHA_MISMATCH,
BLE_OTA_FINISH_ERR_GD32_RELAY,
BLE_OTA_FINISH_ERR_FW_IDENTITY,
TARGETS,
} from './constants.js';
import {
state,
els,
bleLog,
toHex,
setConnectionState,
resetUiAfterDisconnect,
currentPayloadSize,
flowTarget,
currentTarget,
sleep,
} from './state.js';
import { setBleStage, setBleStageError, setBleStageText, setBleStageStatus, resetBleStages } from './stages.js';
import { setProgress, updateProgressFromAck } from './transfer.js';
import { showBleVerifyInfo } from './verify.js';
import {
clearOtaResultWaiter,
resolveOtaResult,
rejectOtaResult,
finishBelongsToWaiter,
finishSessionId,
isSessionlessFinish,
hasCurrentWaiter,
} from './session.js';
import { formatBytes } from '../common/utils.js';
import { showToast } from '../common/toast.js';
/**
* @description 中止当前 ACK 等待 Promise,并复位重连状态。
* 用于所有"错误类 FINISH"和"用户中止"路径,避免 ackWaiter 悬挂。
*/
function abortAckWaiter(reason) {
if (state.ackWaiter) {
const waiter = state.ackWaiter;
state.ackWaiter = null;
clearTimeout(waiter.timer);
waiter.reject(new Error(reason));
}
state.abortRequested = true;
state.expectedReboot = false;
state.reconnecting = false;
state.reconnectAttempts = 0;
}
/** @description 连接 BLE 设备(扫描→连接→发现服务→使能通知)。 */
export async function connectBle() {
if (!navigator.bluetooth) {
bleLog('BLE', '当前浏览器不支持 Web Bluetooth', 'E');
alert('当前浏览器不支持 Web Bluetooth,请使用 Chrome / Edge。');
return;
}
try {
resetBleStages();
setBleStage(1, '扫描中...', 'active');
setBleStageText('正在打开设备选择器...');
bleLog('BLE', '打开设备选择器...');
const device = await navigator.bluetooth.requestDevice({
filters: [{ namePrefix: 'ESP32-OTA' }],
optionalServices: [UUIDS.service],
});
state.device = device;
state.device.addEventListener('gattserverdisconnected', onDisconnected, { once: true });
if (typeof connectionMode === 'undefined' || connectionMode === 'ble') {
els.deviceName.textContent = device.name || '未知设备';
els.deviceId.textContent = device.id || '-';
}
setBleStage(1, '已选择', null);
setBleStage(2, '连接GATT...', 'active');
setBleStageText('正在连接 GATT 服务...');
bleLog('BLE', `已选择设备: ${device.name || 'unknown'}`);
const server = await device.gatt.connect();
state.server = server;
setBleStage(2, '获取服务...', null);
setBleStageText('获取 GATT 服务和特征...');
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);
setBleStage(2, '已就绪', null);
setConnectionState(true);
bleLog('BLE', 'GATT 服务与特征获取完成');
await enableNotifications();
// ★ enableNotifications 内部已包含重试 + 主动断开逻辑;
// 若返回时仍为 false,说明连接半死,UI 已被断开事件重置,不要继续推进到 Stage 3
if (!state.notificationsEnabled) {
bleLog('BLE', '连接就绪但 DATA_OUT 通知开启失败,等待用户重连', 'E');
return;
}
// Mark stages 1-2 as done
setBleStage(3, '', null);
setBleStageText('BLE 连接就绪,请选择固件文件');
} catch (err) {
bleLog('BLE', `连接失败: ${err.message}`, 'E');
if (err.name !== 'NotFoundError') {
setBleStageError(err.message);
} else {
resetBleStages();
}
resetUiAfterDisconnect();
}
}
export function onDisconnected() {
bleLog('BLE', '设备已断开', 'W');
var wasExpected = state.expectedReboot;
resetUiAfterDisconnect();
if (wasExpected && state.device) {
// 是主动重启引起的断开,启动自动重连
state.reconnecting = true;
state.reconnectAttempts = 0;
tryReconnect();
}
}
/** @description 断开 BLE 连接并清理资源。 */
export async function disconnectBle() {
// 用户主动断开:取消任何进行中的重连 / expectedReboot
state.expectedReboot = false;
state.reconnecting = false;
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();
}
export async function readVersion() {
try {
const value = await state.versionChar.readValue();
const text = new TextDecoder().decode(value.buffer).replace(/\0/g, '').trim() || '(空)';
if (typeof connectionMode === 'undefined' || connectionMode === 'ble') {
if (els.versionText) els.versionText.textContent = text;
// 更新头部版本显示:仅显示版本号,编译时间放在 title 中悬停显示
var pv = document.getElementById('pillVer');
if (pv) {
var spaceIdx = text.indexOf(' ');
if (spaceIdx > 0) {
pv.textContent = 'v' + text.substring(0, spaceIdx);
pv.dataset.tooltip = '编译: ' + text.substring(spaceIdx + 1);
} else {
pv.textContent = 'v' + text;
delete pv.dataset.tooltip;
}
}
}
bleLog('BLE', `版本: ${text}`);
} catch (err) {
bleLog('BLE', `读取版本失败: ${err.message}`, 'E');
}
}
/** @description 处理 BLE Notify 事件(解析 ACK/ERROR/PROGRESS 等响应类型)。 */
export 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);
// ★ 保护:陈旧 ACK 来自跨 run / 旧窗口时直接丢弃。
// 仅以「本 run 上界」过滤(ackSeq 不能超过文件总包数 + 一个窗口余量),
// 不再用 ackMinSeenSeq 作下界 —— 因为本 run 第一个窗口的 seq 本身就是 0..31,
// 下界过严会把正常 ACK 也误判为越界(导致 ackBytes 永远 0)。
if (state.uploading && state.activeTotalSize > 0) {
const payloadSize = Math.max(1, currentPayloadSize());
const maxExpectedSeq = Math.ceil(state.activeTotalSize / payloadSize) - 1;
if (ackSeq > maxExpectedSeq + BLE_OTA_ACK_WINDOW) {
bleLog('ACK', `忽略越界 ACK: seq=${ackSeq} (run 范围 0..${maxExpectedSeq})`, 'W');
return;
}
} else if (!state.uploading) {
// 非上传阶段:清空状态,避免污染下次 run
state.lastAckCmd = null;
state.lastAckSeq = -1;
state.lastAckBytes = 0;
return;
}
state.lastAckCmd = ackCmd;
state.lastAckSeq = ackSeq;
state.lastAckBytes = ackBytes >>> 0;
// ★ 以设备端真实 ACK 字节数驱动 UI 进度
if (state.uploading && state.activeTotalSize > 0 && state.activePacketTotal > 0) {
updateProgressFromAck(state.activeTotalSize, state.activePacketTotal, state.lastAckBytes);
}
/* 传输期少打日志:整窗 ACK 频繁时 hex 刷屏拖慢 UI/主线程。
* ★ 降为 D 级:成功路径默认不显示,需要时切过滤级查看。 */
if (!state.uploading || (ackSeq % 16) === 0 || ackSeq < 2) {
bleLog('ACK', `cmd=0x${ackCmd.toString(16).padStart(2, '0').toUpperCase()} seq=${ackSeq} bytes=${state.lastAckBytes} (${formatBytes(state.lastAckBytes)})`, 'D');
}
// ★ 放宽匹配条件:只要 cmd 匹配 + generation 匹配 + 字节数足够,即接受 ACK
// 不再要求 ackSeq >= expectedSeq,因为 ESP32 可能用较低的 seq 发送 ACK
// 但字节数已覆盖期望位置(例如 ACK seq=255 bytes=138240 覆盖 seq=287 的数据)
if (state.ackWaiter && state.ackWaiter.cmd === ackCmd
&& state.ackWaiter.generation === state.ackGeneration) {
const waiter = state.ackWaiter;
const expectedBytes = waiter.expectedTotalSize !== undefined
? Math.min((waiter.expectedSeq + 1) * waiter.payloadSize, waiter.expectedTotalSize)
: 0;
// ★ ACK 字节数不足:立即 resolve,让重试循环立即续传,不等超时
if (expectedBytes > 0 && state.lastAckBytes < expectedBytes) {
bleLog('ACK', `ACK 字节数不足: ${state.lastAckBytes}/${expectedBytes},立即进入重试`, 'W');
state.ackWaiter = null;
clearTimeout(waiter.timer);
waiter.resolve({ seq: ackSeq, bytes: state.lastAckBytes });
return;
}
// 字节数足够,resolve
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 = {
[BLE_OTA_FINISH_ESP32_DONE]: '[FINISH] ESP32 OTA 收尾完成',
[BLE_OTA_FINISH_REBOOT_NOW]: '[FINISH] ESP32 即将重启',
[BLE_OTA_FINISH_GD32_RELAY_START]: '[FINISH] GD32 OTA 转发已开始',
[BLE_OTA_FINISH_GD32_RELAY_DONE]: '[FINISH] GD32 OTA 转发完成',
[BLE_OTA_FINISH_ABORTED]: '[FINISH] OTA 已中止(BLE 保持连接)',
[BLE_OTA_FINISH_ERR_FINALIZE]: `[FINISH] ESP32 OTA 收尾失败: err=${value}`,
[BLE_OTA_FINISH_ERR_MUTEX]: '[FINISH] OTA 被其他通道占用,互斥拒绝',
[BLE_OTA_FINISH_ERR_GD32_SIZE]: `[FINISH] GD32 固件超出分区大小: ${formatBytes(value)} (上限 216KB)`,
[BLE_OTA_FINISH_ERR_SHA_MISMATCH]: '[FINISH] OTA SHA-256 校验失败',
[BLE_OTA_FINISH_ERR_GD32_RELAY]: `[FINISH] GD32 OTA 转发失败: result=${value >> 16}`,
[BLE_OTA_FINISH_ERR_FW_IDENTITY]: value >> 16 === 1
? '[FINISH] 检测到 Bootloader 固件,已拒绝刷入 APP 区'
: '[FINISH] 固件身份校验失败(非 GD32 APP 包),已拒绝刷写',
};
const finishText = finishMap[stage] || `[FINISH] 未知阶段 stage=0x${stage.toString(16).padStart(2, '0').toUpperCase()} value=${value}`;
bleLog('FINISH', finishText);
const finalizeStage = TARGETS.esp32.finalizeStage;
const resultCode = (value >>> 16);
if (stage === BLE_OTA_FINISH_GD32_RELAY_START) {
if (!finishBelongsToWaiter('gd32', value)) return;
if (typeof switchStageFlow === 'function') switchStageFlow('gd32');
if (typeof setActivePhase === 'function') setActivePhase('gd32Ota');
if (typeof updateStagesFromRelayState === 'function') updateStagesFromRelayState(1);
setProgress(50, '[GD32] OTA 转发已开始,正在认证握手...');
showBleVerifyInfo(state.fileSha256 || '-', state.deviceSha256 || '等待设备端上报...', { text: '缓存写入完成,正在通过 SPI 转发' });
return;
}
if (stage === BLE_OTA_FINISH_GD32_RELAY_DONE) {
if (!finishBelongsToWaiter('gd32', value)) return;
if (typeof switchStageFlow === 'function') switchStageFlow('gd32');
if (typeof updateStagesFromRelayState === 'function') updateStagesFromRelayState(5);
if (typeof markPhaseDone === 'function') markPhaseDone('gd32Ota');
setProgress(100, '[GD32] OTA 转发完成,APP 已启动');
showBleVerifyInfo(state.fileSha256 || '-', state.deviceSha256 || '设备未上报', { text: 'GD32 OTA 完成', ok: true });
setBleStageStatus('ok', 'GD32 刷写完成');
state.lastUploadStatus = 'success';
showToast('GD32 OTA 刷写完成', 'success');
resolveOtaResult('success', { stage: stage, value: value });
return;
}
// ── 0x20 ABORTED:ESP32 已中止 OTA(BLE 保持连接)──
// 中止可由用户主动点"中止"按钮(abortTask 发 0xFB)触发,
// 也可能是设备端因错误自动中止。两种情况都走同一清理路径。
if (stage === BLE_OTA_FINISH_ABORTED) {
state.lastUploadStatus = 'aborted';
setProgress(0, 'OTA 已中止');
setBleStageError('OTA 已中止');
showToast('OTA 已中止(BLE 保持连接)', 'warning');
abortAckWaiter('OTA 已中止');
rejectOtaResult('OTA 已中止', { stage: stage, value: value });
return;
}
if (stage === BLE_OTA_FINISH_ERR_GD32_RELAY) {
if (!finishBelongsToWaiter('gd32', value)) return;
if (typeof switchStageFlow === 'function') switchStageFlow('gd32');
if (typeof updateStagesFromRelayState === 'function') updateStagesFromRelayState(255);
setProgress(0, '[GD32] OTA 转发失败');
state.lastUploadStatus = 'failed';
showToast('GD32 OTA 转发失败', 'error');
showBleVerifyInfo(state.fileSha256 || '-', 'GD32 未完成转发', { text: 'GD32 OTA 转发失败: ' + resultCode, err: true });
rejectOtaResult('GD32 OTA 转发失败: ' + resultCode, { stage: stage, value: value });
return;
}
// ── 0x87 包身份不符:设备端在 SPI 转发前拒绝,GD32 APP 区未被擦除 ──
if (stage === BLE_OTA_FINISH_ERR_FW_IDENTITY) {
if (!finishBelongsToWaiter('gd32', value)) return;
if (typeof switchStageFlow === 'function') switchStageFlow('gd32');
if (typeof updateStagesFromRelayState === 'function') updateStagesFromRelayState(255);
const idMsg = resultCode === 1
? '检测到 Bootloader 固件,不能刷入 APP 区,请选择 APP 固件'
: '固件身份校验失败(非 GD32 APP 包),已拒绝刷写';
setProgress(0, '[GD32] 固件身份不符,已拒绝');
state.lastUploadStatus = 'failed';
showToast(idMsg, 'error');
setBleStageError('固件身份不符');
showBleVerifyInfo(state.fileSha256 || '-', '固件身份校验失败', { text: idMsg, err: true });
rejectOtaResult(idMsg, { stage: stage, value: value });
return;
}
// Update BLE stage flow based on FINISH notifications
if (stage === BLE_OTA_FINISH_ESP32_DONE) {
if (!finishBelongsToWaiter('esp32', value)) return;
state.lastEsp32DoneSession = finishSessionId(value);
setBleStage(finalizeStage, '收尾完成', null);
setBleStageText('OTA 收尾完成,等待重启');
state.lastUploadStatus = 'success';
setProgress(100, '[ESP32] OTA 收尾完成,等待重启');
resolveOtaResult('success', { stage: stage, value: value });
} else if (stage === BLE_OTA_FINISH_REBOOT_NOW) {
if (state.lastEsp32DoneSession !== null && state.lastEsp32DoneSession !== finishSessionId(value)) return;
state.expectedReboot = true;
setBleStage(finalizeStage, '设备重启', null);
// ★ ESP32 收尾完成 = 成功路径:保持 status bar 绿色,不要切回 active 蓝
setBleStageStatus('ok', 'ESP32 刷写完成,等待重启');
if (typeof markPhaseDone === 'function') {
markPhaseDone('esp32Ota');
}
}
// OTA 结果 toast 提示(用户可见,不只是在日志里)
if (stage === BLE_OTA_FINISH_ERR_MUTEX) {
if (!isSessionlessFinish(value)) {
if (hasCurrentWaiter('esp32') && !finishBelongsToWaiter('esp32', value)) return;
if (hasCurrentWaiter('gd32') && !finishBelongsToWaiter('gd32', value)) return;
}
showToast('OTA 被其他通道占用,请稍后重试', 'error');
state.abortRequested = true;
setProgress(0, 'OTA 被其他通道占用');
state.lastUploadStatus = 'failed';
setBleStageError('OTA 被其他通道占用');
rejectOtaResult('OTA 被其他通道占用', { stage: stage, value: value });
} else if (stage === BLE_OTA_FINISH_ERR_GD32_SIZE) {
if (hasCurrentWaiter('gd32') && !finishBelongsToWaiter('gd32', value)) return;
showToast(`GD32 固件超出分区大小 (${formatBytes(value)},上限 216KB)`, 'error');
setProgress(0, 'GD32 固件太大,无法刷写');
state.lastUploadStatus = 'failed';
setBleStageError('固件超出分区大小');
rejectOtaResult('GD32 固件超出分区大小', { stage: stage, value: value });
} else if (stage === BLE_OTA_FINISH_ERR_FINALIZE) {
if (!finishBelongsToWaiter('esp32', value)) return;
showToast('ESP32 OTA 刷写失败', 'error');
setProgress(0, '刷写失败');
state.lastUploadStatus = 'failed';
setBleStageError('OTA 刷写失败');
showBleVerifyInfo(state.fileSha256 || '-', state.deviceSha256 || '设备未上报', { text: '刷写失败', err: true });
rejectOtaResult('ESP32 OTA 刷写失败', { stage: stage, value: value });
} else if (stage === BLE_OTA_FINISH_ERR_SHA_MISMATCH) {
const isGd32Waiter = hasCurrentWaiter('gd32');
const targetKey = isGd32Waiter ? 'gd32' : 'esp32';
if (!finishBelongsToWaiter(targetKey, value)) return;
const msg = isGd32Waiter
? 'GD32 缓存 SHA-256 校验失败,未进入 SPI 转发'
: 'ESP32 OTA 校验失败,未触发重启';
showToast(msg, 'error');
setProgress(0, '设备端 SHA-256 校验失败');
state.lastUploadStatus = 'failed';
setBleStage(flowTarget().verifyStage, 'SHA-256 ✗', null);
setBleStageError('设备端 SHA-256 校验失败');
showBleVerifyInfo(state.fileSha256 || '-', state.deviceSha256 || '设备未上报', { text: msg, err: true });
rejectOtaResult(msg, { stage: stage, value: value });
} else if (stage === BLE_OTA_FINISH_REBOOT_NOW) {
if (state.lastEsp32DoneSession !== null && state.lastEsp32DoneSession !== finishSessionId(value)) return;
showToast('ESP32 OTA 刷写完成,等待重启', 'success');
// If device SHA-256 hasn't arrived yet, show what we have
var devSha = state.deviceSha256 || '设备未上报';
if (state.deviceSha256 && state.fileSha256 && state.deviceSha256.toUpperCase() === state.fileSha256.toUpperCase()) {
showBleVerifyInfo(state.fileSha256 || '-', devSha, { text: '校验通过 — SHA-256 一致,设备重启中', ok: true });
} else if (state.deviceSha256) {
showBleVerifyInfo(state.fileSha256 || '-', devSha, { text: '校验失败 — SHA-256 不一致', err: true });
} else {
showBleVerifyInfo(state.fileSha256 || '-', devSha, { text: '刷写成功,设备未上报 SHA-256(重启中)' });
}
}
// 错误类 FINISH 事件立即中断 waitForAck,并中止发送循环
if (stage >= 0x80) {
abortAckWaiter(`设备端 FINISH 错误: stage=0x${stage.toString(16).padStart(2, '0').toUpperCase()} value=${value}`);
}
return;
}
// SHA-256 verification from device (after transfer complete)
if (bytes[0] === BLE_OTA_NOTIFY_SHA256 && bytes.length >= 33) {
if (bytes.length >= 35) {
var shaSessionId = bytes[33] | (bytes[34] << 8);
if (!state.otaResultWaiter ||
state.otaResultWaiter.sessionId !== shaSessionId) {
bleLog('VERIFY', '忽略非本轮 SHA-256 通知: session=' + shaSessionId, 'W');
return;
}
}
var devSha = Array.from(bytes.slice(1, 33)).map(function(b) {
return b.toString(16).padStart(2, '0');
}).join('').toUpperCase();
state.deviceSha256 = devSha;
bleLog('VERIFY', '设备端 SHA-256: ' + devSha, 'D');
// 设备端上报 SHA-256 后,前端仅做展示与状态同步。
var frontSha = state.fileSha256;
var verifyStage = flowTarget().verifyStage;
if (frontSha && frontSha.toUpperCase() === devSha) {
bleLog('VERIFY', 'SHA-256 校验通过,前端与设备端一致', 'I');
showBleVerifyInfo(frontSha, devSha, { text: '校验通过 — SHA-256 一致', ok: true });
setBleStage(verifyStage, 'SHA-256 ✓', null);
// ★ 校验通过 = 刷写成功:status bar 标绿 + 顶部胶囊推进
setBleStageStatus('ok', '刷写成功 — SHA-256 校验通过');
if (typeof markPhaseDone === 'function') {
markPhaseDone(state.uploadTargetKey === 'gd32' ? 'gd32Ota' : 'esp32Ota');
}
} else if (frontSha) {
bleLog('VERIFY', 'SHA-256 校验失败!前端: ' + frontSha + ' 设备: ' + devSha, 'E');
showBleVerifyInfo(frontSha, devSha, { text: '校验失败 — SHA-256 不一致', err: true });
setBleStage(verifyStage, 'SHA-256 ✗', null);
setBleStageError('SHA-256 不一致');
} else {
showBleVerifyInfo(undefined, devSha, { text: '前端 SHA-256 不可用,无法比对' });
setBleStage(verifyStage, '无法比对', null);
}
return;
}
bleLog('NOTIFY', hex);
}
// ===== 自动重连 =====
export async function tryReconnect() {
if (!state.device || !state.expectedReboot) {
state.reconnecting = false;
return;
}
if (!state.device.gatt) {
// 设备引用已丧失,重连不了
state.reconnecting = false;
state.expectedReboot = false;
showToast('设备引用丧失,请手动重连', 'error');
return;
}
state.reconnecting = true;
state.reconnectAttempts += 1;
var attempt = state.reconnectAttempts;
// 退避:约 1s / 1.6s / 2.6s / 4.1s / 6.6s / 10.5s…,上限 10s
var backoff = Math.min(10000, 1000 * Math.pow(1.6, attempt - 1));
if (backoff > 200) await sleep(backoff);
// ★ 退避期间用户可能已手动断开(disconnectBle 会清 expectedReboot),需二次校验避免幽灵重连
if (!state.expectedReboot || !state.device) {
state.reconnecting = false;
return;
}
if (typeof connectionMode !== 'undefined' && connectionMode === 'ble') {
if (els.connState) els.connState.textContent = '重连中(' + attempt + ')';
if (els.connState && els.connState.parentElement) els.connState.parentElement.className = 'pill processing';
}
setConnectionState(!!state.server);
bleLog('BLE', '自动重连第 ' + attempt + ' 次' + (backoff > 0 ? '(等待 ' + Math.round(backoff) + 'ms)' : '') + '...', 'W');
try {
var server = await state.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);
state.notificationsEnabled = false;
await enableNotifications();
state.expectedReboot = false;
state.reconnecting = false;
state.reconnectAttempts = 0;
// 重连后重注 onDisconnected({once: true},旧的已被上次断开时消耗)
state.device.addEventListener('gattserverdisconnected', onDisconnected, { once: true });
bleLog('BLE', '✅ 自动重连成功', 'I');
showToast('BLE 已重连', 'success');
setConnectionState(true);
// 重连后默认刷新一下版本
try { await readVersion(); } catch (_) {}
} catch (e) {
bleLog('BLE', '重连第 ' + attempt + ' 次失败: ' + e.message, 'W');
if (state.expectedReboot && attempt < 12) {
tryReconnect();
} else {
state.reconnecting = false;
state.expectedReboot = false;
showToast('重连失败,请手动连接', 'error');
bleLog('BLE', '自动重连放弃,请点击「连接设备」重连', 'E');
setConnectionState(!!state.server);
}
}
}
export async function enableNotifications() {
// 修复偶发的 GATT CCC 竞态:startNotifications 与 NimBLE gap 完成握手存在窗口冲突。
// 现象:Chrome 报 "GATT operation failed for unknown reason",NimBLE 实际返回 EBUSY/ENOTCONN。
// 处理:内部退避重试 + 最终失败时主动断开,让上层进入干净的重连路径,而不是带着半死连接去刷写。
const MAX_RETRY = 3;
for (let attempt = 1; attempt <= MAX_RETRY; attempt++) {
try {
await state.dataOutChar.startNotifications();
state.dataOutChar.removeEventListener('characteristicvaluechanged', handleNotify);
state.dataOutChar.addEventListener('characteristicvaluechanged', handleNotify);
state.notificationsEnabled = true;
setConnectionState(!!state.server);
bleLog('BLE', 'DATA_OUT 通知已自动开启' + (attempt > 1 ? ` (重试 ${attempt}/${MAX_RETRY} 成功)` : ''));
return;
} catch (err) {
bleLog('BLE', `自动开启 DATA_OUT 通知失败(第 ${attempt}/${MAX_RETRY} 次): ${err.message}`, 'E');
if (attempt < MAX_RETRY) {
// 200ms / 400ms 退避,给 NimBLE 主机任务时间把 connect/MTU 事件消化完
await new Promise(r => setTimeout(r, 200 * attempt));
}
}
}
state.notificationsEnabled = false;
setConnectionState(!!state.server);
// ★ 最终失败:连接处于半死状态,主动断开让用户重连,避免带着残废连接走到刷写
try {
if (state.device && state.device.gatt && state.device.gatt.connected) {
state.device.gatt.disconnect();
}
} catch (_) {}
bleLog('BLE', 'DATA_OUT 通知开启彻底失败,已主动断开,请重连后重试', 'E');
}
+160
View File
@@ -0,0 +1,160 @@
// ==================== BLE OTA 结果等待与中止 ====================
import { BLE_OTA_CTRL_ABORT_OTA } from './constants.js';
import { state, els, bleLog } from './state.js';
import { connectBle, disconnectBle } from './gatt.js';
import { autoFlash } from './transfer.js';
import { syncSelectedFileFromInput } from './file.js';
export function clearOtaResultWaiter() {
if (state.otaResultWaiter) {
clearTimeout(state.otaResultWaiter.timer);
state.otaResultWaiter = null;
}
}
export function resolveOtaResult(status, detail) {
if (!state.otaResultWaiter) return;
var waiter = state.otaResultWaiter;
state.otaResultWaiter = null;
clearTimeout(waiter.timer);
waiter.resolve({ status: status, detail: detail || {} });
}
export function rejectOtaResult(message, detail) {
if (!state.otaResultWaiter) return;
var waiter = state.otaResultWaiter;
state.otaResultWaiter = null;
clearTimeout(waiter.timer);
var err = new Error(message || 'OTA 收尾失败');
err.detail = detail || {};
waiter.reject(err);
}
export function nextOtaSessionId() {
state.otaRunId = (state.otaRunId + 1) & 0xFFFF;
if (state.otaRunId === 0) state.otaRunId = 1;
return state.otaRunId;
}
export function finishBelongsToWaiter(targetKey, value) {
if (!state.otaResultWaiter || state.otaResultWaiter.targetKey !== targetKey) return false;
return (value & 0xFFFF) === state.otaResultWaiter.sessionId;
}
export function finishSessionId(value) {
return value & 0xFFFF;
}
export function isSessionlessFinish(value) {
return finishSessionId(value) === 0;
}
export function hasCurrentWaiter(targetKey) {
return !!(state.otaResultWaiter && state.otaResultWaiter.targetKey === targetKey);
}
export function waitForOtaResult(targetKey, sessionId, timeoutMs) {
clearOtaResultWaiter();
var runId = state.otaRunId;
return new Promise(function(resolve, reject) {
var timer = setTimeout(function() {
if (state.otaResultWaiter && state.otaResultWaiter.runId === runId) {
state.otaResultWaiter = null;
}
reject(new Error(targetKey === 'gd32' ? '等待 GD32 OTA 转发结果超时' : '等待 ESP32 OTA 收尾结果超时'));
}, timeoutMs);
state.otaResultWaiter = {
targetKey: targetKey,
sessionId: sessionId,
runId: runId,
timer: timer,
resolve: resolve,
reject: reject,
};
});
}
/**
* @description 中止当前 BLE OTA 任务并清理状态。
* 改进:发 0xFB 控制命令让 ESP32 主动清理 OTA 会话,BLE 保持连接。
* 旧做法是直接断开 BLE 触发 GAP disconnect 清理,导致用户需重连。
* 若 0xFB 发送失败(如 CTRL 特征不可用),回退到设置标志等待发包循环自然退出。
*/
export async function abortTask() {
state.abortRequested = true; // 立即停止发送循环
bleLog('OTA', '已请求中止,发送 0xFB 让设备端清理(BLE 保持连接)');
if (state.ctrlChar && state.server && state.device.gatt.connected) {
try {
const session = state.sessionId || 0;
const abortCmd = new Uint8Array([BLE_OTA_CTRL_ABORT_OTA, session & 0xFF, (session >> 8) & 0xFF]);
await state.ctrlChar.writeValue(abortCmd);
bleLog('OTA', '0xFB 已发送,等待设备端回复 ABORTED (stage=0x20)');
} catch (e) {
bleLog('OTA', '0xFB 发送失败: ' + e.message + ',回退到本地中止', 'E');
// CTRL 写入失败时回退:本地立即标记失败,不再等设备端 ABORTED 回复
state.lastUploadStatus = 'failed';
if (state.ackWaiter) {
const waiter = state.ackWaiter;
state.ackWaiter = null;
clearTimeout(waiter.timer);
waiter.reject(new Error('中止失败: ' + e.message));
}
rejectOtaResult('中止失败: ' + e.message, { stage: 0xFB, value: 0 });
}
} else {
// CTRL 特征未就绪(连接已断或未完成握手),仅本地清理
bleLog('OTA', 'CTRL 特征不可用,仅本地中止', 'W');
state.lastUploadStatus = 'failed';
rejectOtaResult('CTRL 特征不可用', { stage: 0xFB, value: 0 });
}
}
export function bindEvents() {
if (els.bleConnectToggle) {
// launch 风格按钮:用 click 而非 change 触发——navigator.bluetooth.requestDevice
// 必须在 user gesture 内调用,change 事件不被识别为 user gesture,会报
// "Must be handling a user gesture to show a permission request."
// preventDefault 阻止 label 默认勾选,checked 由 setConnectionState 按实际结果驱动。
els.bleConnectToggle.addEventListener('click', (e) => {
e.preventDefault();
if (state._bleConnecting) return; // 连接进行中,忽略重复点击
var btn = document.querySelector('.ble-launch-btn');
if (state.server) {
els.bleConnectToggle.checked = false;
if (btn) btn.setAttribute('data-ble-state', 'idle');
disconnectBle(); // → resetUiAfterDisconnect 置 idle
} else {
state._bleConnecting = true;
els.bleConnectToggle.checked = true; // 即时视觉反馈
if (btn) btn.setAttribute('data-ble-state', 'connecting');
// 连接结束后按实际结果收口:成功→connected / 失败→idle
// 不依赖 state.js 的 setConnectionState(ES 模块子导入可能被浏览器缓存为旧版)
connectBle().finally(() => {
state._bleConnecting = false;
var b = document.querySelector('.ble-launch-btn');
if (b) b.setAttribute('data-ble-state', state.server ? 'connected' : 'idle');
});
}
});
// 兜底:自动断开(onDisconnected)、重连等路径绕过 click handler,
// 用轮询同步 data-ble-state,确保按钮状态始终与 state.server 一致。
setInterval(function() {
if (state._bleConnecting) return; // 连接中不干扰
var btn = document.querySelector('.ble-launch-btn');
if (!btn || !btn.offsetParent) return; // 按钮不可见(WiFi 模式)时跳过
var expected = state.server ? 'connected' : 'idle';
if (btn.getAttribute('data-ble-state') !== expected) {
btn.setAttribute('data-ble-state', expected);
}
}, 800);
}
els.autoFlashBtn.addEventListener('click', autoFlash);
els.abortBtn.addEventListener('click', abortTask);
// 上传区事件由 app.js 的 setupSharedUploadZone 统一处理
// 文件输入变化时同步 BLE 文件状态
els.firmwareFile.addEventListener('change', syncSelectedFileFromInput);
els.firmwareFile.addEventListener('input', syncSelectedFileFromInput);
window.addEventListener('focus', () => setTimeout(syncSelectedFileFromInput, 0));
}
+123
View File
@@ -0,0 +1,123 @@
// ==================== BLE OTA Stage Flow Management ====================
export var bleStageIds = ['bs1','bs2','bs3','bs4','bs5','bs6'];
export var bleStageDetailIds = ['bsd1','bsd2','bsd3','bsd4','bsd5','bsd6'];
export var bleStageLabelIds = ['bsl5', 'bsl6'];
/* 状态栏 DOM 元素懒缓存(BLE OTA 高频调用路径用,避免每次 getElementById)。
* DOM 未就绪或节点被外部重建时回退到 getElementById(与 ota/progress.js 的 setProgress 同款模式)。 */
var stageStatusBarEl = null;
var stageStatusTextEl = null;
/* 当前激活阶段序号(1-based,0=无激活)。由 setBleStage 维护,供 setBleStageError
* 直接定位错误节点,避免依赖 className 字符串匹配(脆弱且易被外部改动打破)。 */
var currentBleStageIndex = 0;
function getStageStatusBar() {
if (!stageStatusBarEl || !stageStatusBarEl.isConnected) {
stageStatusBarEl = document.getElementById('stageStatusBar');
}
return stageStatusBarEl;
}
function getStageStatusText() {
if (!stageStatusTextEl || !stageStatusTextEl.isConnected) {
stageStatusTextEl = document.getElementById('stageStatusText');
}
return stageStatusTextEl;
}
export function updateBleStageLabels(cfg) {
/* JS 兜底默认值与 constants.js TARGETS.esp32/gd32 的 stage5Label/stage6Label 对齐:
* esp32: stage5Label='校验比对', stage6Label='完成触发'
* gd32: stage5Label='完成触发', stage6Label='校验比对'
* 实际 cfg 由 currentTarget() 返回,始终带 label,此分支仅为防御性兜底。 */
var stage5 = document.getElementById(bleStageLabelIds[0]);
var stage6 = document.getElementById(bleStageLabelIds[1]);
if (stage5) stage5.textContent = cfg.stage5Label || '校验比对';
if (stage6) stage6.textContent = cfg.stage6Label || '完成触发';
}
export function resetBleStages() {
bleStageIds.forEach(function(id) {
var el = document.getElementById(id);
if (el) el.className = 'stage-node';
});
bleStageDetailIds.forEach(function(id) {
var el = document.getElementById(id);
if (el) el.textContent = '';
});
currentBleStageIndex = 0;
/* ★ 只改 className + textContent,不重写 innerHTML:
* 1) 保留 #stageStatusDot(CSS dotPulse 动画依赖该节点,重写后 id 丢失);
* 2) 保留 #stageStatusText 节点引用,避免外部缓存引用失效;
* 3) 与 ota/progress.js、ota/stages.js、app.js 的状态栏操作约定一致。 */
var bar = getStageStatusBar();
if (bar) bar.className = 'stage-status-bar';
var txt = getStageStatusText();
if (txt) txt.textContent = '待机';
}
export function setBleStage(stageNum, detail, barState) {
// stageNum: 1-6, detail: sub-step text
// 终态 stageNum===末步:全部 done(绿);中间步当前为 live(蓝)
var stageCount = bleStageIds.length;
// 边界守卫:stageNum<1 直接清空所有节点(不推进 currentBleStageIndex),避免误判 active
if (!Number.isFinite(stageNum) || stageNum < 1) {
bleStageIds.forEach(function(id) {
var el = document.getElementById(id);
if (el) el.className = 'stage-node';
});
currentBleStageIndex = 0;
return;
}
if (stageNum > stageCount) stageNum = stageCount;
currentBleStageIndex = stageNum;
var finished = stageNum >= stageCount;
for (var i = 0; i < stageCount; i++) {
var el = document.getElementById(bleStageIds[i]);
if (!el) continue;
if (i + 1 < stageNum) el.className = 'stage-node done';
else if (i + 1 === stageNum) el.className = finished ? 'stage-node done' : 'stage-node live';
else el.className = 'stage-node';
}
// Set detail text
if (stageNum >= 1 && stageNum <= stageCount) {
var detailEl = document.getElementById(bleStageDetailIds[stageNum - 1]);
if (detailEl && detail !== undefined) detailEl.textContent = detail;
}
// Update status bar
var bar = getStageStatusBar();
if (bar) {
var cls = barState || (finished ? 'ok' : 'active');
bar.className = 'stage-status-bar' + (cls ? ' ' + cls : '');
}
}
export function setBleStageText(text) {
var txt = getStageStatusText();
if (txt) txt.textContent = text;
}
/** @description 一次性更新状态栏的样式与文本(避免每次通知都重复 getElementById)。 */
export function setBleStageStatus(state, text) {
var bar = getStageStatusBar();
if (bar) bar.className = 'stage-status-bar' + (state ? ' ' + state : '');
var txt = getStageStatusText();
if (txt && text !== undefined) txt.textContent = text;
}
export function setBleStageError(detail) {
// 直接用 currentBleStageIndex 定位错误节点(由 setBleStage 维护),
// 不再依赖 className === 'stage-node live' 字符串匹配——后者在 className
// 顺序变化或被外部改动时会漏标错误阶段。
var stageCount = bleStageIds.length;
if (currentBleStageIndex >= 1 && currentBleStageIndex <= stageCount) {
var el = document.getElementById(bleStageIds[currentBleStageIndex - 1]);
if (el) el.className = 'stage-node err';
var detailEl = document.getElementById(bleStageDetailIds[currentBleStageIndex - 1]);
if (detailEl && detail) detailEl.textContent = detail;
}
var bar = getStageStatusBar();
if (bar) bar.className = 'stage-status-bar err';
setBleStageText('错误: ' + (detail || '未知错误'));
}
+198
View File
@@ -0,0 +1,198 @@
// ==================== BLE OTA 状态与元素缓存 ====================
import { TARGETS } from './constants.js';
import { addLog } from '../common/log.js';
import { updateBleStageLabels, resetBleStages } from './stages.js';
import { hideBleVerifyInfo } from './verify.js';
export const state = {
device: null,
server: null,
service: null,
ctrlChar: null,
dataInChar: null,
dataOutChar: null,
versionChar: null,
notificationsEnabled: false,
selectedFile: null,
selectedFileHandle: null, // FileSystemFileHandle for hot reload
abortRequested: false,
uploading: false,
autoFlashing: false,
lastUploadStatus: 'idle',
lastNotificationHex: '',
lastAckCmd: null,
lastAckSeq: -1,
lastAckBytes: 0,
ackWaiter: null,
otaResultWaiter: null,
otaRunId: 0,
lastEsp32DoneSession: null,
fileSha256: null, // 当前 BLE 会话用的 SHA(与对应芯片槽同步)
deviceSha256: null,
expectedReboot: false, // 发送 0xFA 后置 true,重连后清
reconnecting: false, // 自动重连中
reconnectAttempts: 0, // 重连尝试次数
uploadTargetKey: null,
activeTotalSize: 0,
activePacketTotal: 0,
};
export const $ = (id) => document.getElementById(id);
export const els = {
bleConnectToggle: $('bleConnectToggle'),
autoFlashBtn: $('autoFlashBtn'),
abortBtn: $('abortBtn'),
uploadZone: $('uploadZone'),
firmwareFile: $('firmwareFile'),
connState: $('connState'),
connBadge: $('connBadge'),
deviceName: $('deviceName'),
deviceId: $('deviceId'),
gattState: $('gattState'),
versionText: $('versionText'),
fileInfo: $('fileInfo'),
clearFileBtn: $('clearFileBtn'),
progressFill: $('progressFill'), // 共享进度条
progressTxt: $('progressTxt'), // 共享进度文本
progressPct: $('progressPct'), // 共享进度百分比
fileName: $('fileName'),
fileType: $('fileType'),
fileSize: $('fileSize'),
packetCount: $('packetCount'),
sentCount: $('sentCount'),
logScroll: $('logScroll'), // 共享日志面板
};
export function currentTarget() {
return TARGETS[window.flashTarget] || TARGETS.esp32;
}
/** BLE 通道是否正在上传/自动刷写。集中判定,避免各处重复 state.uploading || state.autoFlashing。 */
export function isBleUploading() {
return !!(state.uploading || state.autoFlashing);
}
export function currentPayloadSize() {
const cfg = currentTarget();
return Math.max(32, Math.min(498, Number(cfg.recommendedPayload) || 480));
}
export function flowTarget() {
return TARGETS[state.uploadTargetKey] || currentTarget();
}
export function bleLog(tag, message, level) {
level = level || 'I';
if (typeof addLog === 'function') {
addLog(level, 'BLE-' + tag, message);
}
}
export function toHex(uint8) {
return Array.from(uint8).map(b => b.toString(16).padStart(2, '0').toUpperCase()).join(' ');
}
export 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;
}
export async function sleep(ms) {
return new Promise(r => setTimeout(r, ms));
}
export function setReadyHint(html) {
const el = $('readyHint');
if (el) el.innerHTML = html;
}
export function setConnectionState(connected) {
const idle = connected && state.selectedFile && !state.uploading;
// BLE 模式:连接状态只写顶栏胶囊,避免下方重复文案
if (typeof connectionMode !== 'undefined' && connectionMode === 'ble') {
var pc = document.getElementById('pillConn');
var pct = document.getElementById('pillConnTxt');
if (pc) pc.className = connected ? 'pill online' : 'pill offline';
if (pct) pct.textContent = connected ? 'BLE 已连接' : 'BLE 未连接';
if (els.connState) {
els.connState.textContent = connected ? '已连接' : '未连接';
if (els.connState.parentElement) {
els.connState.parentElement.className = connected ? 'pill online' : 'pill offline';
}
}
if (els.connBadge) {
els.connBadge.textContent = connected ? 'online' : 'offline';
els.connBadge.className = `v ${connected ? 'ok' : 'bad'}`;
}
if (els.gattState) els.gattState.textContent = connected ? 'GATT 已就绪' : '未就绪';
}
// launch 风格按钮:checked 与 data-ble-state 由连接结果驱动
// connecting 态由点击时设置;此处仅在连接结束(成功→connected / 失败→idle)时收口
if (els.bleConnectToggle) {
if (els.bleConnectToggle.checked !== connected) {
els.bleConnectToggle.checked = !!connected;
}
}
var btn = document.querySelector('.ble-launch-btn');
if (btn) btn.setAttribute('data-ble-state', connected ? 'connected' : 'idle');
els.autoFlashBtn.disabled = !idle;
els.abortBtn.disabled = !state.uploading;
if (!connected) {
setReadyHint('当前未连接设备。建议顺序:连接设备 → 选择固件 → 开始刷写。');
} else if (!state.notificationsEnabled) {
setReadyHint('设备已连接,正在自动准备刷写通道。若长时间无响应,请断开后重连。');
} else if (!state.selectedFile) {
setReadyHint('设备已连接。下一步请选择目标芯片与固件文件。');
} else {
setReadyHint('设备与文件都已就绪,可以直接开始刷写或执行一键刷写。');
}
}
export 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';
state.deviceSha256 = null;
if (typeof connectionMode === 'undefined' || connectionMode === 'ble') {
els.deviceName.textContent = '-';
els.deviceId.textContent = '-';
if (els.versionText) els.versionText.textContent = '未读取';
var pv = document.getElementById('pillVer');
if (pv) pv.textContent = 'v--';
}
resetBleStages();
hideBleVerifyInfo();
setConnectionState(false);
}
export function refreshTargetUi() {
const cfg = currentTarget();
updateBleStageLabels(cfg);
/* 与 WiFi 一致:文件由 syncBleFileFromSelectedTarget 按 flashTarget 绑定;
* 此处只刷新标签/包数/阶段说明。 */
if (state.selectedFile) {
const payloadSize = currentPayloadSize();
const packets = Math.ceil(state.selectedFile.size / payloadSize);
if (els.packetCount) els.packetCount.textContent = String(packets);
}
setConnectionState(!!state.server);
var espTab = document.getElementById('targetEsp32Tab');
var gdTab = document.getElementById('targetGd32Tab');
var isEsp = cfg.label === 'ESP32';
if (espTab) espTab.classList.toggle('active', isEsp);
if (gdTab) gdTab.classList.toggle('active', !isEsp);
var autoBtn = document.getElementById('autoFlashBtn');
if (autoBtn && !state.uploading && !state.autoFlashing) {
autoBtn.textContent = isEsp ? '一键刷写 ESP32' : '一键刷写 GD32';
}
}
+496
View File
@@ -0,0 +1,496 @@
// ==================== BLE OTA 数据写入与 ACK ====================
import { BLE_OTA_ACK_WINDOW, UI_BUILD } from './constants.js';
import { state, els, bleLog, currentTarget, currentPayloadSize, flowTarget, setConnectionState, sleep, toHex, hexToBytes } from './state.js';
import { setBleStage, setBleStageText, setBleStageError } from './stages.js';
import { showBleVerifyInfo, hideBleVerifyInfo } from './verify.js';
import { clearOtaResultWaiter, waitForOtaResult, nextOtaSessionId } from './session.js';
import { enableNotifications } from './gatt.js';
import { syncSelectedFileFromInput, updateFileInfo } from './file.js';
import { computeFileSha } from '../ota/file-handle.js';
import { formatBytes } from '../common/utils.js';
import { showToast } from '../common/toast.js';
export function setProgress(percent, text) {
const p = Math.max(0, Math.min(100, percent));
els.progressFill.style.width = `${p}%`;
els.progressPct.textContent = `${p.toFixed(0)}%`;
els.progressTxt.textContent = text;
// Toggle done class for green finish effect
if (p >= 100) {
els.progressFill.classList.add('done');
} else {
els.progressFill.classList.remove('done');
}
}
// ★ 以设备端 ACK 字节数为唯一进度数据源
// 仅当处于上传阶段(state.uploading)且未中止时才刷 UI,避免陈旧 ACK / abort 后覆盖
export function updateProgressFromAck(totalSize, packetTotal, ackBytes) {
if (!state.uploading) return;
if (state.abortRequested) return;
if (!Number.isFinite(totalSize) || totalSize <= 0) return;
const safeBytes = Math.max(0, Math.min(totalSize, ackBytes >>> 0));
const percent = (safeBytes / totalSize) * 100;
const payloadSize = Math.max(1, currentPayloadSize());
const ackedPackets = Math.min(packetTotal, Math.ceil(safeBytes / payloadSize));
const cfg = currentTarget();
setProgress(percent, `[${cfg.label}] 已确认 ${ackedPackets}/${packetTotal} 包`);
els.sentCount.textContent = String(ackedPackets);
// 阶段 4 detail 文本:只在写入阶段刷新,避免与 stage 5/6 冲突
var detailEl = document.getElementById('bsd4');
if (detailEl) detailEl.textContent = `${ackedPackets}/${packetTotal} 包`;
setBleStageText(`数据写入 ${Math.round(percent)}%`);
}
/** @description 构建 BLE OTA 数据包(seq + totalSize + payload + cmd + CRC)。 */
function buildPacket(seq, totalSize, payload, cmd) {
const packet = new Uint8Array(7 + payload.length);
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;
}
/** @description 将 64 字符十六进制 SHA-256 字符串转为 32 字节数组。 */
function sha256HexToBytes(shaHex) {
const hex = shaHex.toUpperCase();
const out = new Uint8Array(32);
for (let i = 0; i < 32; i++) {
out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
}
return out;
}
export function waitForAck(cmd, expectedSeq, timeoutMs = 8000, opts = {}) {
// ★ 中止快速退出:若 0x20 ABORTED 已先于本调用到达(ackWaiter 当时为 null),
// 这里立即 reject,避免在已中止的会话上盲等超时。
if (state.abortRequested) {
return Promise.reject(new Error('OTA 已中止'));
}
const gen = state.ackGeneration;
// ★ 提前返回:只要 cmd 匹配 + generation 匹配 + 字节数足够,即可 resolve
// 不再要求 lastAckSeq >= expectedSeq(与 handleNotify 保持一致)
if (state.lastAckCmd === cmd && gen === state.ackGeneration) {
const expectedBytes = opts.expectedTotalSize !== undefined
? Math.min((expectedSeq + 1) * (opts.payloadSize || 480), opts.expectedTotalSize)
: 0;
if (expectedBytes === 0 || state.lastAckBytes >= expectedBytes) {
return Promise.resolve({ seq: state.lastAckSeq, bytes: state.lastAckBytes });
}
// 字节数不够,不提前 resolve,等待后续 ACK
}
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, generation: gen,
expectedTotalSize: opts.expectedTotalSize,
payloadSize: opts.payloadSize,
};
});
}
/** @description 分块上传固件到 ESP32(通过 BLE GATT 写入)。 */
export async function uploadFirmware() {
// 热重载:有 handle 则重新读盘(本地编译会改同路径固件);否则用已选/input
if (state.selectedFileHandle) {
try {
const freshFile = await state.selectedFileHandle.getFile();
await updateFileInfo(freshFile);
} catch (e) {
bleLog('FILE', '重新读取文件失败: ' + e.message + ',将使用缓存版本', 'W');
}
} else if (!state.selectedFile) {
await syncSelectedFileFromInput();
}
if (!state.selectedFile || !state.dataInChar) return false;
const cfg = currentTarget();
state.uploadTargetKey = window.flashTarget || 'esp32';
const payloadSize = Math.max(32, Math.min(498, currentPayloadSize()));
const file = state.selectedFile;
state.abortRequested = false;
state.uploading = true;
state.lastUploadStatus = 'uploading';
const otaSessionId = nextOtaSessionId();
clearOtaResultWaiter();
if (typeof setActivePhase === 'function') {
setActivePhase(cfg.label === 'GD32' ? 'gd32Ota' : 'esp32Ota');
}
switchStageFlow('ble');
state.lastAckCmd = null;
state.lastAckSeq = -1;
state.lastAckBytes = 0;
state.ackGeneration = 0;
state.deviceSha256 = null;
state.lastEsp32DoneSession = null;
state.activeTotalSize = 0;
state.activePacketTotal = 0;
if (state.ackWaiter) {
clearTimeout(state.ackWaiter.timer);
state.ackWaiter = null;
}
setConnectionState(true);
// Stage 4: Data writing
setBleStage(4, '准备中...', 'active');
setBleStageText('正在准备数据写入...');
hideBleVerifyInfo();
try {
if (!state.notificationsEnabled) {
await enableNotifications();
}
if (!state.notificationsEnabled) {
throw new Error('DATA_OUT 通知未开启,无法使用 ACK 模式');
}
bleLog('OTA', `开始准备固件: target=${cfg.label}, file=${file.name}, payload=${payloadSize}, cmd=0x${cfg.packetCmd.toString(16).padStart(2, '0').toUpperCase()}, ackWindow=${BLE_OTA_ACK_WINDOW}`, 'D');
setProgress(0, `[${cfg.label}] 正在读取固件文件...`);
setBleStage(4, '读取固件...', null);
const fileBytes = new Uint8Array(await file.arrayBuffer());
const totalSize = fileBytes.length;
const packetTotal = Math.ceil(totalSize / payloadSize);
bleLog('OTA', `固件已读取: totalSize=${totalSize}, packetTotal=${packetTotal}, file.size=${file.size}`, 'D');
if (totalSize !== file.size) {
bleLog('OTA', `⚠️ file.size(${file.size}) ≠ arrayBuffer(${totalSize}),以 arrayBuffer 为准,请确认页面已 Ctrl+F5 刷新`, 'W');
}
/* 上传前始终按本次读到的内容重算 SHA,不沿用选文件时的旧值 */
var chipKey = (cfg.label === 'GD32') ? 'gd32' : 'esp32';
var shaHex = await computeFileSha(file, chipKey, fileBytes.buffer);
if (!shaHex) throw new Error('SHA-256 计算失败');
state.fileSha256 = shaHex;
var fileSha256 = shaHex.toLowerCase();
var shaElem = document.getElementById('fileSha256');
if (shaElem) shaElem.textContent = shaHex.toUpperCase();
setBleStage(4, '写入中...', null);
setBleStageText(`正在写入 ${packetTotal} 包数据...`);
// ★ 写入阶段已读出文件大小/包数,记录到 state 供 handleNotify 在 ACK 到达时使用
state.activeTotalSize = totalSize;
state.activePacketTotal = packetTotal;
els.sentCount.textContent = '0';
setProgress(0, `[${cfg.label}] 等待设备确认 0/${packetTotal} 包`);
bleLog('OTA', `开始写入 DATA_IN: build=${UI_BUILD}, target=${cfg.label}, file=${file.name}, total=${totalSize}, packets=${packetTotal}, payload=${payloadSize}, windowAck=${BLE_OTA_ACK_WINDOW}`, 'D');
const MAX_WINDOW_RETRIES = 3;
// 整分区预擦可长达数十秒;首包 ACK 超时必须覆盖 begin/erase
const BEGIN_READY_TIMEOUT_MS = 120000;
const WINDOW_ACK_TIMEOUT_MS = 8000;
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;
// Windows Web Bluetooth 同一时刻只允许一个 GATT 操作。逐包等待仅保证
// 提交顺序;实际吞吐由设备请求的高速 BLE 连接参数决定。
// ★ 不再在循环内 setProgress:进度完全由设备 ACK 后的 state.lastAckBytes 驱动
//
// ★ 首窗特殊流控:seq=0 触发设备 begin+整分区预擦(worker 线程,可阻塞很久)。
// 旧逻辑 sleep(200) 后立刻 withoutResponse 连发 → 队列满丢包 → 整窗重传,体感极慢。
// 现改为:seq=0 送达后等设备「首包落盘 ACK」再发 1..endSeq。
for (let seq = windowStart; seq <= endSeq; seq++) {
if (state.abortRequested) throw new Error('用户中止');
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);
if (seq === 0) {
try {
await state.dataInChar.writeValueWithResponse(packet);
bleLog('OTA', 'seq=0 已用 writeValueWithResponse 确认送达,等待设备 begin/预擦完成…', 'D');
setBleStageText('设备预擦 Flash 中,请等待…');
} catch (e) {
bleLog('OTA', `seq=0 写入失败: ${e.message}`, 'E');
throw new Error('seq=0 写入失败,OTA 会话无法启动: ' + e.message);
}
state.ackGeneration++;
const firstBytes = Math.min(payloadSize, totalSize);
try {
await waitForAck(cfg.packetCmd, 0, BEGIN_READY_TIMEOUT_MS, {
expectedTotalSize: totalSize,
payloadSize: payloadSize
});
} catch (e) {
throw new Error('等待设备 begin/预擦完成超时: ' + e.message);
}
if (state.lastAckBytes < firstBytes) {
throw new Error('设备 begin 就绪 ACK 字节不足: got=' + state.lastAckBytes + ' need=' + firstBytes);
}
bleLog('OTA', '设备 begin 就绪 (ackBytes=' + state.lastAckBytes + '),继续发送窗口数据', 'D');
setBleStageText('正在写入 ' + packetTotal + ' 包数据...');
continue;
}
/* Windows WebBT 同时只允许一个 GATT 操作,必须逐包 await,禁止 Promise.all 并发写 */
await state.dataInChar.writeValueWithoutResponse(packet);
}
state.ackGeneration++;
try {
await waitForAck(cfg.packetCmd, endSeq, WINDOW_ACK_TIMEOUT_MS, { expectedTotalSize: totalSize, payloadSize });
} catch (e) {
bleLog('OTA', `ACK 等待失败: ${e.message},进入重试`, 'W');
}
let expectedBytes = Math.min((endSeq + 1) * payloadSize, totalSize);
let retryCount = 0;
let prevAckBytes = state.lastAckBytes; // ★ 进度跟踪:检测重试是否有效
// ★ 仅以字节数判断是否完成,不依赖 ackOk(generation 不匹配时 ackOk 不可靠)
while (state.lastAckBytes < expectedBytes && retryCount < MAX_WINDOW_RETRIES) {
// ★ 中止检查:0x20 ABORTED 可能在上一轮 waitForAck 超时期间到达,
// 或在 sleep/续发过程中到达。立即抛出,避免无谓重试已中止的会话。
if (state.abortRequested) throw new Error('用户中止');
// 如果 ESP32 已确认所有数据(ACK 延迟到达),直接跳出
if (state.lastAckBytes >= expectedBytes) break;
// ★ 进度检测:如果重试后字节数没有增长,说明续传无效
if (retryCount > 0 && state.lastAckBytes <= prevAckBytes) {
bleLog('OTA', `上次重试暂未推进: ackBytes=${state.lastAckBytes} (前次=${prevAckBytes}),继续按缺口重试`, 'W');
}
prevAckBytes = state.lastAckBytes;
// 从 ESP32 实际确认的位置续传,而非重发整个窗口
const resumeSeq = Math.max(windowStart, Math.ceil(state.lastAckBytes / payloadSize));
const missing = expectedBytes - state.lastAckBytes;
const missingPackets = Math.ceil(missing / payloadSize);
retryCount++;
bleLog('OTA', `丢包检测: seq=${endSeq} 丢失${missing}字节(${missingPackets}包),从 seq=${resumeSeq} 续发 (非全窗口重发),第${retryCount}次`, 'W');
setBleStage(4, `续发 #${retryCount} (seq=${resumeSeq})`, null);
setBleStageText(`丢包续发中 (${retryCount}/${MAX_WINDOW_RETRIES})...`);
// ★ 等待设备端 reorder buffer flush + ACK 回传。
// 设备端可能正在 flush 缺口包(getReceived 跳跃增长),
// 留足时间让最新的 ACK 到达,避免重发已 flush 的包。
await sleep(10);
for (let seq = resumeSeq; seq <= endSeq; seq++) {
// ★ 中止检查:续发过程中可能收到 0x20 ABORTED
if (state.abortRequested) throw new Error('用户中止');
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);
// ★ 重试路径中 seq=0 也必须用 writeValueWithResponse,并等 begin 就绪 ACK
if (seq === 0) {
try {
await state.dataInChar.writeValueWithResponse(packet);
bleLog('OTA', '重试 seq=0 已送达,等待 begin 就绪 ACK…', 'I');
state.ackGeneration++;
try {
await waitForAck(cfg.packetCmd, 0, BEGIN_READY_TIMEOUT_MS, {
expectedTotalSize: totalSize,
payloadSize: payloadSize
});
} catch (e2) {
bleLog('OTA', '重试 seq=0 等待 begin ACK 失败: ' + e2.message, 'W');
}
} catch (e) {
bleLog('OTA', `重试 seq=0 写入失败: ${e.message}`, 'E');
}
} else {
await state.dataInChar.writeValueWithoutResponse(packet);
}
}
state.ackGeneration++;
try {
await waitForAck(cfg.packetCmd, endSeq, WINDOW_ACK_TIMEOUT_MS, { expectedTotalSize: totalSize, payloadSize });
} catch (e) {
bleLog('OTA', `重试 ACK 等待失败: ${e.message}`, 'W');
}
}
if (state.lastAckBytes < expectedBytes) {
const missing = expectedBytes - state.lastAckBytes;
throw new Error(`BLE 丢包检测: seq=${endSeq} 期望${expectedBytes}字节 实际${state.lastAckBytes}字节 丢失${missing}字节(${(missing / payloadSize).toFixed(1)}包) 已重试${retryCount}次`);
}
}
// Stage 4 done
// ★ 不再用本地 seq 推算固定百分比:进度以设备端 ACK 字节数为准
const confirmedPct = state.activeTotalSize > 0
? Math.min(100, (state.lastAckBytes >>> 0) / state.activeTotalSize * 100)
: 100;
// 阶段 4 完成后,base 进度:GD32 留 55% 给中继/校验,ESP32 留 10% 给收尾
const phaseBase = cfg.label === 'GD32' ? 45 : 90;
// ACK 推进到的进度 = 阶段 4 在整体里占的份额 (phaseBase) × 设备真实确认比例
const stage4Share = phaseBase * (confirmedPct / 100);
setBleStage(4, '完成', null);
setProgress(Math.min(phaseBase, stage4Share), `[${cfg.label}] 固件数据已全部写入 DATA_IN`);
// Stage 5: 发送 0xF8 + 32B 文件 SHA-256,由设备端执行收尾并完成比对
if (cfg.label === 'ESP32') {
state.deviceSha256 = null;
const frontSha = fileSha256.toUpperCase();
const frontShaBytes = sha256HexToBytes(fileSha256);
const trigger = new Uint8Array(35);
trigger[0] = 0xF8;
trigger.set(frontShaBytes, 1);
trigger[33] = otaSessionId & 0xFF;
trigger[34] = (otaSessionId >> 8) & 0xFF;
const resultPromise = waitForOtaResult('esp32', otaSessionId, 45000);
showBleVerifyInfo(frontSha, '等待设备端比对...', { text: '已发送文件 SHA-256,等待设备端校验结果' });
setBleStage(5, '发送校验...', 'active');
setBleStageText('发送 0xF8 + SHA-256,等待设备端校验...');
try {
await state.ctrlChar.writeValue(trigger);
bleLog('OTA', '0xF8 + 32B SHA-256 已发送,等待设备端校验', 'D');
} catch (e) {
clearOtaResultWaiter();
state.lastUploadStatus = 'failed';
setProgress(0, `0xF8 发送失败: ${e.message}`);
bleLog('OTA', `0xF8 发送失败: ${e.message}`, 'E');
setBleStageError('0xF8 发送失败');
return false;
}
setBleStage(5, '等待设备端校验', null);
setBleStage(6, '设备端收尾中', 'active');
setBleStageText('0xF8 已发,等待设备端完成 OTA 收尾...');
try {
await resultPromise;
} catch (e) {
state.lastUploadStatus = 'failed';
setProgress(0, e.message);
setBleStageError(e.message);
clearOtaResultWaiter();
return false;
}
return true;
}
// GD32: send 0xF7 + 32B SHA-256 + session, then wait for SPI relay result.
const frontSha = fileSha256.toUpperCase();
const frontShaBytes = sha256HexToBytes(fileSha256);
state.deviceSha256 = null;
setBleStage(5, '发送触发...', 'active');
setBleStageText('发送 0xF7 + SHA-256,等待设备端缓存校验...');
showBleVerifyInfo(frontSha, '等待设备端比对...', { text: '已发送文件 SHA-256,等待 GD32 缓存校验' });
await sleep(30);
const cmdHex = cfg.ctrlHex;
const cmdBase = hexToBytes(cmdHex);
const cmd = new Uint8Array(cmdBase.length + 32 + 2);
cmd.set(cmdBase, 0);
cmd.set(frontShaBytes, cmdBase.length);
cmd[cmdBase.length + 32] = otaSessionId & 0xFF;
cmd[cmdBase.length + 33] = (otaSessionId >> 8) & 0xFF;
const resultPromise = waitForOtaResult('gd32', otaSessionId, 120000);
try {
await state.ctrlChar.writeValue(cmd);
setBleStage(5, '已触发', null);
setBleStage(6, '等待设备端校验', 'active');
bleLog('OTA', `0xF7 + 32B SHA-256 已发送,等待设备端缓存校验与转发`, 'D');
} catch (_) {
clearOtaResultWaiter();
state.lastUploadStatus = 'failed';
setProgress(0, `触发命令发送失败: ${toHex(cmd)}`);
bleLog('OTA', `触发命令发送失败: ${toHex(cmd)}`, 'E');
setBleStageError('触发命令发送失败');
return false;
}
setBleStageText('GD32 缓存校验已发起,等待 SPI 转发流程...');
try {
await resultPromise;
} catch (e) {
state.lastUploadStatus = 'failed';
setProgress(0, e.message);
setBleStageError(e.message);
clearOtaResultWaiter();
return false;
}
bleLog('OTA', `✅ GD32 OTA 刷写完成!`);
bleLog('OTA', `📊 文件 SHA-256: ${fileSha256.toUpperCase()}`);
return true;
} catch (err) {
// ★ 中止不算失败:0x20 ABORTED handler 已更新 UI(progress/stage/toast),
// 这里只记录日志并返回 false,避免把"用户中止"误报为写入失败。
if (state.abortRequested || state.lastUploadStatus === 'aborted') {
bleLog('OTA', `写入已中止: ${err.message}`, 'W');
return false;
}
state.lastUploadStatus = 'failed';
state.expectedReboot = false; // ★ 刷写失败,取消重连退避
state.reconnecting = false;
state.reconnectAttempts = 0;
bleLog('OTA', `写入失败: ${err.message}`, 'E');
setProgress(0, `写入失败: ${err.message}`);
setBleStageError(err.message);
// ★ BLE OTA 失败后不再强制断开 BLE:错误 FINISH 已通过 0xFB 路径或设备端
// 自动 abort 清理了 ESP32 OTA 会话。保留连接让用户可以快速重试或查看日志。
// 仅当连接已半死(gatt.connected=false)时由 onDisconnected 走重连路径。
return false;
} finally {
if (state.ackWaiter) {
clearTimeout(state.ackWaiter.timer);
state.ackWaiter = null;
}
if (state.lastUploadStatus === 'failed' || state.lastUploadStatus === 'aborted') {
clearOtaResultWaiter();
// 终态后允许下一次 uploadFirmware 干净起步(abortRequested 在下次入口也会清)
// 此处不强制清 abortRequested:中止循环可能仍在 unwind
}
state.uploading = false;
state.uploadTargetKey = null;
setConnectionState(!!state.server);
}
}
/** @description 自动刷写流程:上传 ESP32 固件 → SHA验证 → 触发 GD32 中继。 */
export async function autoFlash() {
/* 与 uploadFirmware 一致:有 handle 则重新读盘,SHA 在 upload 内按内容重算 */
if (state.selectedFileHandle) {
try {
const freshFile = await state.selectedFileHandle.getFile();
await updateFileInfo(freshFile);
} catch (e) {
bleLog('FILE', '重新读取文件失败: ' + e.message + ',将使用缓存版本', 'W');
}
} else if (!state.selectedFile) {
await syncSelectedFileFromInput();
}
if (!state.selectedFile || !state.server) return;
const cfg = currentTarget();
state.autoFlashing = true;
try {
bleLog('AUTO', `开始一键刷写 -> ${cfg.label}`);
const ok = await uploadFirmware();
if (!ok || state.abortRequested) {
bleLog('AUTO', '一键刷写中止', 'W');
// ★ 中止/失败后不再断开 BLE:0xFB 已让设备端清理 OTA 会话,保留连接方便重试。
// 若连接已半死,onDisconnected 会自动走重连路径。
return;
}
if (cfg.label === 'ESP32') {
setProgress(100, '[ESP32] OTA 完成,等待设备重启');
bleLog('AUTO', 'ESP32 OTA 完成,等待设备重启');
} else {
setProgress(100, '[GD32] OTA 转发完成,APP 已启动');
bleLog('AUTO', 'GD32 OTA 完成');
}
} catch (err) {
bleLog('AUTO', `一键刷写异常: ${err && err.message ? err.message : err}`, 'E');
setProgress(0, `一键刷写异常: ${err && err.message ? err.message : err}`);
} finally {
state.autoFlashing = false;
setConnectionState(!!state.server);
}
}
+26
View File
@@ -0,0 +1,26 @@
// ==================== BLE OTA SHA-256 校验展示 ====================
export function showBleVerifyInfo(frontSha, devSha, result) {
var panel = document.getElementById('stageVerifyInfo');
if (panel) { panel.className = 'stage-verify-info'; panel.style.display = 'block'; }
if (frontSha !== undefined) {
var frontEl = document.getElementById('sviFrontSha');
if (frontEl) frontEl.textContent = frontSha || '-';
}
if (devSha !== undefined) {
var devEl = document.getElementById('sviDeviceSha');
if (devEl) devEl.textContent = devSha || '-';
}
if (result) {
var resultEl = document.getElementById('sviResult');
if (resultEl) {
resultEl.textContent = result.text || '-';
resultEl.className = 'svi-val' + (result.ok ? ' ok' : result.err ? ' err' : '');
}
}
}
export function hideBleVerifyInfo() {
var panel = document.getElementById('stageVerifyInfo');
if (panel) { panel.className = 'stage-verify-info'; panel.style.display = 'none'; }
}
+43
View File
@@ -0,0 +1,43 @@
/**
* ble_ota.js - BLE OTA 控制台入口
*
* 薄入口:import 所有 ble/* 模块,组装并导出 window.bleOta API 对象,
* 调用 init() 完成事件绑定与 UI 初始化。
*/
import { UI_BUILD } from './ble/constants.js';
import {
state, els, bleLog, refreshTargetUi, setConnectionState,
resetUiAfterDisconnect
} from './ble/state.js';
import { resetBleStages } from './ble/stages.js';
import { connectBle, disconnectBle } from './ble/gatt.js';
import { clearSelectedFile, notifyFileChange } from './ble/file.js';
import { autoFlash } from './ble/transfer.js';
import { abortTask, bindEvents } from './ble/session.js';
import { showToast } from './common/toast.js';
window.bleOta = {
init: function() {
bindEvents();
refreshTargetUi();
setConnectionState(false);
resetBleStages();
bleLog('SYS', `Web Bluetooth 刷写控制台已就绪 (${UI_BUILD})`);
},
state: state,
disconnect: disconnectBle,
showToast: showToast,
notifyFileChange: notifyFileChange,
clearFile: clearSelectedFile,
refreshButtons: function() {
setConnectionState(!!state.server);
},
refreshTargetUi: refreshTargetUi,
// ★ 暴露上传状态查询,供 index.html 的 selectFlashTarget 等外部守卫使用
isUploading: function() {
return !!(state.uploading || state.autoFlashing);
},
};
// 脚本以 type="module" 加载(默认 defer),DOM 元素已就绪,直接初始化
window.bleOta.init();
+420
View File
@@ -0,0 +1,420 @@
/**
* can.js - CAN 工具页面入口(ESM 组装层)
*
* CAN 页面的编排逻辑:
* - cacheElements:缓存 DOM 元素到 el(dbc.js 导出的共享 el 对象)
* - setConnectionState:顶栏 pills(连接/IP/RSSI/运行/版本)
* - testConnection:GET /system/status,静默刷新顶栏(5s)
* - clearDbc / handleDbcFile / handleDbcError:DBC 文件读取与解析流程编排
* - bindEvents:绑定所有事件监听器(调用子模块的 bindNodeFilterEvents 等)
* - init:页面初始化入口
*
* state / el 由 dbc.js 导出(ES Module live binding),cacheElements() 填充 el 后,
* 各子模块通过 import 引用同一对象,可见最新缓存结果。
*
* 注意:原 can.js 的 testConnection 与 common/connection.js 的 testConnection 实现不同
* (操作不同的 DOM 元素集),此处保留原 can.js 版本以保证行为一致。
*/
/* JS 间 import 一律不带 ?v=,版本戳只出现在 HTML 入口标签上,
* 避免同一模块因 query 不同被浏览器实例化两次(state/toast 单例分裂) */
import {
state, el, getBaseUrl, getDeviceIp, setDeviceIp, parseDbc, readDbcText, buildSummary,
rebuildMergedDbc, makeDbcSourceId
} from './can/dbc.js';
import {
updateDbcFileInfo,
filterMessages,
bindNodeFilterEvents,
renderMessageList,
selectMessage,
recomputeTransmitterFilterEmpty,
updateNodeFilterLabel,
updateBatchUi
} from './can/messages.js';
import {
ensureMessageDefaults,
updateEncodedPreview
} from './can/signals.js';
import {
renderHistory,
sendSelectedMessage,
sendCheckedMessages,
refreshManualHint,
sendManualFrame,
resetManualFields,
loadPreviewToManual
} from './can/send.js?v=20260801f';
import {
scheduleCanPersist,
loadCanSession,
bindCanPersistLifecycle
} from './can/persist.js';
import { escapeHtml as sanitizeHtml, formatUptime } from './common/utils.js';
import { showToast } from './common/toast.js';
import { initCanMonitor } from './can_monitor.js';
// ==================== 本地工具 ====================
// 原 can.js 的 formatUptime 输出格式与 common/utils.js 略有差异(d>0 时只到 h),
// 为保持原行为,此处复用 common/utils.js 的 formatUptime(已在 import 中引入)。
// 若后续需要对齐原 can.js 输出,可在此处覆盖本地实现。
// ==================== 元素缓存 ====================
function cacheElements() {
[
'toolIp', 'defaultChannel', 'manualChannel', 'manualId', 'manualDlc', 'manualData',
'dbcDropZone', 'dbcFileInput', 'dbcFileInfo', 'dbcMessageCount',
'dbcSignalCount', 'dbcNodeCount', 'dbcFileSize', 'dbcFileCount', 'dbcFileChips',
'dbcSearch', 'dbcHint', 'clearDbcBtn',
'importDbcBtn',
'messageList', 'sendSelectedBtn', 'generatedHex', 'generatedBytes', 'encodeHint',
'dbcResp', 'dbcRespText', 'loadPreviewToRawBtn', 'manualHint', 'sendManualBtn',
'resetManualBtn', 'manualResp', 'manualRespText', 'historyList', 'toast', 'toastIco',
'toastMsg', 'canConnPill', 'canConnTxt', 'canIpPill', 'canRssiPill', 'canUptimePill',
'canVersionPill', 'canChannelPill',
'dbcSummaryPill', 'messageSummaryPill',
'selectedCountPill', 'selectAllBox', 'expandAllBtn', 'collapseAllBtn', 'sendCheckedBtn',
'nodeFilterWrap', 'nodeFilterBtn', 'nodeFilterLabel', 'nodeFilterPanel', 'nodeFilterSearch',
'nodeFilterAllBtn', 'nodeFilterNoneBtn', 'nodeFilterList'
].forEach(function (id) {
el[id] = document.getElementById(id);
});
}
// ==================== 连接状态(仅顶栏 pills,与工作台去重) ====================
function setConnectionState(connected, text, buildVersion, buildTime, wifi, uptimeSec) {
if (el.canConnPill) {
el.canConnPill.className = 'pill ' + (connected ? 'online' : 'offline');
}
if (el.canConnTxt) el.canConnTxt.textContent = text;
wifi = wifi || {};
if (el.canIpPill) {
var ip = wifi.ip || getDeviceIp();
el.canIpPill.textContent = 'IP ' + (ip || '--');
}
if (el.canRssiPill) {
var rssiOk = wifi.rssi !== undefined && wifi.rssi !== null && wifi.rssi !== -127;
el.canRssiPill.textContent = rssiOk ? ('RSSI ' + wifi.rssi + ' dBm') : 'RSSI --';
}
if (el.canUptimePill) {
el.canUptimePill.textContent = '运行 ' + (uptimeSec !== undefined && uptimeSec !== null
? formatUptime(uptimeSec) : '--');
}
if (el.canVersionPill) {
el.canVersionPill.textContent = buildVersion ? ('v' + buildVersion) : 'v--';
el.canVersionPill.dataset.tooltip = buildTime ? ('编译: ' + buildTime) : '';
}
}
async function testConnection() {
var baseUrl = getBaseUrl();
try {
/* 状态端点禁用 HTTP 缓存,避免命中旧响应 */
var resp = await fetch(baseUrl + '/system/status', {
signal: AbortSignal.timeout(3000),
cache: 'no-store'
});
if (!resp.ok) {
setConnectionState(false, 'HTTP ' + resp.status, '', '', {}, null);
return;
}
var data = await resp.json();
state.latestStatus = data;
var wifi = data.wifi || {};
if (wifi.ip) setDeviceIp(wifi.ip);
var connected = !!wifi.connected;
var mode = wifi.mode || 'STA';
var text = connected ? (mode + ' 已连接') : (mode + ' 未连接');
setConnectionState(connected, text, data.version, data.build_time, wifi, data.uptime_sec);
} catch (err) {
setConnectionState(false, '连接失败', '', '', {}, null);
}
}
// ==================== DBC 文件流程(支持多文件叠加) ====================
function resetSelectionState() {
state.selectedMessageKey = '';
state.signalValues = {};
state.checkedKeys = {};
state.expandedKeys = {};
state.transmitterFilter = Object.create(null);
state.transmitterFilterEmpty = true;
state.generatedData = new Uint8Array(0);
}
function afterDbcSourcesChanged(opts) {
opts = opts || {};
rebuildMergedDbc();
if (opts.resetSelection) resetSelectionState();
updateDbcFileInfo();
buildSummary();
filterMessages();
if (state.dbc && state.dbc.messages.length) {
if (opts.resetSelection || !state.selectedMessageKey) {
selectMessage(state.dbc.messages[0].key);
} else {
updateEncodedPreview();
}
} else {
updateEncodedPreview();
}
if (el.dbcHint) {
el.dbcHint.hidden = true;
el.dbcHint.textContent = '';
}
if (!opts.skipPersist) scheduleCanPersist();
}
/**
* 添加一个或多个 DBC。同名文件覆盖旧源;不同名叠加。
* @param {FileList|File[]} fileList
*/
async function handleDbcFiles(fileList) {
var files = Array.prototype.slice.call(fileList || []).filter(function (f) {
return f && f.name;
});
if (!files.length) return;
var added = 0;
var replaced = 0;
var errors = [];
for (var i = 0; i < files.length; i++) {
var file = files[i];
try {
var text = await readDbcText(file);
var dbc = parseDbc(text);
if (!dbc.messages.length) {
errors.push(file.name + '(无报文)');
continue;
}
var existingIdx = -1;
for (var j = 0; j < state.dbcSources.length; j++) {
if (state.dbcSources[j].name === file.name) {
existingIdx = j;
break;
}
}
var entry = {
id: existingIdx >= 0 ? state.dbcSources[existingIdx].id : makeDbcSourceId(file.name),
name: file.name,
size: file.size || 0,
text: text, /* 原文,供刷新后恢复 */
dbc: dbc
};
if (existingIdx >= 0) {
state.dbcSources[existingIdx] = entry;
replaced++;
} else {
state.dbcSources.push(entry);
added++;
}
} catch (err) {
errors.push(file.name + '(' + (err && err.message ? err.message : err) + ')');
}
}
/* 仅在确有新增/覆盖时才 reset 并刷新 UI;
* 全部解析失败时保留用户当前选中/勾选/信号值,只弹错误提示。 */
if (added || replaced) {
afterDbcSourcesChanged({ resetSelection: true });
}
if (added || replaced) {
var msg = 'DBC:';
if (added) msg += '新增 ' + added;
if (added && replaced) msg += ',';
if (replaced) msg += '覆盖 ' + replaced;
msg += ',合计 ' + state.dbcSources.length + ' 个文件';
showToast(msg, 'success');
}
if (errors.length) {
showToast('部分失败: ' + errors.slice(0, 2).join('; '), 'warning');
if (el.dbcHint) {
el.dbcHint.hidden = false;
el.dbcHint.className = 'helper-note err';
el.dbcHint.innerHTML = '导入问题:' + sanitizeHtml(errors.join(';'));
}
}
}
function removeDbcSource(sourceId) {
state.dbcSources = state.dbcSources.filter(function (s) { return s.id !== sourceId; });
afterDbcSourcesChanged({ resetSelection: true });
if (el.generatedHex) el.generatedHex.textContent = '--';
if (el.generatedBytes) el.generatedBytes.innerHTML = '';
showToast(state.dbcSources.length ? '已移除 1 个 DBC' : '已清除全部 DBC', 'success');
}
function clearDbc() {
state.dbcSources = [];
state.dbc = null;
state.filteredMessages = [];
resetSelectionState();
if (el.dbcFileInput) el.dbcFileInput.value = '';
if (el.dbcSearch) el.dbcSearch.value = '';
afterDbcSourcesChanged({ resetSelection: true });
/* 历史/原始帧表单仍保留并写回 localStorage */
if (el.generatedHex) el.generatedHex.textContent = '--';
if (el.generatedBytes) el.generatedBytes.innerHTML = '';
}
function handleDbcError(err) {
if (el.dbcHint) {
el.dbcHint.hidden = false;
el.dbcHint.className = 'helper-note err';
el.dbcHint.innerHTML = 'DBC 解析失败:' + sanitizeHtml(err.message);
}
showToast('DBC 解析失败: ' + err.message, 'error');
}
// ==================== 事件绑定 ====================
function bindEvents() {
if (el.defaultChannel) {
el.defaultChannel.addEventListener('change', function () {
if (el.manualChannel) el.manualChannel.value = el.defaultChannel.value;
if (el.canChannelPill) el.canChannelPill.textContent = 'CH' + el.defaultChannel.value;
renderMessageList();
updateEncodedPreview();
scheduleCanPersist();
});
}
function openDbcPicker() {
if (el.dbcFileInput) el.dbcFileInput.click();
}
if (el.importDbcBtn) el.importDbcBtn.addEventListener('click', openDbcPicker);
if (el.dbcDropZone) {
el.dbcDropZone.addEventListener('dragover', function (event) {
event.preventDefault();
el.dbcDropZone.classList.add('dragover');
});
el.dbcDropZone.addEventListener('dragleave', function (event) {
if (!el.dbcDropZone.contains(event.relatedTarget)) {
el.dbcDropZone.classList.remove('dragover');
}
});
el.dbcDropZone.addEventListener('drop', function (event) {
event.preventDefault();
el.dbcDropZone.classList.remove('dragover');
var files = event.dataTransfer && event.dataTransfer.files ? event.dataTransfer.files : null;
if (files && files.length) handleDbcFiles(files).catch(handleDbcError);
});
el.dbcDropZone.addEventListener('click', function (event) {
var removeBtn = event.target.closest('[data-remove-id]');
if (removeBtn) {
event.preventDefault();
event.stopPropagation();
removeDbcSource(removeBtn.getAttribute('data-remove-id'));
return;
}
if (state.dbcSources.length) return;
if (event.target.closest('button, a, input, select, label')) return;
openDbcPicker();
});
}
if (el.dbcFileInput) {
el.dbcFileInput.addEventListener('change', function (event) {
var files = event.target && event.target.files ? event.target.files : null;
if (files && files.length) handleDbcFiles(files).catch(handleDbcError);
event.target.value = '';
});
}
if (el.clearDbcBtn) el.clearDbcBtn.addEventListener('click', clearDbc);
if (el.dbcSearch) {
el.dbcSearch.addEventListener('input', function () {
filterMessages();
scheduleCanPersist();
});
}
if (el.sendSelectedBtn) el.sendSelectedBtn.addEventListener('click', sendSelectedMessage);
if (el.loadPreviewToRawBtn) el.loadPreviewToRawBtn.addEventListener('click', loadPreviewToManual);
if (el.sendManualBtn) el.sendManualBtn.addEventListener('click', sendManualFrame);
if (el.resetManualBtn) el.resetManualBtn.addEventListener('click', function () {
resetManualFields();
scheduleCanPersist();
});
function onManualFieldChange() {
refreshManualHint();
scheduleCanPersist();
}
/* 手动帧三字段统一守卫,避免任一元素缺失时后续绑定被中断 */
['manualData', 'manualDlc', 'manualId'].forEach(function (id) {
if (el[id]) el[id].addEventListener('input', onManualFieldChange);
});
if (el.manualChannel) el.manualChannel.addEventListener('change', scheduleCanPersist);
if (el.sendCheckedBtn) el.sendCheckedBtn.addEventListener('click', sendCheckedMessages);
if (el.selectAllBox) el.selectAllBox.addEventListener('change', function () {
var visible = state.filteredMessages || [];
if (this.checked) {
visible.forEach(function (m) { state.checkedKeys[m.key] = true; });
} else {
visible.forEach(function (m) { delete state.checkedKeys[m.key]; });
}
renderMessageList();
scheduleCanPersist();
});
if (el.expandAllBtn) el.expandAllBtn.addEventListener('click', function () {
var visible = state.filteredMessages || [];
visible.forEach(function (m) { state.expandedKeys[m.key] = true; ensureMessageDefaults(m); });
renderMessageList();
scheduleCanPersist();
});
if (el.collapseAllBtn) el.collapseAllBtn.addEventListener('click', function () {
var visible = state.filteredMessages || [];
visible.forEach(function (m) { delete state.expandedKeys[m.key]; });
renderMessageList();
scheduleCanPersist();
});
bindNodeFilterEvents();
}
// ==================== 初始化 ====================
function init() {
cacheElements();
bindEvents();
bindCanPersistLifecycle();
var restoredInfo = loadCanSession();
setConnectionState(false, '未连接', '', '', { ip: getDeviceIp() }, null);
if (el.canChannelPill && el.defaultChannel) {
el.canChannelPill.textContent = 'CH' + el.defaultChannel.value;
}
if (el.manualChannel && el.defaultChannel && !restoredInfo.restored) {
el.manualChannel.value = el.defaultChannel.value;
}
recomputeTransmitterFilterEmpty();
updateNodeFilterLabel();
updateDbcFileInfo();
buildSummary();
filterMessages();
if (state.dbc && state.dbc.messages.length) {
if (state.selectedMessageKey) {
selectMessage(state.selectedMessageKey);
} else {
selectMessage(state.dbc.messages[0].key);
}
} else {
updateEncodedPreview();
}
updateBatchUi();
refreshManualHint();
renderHistory();
if (restoredInfo.restored && (restoredInfo.dbcCount || restoredInfo.historyCount)) {
var tip = '已恢复会话';
if (restoredInfo.dbcCount) tip += ':' + restoredInfo.dbcCount + ' 个 DBC';
if (restoredInfo.historyCount) {
tip += (restoredInfo.dbcCount ? ',' : ':') + restoredInfo.historyCount + ' 条发送记录';
}
showToast(tip, 'success');
}
setTimeout(testConnection, 150);
/* 周期性刷新顶栏 STA/IP/RSSI/运行时间(与监控 1Hz 解耦,避免重复 pill) */
setInterval(testConnection, 5000);
/* 右侧抽屉:CAN 接收监控 */
initCanMonitor();
}
init();
+350
View File
@@ -0,0 +1,350 @@
/**
* dbc.js - DBC 解析器 + 共享状态/元素缓存/基础工具
*
* 包含:共享 state/el、基础工具、DBC 解析、cssEscape、readDbcText。
*
* 本模块是 can 子系统的基础层,不依赖其他 can 子模块;state/el 在此导出,
* 供 messages/signals/send 共享(ES Module 的 live binding 保证引用一致)。
*/
import {
DEFAULT_ESP_IP, getSavedDeviceIp, saveDeviceIp
} from '../common/base-url.js';
// ==================== 共享状态 ====================
export var state = {
/** @type {{ id: string, name: string, size: number, dbc: object }[]} */
dbcSources: [],
/** 合并视图 { messages, nodeNames, messageMap };由 rebuildMergedDbc() 维护 */
dbc: null,
filteredMessages: [],
selectedMessageKey: '',
signalValues: {},
checkedKeys: {},
expandedKeys: {},
transmitterFilter: Object.create(null),
transmitterFilterEmpty: true,
generatedData: new Uint8Array(0),
history: [],
latestStatus: null,
batchInFlight: false
};
// ==================== 共享元素缓存 ====================
// 由入口文件(js/can.js)的 cacheElements() 填充,各子模块通过 import 引用同一对象。
export var el = {};
// ==================== 基础工具 ====================
// IP 存取通用实现在 common/base-url.js,此处包装 CAN 页特有的 el.toolIp 回退。
/** @description 设备 IP:优先 localStorage(与 OTA 页共用),其次页面输入框,否则默认。 */
export function getDeviceIp() {
var saved = getSavedDeviceIp();
if (saved) return saved;
if (el.toolIp && el.toolIp.value && el.toolIp.value.trim()) {
return el.toolIp.value.trim();
}
return DEFAULT_ESP_IP;
}
/** @description 记住设备 IP(status 回报或其它页写入)。 */
export function setDeviceIp(ip) {
if (!ip || !String(ip).trim()) return;
var v = String(ip).trim();
saveDeviceIp(v);
if (el.toolIp) el.toolIp.value = v;
}
/** @description 拼接设备基础 URL(http://<ip>)。 */
export function getBaseUrl() {
return 'http://' + getDeviceIp();
}
/** @description 解析手动输入的 CAN ID(支持 0x/hex/十进制)。 */
export function parseNumericId(input) {
var raw = String(input == null ? '' : input).trim();
if (!raw) return NaN;
if (/^0x/i.test(raw)) return parseInt(raw, 16);
if (/^[0-9]+$/.test(raw)) return parseInt(raw, 10);
if (/^[0-9a-fA-F]+h$/i.test(raw)) return parseInt(raw.slice(0, -1), 16);
if (/^[0-9a-fA-F]+$/.test(raw)) return parseInt(raw, 16);
return NaN;
}
/** @description 将数值格式化为指定宽度的十六进制大写字符串。 */
export function padHex(value, width) {
return value.toString(16).toUpperCase().padStart(width, '0');
}
/** @description 将字节数组转为连续的十六进制字符串。 */
export function bytesToHex(bytes) {
if (!bytes || !bytes.length) return '';
var parts = [];
for (var i = 0; i < bytes.length; i++) {
parts.push(padHex(bytes[i], 2));
}
return parts.join('');
}
/** @description 转义 CSS 选择器中的特殊字符。 */
export function cssEscape(value) {
if (window.CSS && CSS.escape) return CSS.escape(value);
/* fallback:每个非安全字符前加单个反斜杠(CSS 转义),双引号属性值内可安全使用 */
return String(value).replace(/[^a-zA-Z0-9_-]/g, function (ch) { return '\\' + ch; });
}
/** @description 去除回车并 trim,用于 DBC 搜索等输入归一化。 */
export function normalizeWhitespace(text) {
return String(text || '').replace(/\r/g, '').trim();
}
// ==================== DBC 文件读取 ====================
/** @description 读取 DBC 文件文本,优先 UTF-8,失败回退 GB18030,自动去 BOM。 */
export async function readDbcText(file) {
var buf = await file.arrayBuffer();
var bytes = new Uint8Array(buf);
var stripBom = function (s) {
return (s && s.charCodeAt(0) === 0xFEFF) ? s.slice(1) : s;
};
try {
var strictUtf8 = new TextDecoder('utf-8', { fatal: true });
return stripBom(strictUtf8.decode(bytes));
} catch (e) {
try {
var gb = new TextDecoder('gb18030');
return stripBom(gb.decode(bytes));
} catch (e2) {
return stripBom(new TextDecoder('utf-8').decode(bytes));
}
}
}
// ==================== DBC 解析 ====================
/** @description 解析 DBC 文本,返回 { messages, nodeNames, messageMap }。 */
export function parseDbc(text) {
var lines = String(text || '').replace(/\r/g, '').split('\n');
var messages = [];
var messageMap = {};
var comments = { messages: {}, signals: {} };
var valueTables = {};
var sigDefaults = {};
var nodeNames = [];
var current = null;
for (var i = 0; i < lines.length; i++) {
var line = lines[i].trim();
if (!line) continue;
var nodeMatch = line.match(/^BU_\s*:\s*(.*)$/);
if (nodeMatch) {
nodeNames = nodeMatch[1].trim() ? nodeMatch[1].trim().split(/\s+/) : [];
continue;
}
var messageMatch = line.match(/^BO_\s+(\d+)\s+([A-Za-z0-9_]+)\s*:\s*(\d+)\s+(.+)$/);
if (messageMatch) {
current = {
idRaw: parseInt(messageMatch[1], 10),
name: messageMatch[2],
dlc: parseInt(messageMatch[3], 10),
transmitter: messageMatch[4].trim(),
signals: [],
comment: ''
};
current.id = current.idRaw & 0x1FFFFFFF;
current.isExtended = current.idRaw > 0x7FF;
current.key = String(current.idRaw) + ':' + current.name;
messages.push(current);
messageMap[current.idRaw] = current;
continue;
}
var signalMatch = line.match(/^SG_\s+([A-Za-z0-9_]+)(?:\s+((?:M|m-?\d+)))?\s*:\s*(\d+)\|(\d+)@([01])([+-])\s+\(([^\),]+),([^\)]+)\)\s+\[([^\|]*)\|([^\]]*)\]\s+"([^"]*)"\s+(.+)$/);
if (signalMatch && current) {
var muxInfo = parseMultiplexToken(signalMatch[2]);
current.signals.push({
name: signalMatch[1],
startBit: parseInt(signalMatch[3], 10),
length: parseInt(signalMatch[4], 10),
byteOrder: signalMatch[5] === '1' ? 'little' : 'big',
isSigned: signalMatch[6] === '-',
factor: parseFloat(signalMatch[7]),
offset: parseFloat(signalMatch[8]),
min: signalMatch[9].trim() === '' ? null : parseFloat(signalMatch[9]),
max: signalMatch[10].trim() === '' ? null : parseFloat(signalMatch[10]),
unit: signalMatch[11],
receivers: signalMatch[12].split(/\s*,\s*/),
comment: '',
valueType: 'int',
enumMap: {},
defaultValue: null,
multiplexRole: muxInfo.role,
multiplexValue: muxInfo.value
});
continue;
}
var messageCommentMatch = line.match(/^CM_\s+BO_\s+(\d+)\s+"([\s\S]*)";$/);
if (messageCommentMatch) {
comments.messages[messageCommentMatch[1]] = messageCommentMatch[2];
continue;
}
var signalCommentMatch = line.match(/^CM_\s+SG_\s+(\d+)\s+([A-Za-z0-9_]+)\s+"([\s\S]*)";$/);
if (signalCommentMatch) {
comments.signals[signalCommentMatch[1] + ':' + signalCommentMatch[2]] = signalCommentMatch[3];
continue;
}
var valueMatch = line.match(/^VAL_\s+(\d+)\s+([A-Za-z0-9_]+)\s+(.+);$/);
if (valueMatch) {
var table = {};
var pairRe = /(-?\d+)\s+"([^"]*)"/g;
var m;
while ((m = pairRe.exec(valueMatch[3])) !== null) {
table[m[1]] = m[2];
}
valueTables[valueMatch[1] + ':' + valueMatch[2]] = table;
continue;
}
var valTypeMatch = line.match(/^SIG_VALTYPE_\s+(\d+)\s+([A-Za-z0-9_]+)\s*:\s*(\d+)\s*;$/);
if (valTypeMatch) {
var msg = messageMap[valTypeMatch[1]];
if (!msg) continue;
var signal = findSignal(msg, valTypeMatch[2]);
if (signal) {
signal.valueType = valTypeMatch[3] === '1' ? 'float' : valTypeMatch[3] === '2' ? 'double' : 'int';
}
continue;
}
/* BA_ "GenSigDefaultValue" SG_ <msgId> <signalName> <value>;
* 注意该值是物理值(已应用 factor/offset),与 GenSigStartValue(raw)不同 */
var sigDefaultMatch = line.match(/^BA_\s+"GenSigDefaultValue"\s+SG_\s+(\d+)\s+([A-Za-z0-9_]+)\s+(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)\s*;$/);
if (sigDefaultMatch) {
sigDefaults[sigDefaultMatch[1] + ':' + sigDefaultMatch[2]] = sigDefaultMatch[3];
continue;
}
}
messages.forEach(function (msg) {
msg.comment = comments.messages[String(msg.idRaw)] || '';
msg.signals.forEach(function (signal) {
signal.comment = comments.signals[String(msg.idRaw) + ':' + signal.name] || '';
signal.enumMap = valueTables[String(msg.idRaw) + ':' + signal.name] || {};
var dv = sigDefaults[String(msg.idRaw) + ':' + signal.name];
signal.defaultValue = (dv != null && Number.isFinite(Number(dv))) ? Number(dv) : null;
});
});
return {
messages: messages,
nodeNames: nodeNames,
messageMap: messageMap
};
}
/** @description 在报文的 signals 数组中按名称查找信号。 */
export function findSignal(message, signalName) {
for (var i = 0; i < message.signals.length; i++) {
if (message.signals[i].name === signalName) return message.signals[i];
}
return null;
}
/**
* @description 按 key 在当前合并 DBC 中查找报文(O(1),走 messageMap)。
* messageMap 由 rebuildMergedDbc() 维护,key 格式与 message.key / state.selectedMessageKey 一致。
* 无 DBC 或 key 不存在时返回 null。
*/
export function findMessageByKey(key) {
if (!state.dbc || !state.dbc.messageMap || !key) return null;
return state.dbc.messageMap[key] || null;
}
/** @description 解析 SG_ 行的 multiplex token(M / mN / 空)。 */
export function parseMultiplexToken(token) {
if (!token) return { role: null, value: null };
if (token === 'M') return { role: 'mux', value: null };
var match = token.match(/^m(-?\d+)$/);
if (match) {
return { role: 'value', value: parseInt(match[1], 10) };
}
return { role: null, value: null };
}
/**
* 将多个 DBC 源合并为统一 messages 列表。
* 每条报文 key = sourceId:idRaw:name,避免不同文件同 ID/同名冲突。
*/
export function rebuildMergedDbc() {
if (!state.dbcSources.length) {
state.dbc = null;
return null;
}
var messages = [];
var nodeSet = Object.create(null);
var nodeNames = [];
var messageMap = {};
for (var s = 0; s < state.dbcSources.length; s++) {
var src = state.dbcSources[s];
var dbc = src.dbc;
if (!dbc || !dbc.messages) continue;
for (var n = 0; n < (dbc.nodeNames || []).length; n++) {
var nn = dbc.nodeNames[n];
if (nn && !nodeSet[nn]) {
nodeSet[nn] = 1;
nodeNames.push(nn);
}
}
for (var i = 0; i < dbc.messages.length; i++) {
var msg = dbc.messages[i];
var merged = {
idRaw: msg.idRaw,
name: msg.name,
dlc: msg.dlc,
transmitter: msg.transmitter,
signals: msg.signals,
comment: msg.comment,
id: msg.id,
isExtended: msg.isExtended,
sourceId: src.id,
sourceName: src.name,
key: src.id + ':' + msg.idRaw + ':' + msg.name
};
messages.push(merged);
messageMap[merged.key] = merged;
}
}
state.dbc = { messages: messages, nodeNames: nodeNames, messageMap: messageMap };
return state.dbc;
}
/** @description 生成唯一 source id(同名文件可多次添加时追加 #2) */
export function makeDbcSourceId(fileName) {
var base = String(fileName || 'dbc').replace(/[^\w.\-]+/g, '_');
var id = base;
var n = 2;
while (state.dbcSources.some(function (s) { return s.id === id; })) {
id = base + '#' + n;
n++;
}
return id;
}
/** @description 更新顶部 DBC / 报文数量摘要 pill。 */
export function buildSummary() {
if (!state.dbc || !state.dbcSources.length) {
if (el.dbcSummaryPill) el.dbcSummaryPill.textContent = '未导入 DBC';
if (el.messageSummaryPill) el.messageSummaryPill.textContent = '0 报文';
return;
}
var nFiles = state.dbcSources.length;
if (el.dbcSummaryPill) {
el.dbcSummaryPill.textContent =
nFiles + ' 文件 · ' + (state.dbc.nodeNames.length || 0) + ' 节点';
}
if (el.messageSummaryPill) {
el.messageSummaryPill.textContent = state.dbc.messages.length + ' 报文';
}
}
+390
View File
@@ -0,0 +1,390 @@
/**
* messages.js - 报文列表管理(渲染、过滤、节点过滤、批量 UI)
*
* 包含:DBC 文件信息、报文过滤、节点过滤面板、报文列表渲染、报文选择/展开、批量 UI。
*
* 依赖:dbc.js(state/el/padHex/normalizeWhitespace/cssEscape)、
* common/utils.js(escapeHtml/formatBytes)、
* signals.js(renderInlineSignalGrid/ensureMessageDefaults/updateEncodedPreview)。
*/
import { state, el, padHex, normalizeWhitespace, cssEscape, findMessageByKey } from './dbc.js';
import { escapeHtml, formatBytes } from '../common/utils.js';
import { renderInlineSignalGrid, ensureMessageDefaults, updateEncodedPreview } from './signals.js';
import { scheduleCanPersist } from './persist.js';
// ==================== DBC 文件信息 ====================
/** @description 更新 DBC 文件信息卡片(文件名/报文数/信号数/节点数/大小),并刷新多 DBC 摘要条与文件 chip 列表。 */
export function updateDbcFileInfo() {
var sources = state.dbcSources || [];
var has = sources.length > 0;
var dbc = state.dbc;
if (el.dbcFileInfo) {
el.dbcFileInfo.hidden = !has;
el.dbcFileInfo.classList.toggle('show', has);
}
/* 有文件时仍显示「添加」,文案改成添加;「全部清除」紧挨添加按钮 */
if (el.importDbcBtn) {
el.importDbcBtn.hidden = false;
el.importDbcBtn.textContent = has ? '添加 DBC' : '导入 DBC';
}
if (el.clearDbcBtn) el.clearDbcBtn.hidden = !has;
if (el.dbcDropZone) el.dbcDropZone.classList.toggle('has-dbc', has);
if (!has || !dbc) {
if (el.dbcFileCount) el.dbcFileCount.textContent = '0';
if (el.dbcMessageCount) el.dbcMessageCount.textContent = '0';
if (el.dbcSignalCount) el.dbcSignalCount.textContent = '0';
if (el.dbcNodeCount) el.dbcNodeCount.textContent = '0';
if (el.dbcFileSize) el.dbcFileSize.textContent = '0 B';
if (el.dbcFileChips) el.dbcFileChips.innerHTML = '';
return;
}
var signalCount = dbc.messages.reduce(function (sum, msg) { return sum + msg.signals.length; }, 0);
var totalSize = sources.reduce(function (sum, s) { return sum + (s.size || 0); }, 0);
if (el.dbcFileCount) el.dbcFileCount.textContent = String(sources.length);
if (el.dbcMessageCount) el.dbcMessageCount.textContent = String(dbc.messages.length);
if (el.dbcSignalCount) el.dbcSignalCount.textContent = String(signalCount);
if (el.dbcNodeCount) el.dbcNodeCount.textContent = String(dbc.nodeNames.length);
if (el.dbcFileSize) el.dbcFileSize.textContent = formatBytes(totalSize);
if (el.dbcFileChips) {
el.dbcFileChips.innerHTML = sources.map(function (s) {
var nMsg = (s.dbc && s.dbc.messages) ? s.dbc.messages.length : 0;
return '' +
'<span class="dbc-file-chip" data-source-id="' + escapeHtml(s.id) + '" title="' + escapeHtml(s.name) + ' · ' + nMsg + ' 报文">' +
'<span class="dbc-file-chip-name">' + escapeHtml(s.name) + '</span>' +
'<button type="button" class="dbc-file-chip-remove" data-remove-id="' + escapeHtml(s.id) + '" title="移除此文件">×</button>' +
'</span>';
}).join('');
}
}
// ==================== 报文过滤 ====================
/** @description 根据 DBC 搜索关键字 + 发送节点过滤报文,并重新渲染列表。 */
export function filterMessages() {
var keyword = normalizeWhitespace(el.dbcSearch ? el.dbcSearch.value : '').toLowerCase();
var messages = state.dbc ? state.dbc.messages.slice() : [];
if (!state.transmitterFilterEmpty) {
messages = messages.filter(function (msg) {
var tx = msg.transmitter || 'Vector__XXX';
return !!state.transmitterFilter[tx];
});
}
if (keyword) {
messages = messages.filter(function (msg) {
var idHex = '0x' + padHex(msg.id, msg.isExtended ? 8 : 3);
if (msg.name.toLowerCase().indexOf(keyword) >= 0) return true;
if (idHex.toLowerCase().indexOf(keyword) >= 0) return true;
if (String(msg.idRaw).indexOf(keyword) >= 0) return true;
if ((msg.transmitter || '').toLowerCase().indexOf(keyword) >= 0) return true;
for (var i = 0; i < msg.signals.length; i++) {
if (msg.signals[i].name.toLowerCase().indexOf(keyword) >= 0) return true;
}
return false;
});
}
state.filteredMessages = messages;
renderMessageList();
updateNodeFilterLabel();
}
// ==================== 发送节点过滤 ====================
/** @description 收集 DBC 中所有发送节点(去重 + 排序)。 */
function collectTransmitters() {
var set = Object.create(null);
var list = [];
if (!state.dbc) return list;
state.dbc.messages.forEach(function (msg) {
var tx = msg.transmitter || 'Vector__XXX';
if (!set[tx]) { set[tx] = 1; list.push(tx); }
});
list.sort(function (a, b) { return a.localeCompare(b); });
return list;
}
/** @description 渲染发送节点过滤面板的复选框列表。 */
function renderNodeFilterList() {
if (!el.nodeFilterList) return;
var txs = collectTransmitters();
var kw = (el.nodeFilterSearch ? (el.nodeFilterSearch.value || '') : '').trim().toLowerCase();
if (kw) txs = txs.filter(function (n) { return n.toLowerCase().indexOf(kw) >= 0; });
if (!txs.length) {
el.nodeFilterList.innerHTML = '<div class="status-empty">无匹配节点</div>';
return;
}
var counts = Object.create(null);
if (state.dbc) {
state.dbc.messages.forEach(function (msg) {
var tx = msg.transmitter || 'Vector__XXX';
counts[tx] = (counts[tx] || 0) + 1;
});
}
el.nodeFilterList.innerHTML = txs.map(function (name) {
var checked = !!state.transmitterFilter[name] ? ' checked' : '';
return '<label class="can-node-filter-item">' +
'<input type="checkbox" data-node="' + escapeHtml(name) + '"' + checked + '>' +
'<span class="can-node-filter-name">' + escapeHtml(name) + '</span>' +
'<span class="can-node-filter-count">' + (counts[name] || 0) + '</span>' +
'</label>';
}).join('');
var inputs = el.nodeFilterList.querySelectorAll('input[type="checkbox"]');
for (var i = 0; i < inputs.length; i++) {
inputs[i].addEventListener('change', function () {
var name = this.getAttribute('data-node');
if (this.checked) state.transmitterFilter[name] = true;
else delete state.transmitterFilter[name];
recomputeTransmitterFilterEmpty();
filterMessages();
scheduleCanPersist();
});
}
}
/** @description 重新计算 transmitterFilterEmpty 标志(无任何勾选时为 true)。 */
export function recomputeTransmitterFilterEmpty() {
var any = false;
for (var k in state.transmitterFilter) {
if (state.transmitterFilter[k]) { any = true; break; }
}
state.transmitterFilterEmpty = !any;
}
/** @description 更新发送节点过滤按钮的标签文本。 */
export function updateNodeFilterLabel() {
if (!el.nodeFilterLabel) return;
if (state.transmitterFilterEmpty) {
el.nodeFilterLabel.textContent = '发送节点:全部';
if (el.nodeFilterBtn) el.nodeFilterBtn.classList.remove('active');
} else {
var picked = Object.keys(state.transmitterFilter).filter(function (k) { return state.transmitterFilter[k]; });
var preview = picked.length === 1 ? picked[0] : ('已选 ' + picked.length + ' 个');
el.nodeFilterLabel.textContent = '发送节点:' + preview;
if (el.nodeFilterBtn) el.nodeFilterBtn.classList.add('active');
}
}
/** @description 打开/关闭发送节点过滤面板。 */
function setNodeFilterPanelOpen(open) {
if (!el.nodeFilterPanel || !el.nodeFilterBtn) return;
if (open) {
renderNodeFilterList();
el.nodeFilterPanel.hidden = false;
el.nodeFilterBtn.setAttribute('aria-expanded', 'true');
if (el.nodeFilterWrap) el.nodeFilterWrap.classList.add('is-open');
} else {
el.nodeFilterPanel.hidden = true;
el.nodeFilterBtn.setAttribute('aria-expanded', 'false');
if (el.nodeFilterWrap) el.nodeFilterWrap.classList.remove('is-open');
}
}
/** @description 绑定发送节点过滤面板的事件(按钮/搜索/全选/全不选/外部点击/Esc)。 */
export function bindNodeFilterEvents() {
if (!el.nodeFilterBtn) return;
el.nodeFilterBtn.addEventListener('click', function (event) {
event.stopPropagation();
setNodeFilterPanelOpen(el.nodeFilterPanel.hidden);
});
document.addEventListener('click', function (event) {
if (!el.nodeFilterWrap || el.nodeFilterPanel.hidden) return;
if (!el.nodeFilterWrap.contains(event.target)) setNodeFilterPanelOpen(false);
});
document.addEventListener('keydown', function (event) {
if (event.key === 'Escape' && el.nodeFilterPanel && !el.nodeFilterPanel.hidden) {
setNodeFilterPanelOpen(false);
}
});
if (el.nodeFilterSearch) {
el.nodeFilterSearch.addEventListener('input', renderNodeFilterList);
}
if (el.nodeFilterAllBtn) el.nodeFilterAllBtn.addEventListener('click', function () {
collectTransmitters().forEach(function (n) { state.transmitterFilter[n] = true; });
recomputeTransmitterFilterEmpty();
renderNodeFilterList();
filterMessages();
scheduleCanPersist();
});
if (el.nodeFilterNoneBtn) el.nodeFilterNoneBtn.addEventListener('click', function () {
state.transmitterFilter = Object.create(null);
state.transmitterFilterEmpty = true;
renderNodeFilterList();
filterMessages();
scheduleCanPersist();
});
}
// ==================== 报文列表渲染 ====================
/** @description 渲染报文列表表格(表头 + 行 + 展开区)。 */
export function renderMessageList() {
if (!el.messageList) return;
if (!state.dbc || !state.dbcSources.length) {
el.messageList.innerHTML =
'<div class="dbc-empty-drop" id="dbcEmptyDrop">' +
'<div class="upload-ico">📥</div>' +
'<h4>导入 DBC 开始编辑报文</h4>' +
'<p>支持一次选择或拖入 <b>多个</b> <code>.dbc</code> 文件</p>' +
'</div>';
updateBatchUi();
return;
}
if (!state.filteredMessages.length) {
el.messageList.innerHTML = '<div class="status-empty">没有匹配的报文。</div>';
updateBatchUi();
return;
}
var channelTxt = el.defaultChannel ? el.defaultChannel.value : '0';
var rows = state.filteredMessages.map(function (msg, index) {
var active = msg.key === state.selectedMessageKey ? ' active' : '';
var checked = state.checkedKeys[msg.key] ? ' checked' : '';
var expanded = !!state.expandedKeys[msg.key];
var caret = expanded ? '\u25BE' : '\u25B8';
var idHex = '0x' + padHex(msg.id, msg.isExtended ? 8 : 3);
var comment = msg.comment || '';
if (comment.length > 36) comment = comment.slice(0, 36) + '...';
var rowHtml = '' +
'<div class="message-row' + active + '" data-key="' + escapeHtml(msg.key) + '" role="button" tabindex="0">' +
'<span class="message-col message-col-check"><input type="checkbox" class="message-check" data-key="' + escapeHtml(msg.key) + '"' + checked + '></span>' +
'<span class="message-col message-col-toggle"><button type="button" class="message-toggle' + (expanded ? ' expanded' : '') + '" data-key="' + escapeHtml(msg.key) + '" aria-label="展开/收起信号">' + caret + '</button></span>' +
'<span class="message-col message-col-index">' + (index + 1) + '</span>' +
'<span class="message-col message-col-dbc" title="' + escapeHtml(msg.sourceName || '-') + '">' +
escapeHtml(msg.sourceName ? String(msg.sourceName).replace(/\.dbc$/i, '') : '-') + '</span>' +
'<span class="message-col message-col-name" title="' + escapeHtml(msg.name) + '">' +
escapeHtml(msg.name) + '</span>' +
'<span class="message-col message-col-id">' + escapeHtml(idHex) + '</span>' +
'<span class="message-col message-col-channel">CH' + escapeHtml(channelTxt) + '</span>' +
'<span class="message-col message-col-type">' + escapeHtml(msg.isExtended ? '扩展' : '标准') + '</span>' +
'<span class="message-col message-col-dlc">' + escapeHtml(msg.dlc) + '</span>' +
'<span class="message-col message-col-signal">' + escapeHtml(msg.signals.length) + '</span>' +
'<span class="message-col message-col-node" title="' + escapeHtml(msg.transmitter || 'Vector__XXX') + '">' + escapeHtml(msg.transmitter || 'Vector__XXX') + '</span>' +
'<span class="message-col message-col-comment" title="' + escapeHtml(msg.comment || '') + '">' + escapeHtml(comment || '-') + '</span>' +
'</div>';
var expandHtml = '<div class="message-row-expand" data-key="' + escapeHtml(msg.key) + '"' + (expanded ? '' : ' hidden') + '>' +
'<div class="signal-table-host"></div>' +
'</div>';
return rowHtml + expandHtml;
}).join('');
el.messageList.innerHTML =
'<div class="message-table">' +
'<div class="message-table-head">' +
'<span class="message-col message-col-check"></span>' +
'<span class="message-col message-col-toggle"></span>' +
'<span class="message-col message-col-index">#</span>' +
'<span class="message-col message-col-dbc">DBC名称</span>' +
'<span class="message-col message-col-name">报文名称</span>' +
'<span class="message-col message-col-id">标识符</span>' +
'<span class="message-col message-col-channel">通道</span>' +
'<span class="message-col message-col-type">类型</span>' +
'<span class="message-col message-col-dlc">DLC</span>' +
'<span class="message-col message-col-signal">信号</span>' +
'<span class="message-col message-col-node">发送节点</span>' +
'<span class="message-col message-col-comment">注释</span>' +
'</div>' +
'<div class="message-table-body">' + rows + '</div>' +
'</div>';
var rowEls = el.messageList.querySelectorAll('.message-row');
for (var i = 0; i < rowEls.length; i++) {
rowEls[i].addEventListener('click', handleMessageRowClick);
rowEls[i].addEventListener('keydown', function (event) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
selectMessage(this.getAttribute('data-key'));
}
});
}
var checks = el.messageList.querySelectorAll('.message-check');
for (var j = 0; j < checks.length; j++) {
checks[j].addEventListener('click', function (event) { event.stopPropagation(); });
checks[j].addEventListener('change', function () {
var k = this.getAttribute('data-key');
if (this.checked) state.checkedKeys[k] = true;
else delete state.checkedKeys[k];
updateBatchUi();
scheduleCanPersist();
});
}
var toggles = el.messageList.querySelectorAll('.message-toggle');
for (var t = 0; t < toggles.length; t++) {
toggles[t].addEventListener('click', function (event) {
event.stopPropagation();
toggleMessageExpand(this.getAttribute('data-key'));
});
}
state.filteredMessages.forEach(function (msg) {
if (state.expandedKeys[msg.key]) {
var host = el.messageList.querySelector('.message-row-expand[data-key="' + cssEscape(msg.key) + '"] .signal-table-host');
if (host) renderInlineSignalGrid(msg, host);
}
});
updateBatchUi();
}
function handleMessageRowClick(event) {
if (event.target.closest('.message-check') || event.target.closest('.message-toggle')) return;
var key = this.getAttribute('data-key');
selectMessage(key);
}
function toggleMessageExpand(key) {
if (!state.dbc) return;
var msg = findMessageByKey(key);
if (!msg) return;
var willExpand = !state.expandedKeys[key];
if (willExpand) {
state.expandedKeys[key] = true;
ensureMessageDefaults(msg);
} else {
delete state.expandedKeys[key];
}
var panel = el.messageList.querySelector('.message-row-expand[data-key="' + cssEscape(key) + '"]');
var toggle = el.messageList.querySelector('.message-toggle[data-key="' + cssEscape(key) + '"]');
if (panel) {
if (willExpand) {
panel.hidden = false;
var host = panel.querySelector('.signal-table-host');
if (host) renderInlineSignalGrid(msg, host);
} else {
panel.hidden = true;
}
}
if (toggle) {
toggle.classList.toggle('expanded', willExpand);
toggle.textContent = willExpand ? '\u25BE' : '\u25B8';
}
scheduleCanPersist();
}
// ==================== 批量 UI ====================
/** @description 更新批量发送按钮 / 全选框 / 已选计数 pill。 */
export function updateBatchUi() {
var keys = Object.keys(state.checkedKeys);
var count = keys.length;
if (el.selectedCountPill) el.selectedCountPill.textContent = '已勾选 ' + count;
if (el.sendCheckedBtn) {
el.sendCheckedBtn.disabled = count === 0 || state.batchInFlight;
el.sendCheckedBtn.textContent = state.batchInFlight ? '发送中...' : ('批量发送勾选 (' + count + ')');
}
if (el.selectAllBox) {
var visible = state.filteredMessages || [];
var allChecked = visible.length > 0 && visible.every(function (m) { return !!state.checkedKeys[m.key]; });
var someChecked = visible.some(function (m) { return !!state.checkedKeys[m.key]; });
el.selectAllBox.checked = allChecked;
el.selectAllBox.indeterminate = !allChecked && someChecked;
}
}
// ==================== 报文选择 ====================
/** @description 选中指定 key 的报文,并刷新列表/信号编辑/编码预览。 */
export function selectMessage(key) {
if (!state.dbc) return;
var msg = findMessageByKey(key);
if (!msg) return;
state.selectedMessageKey = key;
ensureMessageDefaults(msg);
renderMessageList();
updateEncodedPreview();
scheduleCanPersist();
}
+222
View File
@@ -0,0 +1,222 @@
/**
* persist.js - CAN 控制台会话持久化(刷新后恢复)
*
* 保存到 localStorage:多 DBC 原文、信号编辑值、勾选/展开、发送节点过滤、
* 最近发送历史、通道/搜索/原始帧表单。
*
* 依赖:dbc.js(state/el/parseDbc/rebuildMergedDbc)
*/
import { state, el, parseDbc, rebuildMergedDbc } from './dbc.js';
var LS_KEY = 'dcu_can_session_v1';
var MAX_HISTORY = 200;
var SAVE_DEBOUNCE_MS = 350;
var saveTimer = null;
var restoring = false;
function safeJsonParse(raw) {
try {
return JSON.parse(raw);
} catch (_) {
return null;
}
}
function transmitterFilterToPlain() {
var out = {};
for (var k in state.transmitterFilter) {
if (state.transmitterFilter[k]) out[k] = true;
}
return out;
}
function collectUiFields() {
return {
defaultChannel: el.defaultChannel ? el.defaultChannel.value : '0',
manualChannel: el.manualChannel ? el.manualChannel.value : '0',
manualId: el.manualId ? el.manualId.value : '',
manualDlc: el.manualDlc ? el.manualDlc.value : '8',
manualData: el.manualData ? el.manualData.value : '',
dbcSearch: el.dbcSearch ? el.dbcSearch.value : ''
};
}
function applyUiFields(ui) {
if (!ui) return;
if (el.defaultChannel && ui.defaultChannel != null) {
el.defaultChannel.value = String(ui.defaultChannel);
}
if (el.manualChannel && ui.manualChannel != null) {
el.manualChannel.value = String(ui.manualChannel);
} else if (el.manualChannel && el.defaultChannel) {
el.manualChannel.value = el.defaultChannel.value;
}
if (el.manualId && ui.manualId != null) el.manualId.value = String(ui.manualId);
if (el.manualDlc && ui.manualDlc != null) el.manualDlc.value = String(ui.manualDlc);
if (el.manualData && ui.manualData != null) el.manualData.value = String(ui.manualData);
if (el.dbcSearch && ui.dbcSearch != null) el.dbcSearch.value = String(ui.dbcSearch);
if (el.canChannelPill && el.defaultChannel) {
el.canChannelPill.textContent = 'CH' + el.defaultChannel.value;
}
}
function buildPayload(options) {
options = options || {};
var sources = (state.dbcSources || []).map(function (s) {
return {
id: s.id,
name: s.name,
size: s.size || 0,
text: s.text || ''
};
}).filter(function (s) { return s.text; });
var history = Array.isArray(state.history) ? state.history.slice(0, MAX_HISTORY) : [];
if (options.historyLimit != null) {
history = history.slice(0, options.historyLimit);
}
return {
v: 1,
ts: Date.now(),
dbcSources: sources,
selectedMessageKey: state.selectedMessageKey || '',
signalValues: state.signalValues || {},
checkedKeys: state.checkedKeys || {},
expandedKeys: state.expandedKeys || {},
transmitterFilter: transmitterFilterToPlain(),
history: history,
ui: collectUiFields()
};
}
function writePayload(payload) {
localStorage.setItem(LS_KEY, JSON.stringify(payload));
}
/** @description 立即写入 localStorage(空间不足时逐步丢弃历史/信号)。 */
export function saveCanSessionNow() {
if (restoring) return;
if (saveTimer) {
clearTimeout(saveTimer);
saveTimer = null;
}
try {
writePayload(buildPayload());
return;
} catch (e1) {
/* quota:先砍历史 */
}
try {
writePayload(buildPayload({ historyLimit: 40 }));
return;
} catch (e2) {
/* 再砍信号值,只保 DBC + 勾选 */
}
try {
var slim = buildPayload({ historyLimit: 0 });
slim.signalValues = {};
slim.history = [];
writePayload(slim);
} catch (e3) {
/* 放弃 */
}
}
/** @description 防抖保存(编辑信号/勾选等高频操作)。 */
export function scheduleCanPersist() {
if (restoring) return;
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(function () {
saveTimer = null;
saveCanSessionNow();
}, SAVE_DEBOUNCE_MS);
}
/**
* @description 从 localStorage 恢复会话到 state + 表单。
* @returns {{ restored: boolean, dbcCount: number, historyCount: number }}
*/
export function loadCanSession() {
var empty = { restored: false, dbcCount: 0, historyCount: 0 };
var raw = null;
try {
raw = localStorage.getItem(LS_KEY);
} catch (_) {
return empty;
}
if (!raw) return empty;
var data = safeJsonParse(raw);
if (!data || data.v !== 1) return empty;
restoring = true;
try {
state.dbcSources = [];
var list = Array.isArray(data.dbcSources) ? data.dbcSources : [];
for (var i = 0; i < list.length; i++) {
var src = list[i];
if (!src || !src.text) continue;
try {
var dbc = parseDbc(src.text);
if (!dbc.messages || !dbc.messages.length) continue;
state.dbcSources.push({
id: src.id || ('restored_' + i),
name: src.name || ('saved_' + (i + 1) + '.dbc'),
size: src.size || src.text.length,
text: src.text,
dbc: dbc
});
} catch (_) {
/* 单文件损坏跳过 */
}
}
rebuildMergedDbc();
state.signalValues = (data.signalValues && typeof data.signalValues === 'object')
? data.signalValues : {};
state.checkedKeys = (data.checkedKeys && typeof data.checkedKeys === 'object')
? data.checkedKeys : {};
state.expandedKeys = (data.expandedKeys && typeof data.expandedKeys === 'object')
? data.expandedKeys : {};
state.selectedMessageKey = data.selectedMessageKey || '';
state.history = Array.isArray(data.history) ? data.history.slice(0, MAX_HISTORY) : [];
state.transmitterFilter = Object.create(null);
if (data.transmitterFilter && typeof data.transmitterFilter === 'object') {
Object.keys(data.transmitterFilter).forEach(function (k) {
state.transmitterFilter[k] = !!data.transmitterFilter[k];
});
}
var anyTx = false;
for (var tk in state.transmitterFilter) {
if (state.transmitterFilter[tk]) { anyTx = true; break; }
}
state.transmitterFilterEmpty = !anyTx;
/* 选中键若不存在则清空,交由调用方选第一条 */
if (state.selectedMessageKey &&
(!state.dbc || !state.dbc.messageMap[state.selectedMessageKey])) {
state.selectedMessageKey = '';
}
applyUiFields(data.ui);
return {
restored: true,
dbcCount: state.dbcSources.length,
historyCount: state.history.length
};
} finally {
restoring = false;
}
}
/** @description 绑定 beforeunload / 可见性,降低丢失。 */
export function bindCanPersistLifecycle() {
window.addEventListener('beforeunload', function () {
saveCanSessionNow();
});
document.addEventListener('visibilitychange', function () {
if (document.visibilityState === 'hidden') saveCanSessionNow();
});
}
+504
View File
@@ -0,0 +1,504 @@
/**
* send.js - CAN 帧构建与发送(DBC 报文 / 原始帧 / 批量发送 / 发送历史)
*
* 包含:sendCanBatchRequest / sendCanOneFrame(仅 /can/send_batch)、响应框、历史、
* 批量/选中/手动发送、手动帧校验/重置/载入预览、错误码说明。
*
* 依赖:dbc.js(state/el/getBaseUrl/padHex/bytesToHex/parseNumericId)、
* common/toast.js(showToast)、common/utils.js(escapeHtml)、
* messages.js(updateBatchUi)、signals.js(ensureMessageDefaults/getSignalValueMap/
* getSelectedMessage/updateEncodedPreview/encodeMessageData)。
*
* 注意:formatTime 保留本地实现(HH:MM:SS,不含毫秒),与 common/utils.js
* 的 formatTime(含毫秒)不同,以保证发送历史时间戳输出格式一致。
*/
import { state, el, getBaseUrl, padHex, bytesToHex, parseNumericId } from './dbc.js';
import { escapeHtml } from '../common/utils.js';
import { showToast } from '../common/toast.js?v=20260801f';
import { updateBatchUi } from './messages.js';
import { ensureMessageDefaults, getSignalValueMap, getSelectedMessage, updateEncodedPreview, encodeMessageData } from './signals.js';
import { scheduleCanPersist } from './persist.js';
// ==================== 本地工具(保持与原 can.js 一致) ====================
function formatTime(date) {
var d = date instanceof Date ? date : new Date();
var hh = String(d.getHours()).padStart(2, '0');
var mm = String(d.getMinutes()).padStart(2, '0');
var ss = String(d.getSeconds()).padStart(2, '0');
return hh + ':' + mm + ':' + ss;
}
/** 简单延时 helper,用于批次间间隔 */
function sleep(ms) { return new Promise(function (r) { setTimeout(r, ms); }); }
// ==================== Click-to-Copy ====================
/** 将文本写入剪贴板并在元素上闪烁 copied 提示 */
function copyCell(text, target) {
if (!text) return;
function onDone() {
target.classList.add('copied');
setTimeout(function () { target.classList.remove('copied'); }, 1200);
showToast('已复制', 'success');
}
function fallback() {
var ta = document.createElement('textarea');
ta.value = text;
ta.style.cssText = 'position:fixed;left:-9999px;opacity:0';
document.body.appendChild(ta);
ta.select();
try { document.execCommand('copy'); onDone(); } catch (_) {}
document.body.removeChild(ta);
}
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(onDone).catch(fallback);
} else {
fallback();
}
}
/** 在 historyList 上绑定一次事件代理 */
var _historyCopyBound = false;
function ensureHistoryCopyHandler() {
if (_historyCopyBound || !el.historyList) return;
_historyCopyBound = true;
el.historyList.addEventListener('click', function (e) {
var target = e.target.closest('[data-copy]');
if (!target) return;
copyCell(target.getAttribute('data-copy'), target);
});
}
// ==================== 发送错误码描述 ====================
var CAN_SEND_ERR_DESC = {
'-1': '参数错误(通道/ID/DLC 无效)',
'-2': 'SPI 通信失败(ESP32 与 GD32 通信异常)',
'-3': 'GD32 CAN 总线拒绝',
};
var GD32_ERR_DESC = {
0xE0: 'payload 过短',
0xE1: '通道号非法',
0xE2: 'BUS_OFF(总线离线,检查接线/终端电阻/对端上电)',
0xE3: 'TEC 过高(错误计数 >96,对端 ACK 失败率高)',
0xE4: '邮箱满(3 个硬件邮箱全 PENDING,对端不 ACK)',
0xE5: '参数无效(通道未启用/DLC 越界)',
0xEE: 'SPI 帧解析失败(GD32 收到的帧 CRC/SOF 校验失败,通常是 SPI 字节丢失或 CS 时序冲突)'
};
/** @description 构建发送响应的可读文本(成功显示 recv,失败拼接 CAN_SEND_ERR_DESC + GD32_ERR_DESC)。 */
function buildSendResponseText(result) {
if (result.status === 'ok') {
return result.recv ? ('0x' + result.recv + ' (' + result.recv_len + ' 字节)') : '无响应数据';
}
var code = String(result.code);
var desc = CAN_SEND_ERR_DESC[code] || '未知错误';
/* 用 GD32 细分错误码补充具体原因 */
if (result.gd32_err != null && result.gd32_err !== 0) {
var gd32Desc = GD32_ERR_DESC[result.gd32_err];
if (gd32Desc) {
desc = desc + ' · ' + gd32Desc;
} else {
desc = desc + ' · gd32_err=0x' + result.gd32_err.toString(16).toUpperCase();
}
}
return '发送失败: ' + desc + ' (code=' + result.code + ')';
}
/**
* @description 历史列表用的短文案:红色标签已表明失败,去掉「发送失败:」前缀、
* gd32_err 细分与 (code=) 尾巴,只保留核心原因,避免挤占空间(完整原因见 hover/复制)。
*/
function buildSendResponseShort(result) {
if (result.status === 'ok') {
return result.recv ? ('0x' + result.recv) : 'OK';
}
return CAN_SEND_ERR_DESC[String(result.code)] || ('code=' + result.code);
}
// ==================== HTTP 请求(唯一入口 /can/send_batch)====================
/**
* @description 同通道发送 1~N 帧。单帧也走 batch(frames 长度 1)。
* @returns {Promise<object[]>} 与 frames 等长的 results
*/
async function sendCanBatchRequest(channel, frames) {
var n = frames && frames.length ? frames.length : 0;
/* 单帧设备侧 timeout 2s;多帧按帧数留余量 */
var timeoutMs = (n <= 1) ? 8000 : Math.max(15000, n * 500);
/* AbortSignal.timeout 在连接被 reset 的边缘情况下偶发不触发,
* 叠加一条手动竞速超时,避免 await 永久挂起导致整批不渲染。 */
var ac = new AbortController();
var timer = setTimeout(function () { ac.abort(); }, timeoutMs);
var resp;
try {
resp = await fetch(getBaseUrl() + '/can/send_batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ch: channel, frames: frames }),
signal: ac.signal
});
} catch (err) {
throw new Error('请求失败: ' + (err && err.message ? err.message : err));
} finally {
clearTimeout(timer);
}
var text = await resp.text();
var json = {};
try {
json = text ? JSON.parse(text) : {};
} catch (err) {
throw new Error('CAN 响应不是合法 JSON: ' + text);
}
if (!resp.ok) {
throw new Error('HTTP ' + resp.status + ': ' + (json.message || text || '发送失败'));
}
if (!json.results || !Array.isArray(json.results)) {
throw new Error('响应缺少 results');
}
var results = json.results.slice();
var need = n - results.length;
if (need > 0) {
var pad = {
status: 'error',
code: (json.abort_code != null) ? json.abort_code : -2,
gd32_err: (json.abort_gd32_err != null) ? json.abort_gd32_err : 0
};
for (var p = 0; p < need; p++) results.push(pad);
}
return results;
}
/** 单帧便捷封装:POST /can/send_batch,取 results[0] */
async function sendCanOneFrame(channel, frame) {
var results = await sendCanBatchRequest(channel, [frame]);
return results[0] || { status: 'error', code: -3 };
}
// ==================== 响应框 ====================
function updateResponseBox(box, textEl, result, mode) {
if (!box || !textEl) return;
box.style.display = 'block';
box.className = 'can-response' + (result.status === 'ok' ? ' ok' : ' err');
textEl.textContent = buildSendResponseText(result);
if (mode === 'manual') {
el.manualHint.className = 'helper-note ' + (result.status === 'ok' ? 'ok' : 'err');
el.manualHint.innerHTML = result.status === 'ok'
? '原始帧已发送到设备,若有响应则显示在下方。'
: '设备返回错误码 <b>' + escapeHtml(result.code) + '</b>。';
}
}
// ==================== 发送历史 ====================
/**
* 构造一条历史条目。集中字段,避免多处重复拼对象。
*/
function makeHistoryEntry(opts) {
return {
time: formatTime(new Date()),
mode: opts.mode,
ok: opts.ok,
channel: opts.channel,
idHex: opts.idHex,
dlc: opts.dlc,
dataHex: opts.dataHex || '',
resultText: opts.resultText,
/* 列表显示用短文案;缺省回退到完整文案,兼容旧持久化数据 */
resultShort: opts.resultShort || opts.resultText
};
}
/**
* @param {object} entry
* @param {{ silent?: boolean }} [opts] silent=true 时不立刻重绘/落盘(批量用)
*/
function addHistoryEntry(entry, opts) {
state.history.unshift(entry);
/* 批量发送会快速产生大量条目,上限放宽便于回看 */
if (state.history.length > 200) state.history.length = 200;
if (opts && opts.silent) return;
renderHistory();
scheduleCanPersist();
}
/** @description 渲染发送历史列表(单行紧凑:状态|时间|CH·ID·DLC|HEX|结果)。 */
export function renderHistory() {
if (!el.historyList) return;
ensureHistoryCopyHandler();
if (!state.history.length) {
el.historyList.innerHTML = '<div class="status-empty">尚未发送任何 CAN 帧。</div>';
return;
}
el.historyList.innerHTML = state.history.map(function (item) {
var meta = 'CH' + item.channel + ' · ' + item.idHex + ' · DLC ' + item.dlc;
return '' +
'<div class="history-item history-item-row' + (item.ok ? '' : ' is-err') + '">' +
'<span class="history-item-tag ' + (item.ok ? 'ok' : 'err') + '">' + escapeHtml(item.mode) + '</span>' +
'<span class="history-item-time">' + escapeHtml(item.time) + '</span>' +
'<span class="history-item-main copyable" data-copy="' + escapeHtml(meta) + '" title="点击复制">' + escapeHtml(meta) + '</span>' +
'<span class="history-item-hex copyable" data-copy="' + escapeHtml(item.dataHex || '') + '" title="点击复制">' + escapeHtml(item.dataHex || '--') + '</span>' +
'<span class="history-item-foot copyable" data-copy="' + escapeHtml(item.resultText || '') + '" title="' + escapeHtml(item.resultText || '点击复制') + '">' + escapeHtml(item.resultShort || item.resultText) + '</span>' +
'</div>';
}).join('');
}
// ==================== 批量发送 ====================
/**
* 单批最多帧数。按 64 帧分批(一次请求发 64 帧);批次之间留
* BATCH_INTER_CHUNK_DELAY_MS 间隔,给 CAN/SPI 总线喘息时间。
*/
var BATCH_CHUNK = 64;
/** 批次间延时(ms),仅在还有后续批次时等待 */
var BATCH_INTER_CHUNK_DELAY_MS = 100;
/**
* @description 批量发送所有勾选的报文(仅 /can/send_batch)。
* 批量过程中不重绘历史,结束后统一刷新。
*/
export async function sendCheckedMessages() {
if (!state.dbc) { showToast('请先导入 DBC', 'warning'); return; }
var keys = Object.keys(state.checkedKeys);
if (!keys.length) { showToast('请先勾选要发送的报文', 'warning'); return; }
if (state.batchInFlight) return;
state.batchInFlight = true;
updateBatchUi();
var channel = parseInt(el.defaultChannel.value, 10) || 0;
var ok = 0, fail = 0;
var silent = { silent: true };
/* 按 key 建索引,避免勾选数百条时 O(n²) 扫描 messages */
var msgByKey = Object.create(null);
for (var mi = 0; mi < state.dbc.messages.length; mi++) {
var mm = state.dbc.messages[mi];
msgByKey[mm.key] = mm;
}
try {
/* 先全部编码,避免发到一半才因编码失败打断节奏 */
var prepared = [];
for (var i = 0; i < keys.length; i++) {
var key = keys[i];
var msg = msgByKey[key];
if (!msg) continue;
ensureMessageDefaults(msg);
var idHex = '0x' + padHex(msg.id, msg.isExtended ? 8 : 3);
var dataBytes;
try {
dataBytes = encodeMessageData(msg, getSignalValueMap(msg));
} catch (err) {
fail++;
addHistoryEntry(makeHistoryEntry({
mode: 'BATCH', ok: false, channel: channel, idHex: idHex,
dlc: msg.dlc, dataHex: '',
resultText: msg.name + ' · 编码失败: ' + err.message
}), silent);
continue;
}
var dataHex = bytesToHex(dataBytes);
var dlc = (typeof msg.dlc === 'number') ? msg.dlc : dataBytes.length;
prepared.push({
msg: msg,
idHex: idHex,
dlc: dlc,
dataHex: dataHex,
frame: { id: msg.id, dlc: dlc, data: dataHex }
});
}
for (var off = 0; off < prepared.length; ) {
var chunk = prepared.slice(off, off + BATCH_CHUNK);
off += chunk.length;
var frames = chunk.map(function (p) { return p.frame; });
try {
var results = await sendCanBatchRequest(channel, frames);
for (var r = 0; r < chunk.length; r++) {
var item = chunk[r];
var result = results[r] || { status: 'error', code: -3 };
if (result.status === 'ok') ok++; else fail++;
addHistoryEntry(makeHistoryEntry({
mode: 'BATCH', ok: result.status === 'ok', channel: channel,
idHex: item.idHex, dlc: item.dlc, dataHex: item.dataHex,
resultText: item.msg.name + ' · ' + buildSendResponseText(result),
resultShort: item.msg.name + ' · ' + buildSendResponseShort(result)
}), silent);
}
} catch (err) {
for (var s = 0; s < chunk.length; s++) {
fail++;
var one = chunk[s];
addHistoryEntry(makeHistoryEntry({
mode: 'BATCH', ok: false, channel: channel,
idHex: one.idHex, dlc: one.dlc, dataHex: one.dataHex,
resultText: one.msg.name + ' · ' + err.message
}), silent);
}
}
/* 批次间间隔:还有下一批时等待,给 CAN/SPI 总线喘息时间 */
if (off < prepared.length) {
await sleep(BATCH_INTER_CHUNK_DELAY_MS);
}
}
renderHistory();
scheduleCanPersist();
var tone = fail === 0 ? 'success' : (ok === 0 ? 'error' : 'warning');
showToast('批量发送完成:成功 ' + ok + ',失败 ' + fail, tone, { force: true });
} finally {
state.batchInFlight = false;
updateBatchUi();
/* 兜底渲染:无论 try 内是否抛异常,都保证已写入的 silent 历史条目可见,
* 避免「批量发了但结果不显示」的偶发现象(renderHistory 幂等,多调一次无害)。 */
renderHistory();
}
}
// ==================== 选中报文发送 ====================
/** @description 发送当前选中报文(DBC 编码后 POST /can/send_batch,1 帧)。 */
export async function sendSelectedMessage() {
var msg = getSelectedMessage();
if (!msg) {
showToast('请先选择一条报文', 'warning');
return;
}
updateEncodedPreview();
if (!state.generatedData.length && msg.dlc > 0) {
showToast('当前报文还没有可发送的数据', 'error');
return;
}
var channel = parseInt(el.defaultChannel.value, 10) || 0;
var dataHex = bytesToHex(state.generatedData);
try {
var result = await sendCanOneFrame(channel, {
id: msg.id,
dlc: msg.dlc,
data: dataHex
});
updateResponseBox(el.dbcResp, el.dbcRespText, result, 'dbc');
addHistoryEntry(makeHistoryEntry({
mode: 'DBC', ok: result.status === 'ok', channel: channel,
idHex: '0x' + padHex(msg.id, msg.isExtended ? 8 : 3),
dlc: msg.dlc, dataHex: dataHex,
resultText: buildSendResponseText(result),
resultShort: buildSendResponseShort(result)
}));
showToast(result.status === 'ok' ? 'DBC 报文发送成功' : '发送失败', result.status === 'ok' ? 'success' : 'error');
} catch (err) {
if (el.dbcResp) {
el.dbcResp.style.display = 'block';
el.dbcResp.className = 'can-response err';
el.dbcRespText.textContent = err.message;
}
addHistoryEntry(makeHistoryEntry({
mode: 'DBC', ok: false, channel: channel,
idHex: '0x' + padHex(msg.id, msg.isExtended ? 8 : 3),
dlc: msg.dlc, dataHex: dataHex,
resultText: err.message
}));
showToast('发送失败: ' + err.message, 'error');
}
}
// ==================== 原始帧发送 ====================
function validateManualData() {
var dataHex = (el.manualData.value || '').trim().replace(/\s+/g, '');
var dlc = parseInt(el.manualDlc.value, 10);
if (!Number.isFinite(dlc) || dlc < 0 || dlc > 64) {
throw new Error('DLC 需在 0~64 之间');
}
if (dataHex && !/^[0-9a-fA-F]+$/.test(dataHex)) {
throw new Error('HEX 数据包含非法字符');
}
if (dataHex.length % 2 !== 0) {
throw new Error('HEX 数据长度必须为偶数');
}
var byteLen = dataHex.length / 2;
if (byteLen !== dlc) {
throw new Error('当前 HEX 为 ' + byteLen + ' 字节,但 DLC=' + dlc);
}
return dataHex.toUpperCase();
}
/** @description 刷新手动帧输入的校验提示。 */
export function refreshManualHint() {
try {
var dataHex = validateManualData();
el.manualHint.className = 'helper-note ok';
el.manualHint.innerHTML = '原始 HEX 合法,当前长度 <b>' + (dataHex.length / 2) + '</b> 字节。';
} catch (err) {
el.manualHint.className = 'helper-note err';
el.manualHint.innerHTML = escapeHtml(err.message);
}
}
/** @description 发送手动输入的原始 CAN 帧。 */
export async function sendManualFrame() {
var id = parseNumericId(el.manualId.value);
if (!Number.isFinite(id)) {
showToast('CAN ID 格式错误', 'error');
return;
}
var channel = parseInt(el.manualChannel.value, 10) || 0;
var dlc = parseInt(el.manualDlc.value, 10) || 0;
var dataHex = '';
try {
dataHex = validateManualData();
} catch (err) {
showToast(err.message, 'error');
refreshManualHint();
return;
}
try {
var result = await sendCanOneFrame(channel, {
id: id,
dlc: dlc,
data: dataHex
});
updateResponseBox(el.manualResp, el.manualRespText, result, 'manual');
addHistoryEntry(makeHistoryEntry({
mode: 'RAW', ok: result.status === 'ok', channel: channel,
idHex: '0x' + padHex(id, id > 0x7FF ? 8 : 3),
dlc: dlc, dataHex: dataHex,
resultText: buildSendResponseText(result),
resultShort: buildSendResponseShort(result)
}));
showToast(result.status === 'ok' ? '原始帧发送成功' : '发送失败', result.status === 'ok' ? 'success' : 'error');
} catch (err) {
if (el.manualResp) {
el.manualResp.style.display = 'block';
el.manualResp.className = 'can-response err';
el.manualRespText.textContent = err.message;
}
addHistoryEntry(makeHistoryEntry({
mode: 'RAW', ok: false, channel: channel,
idHex: '0x' + padHex(id, id > 0x7FF ? 8 : 3),
dlc: dlc, dataHex: dataHex,
resultText: err.message
}));
showToast('发送失败: ' + err.message, 'error');
}
}
/** @description 重置手动帧输入区为默认值。 */
export function resetManualFields() {
el.manualChannel.value = el.defaultChannel.value;
el.manualId.value = '0x18DA00F1';
el.manualDlc.value = '8';
el.manualData.value = '0102030405060708';
if (el.manualResp) el.manualResp.style.display = 'none';
refreshManualHint();
}
/** @description 将当前选中报文的编码预览载入到手动帧输入区。 */
export function loadPreviewToManual() {
var msg = getSelectedMessage();
if (!msg || !state.generatedData.length) {
showToast('当前没有可载入的预览帧', 'warning');
return;
}
el.manualChannel.value = el.defaultChannel.value;
el.manualId.value = '0x' + padHex(msg.id, msg.isExtended ? 8 : 3);
el.manualDlc.value = String(msg.dlc);
el.manualData.value = bytesToHex(state.generatedData);
refreshManualHint();
showToast('已载入到原始发送区', 'success');
}
+434
View File
@@ -0,0 +1,434 @@
/**
* signals.js - 信号编辑器渲染 + 信号编解码(raw ↔ physical)
*
* 包含:行内信号表格、信号值存取、编码写入、浮点/整数/Motorola/Intel 位操作。
*
* 依赖:dbc.js(state/el/padHex/bytesToHex/cssEscape/findSignal)、common/utils.js(escapeHtml)。
*/
import { state, el, padHex, bytesToHex, cssEscape, findSignal, findMessageByKey } from './dbc.js';
import { escapeHtml as sanitizeHtml } from '../common/utils.js';
import { scheduleCanPersist } from './persist.js';
// ==================== 信号值存取 ====================
/** @description 确保报文有 signalValues bag,并为每个信号填充默认值。返回 bag。 */
export function ensureMessageDefaults(message) {
if (!message) return null;
var bag = state.signalValues[message.key];
if (!bag) {
bag = {};
state.signalValues[message.key] = bag;
}
for (var i = 0; i < message.signals.length; i++) {
var sig = message.signals[i];
if (bag[sig.name] == null || bag[sig.name] === '') {
bag[sig.name] = getDefaultSignalValue(sig);
}
}
return bag;
}
export function getSignalValueMap(message) {
if (!message) return {};
return state.signalValues[message.key] || ensureMessageDefaults(message);
}
function setSignalValue(message, signalName, value) {
if (!message) return;
var bag = state.signalValues[message.key];
if (!bag) {
bag = {};
state.signalValues[message.key] = bag;
}
bag[signalName] = value;
}
function getDefaultSignalValue(signal) {
/* 优先使用 DBC 中 BA_ "GenSigDefaultValue" 声明的物理值,否则回退为 0 */
if (signal && signal.defaultValue != null && Number.isFinite(Number(signal.defaultValue))) {
return String(signal.defaultValue);
}
return '0';
}
/** @description 获取当前选中的报文对象(无选中返回 null)。 */
export function getSelectedMessage() {
if (!state.dbc || !state.selectedMessageKey) return null;
return findMessageByKey(state.selectedMessageKey);
}
function getMultiplexerSignal(message) {
if (!message) return null;
for (var i = 0; i < message.signals.length; i++) {
if (message.signals[i].multiplexRole === 'mux') return message.signals[i];
}
return null;
}
/** @description 根据当前 multiplexer 值返回激活信号列表。 */
export function getActiveSignals(message) {
if (!message) return [];
var muxSignal = getMultiplexerSignal(message);
if (!muxSignal) return message.signals.slice();
var bag = getSignalValueMap(message);
var currentValue = Number(bag[muxSignal.name]);
return message.signals.filter(function (signal) {
if (!signal.multiplexRole || signal.multiplexRole === 'mux') return true;
return signal.multiplexRole === 'value' && Number.isFinite(currentValue) && signal.multiplexValue === currentValue;
});
}
// ==================== 信号行渲染 ====================
/** @description 在报文展开行内渲染信号表格(inline grid)。 */
export function renderInlineSignalGrid(message, host) {
if (!host) return;
ensureMessageDefaults(message);
var activeSignals = getActiveSignals(message);
if (!activeSignals.length) {
host.innerHTML = '<div class="status-empty">当前报文没有信号定义。</div>';
return;
}
var rows = activeSignals.map(function (signal, index) {
return renderSignalRow(message, signal, index);
}).join('');
host.innerHTML =
'<div class="signal-table signal-table-inline">' +
'<div class="signal-table-head">' +
'<span class="signal-cell signal-cell-index">#</span>' +
'<span class="signal-cell signal-cell-name">信号名称</span>' +
'<span class="signal-cell signal-cell-value">输入值</span>' +
'<span class="signal-cell signal-cell-range">范围 / 枚举</span>' +
'<span class="signal-cell signal-cell-meta">位定义</span>' +
'<span class="signal-cell signal-cell-comment">注释</span>' +
'</div>' +
'<div class="signal-table-body">' + rows + '</div>' +
'</div>';
bindSignalInputs(host, message);
}
function renderSignalRow(message, signal, index) {
var bag = getSignalValueMap(message);
var enumKeys = Object.keys(signal.enumMap || {});
var enumCount = enumKeys.length;
var bitRange = signal.startBit + '|' + signal.length + ' @' + (signal.byteOrder === 'little' ? 'Intel' : 'Motorola');
var rangeText = '';
if (Number.isFinite(signal.min) || Number.isFinite(signal.max)) {
rangeText = (Number.isFinite(signal.min) ? signal.min : '-') + ' ~ ' + (Number.isFinite(signal.max) ? signal.max : '-');
} else {
rangeText = '未声明';
}
var typeText = (signal.valueType || 'int').toUpperCase() + ' / ' + (signal.isSigned ? 'signed' : 'unsigned');
var factorText = 'factor ' + signal.factor + ' / offset ' + signal.offset + (signal.unit ? (' / ' + signal.unit) : '');
var comment = signal.comment || '-';
if (comment.length > 48) comment = comment.slice(0, 48) + '...';
var inputHtml = '';
if (enumCount) {
inputHtml = '<select class="input signal-input signal-input-compact" data-msg="' + sanitizeHtml(message.key) + '" data-signal="' + sanitizeHtml(signal.name) + '">' +
enumKeys.map(function (key) {
var label = signal.enumMap[key];
var selected = String(bag[signal.name]) === String(key) ? ' selected' : '';
return '<option value="' + sanitizeHtml(key) + '"' + selected + '>' +
sanitizeHtml(key + ' - ' + label) + '</option>';
}).join('') + '</select>';
} else {
inputHtml = '<input class="input signal-input signal-input-compact" type="number" step="any" data-msg="' + sanitizeHtml(message.key) + '" data-signal="' + sanitizeHtml(signal.name) +
'" value="' + sanitizeHtml(bag[signal.name]) + '">';
}
return '' +
'<div class="signal-row">' +
'<span class="signal-cell signal-cell-index">' + (index + 1) + '</span>' +
'<span class="signal-cell signal-cell-name">' +
'<b>' + sanitizeHtml(signal.name) + '</b>' +
'<small>' + sanitizeHtml((signal.receivers || []).join(', ') || '-') + '</small>' +
'</span>' +
'<span class="signal-cell signal-cell-value">' + inputHtml + '</span>' +
'<span class="signal-cell signal-cell-range">' +
'<b>' + sanitizeHtml(rangeText) + '</b>' +
(enumCount ? '<small>' + sanitizeHtml(enumKeys.slice(0, 3).map(function (key) { return key + ':' + signal.enumMap[key]; }).join(' / ')) + (enumCount > 3 ? ' ...' : '') + '</small>' : '<small>' + sanitizeHtml(factorText) + '</small>') +
'</span>' +
'<span class="signal-cell signal-cell-meta">' +
'<b>' + sanitizeHtml(bitRange) + '</b>' +
'<small>' + sanitizeHtml(typeText) + '</small>' +
'</span>' +
'<span class="signal-cell signal-cell-comment" title="' + sanitizeHtml(signal.comment || '') + '">' + sanitizeHtml(comment) + '</span>' +
'</div>';
}
function handleSignalInputEvent(evt) {
var input = evt.currentTarget;
var key = input.getAttribute('data-msg');
var name = input.getAttribute('data-signal');
if (!key || !name || !state.dbc) return;
var message = findMessageByKey(key);
if (!message) return;
setSignalValue(message, name, input.value);
var signal = findSignal(message, name);
if (signal && signal.multiplexRole === 'mux') {
/* MUX 值变更后需重建信号表格(激活信号集变化)。
* 限定在 el.messageList 作用域内查找,避免多列表/多 DBC 场景匹配到错误节点
*(与 messages.js 中 toggleMessageExpand 的作用域策略一致)。 */
var root = el.messageList || document;
var inlineGrid = root.querySelector('.message-row-expand[data-key="' + cssEscape(key) + '"] .signal-table-host');
if (inlineGrid) renderInlineSignalGrid(message, inlineGrid);
}
if (key === state.selectedMessageKey) updateEncodedPreview();
scheduleCanPersist();
}
function bindSignalInputs(container, message) {
if (!container) return;
var inputs = container.querySelectorAll('.signal-input');
for (var i = 0; i < inputs.length; i++) {
inputs[i].addEventListener('input', handleSignalInputEvent);
inputs[i].addEventListener('change', handleSignalInputEvent);
}
if (message && message.signals.length && message.key === state.selectedMessageKey) {
updateEncodedPreview();
}
}
// ==================== 编码预览 ====================
/** @description 根据当前选中报文 + 信号值更新编码预览(hex / byte strip / hint)。 */
export function updateEncodedPreview() {
var message = getSelectedMessage();
if (!message) {
state.generatedData = new Uint8Array(0);
if (el.generatedHex) el.generatedHex.textContent = '--';
if (el.generatedBytes) el.generatedBytes.innerHTML = '';
return;
}
try {
state.generatedData = encodeMessageData(message, getSignalValueMap(message));
var hex = bytesToHex(state.generatedData);
el.generatedHex.textContent = hex || '--';
el.generatedBytes.innerHTML = renderByteStrip(state.generatedData);
var hint = 'DLC <b>' + message.dlc + '</b>,当前编码 <b>' + state.generatedData.length +
'</b> 字节,发送通道 <b>CH' + (el.defaultChannel ? el.defaultChannel.value : '0') + '</b>。';
if (el.encodeHint) {
el.encodeHint.className = 'helper-note ok';
el.encodeHint.innerHTML = hint;
}
} catch (err) {
state.generatedData = new Uint8Array(0);
el.generatedHex.textContent = '--';
el.generatedBytes.innerHTML = '';
if (el.encodeHint) {
el.encodeHint.className = 'helper-note err';
el.encodeHint.innerHTML = '编码失败:' + sanitizeHtml(err.message);
}
}
}
function renderByteStrip(bytes) {
if (!bytes || !bytes.length) return '<div class="status-empty">无数据</div>';
var html = [];
for (var i = 0; i < bytes.length; i++) {
html.push('<span class="byte-pill"><b>B' + i + '</b><span>' + padHex(bytes[i], 2) + '</span></span>');
}
return html.join('');
}
// ==================== 信号编解码(raw ↔ physical) ====================
/** @description 将报文 + 信号值编码为 Uint8Array。 */
export function encodeMessageData(message, signalValues) {
var dlc = message && message.dlc != null ? Number(message.dlc) : 0;
if (!Number.isFinite(dlc) || dlc < 0) dlc = 0;
if (dlc > 64) dlc = 64;
/* DLC=0:无数据区(如 CANFD_BOOT),不写信号,直接空帧 */
if (dlc === 0) {
return new Uint8Array(0);
}
var bytes = new Uint8Array(dlc);
var activeSignals = getActiveSignals(message);
for (var i = 0; i < activeSignals.length; i++) {
var signal = activeSignals[i];
/* 信号完全落在 DLC 外则跳过,避免把空帧/定义异常拖成批量失败 */
if (!signalFitsInDlc(signal, dlc)) {
continue;
}
var parsed = parseSignalInput(signal, signalValues[signal.name]);
if (signal.valueType === 'float' || signal.valueType === 'double') {
writeFloatSignal(bytes, signal, parsed.numeric);
} else {
writeIntegerSignal(bytes, signal, parsed);
}
}
return bytes;
}
/** @description 粗判信号位域是否完全落在 dlc 字节内(越界整段则跳过)。 */
function signalFitsInDlc(signal, dlc) {
if (!signal || dlc <= 0) return false;
var maxBit = dlc * 8 - 1;
if (signal.byteOrder === 'little') {
var end = signal.startBit + signal.length - 1;
return signal.startBit >= 0 && signal.startBit <= maxBit && end <= maxBit;
}
/* float:要求整段字节落在 DLC 内(startBit 必须字节对齐,由 writeFloatSignal 另校验) */
if (signal.valueType === 'float' || signal.valueType === 'double') {
var startByte = signal.startBit / 8;
var byteLen = signal.length / 8;
return startByte >= 0 && Number.isInteger(startByte) &&
startByte + byteLen <= dlc;
}
/* Motorola:用 buildMotorolaBitPositions 计算实际占用位序,检查最大位是否落在 DLC 内。
* 此前仅判 startByte,长跨字节信号末位可能越界 → 会被 setBitByIndex 抛错而非跳过,
* 与 little 分支"越界整段跳过"语义不一致;这里统一为"越界即跳过"。 */
if (signal.length <= 0 || signal.length > 64) return false;
var positions = buildMotorolaBitPositions(signal.startBit, signal.length);
for (var j = 0; j < positions.length; j++) {
if (positions[j] < 0 || positions[j] > maxBit) return false;
}
return true;
}
function parseSignalInput(signal, inputValue) {
var value = inputValue;
if (value === '' || value == null) value = getDefaultSignalValue(signal);
var num = Number(value);
if (!Number.isFinite(num)) throw new Error(signal.name + ' 不是合法数字');
/* enum 信号:取值是离散枚举 key(数字字符串),DBC 的 min/max 是物理连续值范围,
* 对 enum 不适用 —— 合法 enum 值可能落在 [min,max] 之外(例如 fault 枚举)。
* 因此 enum 信号只做"值必须 ∈ enumMap keys"的校验,跳过 min/max。 */
var enumKeys = Object.keys(signal.enumMap || {});
if (enumKeys.length) {
if (enumKeys.indexOf(String(value)) < 0) {
throw new Error(signal.name + ' 不是合法枚举值:' + value);
}
return { numeric: num, text: String(value).trim() };
}
if (Number.isFinite(signal.min) && num < signal.min) {
throw new Error(signal.name + ' 低于最小值 ' + signal.min);
}
if (Number.isFinite(signal.max) && num > signal.max) {
throw new Error(signal.name + ' 高于最大值 ' + signal.max);
}
return {
numeric: num,
text: String(value).trim()
};
}
function isIntegerLike(value) {
return /^-?\d+$/.test(String(value == null ? '' : value).trim());
}
function physicalToRawBigInt(signal, parsedValue) {
if ((signal.factor === 1 || signal.factor === 1.0) &&
(signal.offset === 0 || signal.offset === 0.0) &&
isIntegerLike(parsedValue.text)) {
return BigInt(parsedValue.text);
}
/* factor=0 是 DBC 异常情况(规范上 factor 不应为 0),此处为防御性兜底:
* - 有 offset:物理值直接减 offset 作为 raw(把 offset 视为"物理=raw 的偏移基线")
* - 无 offset:物理值即 raw
* 实际 DBC 不应走到此分支,但避免除零导致 NaN。 */
var rawNumber = signal.factor === 0
? Math.round(signal.offset ? (parsedValue.numeric - signal.offset) : parsedValue.numeric)
: Math.round((parsedValue.numeric - signal.offset) / signal.factor);
if (!Number.isFinite(rawNumber)) {
throw new Error(signal.name + ' 原始值计算失败');
}
return BigInt(rawNumber);
}
function bigPow2(bits) {
return 1n << BigInt(bits);
}
function maxUnsignedValue(bits) {
return bigPow2(bits) - 1n;
}
function minSignedValue(bits) {
return -(1n << BigInt(bits - 1));
}
function maxSignedValue(bits) {
return (1n << BigInt(bits - 1)) - 1n;
}
function writeIntegerSignal(bytes, signal, parsedValue) {
var raw = physicalToRawBigInt(signal, parsedValue);
if (signal.length <= 0 || signal.length > 64) {
throw new Error(signal.name + ' 位宽超出当前页面支持范围');
}
if (signal.isSigned) {
var min = minSignedValue(signal.length);
var max = maxSignedValue(signal.length);
if (raw < min || raw > max) {
throw new Error(signal.name + ' 原始值超出带符号范围');
}
} else {
var maxUnsigned = maxUnsignedValue(signal.length);
if (raw < 0 || raw > maxUnsigned) {
throw new Error(signal.name + ' 原始值超出无符号范围');
}
}
var unsignedRaw = raw < 0 ? bigPow2(signal.length) + raw : raw;
if (signal.byteOrder === 'little') {
for (var i = 0; i < signal.length; i++) {
var bitVal = Number((unsignedRaw >> BigInt(i)) & 1n);
var bitIndex = signal.startBit + i;
setBitByIndex(bytes, bitIndex, bitVal);
}
return;
}
var positions = buildMotorolaBitPositions(signal.startBit, signal.length);
for (var j = 0; j < positions.length; j++) {
var shift = signal.length - 1 - j;
var motorolaBit = Number((unsignedRaw >> BigInt(shift)) & 1n);
setBitByIndex(bytes, positions[j], motorolaBit);
}
}
function writeFloatSignal(bytes, signal, physicalValue) {
if (signal.length !== 32 && signal.length !== 64) {
throw new Error(signal.name + ' 浮点信号长度需为 32 或 64 位');
}
if (signal.byteOrder !== 'little') {
throw new Error(signal.name + ' 浮点信号当前仅支持 Intel 小端');
}
if (signal.startBit % 8 !== 0) {
throw new Error(signal.name + ' 浮点信号起始位必须按字节对齐');
}
var startByte = signal.startBit / 8;
var byteLen = signal.length / 8;
/* 越界防御:signalFitsInDlc 已在 encodeMessageData 入口过滤,这里二次断言,
* 避免任何直接调用方(如未来新增的 decode 路径)漏检导致写越界。 */
if (startByte + byteLen > bytes.length) {
throw new Error(signal.name + ' 浮点信号写入越界:startByte=' + startByte +
' + length=' + byteLen + ' > 缓冲区 ' + bytes.length + ' 字节');
}
var buffer = new ArrayBuffer(byteLen);
var view = new DataView(buffer);
if (signal.length === 32) view.setFloat32(0, physicalValue, true);
else view.setFloat64(0, physicalValue, true);
var rawBytes = new Uint8Array(buffer);
for (var i = 0; i < rawBytes.length; i++) {
bytes[startByte + i] = rawBytes[i];
}
}
function buildMotorolaBitPositions(startBit, length) {
var positions = [];
var bit = startBit;
for (var i = 0; i < length; i++) {
positions.push(bit);
if (bit % 8 === 0) bit += 15;
else bit -= 1;
}
return positions;
}
function setBitByIndex(bytes, bitIndex, bitValue) {
var byteIndex = Math.floor(bitIndex / 8);
var bitInByte = bitIndex % 8;
if (byteIndex < 0 || byteIndex >= bytes.length) {
throw new Error('信号写入越界,bit=' + bitIndex +
'(缓冲区 ' + bytes.length + ' 字节)');
}
if (bitValue) bytes[byteIndex] |= (1 << bitInByte);
else bytes[byteIndex] &= ~(1 << bitInByte);
}
+350
View File
@@ -0,0 +1,350 @@
/**
* can_monitor.js - CAN 接收监控(独立页 can_monitor.html 与 can.html 右侧抽屉共用)
*/
import { showToast } from './common/toast.js';
import { escapeHtml } from './common/utils.js';
var state = { fbTimer: null, fbInFlight: false };
var el = {};
// ── Click-to-Copy (feedback 值单元格) ──────────────────────
function copyFbVal(text, target) {
if (!text || text === '--') return;
function onDone() {
target.classList.add('copied');
setTimeout(function () { target.classList.remove('copied'); }, 1200);
showToast('已复制', 'success');
}
function fallback() {
var ta = document.createElement('textarea');
ta.value = text;
ta.style.cssText = 'position:fixed;left:-9999px;opacity:0';
document.body.appendChild(ta);
ta.select();
try { document.execCommand('copy'); onDone(); } catch (_) {}
document.body.removeChild(ta);
}
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(onDone).catch(fallback);
} else {
fallback();
}
}
var ids = [
'monIp', 'monTestBtn', 'monConnPill', 'monConnTxt',
'monConnIndicator', 'monConnIndicatorTxt', 'monHelper',
'fbModePill', 'fbPollStatus',
'fb0State', 'fb0Rx', 'fb0Tx', 'fb0Drop', 'fb0Err', 'fb0Tec', 'fb0Rec', 'fb0Bus', 'fb0Seq', 'fb0Delta',
'fb0LastId', 'fb0Dlc', 'fb0Flags', 'fb0LastData',
'fb1State', 'fb1Rx', 'fb1Tx', 'fb1Drop', 'fb1Err', 'fb1Tec', 'fb1Rec', 'fb1Bus', 'fb1Seq', 'fb1Delta',
'fb1LastId', 'fb1Dlc', 'fb1Flags', 'fb1LastData',
'fb2State', 'fb2Rx', 'fb2Tx', 'fb2Drop', 'fb2Err', 'fb2Tec', 'fb2Rec', 'fb2Bus', 'fb2Seq', 'fb2Delta',
'fb2LastId', 'fb2Dlc', 'fb2Flags', 'fb2LastData',
'btnModeAllPass', 'btnModeFilter', 'btnModeBridge',
'idsnapChannel', 'idsnapStatus', 'idsnapBody',
'toast', 'toastIco', 'toastMsg',
'toolIp' /* can.html 共用 IP */
];
function cacheElements() {
ids.forEach(function (id) { el[id] = document.getElementById(id); });
}
function getBaseUrl() {
var ip = '';
if (el.monIp && el.monIp.value) {
ip = el.monIp.value.trim();
} else if (el.toolIp && el.toolIp.value) {
ip = el.toolIp.value.trim();
} else {
try {
var saved = localStorage.getItem('dcu_esp_ip');
if (saved && saved.trim()) ip = saved.trim();
} catch (_) {}
}
if (!ip) ip = '192.168.31.127';
return 'http://' + ip;
}
function setConnState(ok, text) {
/* 嵌入 can.html 时顶栏已有 canConnPill,不再用 monConn 重复占位 */
if (el.monConnPill) el.monConnPill.className = 'pill ' + (ok ? 'online' : 'offline');
if (el.monConnTxt) el.monConnTxt.textContent = text;
if (el.monConnIndicator) el.monConnIndicator.className = 'conn-indicator ' + (ok ? 'ok' : 'err');
if (el.monConnIndicatorTxt) el.monConnIndicatorTxt.textContent = text;
/* 轮询状态即监控连通性(与顶栏 canConn 去重时仍更新 fbPollStatus) */
if (!el.monConnPill && el.fbPollStatus && !ok) {
el.fbPollStatus.textContent = '轮询: 离线';
el.fbPollStatus.className = 'pill offline';
}
}
// ── 1Hz 轮询 /can/feedback ─────────────────────────────────
async function pollFeedback() {
// 防重入:上一次 fetch 未完成则跳过本次 tick,避免并发请求叠加
if (state.fbInFlight) return;
state.fbInFlight = true;
var ac = new AbortController();
var timer = setTimeout(function () { ac.abort(); }, 3000);
try {
var resp = await fetch(getBaseUrl() + '/can/feedback', { signal: ac.signal });
if (!resp.ok) return;
var data = await resp.json();
var channels = data.channels || [];
var selectedCh = el.idsnapChannel ? parseInt(el.idsnapChannel.value, 10) : 0;
if (!Number.isFinite(selectedCh)) selectedCh = 0;
for (var i = 0; i < 3; i++) {
var ch = channels[i];
var card = document.getElementById('fbCh' + i);
/* enabled 来自 GD32 HELLO/PING 身份;未知时默认按开启展示 */
var enabled = !ch || ch.enabled === undefined || ch.enabled === null || !!ch.enabled;
if (card) {
card.classList.toggle('ch-disabled', !enabled);
card.title = enabled ? '' : 'GD32 CONFIG_CAN_CH' + i + '_ENABLE=false(HELLO/PING 身份)';
}
if (!enabled) {
renderChannelDisabled(i);
continue;
}
if (!ch || !ch.valid) {
renderChannelEmpty(i);
continue;
}
renderChannel(i, ch);
}
var sel = channels[selectedCh];
if (sel && sel.valid && sel.entries) {
renderIdTable(sel.entries);
if (el.idsnapStatus) {
el.idsnapStatus.textContent = (sel.entries.length) + ' 个 ID';
el.idsnapStatus.className = 'pill online';
}
} else {
renderIdTableEmpty();
}
if (el.fbPollStatus) {
el.fbPollStatus.textContent = '轮询: 正常';
el.fbPollStatus.className = 'pill online';
}
setConnState(true, '已连接');
} catch (e) {
if (e && e.name === 'AbortError') return;
if (el.fbPollStatus) {
el.fbPollStatus.textContent = '轮询: 离线';
el.fbPollStatus.className = 'pill offline';
}
setConnState(false, '连接失败');
} finally {
clearTimeout(timer);
state.fbInFlight = false;
}
}
function renderChannel(i, ch) {
var p = 'fb' + i;
if (el[p + 'State']) {
var hasIds = ch.count > 0;
el[p + 'State'].textContent = hasIds ? '活跃 (' + ch.count + ' ID)' : '无帧(总线空闲)';
el[p + 'State'].className = 'fb-val ' + (hasIds ? 'ok' : 'info');
}
if (el[p + 'Rx']) el[p + 'Rx'].textContent = ch.rx;
if (el[p + 'Tx']) el[p + 'Tx'].textContent = ch.tx;
if (el[p + 'Drop']) { el[p + 'Drop'].textContent = ch.drop; el[p + 'Drop'].className = 'fb-val' + (ch.drop > 0 ? ' warn' : ''); }
if (el[p + 'Err']) { el[p + 'Err'].textContent = ch.err; el[p + 'Err'].className = 'fb-val' + (ch.err > 0 ? ' err' : ''); }
if (el[p + 'Tec']) {
el[p + 'Tec'].textContent = (ch.tec !== undefined && ch.tec !== null) ? ch.tec : '--';
el[p + 'Tec'].className = 'fb-val' + (ch.tec > 0 ? ' warn' : '');
}
if (el[p + 'Rec']) {
el[p + 'Rec'].textContent = (ch.rec !== undefined && ch.rec !== null) ? ch.rec : '--';
el[p + 'Rec'].className = 'fb-val' + (ch.rec > 0 ? ' warn' : '');
}
if (el[p + 'Bus']) {
if (ch.bus_state === undefined || ch.bus_state === null) {
el[p + 'Bus'].textContent = '未知';
el[p + 'Bus'].className = 'fb-val';
} else {
var bs = ch.bus_state;
var parts = [];
if (bs & 1) parts.push('INIT');
if (bs & 2) parts.push('SLEEP');
if (bs & 4) parts.push('ERR_PASSIVE');
if (bs & 8) parts.push('BUS_OFF');
if (bs & 16) parts.push('ERR_WARN');
el[p + 'Bus'].textContent = (bs === 0) ? '正常' : (parts.length ? parts.join(' ') : '正常');
el[p + 'Bus'].className = 'fb-val' + ((bs & 8) ? ' err' : (bs & (4 | 16)) ? ' warn' : '');
}
}
if (el[p + 'Seq']) el[p + 'Seq'].textContent = ch.seq;
if (el[p + 'Delta']) { el[p + 'Delta'].textContent = ch.count || 0; el[p + 'Delta'].className = 'fb-val'; }
var entries = ch.entries;
if (entries && entries.length) {
var last = entries[entries.length - 1];
if (el[p + 'LastId']) { el[p + 'LastId'].textContent = last.id; el[p + 'LastId'].className = 'fb-val mono'; }
if (el[p + 'Dlc']) el[p + 'Dlc'].textContent = last.dlc;
if (el[p + 'Flags']) el[p + 'Flags'].textContent = (last.fd ? 'FD' : '-') + ' / ' + (last.brs ? 'BRS' : '-') + ' / ' + (last.esi ? 'ESI' : '-');
if (el[p + 'LastData']) { el[p + 'LastData'].textContent = last.data || '--'; el[p + 'LastData'].className = 'fb-val mono fb-data-cell'; }
} else {
if (el[p + 'LastId']) { el[p + 'LastId'].textContent = '--'; el[p + 'LastId'].className = 'fb-val mono'; }
if (el[p + 'Dlc']) el[p + 'Dlc'].textContent = '--';
if (el[p + 'Flags']) el[p + 'Flags'].textContent = '--';
if (el[p + 'LastData']) { el[p + 'LastData'].textContent = '--'; el[p + 'LastData'].className = 'fb-val mono fb-data-cell'; }
}
if (i === 0 && el.fbModePill) {
var modeText = ch.mode === 2 ? '桥接(CAN0→1/2)' : (ch.mode === 1 ? '过滤' : '全收');
el.fbModePill.textContent = '模式: ' + modeText;
}
}
function renderChannelEmpty(i) {
var p = 'fb' + i;
if (el[p + 'State']) { el[p + 'State'].textContent = 'SPI 离线'; el[p + 'State'].className = 'fb-val err'; }
['Rx', 'Tx', 'Drop', 'Err', 'Tec', 'Rec', 'Bus', 'Seq', 'Delta', 'Dlc', 'Flags'].forEach(function (f) {
if (el[p + f]) { el[p + f].textContent = '--'; el[p + f].className = 'fb-val'; }
});
if (el[p + 'LastId']) { el[p + 'LastId'].textContent = '--'; el[p + 'LastId'].className = 'fb-val mono'; }
if (el[p + 'LastData']) { el[p + 'LastData'].textContent = '--'; el[p + 'LastData'].className = 'fb-val mono fb-data-cell'; }
}
/** GD32 编译关闭该通道(APP PING feat can_chN=false) */
function renderChannelDisabled(i) {
var p = 'fb' + i;
if (el[p + 'State']) {
el[p + 'State'].textContent = '固件未启用';
el[p + 'State'].className = 'fb-val warn';
}
['Rx', 'Tx', 'Drop', 'Err', 'Tec', 'Rec', 'Bus', 'Seq', 'Delta', 'Dlc', 'Flags'].forEach(function (f) {
if (el[p + f]) { el[p + f].textContent = '--'; el[p + f].className = 'fb-val'; }
});
if (el[p + 'LastId']) { el[p + 'LastId'].textContent = '--'; el[p + 'LastId'].className = 'fb-val mono'; }
if (el[p + 'LastData']) { el[p + 'LastData'].textContent = '--'; el[p + 'LastData'].className = 'fb-val mono fb-data-cell'; }
}
function renderIdTable(entries) {
var tbody = el.idsnapBody;
if (!tbody) return;
if (!entries || !entries.length) {
tbody.innerHTML = '<tr><td colspan="7" class="status-empty">无数据(CAN 未收到帧)</td></tr>';
return;
}
var frag = document.createDocumentFragment();
for (var i = 0; i < entries.length; i++) {
var e = entries[i];
var tr = document.createElement('tr');
tr.innerHTML =
'<td>' + (i + 1) + '</td>' +
'<td class="mono">' + escapeHtml(e.id) + '</td>' +
'<td>' + escapeHtml(e.dlc) + '</td>' +
'<td class="' + (e.fd ? 'flag-on' : 'flag-off') + '">' + (e.fd ? 'Y' : '-') + '</td>' +
'<td class="' + (e.brs ? 'flag-on' : 'flag-off') + '">' + (e.brs ? 'Y' : '-') + '</td>' +
'<td class="' + (e.esi ? 'flag-on' : 'flag-off') + '">' + (e.esi ? 'Y' : '-') + '</td>' +
'<td class="idsnap-data mono">' + escapeHtml(e.data || '--') + '</td>';
frag.appendChild(tr);
}
tbody.innerHTML = '';
tbody.appendChild(frag);
}
function renderIdTableEmpty() {
var tbody = el.idsnapBody;
if (tbody) tbody.innerHTML = '<tr><td colspan="7" class="status-empty">等待数据...</td></tr>';
if (el.idsnapStatus) { el.idsnapStatus.textContent = '--'; el.idsnapStatus.className = 'pill'; }
}
async function testConnection() {
try {
var resp = await fetch(getBaseUrl() + '/can/feedback', { signal: AbortSignal.timeout(3000) });
if (!resp.ok) { setConnState(false, 'HTTP ' + resp.status); return; }
setConnState(true, '已连接');
showToast('ESP32 已连接', 'success');
} catch (e) {
setConnState(false, '连接失败');
showToast('连接失败: ' + e.message, 'error');
}
}
async function setCanMode(mode) {
try {
var resp = await fetch(getBaseUrl() + '/can/mode', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode: mode }),
signal: AbortSignal.timeout(3000)
});
var data = await resp.json();
if (data.ok) {
showToast('CAN 模式已切换: ' + (data.actual === 2 ? '桥接(CAN0→1/2)' : (data.actual === 1 ? '过滤' : '全收')), 'success');
} else {
showToast('模式切换失败', 'error');
}
} catch (e) {
showToast('模式切换失败: ' + e.message, 'error');
}
}
/**
* 初始化监控逻辑。可在 can_monitor.html(独立)或 can.html(抽屉)中调用。
* 幂等:重复调用不会叠加多个 interval。
*/
export function initCanMonitor() {
cacheElements();
/* 无监控 DOM 时跳过(纯 can 发送页历史形态) */
if (!el.fb0State && !el.idsnapBody) return;
if (state.fbTimer) {
clearInterval(state.fbTimer);
state.fbTimer = null;
}
setConnState(false, '未连接');
if (!state.bound) {
state.bound = true;
if (el.monTestBtn) el.monTestBtn.addEventListener('click', testConnection);
if (el.monIp) el.monIp.addEventListener('change', testConnection);
/* 嵌入 can.html 时 IP 与发送台共用 */
if (el.toolIp && !el.monIp) {
el.toolIp.addEventListener('change', function () {
pollFeedback();
});
}
if (el.btnModeAllPass) el.btnModeAllPass.addEventListener('click', function () { setCanMode(0); });
if (el.btnModeFilter) el.btnModeFilter.addEventListener('click', function () { setCanMode(1); });
if (el.btnModeBridge) el.btnModeBridge.addEventListener('click', function () { setCanMode(2); });
if (el.idsnapChannel) el.idsnapChannel.addEventListener('change', pollFeedback);
/* feedback 值 click-to-copy:在 grid 容器上事件代理 */
document.querySelectorAll('.feedback-grid').forEach(function (grid) {
grid.addEventListener('click', function (e) {
var target = e.target.closest('.fb-val');
if (!target) return;
copyFbVal(target.textContent.trim(), target);
});
});
/* 通道卡:默认折叠详情,点标题展开/收起 */
document.querySelectorAll('.feedback-channel .feedback-ch-head').forEach(function (head) {
var toggle = function (e) {
e.preventDefault();
var card = head.closest('.feedback-channel');
if (card) card.classList.toggle('collapsed');
};
head.addEventListener('click', toggle);
head.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') toggle(e);
});
});
}
setTimeout(testConnection, 150);
pollFeedback();
state.fbTimer = setInterval(pollFeedback, 1000);
}
/* 独立页 can_monitor.html:有 monIp 时自启;嵌入 can.html 由 can.js 调用 initCanMonitor() */
if (document.getElementById('monIp')) {
initCanMonitor();
}
+34
View File
@@ -0,0 +1,34 @@
/**
* base-url.js - 设备 IP / 基础 URL 公共助手
*
* 各页面共用 localStorage key dcu_esp_ip(OTA / CAN / 电机页一致)。
* 本模块只提供纯 localStorage + 默认值的通用实现;页面特有的输入框回退
* (如 CAN 页 el.toolIp、OTA 页 #espIp)由各自入口在此基础上包装。
*/
export var LS_ESP_IP = 'dcu_esp_ip';
export var DEFAULT_ESP_IP = '192.168.31.127';
/** @description 读取 localStorage 中保存的设备 IP,无有效值返回空串。 */
export function getSavedDeviceIp() {
try {
var saved = localStorage.getItem(LS_ESP_IP);
if (saved && saved.trim()) return saved.trim();
} catch (_) {}
return '';
}
/** @description 保存设备 IP 到 localStorage(空值忽略)。 */
export function saveDeviceIp(ip) {
if (!ip || !String(ip).trim()) return;
try { localStorage.setItem(LS_ESP_IP, String(ip).trim()); } catch (_) {}
}
/** @description 设备 IP:localStorage 优先,否则默认值。 */
export function getDeviceIp() {
return getSavedDeviceIp() || DEFAULT_ESP_IP;
}
/** @description 拼接设备基础 URL(http://<ip>)。 */
export function getBaseUrl() {
return 'http://' + getDeviceIp();
}
File diff suppressed because it is too large Load Diff
+196
View File
@@ -0,0 +1,196 @@
/**
* heap-low-history.js - 堆"最低空闲创新低"记录的浏览器端持久化
*
* 目的:设备 RAM 只保留最新 1 条创新低;长时间挂机早期记录会被覆盖。
* 前端定时拉 /system/heap_low_log,发现新记录就追加到 IndexedDB,
* 弹窗里合并显示「设备当前 1 条 + 浏览器累积历史」,掉电/重启不丢本地史。
*
* 存储设计:
* - 单 objectStore 'heap_low_records',keyPath 'id'(自增)
* - 每条记录附前端写入时的时间戳 saved_at_ms(wall clock)
* - 设备重启会重置 ts_ms(启动后毫秒)和 min_free_heap(水位清零),
* 所以去重 key 用 (ts_ms, min_free_heap, free_heap, alloc_count) 组合,
* 重启后第一条 min_free_heap 必然高于上一次会话最低值 → 自然不会误并入。
* - 上限 HEAP_HISTORY_MAX 条,超出按 saved_at_ms 升序删最早的。
*/
var IDB_NAME = 'esp_dcu_heap_low';
var IDB_STORE = 'heap_low_records';
var IDB_VERSION = 1; // 独立库,与 file-handle.js 的 dcu_ota_fw_v1 不冲突
/** 浏览器可保存的创新低条数上限(设备仅 1 槽;多条全靠这里)。 */
export var HEAP_HISTORY_MAX = 500;
var _dbPromise = null;
function openDb() {
if (!_dbPromise) {
_dbPromise = new Promise(function (resolve, reject) {
if (!window.indexedDB) {
reject(new Error('indexedDB unavailable'));
return;
}
var req = indexedDB.open(IDB_NAME, IDB_VERSION);
req.onupgradeneeded = function () {
var db = req.result;
if (!db.objectStoreNames.contains(IDB_STORE)) {
var store = db.createObjectStore(IDB_STORE, { keyPath: 'id', autoIncrement: true });
// 去重查询索引:同一会话内 (ts_ms,min_free_heap,free_heap,alloc_count) 唯一
store.createIndex('dedup', ['ts_ms', 'min_free_heap', 'free_heap', 'alloc_count'], { unique: false });
store.createIndex('saved_at', 'saved_at_ms', { unique: false });
}
};
req.onsuccess = function () { resolve(req.result); };
req.onerror = function () { reject(req.error || new Error('idb open failed')); };
});
_dbPromise.catch(function () { _dbPromise = null; });
}
return _dbPromise;
}
/** 计算单条记录的去重 key 字符串(用于 in-memory 比对,避免每次都走 IDB 索引) */
function dedupKey(r) {
return r.ts_ms + '|' + r.min_free_heap + '|' + r.free_heap + '|' + (r.alloc_count || 0);
}
/** 把设备拉到的一批 records(最新在前)增量写入 IndexedDB,去重。
* 返回 Promise<number>:本次实际新增的条数。 */
export function appendHeapLowRecords(records) {
if (!Array.isArray(records) || !records.length) return Promise.resolve(0);
return openDb().then(function (db) {
return new Promise(function (resolve, reject) {
var tx = db.transaction(IDB_STORE, 'readwrite');
var store = tx.objectStore(IDB_STORE);
var idx = store.index('dedup');
var newCount = 0;
var savedAt = Date.now();
// 顺序处理:每条先查 dedup 索引,不存在则 put
var i = 0;
function next() {
if (i >= records.length) {
// 全部处理完后再裁剪到上限
pruneToMax(store, function () {
tx.oncomplete ? null : null;
});
return;
}
var r = records[i++];
var key = [r.ts_ms, r.min_free_heap, r.free_heap, r.alloc_count || 0];
var getReq = idx.get(key);
getReq.onsuccess = function () {
if (!getReq.result) {
// 新记录:写入
var rec = Object.assign({}, r, { saved_at_ms: savedAt });
var putReq = store.put(rec);
putReq.onsuccess = function () { newCount++; next(); };
putReq.onerror = function () { next(); }; // 单条失败不阻断
} else {
next(); // 已存在跳过
}
};
getReq.onerror = function () { next(); };
}
next();
tx.oncomplete = function () { resolve(newCount); };
tx.onerror = function () { reject(tx.error); };
tx.onabort = function () { reject(tx.error || new Error('tx aborted')); };
});
}).catch(function (e) {
// IndexedDB 不可用(无痕模式等)静默降级
return 0;
});
}
/** 删最早的记录直到条数 <= HEAP_HISTORY_MAX。需在 readwrite tx 内调用。 */
function pruneToMax(store, done) {
var countReq = store.count();
countReq.onsuccess = function () {
var total = countReq.result;
if (total <= HEAP_HISTORY_MAX) { done(); return; }
var toRemove = total - HEAP_HISTORY_MAX;
// 按 saved_at 索引升序游标删前 toRemove 条
var idx = store.index('saved_at');
var curReq = idx.openCursor();
var removed = 0;
curReq.onsuccess = function (ev) {
var cursor = ev.target.result;
if (!cursor || removed >= toRemove) { done(); return; }
var delReq = store.delete(cursor.primaryKey);
delReq.onsuccess = function () { removed++; cursor.continue(); };
delReq.onerror = function () { cursor.continue(); };
};
curReq.onerror = function () { done(); };
};
countReq.onerror = function () { done(); };
}
/** 读全部累积历史,按设备时间 ts_ms 升序(旧的在前),便于弹窗"历史段"显示。
* 返回 Promise<Array>。IndexedDB 不可用时返回 []。 */
export function loadHeapLowHistory() {
return openDb().then(function (db) {
return new Promise(function (resolve, reject) {
var tx = db.transaction(IDB_STORE, 'readonly');
var store = tx.objectStore(IDB_STORE);
var all = [];
var curReq = store.openCursor();
curReq.onsuccess = function (ev) {
var cursor = ev.target.result;
if (!cursor) return;
all.push(cursor.value);
cursor.continue();
};
curReq.onerror = function () { reject(curReq.error); };
tx.oncomplete = function () {
// 按 ts_ms 升序;同 ts_ms 时按 saved_at 升序(保证稳定)
all.sort(function (a, b) {
if (a.ts_ms !== b.ts_ms) return a.ts_ms - b.ts_ms;
return (a.saved_at_ms || 0) - (b.saved_at_ms || 0);
});
resolve(all);
};
tx.onerror = function () { reject(tx.error); };
});
}).catch(function () { return []; });
}
/** 清空全部历史(用于"清空"按钮,可选实现)。返回 Promise<boolean>。 */
export function clearHeapLowHistory() {
return openDb().then(function (db) {
return new Promise(function (resolve) {
var tx = db.transaction(IDB_STORE, 'readwrite');
var req = tx.objectStore(IDB_STORE).clear();
req.onsuccess = function () { resolve(true); };
req.onerror = function () { resolve(false); };
});
}).catch(function () { return false; });
}
/** 返回当前累积条数(仅用于 UI 提示,不需要数据)。返回 Promise<number>。 */
export function countHeapLowHistory() {
return openDb().then(function (db) {
return new Promise(function (resolve) {
var tx = db.transaction(IDB_STORE, 'readonly');
var req = tx.objectStore(IDB_STORE).count();
req.onsuccess = function () { resolve(req.result || 0); };
req.onerror = function () { resolve(0); };
});
}).catch(function () { return 0; });
}
/** 计算给定 records 数组(设备拉到的最新在前)与参考集合(已存历史)相比的新条数。
* 纯内存比对,不写库,用于先判断是否需要写。 */
export function diffNewCount(records, existingHistory) {
if (!Array.isArray(records) || !records.length) return 0;
var seen = {};
if (Array.isArray(existingHistory)) {
for (var i = 0; i < existingHistory.length; ++i) {
seen[dedupKey(existingHistory[i])] = true;
}
}
var n = 0;
for (var j = 0; j < records.length; ++j) {
if (!seen[dedupKey(records[j])]) ++n;
}
return n;
}
+113
View File
@@ -0,0 +1,113 @@
/**
* log.js - 日志系统(带去重 + 自动滚动 + 上限裁剪)
*
* 日志环形缓冲、去重、过滤、自动滚动逻辑。
* 操作 DOM 元素 #logScroll / #logCount / #autoScrollBtn / .log-filter。
*/
import { formatTime, escapeHtml } from './utils.js';
// ==================== 日志系统状态 ====================
var MAX_LOGS = 1000; // 缓冲上限,超出后丢弃最旧条目
var logFilter = 'all';
var logEntries = [];
var lastLogHash = null;
var autoScroll = true;
// 缓存 DOM 引用,避免高频日志下重复 getElementById
var scrollEl = document.getElementById('logScroll');
var countEl = document.getElementById('logCount');
var scrollPending = false;
/** rAF 批量自动滚动:/logs 一轮可能追加几十行,逐行读 scrollHeight 会每行
* 触发一次强制布局;合并到下一帧只滚一次,行为不变。 */
function scheduleAutoScroll() {
if (!autoScroll || !scrollEl || scrollPending) return;
scrollPending = true;
requestAnimationFrame(function () {
scrollPending = false;
if (autoScroll && scrollEl) scrollEl.scrollTop = scrollEl.scrollHeight;
});
}
// ==================== 日志系统 (带去重 + 自动滚动) ====================
function makeLogHash(level, tag, message) {
return level + '|' + tag + '|' + message;
}
/** 构造一条日志 DOM 节点(tag/message 经 HTML 转义,防 XSS)。 */
function createLogLine(entry) {
var div = document.createElement('div');
div.className = 'log-line';
div.innerHTML =
'<span class="lt">[' + escapeHtml(entry.time) + ']</span>' +
'<span class="lv ' + escapeHtml(entry.level) + '">' + escapeHtml(entry.level) + '</span>' +
'<span class="lg">[' + escapeHtml(entry.tag) + ']</span>' +
'<span class="lm">' + escapeHtml(entry.message) + '</span>';
return div;
}
/** 同步 #logCount 为当前可见条目数。 */
function syncCount() {
if (countEl) countEl.textContent = logEntries.length;
}
/** 超过上限时丢弃最旧条目,并同步移除其 DOM 节点。 */
function trimIfNeeded() {
while (logEntries.length > MAX_LOGS) {
var old = logEntries.shift();
if (old && old.div && old.div.parentNode) old.div.parentNode.removeChild(old.div);
}
}
/** @description 添加一条日志条目到环形缓冲区和日志面板。 */
export function addLog(level, tag, message) {
var hash = makeLogHash(level, tag, message);
if (hash === lastLogHash) return; // 去重
lastLogHash = hash;
var entry = {
time: formatTime(new Date()),
level: level,
tag: tag,
message: message,
div: null
};
logEntries.push(entry);
trimIfNeeded();
if (logFilter !== 'all' && level !== logFilter) {
syncCount();
return;
}
var div = createLogLine(entry);
entry.div = div;
if (scrollEl) {
scrollEl.appendChild(div);
scheduleAutoScroll();
}
syncCount();
}
export function clearLogs() {
if (scrollEl) scrollEl.innerHTML = '';
logEntries = [];
lastLogHash = null;
syncCount();
}
export function setLogFilter(level, btn) {
document.querySelectorAll('.log-filter').forEach(function (b) { b.classList.remove('active'); });
if (btn) btn.classList.add('active');
logFilter = level;
if (!scrollEl) return;
var frag = document.createDocumentFragment();
logEntries.forEach(function (e) {
if (level === 'all' || e.level === level) {
var div = createLogLine(e);
e.div = div;
frag.appendChild(div);
} else {
e.div = null;
}
});
scrollEl.innerHTML = '';
scrollEl.appendChild(frag);
scheduleAutoScroll();
}
export function toggleAutoScroll() {
autoScroll = !autoScroll;
var btn = document.getElementById('autoScrollBtn');
if (btn) btn.textContent = autoScroll ? '\uD83D\uDCCC 自动滚动' : '\u23F8\uFE0F 暂停滚动';
}
+418
View File
@@ -0,0 +1,418 @@
/**
* polling.js - 轮询状态 + Page Visibility 补偿 + ESP32 实时日志拉取
*
* 递归 setTimeout 调度 /system/status + 递归 setTimeout 调度 /logs,避免 setInterval 慢响应被 abort / 重叠抢 socket。
*
* 模块级状态(仅本模块使用,不导出):
* statusTimerId / logsTimerId / statusAbortCtrl / statusPollInFlight / logsInFlight / statusPollIntervalMs
* uploadPollChip / pageVisible
*
* 共享状态(通过 window.* 引用):
* window.pollingPausedForUpload — 上传期间暂停轮询标志
* window.consecutivePollFailures — 连续轮询失败计数(connection.js 也写入)
* window.lastSysStateCode — 上次成功的 sys_state_code(connection.js 写入)
* window.lastRelayState — 上次成功的 relay_state(connection.js 写入)
* window.isEsp32Rebooting — ESP32 重启中标志
* window.rebootStartTime — 重启开始时间戳
* window.rebootObservedDown — 已观察到设备下线
* window.otaSessionState — OTA 会话状态机当前状态
* window.OtaSessionState — OTA 会话状态枚举
* window.activeUploadChip — 当前上传目标芯片
* window.currentOtaTarget — 当前 OTA 目标
*
* 复用的函数(通过 window.* 引用):
* window.getEspIp / window.getBaseUrl
* window.updateSystemStatusUi / window.updateEsp32StagesFromPhase
* window.applyRebootingRuntimeState / window.applyDisconnectedRuntimeState / window.applyUploadingRuntimeState
* window.applyDeviceBusyRuntimeState
* window.otaSessionActive / window.otaSessionTransition / window.otaSessionReset
* window.updateUploadButtonLabel
*/
import { addLog } from './log.js';
import { showToast } from './toast.js';
import { updateConnectionUi, diagnoseNetwork } from './connection.js';
// ==================== 状态码常量(与后端 ota_coordinator.h / ota_relay.h 枚举对齐) ====================
// sys_state_code
const SYS_GD32_STORING = 1;
const SYS_GD32_RELAYING = 3;
const SYS_ESP32_RECEIVING = 5;
const SYS_ESP32_FINALIZING = 6;
const SYS_ESP32_REBOOTING = 7;
// relay_state(1~4 为握手/协商/传输/收尾,非 IDLE)
const RELAY_ACTIVE_MIN = 1;
const RELAY_ACTIVE_MAX = 4;
// ==================== 轮询状态 ====================
// 共享状态(初始化到 window,供 connection.js 等其他模块读写)
window.consecutivePollFailures = 0;
window.lastSysStateCode = -1; // 上次成功的 sys_state_code,用于失败时判断设备是否在忙
window.lastRelayState = 0; // 上次成功的 relay_state
// 模块级状态(仅本模块使用)
var statusTimerId = null;
var logsTimerId = null;
var statusAbortCtrl = null; // 当前 /system/status 请求的 AbortController(5s 超时用)
var statusPollIntervalMs = 1000; // 状态轮询目标间隔(中继期可由 setStatusPollIntervalMs 降到 400ms)
var statusPollInFlight = false; // 是否正在请求 /system/status,防止重叠
var logsInFlight = false; // 是否正在请求 /logs,防止重叠抢 socket
var pageVisible = !document.hidden; // 页面可见性:切到后台/最小化/切标签会变 false
var uploadPollChip = null; // 当前暂停轮询的上传目标,用于区分 WiFi/日志策略
/**
* 调整 /system/status 目标间隔。GD32 中继期用 400ms 缩小「转发 N/M」相对设备日志的滞后;
* 结束后恢复 1000ms,避免常态堆/栈卡片过刷。
*/
export function setStatusPollIntervalMs(ms) {
var n = Number(ms);
if (!Number.isFinite(n) || n < 200) n = 1000;
if (n > 5000) n = 5000;
statusPollIntervalMs = n;
}
/** @description 调度下一次 /system/status 轮询(递归 setTimeout)。
* 默认 delay = statusPollIntervalMs;pollStatus 完成后会按「固定周期」
* 传入 max(0, interval - 本轮耗时),使 CPU 采样窗口 sample_ms ≈ interval,
* 而不是「完成后 + interval」(会稳定偏到 1100+ ms)。
* 仍保证 in-flight 不重叠,避免 setInterval 慢响应被 abort。
* ★ 上传暂停期间(window.pollingPausedForUpload)不重挂定时器,
* 避免在途请求的 finally 把刚清掉的轮询又挂回来。
*/
export function scheduleStatusPoll(delayMs) {
if (window.pollingPausedForUpload) return;
if (statusTimerId) clearTimeout(statusTimerId);
var interval = statusPollIntervalMs;
statusTimerId = setTimeout(function() {
statusTimerId = null;
pollStatus();
}, delayMs !== undefined ? delayMs : interval);
}
/** @description 调度下一次 /logs 轮询(递归 setTimeout,仿 scheduleStatusPoll)。 */
function scheduleLogsPoll(delayMs) {
if (logsTimerId) clearTimeout(logsTimerId);
logsTimerId = setTimeout(function() {
logsTimerId = null;
pollLogs();
}, delayMs !== undefined ? delayMs : statusPollIntervalMs);
}
/** @description 启动定时轮询(状态 + 日志)。 */
export function startPolling() {
window.consecutivePollFailures = 0;
statusPollIntervalMs = 1000;
if (statusTimerId) clearTimeout(statusTimerId);
if (logsTimerId) clearTimeout(logsTimerId);
scheduleStatusPoll(0); // 立即开始第一次,完成后按 interval 递归调度
scheduleLogsPoll(0);
}
export function pausePollingForUpload() {
if (window.pollingPausedForUpload) return;
window.pollingPausedForUpload = true;
uploadPollChip = window.activeUploadChip || window.flashTarget || null;
window.consecutivePollFailures = 0;
// ★ 上传大固件期间必须停掉 status/logs 轮询。
// httpd max_open_sockets=4 + lru_purge:轮询会再占 socket,LRU 可能踢掉
// 正在写 Flash 的 /ota/upload 连接 → 浏览器 upload 进度已 100%(发完),
// 但永远等不到 HTTP 响应,卡在「已上传 xxx / xxx」。
if (statusTimerId) {
clearTimeout(statusTimerId);
statusTimerId = null;
}
if (logsTimerId) {
clearTimeout(logsTimerId);
logsTimerId = null;
}
if (statusAbortCtrl) {
try { statusAbortCtrl.abort(); } catch (e) { /* ignore */ }
statusAbortCtrl = null;
statusPollInFlight = false;
}
}
export function resumePollingAfterUpload() {
if (!window.pollingPausedForUpload) return;
window.pollingPausedForUpload = false;
uploadPollChip = null;
window.consecutivePollFailures = 0;
statusPollIntervalMs = 1000;
if (logsTimerId) clearTimeout(logsTimerId);
// 上传结束立即刷新一次状态;若当前已在请求中,则让它完成后按新间隔继续
if (statusPollInFlight) {
if (statusTimerId) clearTimeout(statusTimerId);
} else {
scheduleStatusPoll(0);
}
scheduleLogsPoll(0);
}
/**
* @description 统一的 ESP32 重启完成处理:清重启标志 → 推进阶段 6 → 状态机转 DONE → 刷新按钮。
* 抽出来供 pollStatus 成功分支与 pollLogs 启动 banner 分支复用,避免两处逻辑漂移。
* 以 isEsp32Rebooting 作幂等守卫:已处理过的重启不再重复弹 toast / 写日志 / 重复转 DONE。
* @param {string} source 日志来源描述(如 '启动 banner 收到'),用于区分日志文案。
*/
function markEsp32RebootComplete(source) {
if (!window.isEsp32Rebooting) return;
window.isEsp32Rebooting = false;
window.rebootObservedDown = false;
/* 重启后重新拉 memory.layout(换固件后静态段可能变化) */
window._memLayout = null;
window.updateEsp32StagesFromPhase(6); // 启动完成
addLog('I', '系统', source ? 'ESP32 已恢复在线(' + source + ')' : 'ESP32 已恢复在线');
showToast('ESP32 已恢复在线', 'success');
// ★ 状态机:ESP32 重启完成 → DONE
if (window.otaSessionState === window.OtaSessionState.REBOOTING) {
window.otaSessionTransition(window.OtaSessionState.DONE);
}
if (typeof window.updateUploadButtonLabel === 'function') {
window.updateUploadButtonLabel();
}
}
/** @description 判断设备最近一次成功轮询时是否处于「忙」状态(OTA 存储/中继/接收/收尾/重启 或 relay 握手~收尾)。 */
function isLastDeviceBusy() {
var s = window.lastSysStateCode;
var r = window.lastRelayState;
return s === SYS_GD32_STORING || s === SYS_GD32_RELAYING ||
s === SYS_ESP32_RECEIVING || s === SYS_ESP32_FINALIZING ||
s === SYS_ESP32_REBOOTING ||
(r >= RELAY_ACTIVE_MIN && r <= RELAY_ACTIVE_MAX);
}
/** @description 轮询 ESP32 系统状态(GET /status)并更新 UI。 */
export async function pollStatus() {
// 递归 setTimeout 调度,正常情况下不会重叠;做个保险避免并发请求
if (statusPollInFlight) return;
statusPollInFlight = true;
var pollStartedAt = Date.now();
statusAbortCtrl = new AbortController();
var pollTimer = setTimeout(function() { try { statusAbortCtrl.abort(); } catch(_e) {} }, 5000);
try {
/* 首次/无缓存:?layout=1 取静态内存布局;之后只轮询动态字段 */
var statusUrl = (window.systemStatusUrl
? window.systemStatusUrl(false)
: (window.getBaseUrl() + '/system/status' + (window._memLayout ? '' : '?layout=1')));
var resp = await fetch(statusUrl, {
signal: statusAbortCtrl.signal
});
if (resp.ok) {
var data = await resp.json();
// 恢复:之前有失败计数,说明设备曾离线,现在恢复在线
if (window.consecutivePollFailures > 0) {
window.consecutivePollFailures = 0;
addLog('I', '系统', '设备已恢复在线,轮询恢复正常');
showToast('设备已恢复在线', 'success');
// 仅清理终态残留。禁止对活跃会话 otaSessionReset:
// 旧逻辑任意非 IDLE 都 reset -> 中继中一次 status 超时恢复后会把 RELAYING
// 清成 IDLE,UI 不再跟踪;REBOOTING 被清掉后永远进不了启动完成。
var ssRec = window.otaSessionState;
if (ssRec === window.OtaSessionState.FAILED ||
ssRec === window.OtaSessionState.DONE) {
window.otaSessionReset();
addLog('I', '系统', '已清理终态 OTA 会话残留');
}
}
if (window.isEsp32Rebooting ||
window.otaSessionState === window.OtaSessionState.REBOOTING) {
var rbElapsed = Math.round((Date.now() - window.rebootStartTime) / 1000);
/* 重启完成的证据(任一即可):
* 1) rebootObservedDown:已观察到设备下线后恢复。
* 2) freshBoot:设备回报 uptime 很小(<120s,与 resolveEsp32VerifyAmbiguous
* 的 freshBoot 阈值一致)。★ 关键:设备掉线仅 3~5s 时(FAST_SCAN 生效后
* 常见),在途轮询的 TCP SYN 重传会在设备起来后完成握手、全部成功,
* rebootObservedDown 永远不置位,旧逻辑只能干等 10s 兜底——
* 实测设备 3s 就绪、页面却 10s 才恢复。uptime 是新固件启动的权威信号。
* 3) rbElapsed >= 10:安全兜底(兼容 uptime 缺失 / 设备未真重启)。
* pre-restart 窗口(finalize 响应后约 1s 设备仍在跑旧固件)uptime 很大,
* 不会误判为 freshBoot。 */
var upS = Number(data && data.uptime_sec);
var freshBoot = Number.isFinite(upS) && upS >= 0 && upS < 120;
if (window.rebootObservedDown || freshBoot || rbElapsed >= 10) {
markEsp32RebootComplete();
}
}
try { window.updateSystemStatusUi(data); } catch (e2) { console.warn('updateSystemStatusUi 异常:', e2); }
// pre-reboot 窗口内 updateSystemStatusUi 会把连接胶囊刷成"已连接",需覆盖回"重启中"
if (window.isEsp32Rebooting) {
window.applyRebootingRuntimeState();
}
}
clearTimeout(pollTimer);
} catch (e) {
clearTimeout(pollTimer);
// 自身 AbortController 触发的取消不算失败,静默返回
if (e && e.name === "AbortError") return;
// 先判定 OTA 是否进行中:OTA 期间所有失败都不计入 consecutivePollFailures,
// 也不触发诊断。避免"上传完成→finalize→重启"窗口期失败计数累积,
// 切回 IDLE 瞬间立即触发 5-fail 诊断,干扰进度显示。
var lastDeviceBusy0 = isLastDeviceBusy();
var sessionActive0 = window.otaSessionActive();
var sessionFailed0 = window.otaSessionState === window.OtaSessionState.FAILED;
var otaOngoing0 = window.pollingPausedForUpload ||
sessionActive0 || sessionFailed0 ||
window.isEsp32Rebooting || !!window.currentOtaTarget || lastDeviceBusy0;
// 后台期间 setInterval 会被 Chrome/Edge 节流到最低 1 分钟/次,
// 因此后台累积的失败计数不应递增,避免切回前台瞬间误触发 5-fail 诊断。
// OTA 期间也不递增:设备忙/重启无法响应是正常的,不该计入"诊断触发计数"。
if (pageVisible && !otaOngoing0) window.consecutivePollFailures++;
var lastDeviceBusy = lastDeviceBusy0;
// OTA/传输/重启中屏蔽网关无响应的检测功能(设备忙或正在重启,无法响应轮询是正常的)
// 设备已知忙时:保持 1s 轮询,不触发降速诊断,避免错过设备恢复的即时反馈
// ★ 状态机:用统一的会话活跃/失败判断替代散布的标志。
// FAILED 态不触发诊断降速(让用户尽快看到设备恢复),但也不覆盖失败 UI。
var sessionActive = sessionActive0;
var sessionFailed = sessionFailed0;
// ★ OTA 期间(含暂停轮询、会话活跃、设备忙、正在重启、FAILED、当前 OTA 目标存在)
// 一律不触发诊断日志/UI 覆盖。这些 W 级日志会刷在日志区,与"上传 xx/xx" "重启中 Xs"
// "中继 N/M" 等进度行混在一起,用户反馈干扰 OTA 进度显示。
// 之前条件漏了 pollingPausedForUpload,且 sessionActive 可能在 finalize/reboot 短窗口
// 翻 false,导致 5 次失败后误触发诊断。这里把所有 OTA 相关态统一兜底。
var otaOngoing = window.pollingPausedForUpload ||
sessionActive || sessionFailed ||
window.isEsp32Rebooting || !!window.currentOtaTarget || lastDeviceBusy;
if (pageVisible && window.consecutivePollFailures >= 5 && !otaOngoing) {
addLog('W', '诊断', '设备连续 5 次无响应,正在诊断网络...');
var diag = await diagnoseNetwork();
addLog('W', '诊断', diag);
updateConnectionUi(false, '诊断中');
window.applyDisconnectedRuntimeState();
addLog('W', '诊断', '诊断结果: ' + diag);
}
// ★ 状态机优先:会话已 FAILED → 保持失败 UI,仅标连接胶囊为"设备忙/未连接"
if (sessionFailed) {
if (lastDeviceBusy) {
// 设备端仍在跑(中继没停),标"设备忙"但不覆盖进度/阶段
window.applyDeviceBusyRuntimeState('设备忙', '设备忙(OTA 后台运行中),等待回 IDLE', '设备忙');
updateConnectionUi(true, '设备忙');
} else {
// 设备已回 IDLE:连接胶囊显示未连接,但失败进度保留
updateConnectionUi(false, '未连接');
}
} else if (window.otaSessionState === window.OtaSessionState.UPLOADING) {
window.applyUploadingRuntimeState();
} else if (window.otaSessionState === window.OtaSessionState.REBOOTING || window.isEsp32Rebooting) {
window.rebootObservedDown = true; // 确认设备已下线(正在重启)
var elapsed = Math.round((Date.now() - window.rebootStartTime) / 1000);
if (elapsed > 60) {
// ★ 统一走 cleanupAfterOtaFailure:清 xhr/定时器/按钮/阶段,避免只改 state 留下假死 UI
addLog('E', '系统', 'ESP32 重启超时,设备未恢复');
showToast('ESP32 重启超时,请检查设备', 'error');
if (typeof window.cleanupAfterOtaFailure === 'function') {
window.cleanupAfterOtaFailure('ESP32 重启超时');
} else {
window.otaSessionTransition(window.OtaSessionState.FAILED, 'ESP32 重启超时');
window.isEsp32Rebooting = false;
window.rebootObservedDown = false;
}
updateConnectionUi(false, '未连接');
window.applyDisconnectedRuntimeState();
} else {
window.applyRebootingRuntimeState();
}
} else if (sessionActive || lastDeviceBusy) {
// 设备在 OTA/中继/重启期间不响应是正常的:保持 1s 轮询,UI 标记为"设备忙"
// 不调用 applyRebootingRuntimeState(避免显示"重启中 Xs"误导)
window.applyDeviceBusyRuntimeState('设备忙', '设备忙(OTA/重启中),保持 1s 轮询', '设备忙');
updateConnectionUi(true, '设备忙');
} else {
updateConnectionUi(false, '未连接');
window.applyDisconnectedRuntimeState();
}
} finally {
statusPollInFlight = false;
statusAbortCtrl = null;
/* 固定周期调度:interval 从本轮开始计时扣减请求耗时。
* 旧逻辑「完成后 + 1000ms」→ 窗口稳定 1100+;现 sample_ms 贴近 1000/400。
* 本轮超时/很慢时 delay=0,立即补下一轮,仍不重叠。
* ★ 上传暂停期间不重挂(由 scheduleStatusPoll 内部判断),避免在途请求 abort 后
* 的 finally 把刚清掉的轮询又挂回来,导致上传中途再打 /system/status 抢 socket。 */
var elapsed = Date.now() - pollStartedAt;
var delay = Math.max(0, statusPollIntervalMs - elapsed);
scheduleStatusPoll(delay);
}
}
// 注意:startPoll() 由入口模块(app.js / ble_ota.js)在所有模块加载完成后调用,
// 此处不在模块顶层自动启动,避免 ESM 求值顺序导致 window.OtaSessionState 尚未初始化。
// ==================== Page Visibility 补偿 ====================
// Chrome/Edge 在标签隐藏/窗口最小化时会把 setInterval 节流到最低 1 分钟/次。
// 切回前台时立即拉一次状态 + 日志,并清零后台期间累积的失败计数,
// 避免在"刚切回前台那一轮"被误判为 5-fail 离线。
document.addEventListener('visibilitychange', function () {
pageVisible = !document.hidden;
if (!pageVisible) return;
window.consecutivePollFailures = 0;
// 切回前台立即刷新状态;若当前已在请求中,取消等待中的下一次让它尽快重排
if (statusPollInFlight) {
if (statusTimerId) clearTimeout(statusTimerId);
} else {
if (statusTimerId) clearTimeout(statusTimerId);
scheduleStatusPoll(0);
}
pollLogs();
});
// ==================== ESP32 实时日志拉取 (增量) ====================
/** @description 轮询 ESP32 日志(GET /logs)并追加到日志面板。 */
export async function pollLogs() {
// ★ ESP32 上传期间也允许日志轮询;in-flight 守卫避免与 /system/status、/ota/upload 抢 socket
if (logsInFlight) return;
logsInFlight = true;
try {
var resp = await fetch(window.getBaseUrl() + '/logs', {
signal: AbortSignal.timeout(5000)
});
if (!resp.ok) return;
var logs = await resp.json();
if (!Array.isArray(logs) || logs.length === 0) return;
logs.forEach(function (line) {
var m = line.match(/^\[([EWID])\]\[([^\]]+)\]\s*(.*)$/);
if (m) {
addLog(m[1], m[2], m[3].trimEnd());
// ★ 复用设备启动 banner 作为 ESP32 重启完成的权威信号。
// esp_restart() 是硬件复位,不保证 TCP FIN/RST;靠轮询超时/网络错误判断
// "已下线"不可靠。设备启动后会推 [I][MAIN] ESP32-C3 OTA Relay v1.0 启动,
// 收到这行即说明设备已重启回来,直接推进到阶段 6(启动完成)。
/* 与 main 启动 banner 对齐:ESP32-C3 OTA Relay <FW_VERSION> 启动(勿写死 v1.0) */
if (window.isEsp32Rebooting && m[2] === 'MAIN' && /OTA Relay .*启动/.test(m[3])) {
markEsp32RebootComplete('启动 banner 收到');
}
/*
* 设备已报「全部数据包已转发」时,立刻补 UI 100% 进度。
* status 轮询可能仍停在旧 relay_seq,否则会出现 FINISH 日志之后还刷 94%。
*/
if (m[2] === 'OTA' && /全部数据包已转发/.test(m[3]) &&
typeof window.emitRelayProgress === 'function') {
var otaSnap = (window.latestSystemStatus && window.latestSystemStatus.ota) || {};
var tot = Number(otaSnap.relay_total_packets) || 0;
if (tot > 0) {
window.emitRelayProgress(tot, tot, { force100: true });
}
}
} else {
addLog('I', 'ESP32', line.trimEnd());
}
});
} catch (e) { /* 静默 */ } finally {
logsInFlight = false;
// 递归调度下一轮:固定周期扣减本轮耗时,避免慢响应时重叠抢 socket
if (!window.pollingPausedForUpload) {
scheduleLogsPoll(statusPollIntervalMs);
}
}
}
setTimeout(function () {
window.updateUploadButtonLabel();
addLog('I', '系统', '控制台初始化完成,等待固件文件...');
// IP 输入变化时清零失败计数并立即刷新状态
var ipInput = document.getElementById('espIp');
if (ipInput) {
ipInput.addEventListener('change', function () {
window.consecutivePollFailures = 0;
addLog('I', '系统', 'IP 已修改,重新启动轮询...');
if (!statusPollInFlight) {
if (statusTimerId) clearTimeout(statusTimerId);
scheduleStatusPoll(0);
}
});
}
}, 500);
+44
View File
@@ -0,0 +1,44 @@
/**
* toast.js - Toast 提示组件
*
* showToast 函数,操作 DOM 元素 #toast / #toastIco / #toastMsg。
* 支持 success / error / warning 三类;info 走 warning 同款样式(历史兼容,无专属配色)。
* 内置 5s 去重防抖,相同 (type|message) 5s 内不重复弹出。
*/
/** @description 显示 Toast 提示消息,支持 success/error/warning(info 同 warning)。 */
const TOAST_DEDUPE_MS = 5000;
const TOAST_AUTOHIDE_MS = 3000;
var _toastHideTimer = null;
var _toastLastKey = '';
var _toastLastAt = 0;
/**
* @param {string} message
* @param {string} [type] success/error/warning
* @param {{ force?: boolean }} [opts] force=true 时跳过 5s 去重,
* 用于用户主动操作(如批量发送完成)的反馈,避免 alert 阻塞后时间漂移导致被去重吞掉。
*/
export function showToast(message, type, opts) {
type = type || 'success';
var toast = document.getElementById('toast');
if (!toast) return;
// 相同文案 5s 内不重复弹,避免轮询把 ✓ 刷成「疯狂跳」
var key = type + '|' + message;
var now = Date.now();
if (!(opts && opts.force) && key === _toastLastKey && (now - _toastLastAt) < TOAST_DEDUPE_MS) {
return;
}
_toastLastKey = key;
_toastLastAt = now;
var ico = document.getElementById('toastIco');
var msg = document.getElementById('toastMsg');
toast.className = 'toast ' + (type === 'success' ? 'ok' : type === 'error' ? 'err' : 'warn') + ' show';
if (ico) ico.textContent = type === 'success' ? '\u2713' : type === 'error' ? '\u2715' : '\u26A0';
if (msg) msg.textContent = message;
if (_toastHideTimer) clearTimeout(_toastHideTimer);
_toastHideTimer = setTimeout(function () {
toast.classList.remove('show');
_toastHideTimer = null;
}, TOAST_AUTOHIDE_MS);
}
+60
View File
@@ -0,0 +1,60 @@
/**
* utils.js - 通用工具函数
*
* 格式化 / 转义工具函数,供各子模块和 can.js / can_monitor.js 共享。
*/
// ==================== 工具函数 ====================
/** @description 格式化字节大小为人类可读字符串(B/KB/MB)。 */
/** @description 字节格式化为 B / KB(不升到 MB,避免 1024KB 显示成 1.00MB)。 */
export function formatBytes(bytes) {
if (!Number.isFinite(bytes) || bytes < 0) return '--';
if (bytes < 1024) return bytes + ' B';
var kb = bytes / 1024;
if (Math.abs(kb - Math.round(kb)) < 1e-9) return Math.round(kb) + ' KB';
return kb.toFixed(1) + ' KB';
}
/** @description 格式化 Date 对象为 HH:MM:SS.mmm 时间字符串。 */
export function formatTime(date) {
var h = String(date.getHours()).padStart(2, '0');
var m = String(date.getMinutes()).padStart(2, '0');
var s = String(date.getSeconds()).padStart(2, '0');
var ms = String(date.getMilliseconds()).padStart(3, '0');
return h + ':' + m + ':' + s + '.' + ms;
}
/** @description 格式化秒数为人类可读运行时间(d/h/m/s)。 */
export function formatUptime(sec) {
if (!Number.isFinite(sec) || sec < 0) return '--';
const d = Math.floor(sec / 86400);
const h = Math.floor((sec % 86400) / 3600);
const m = Math.floor((sec % 3600) / 60);
const s = Math.floor(sec % 60);
if (d > 0) return d + 'd ' + h + 'h ' + m + 'm';
if (h > 0) return h + 'h ' + m + 'm ' + s + 's';
if (m > 0) return m + 'm ' + s + 's';
return s + 's';
}
/** @description 转义 HTML 特殊字符,防止 XSS 注入。 */
export function escapeHtml(text) {
return String(text == null ? '' : text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/**
* @description 将字节数组(Uint8Array 或 ArrayBuffer)转为小写 hex 字符串。
* SHA-256 等哈希显示前需要 toUpperCase()。
* @param {Uint8Array|ArrayBuffer} bytes
* @returns {string}
*/
export function bytesToHex(bytes) {
var arr = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
var out = '';
for (var i = 0; i < arr.length; i++) {
var h = arr[i].toString(16);
if (h.length < 2) h = '0' + h;
out += h;
}
return out;
}
+3428
View File
File diff suppressed because it is too large Load Diff
+33
View File
@@ -0,0 +1,33 @@
/**
* tick.worker.js - 电机轮询节拍心跳 Worker
*
* 浏览器对后台(隐藏)标签页的 setTimeout/setInterval 做 ≥1s 节流,
* 但 Dedicated Worker 的定时器不受此限制。
* 此 Worker 只做一件事:按主线程设定的间隔定期发送 'tick' 消息。
*
* 主线程收到 tick 后触发 pollLoop(如不在飞行中),
* 从而实现后台标签页仍保持设定刷新速度。
*
* 消息协议:
* { type: 'start', interval: N } → 启动节拍,interval 单位 ms
* { type: 'stop' } → 停止节拍
* { type: 'update', interval: N } → 更新间隔(不重置 tick 计数)
*/
let timer = null;
self.onmessage = function(e) {
const msg = e.data;
if (!msg || !msg.type) return;
if (msg.type === 'start' || msg.type === 'update') {
if (timer) clearInterval(timer);
timer = setInterval(() => {
self.postMessage('tick');
}, msg.interval || 250);
} else if (msg.type === 'stop') {
if (timer) {
clearInterval(timer);
timer = null;
}
}
};
+320
View File
@@ -0,0 +1,320 @@
/**
* console-mode.js - 共享模式/目标切换胶水代码(从 index.html 内联脚本抽取)
*
* 职责:
* - 覆盖 app.js 中的 switchStageFlow,支持 BLE 阶段流程
* - 提供 setConsoleMode / selectFlashTarget 等供 HTML onclick 调用
* - 维护共享固件文件状态 (sharedFirmwareFiles) 并在 WiFi/BLE 间同步
* - 页面刷新后恢复上次升级目标与固件选择
*
* 依赖:app.js (通过 window.* 或 bare 引用) 提供 switchStageFlow、
* handleFileSelect、clearFile、pickFileViaFSAPI、addLog、showToast、setActivePhase 等。
*/
import {
restorePersistedFirmwareSelection,
persistFlashTarget,
loadPersistedFlashTarget
} from './file-handle.js';
window.connectionMode = window.connectionMode || 'wifi';
/* 刷新后优先用上次目标,避免默认回到 ESP32 丢掉 GD32 选择上下文 */
window.flashTarget = loadPersistedFlashTarget() || 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) {
// ★ 刷写进行中禁止切换连接模式:setConsoleMode → syncBleFileFromSelectedTarget →
// notifyFileChange → updateFileInfo → finalizeFileInfo → setProgress(0) 会覆盖
// 进行中的 WiFi/BLE 上传进度(与 selectFlashTarget 同样的守卫策略)。
var bleBusy = window.bleOta && window.bleOta.isUploading && window.bleOta.isUploading();
if (window.isUploading || bleBusy) {
var active = bleBusy ? 'BLE' : ((window.activeUploadChip || 'OTA').toUpperCase());
if (typeof addLog === 'function') addLog('W', 'OTA', active + ' 刷写进行中,无法切换连接模式');
if (typeof showToast === 'function') showToast('当前有 ' + active + ' 任务在运行,请等待结束后再切换', 'error');
return;
}
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';
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) {
// OTA 上传进行中禁止切换目标:避免前端状态与后端 relay/upload 不同步导致卡死
// ★ 兼容 BLE 模式:BLE 上传用 bleOta.state.uploading,window.isUploading 仅 WiFi 通道使用。
var bleBusy = window.bleOta && window.bleOta.isUploading && window.bleOta.isUploading();
if (window.isUploading || bleBusy) {
var active = bleBusy ? 'BLE' : ((window.activeUploadChip || 'OTA').toUpperCase());
if (typeof addLog === 'function') addLog('W', 'OTA', active + ' 刷写进行中,无法切换目标');
if (typeof showToast === 'function') showToast('当前有 ' + active + ' 任务在运行,请等待结束后再切换', 'error');
return;
}
window.flashTarget = target === 'gd32' ? 'gd32' : 'esp32';
persistFlashTarget(window.flashTarget);
applySharedModeUi();
/* 与 WiFi 一致:两路文件独立;切换只换「当前目标」面板 + 绑定该路文件到会话 */
if (window.connectionMode === 'ble') {
window.switchStageFlow('ble');
if (typeof setActivePhase === 'function') {
setActivePhase(window.flashTarget === 'gd32' ? 'gd32Ota' : 'esp32Ota');
}
if (window.bleOta && typeof window.bleOta.refreshTargetUi === 'function') {
window.bleOta.refreshTargetUi();
}
/* 把当前目标对应的 esp32/gd32 固件绑到 BLE 会话(无文件则清空,避免误刷另一芯片) */
syncBleFileFromSelectedTarget();
} else {
window.switchStageFlow(window.flashTarget);
}
};
window.handleSharedFileSelect = function (event, chip) {
window.flashTarget = chip === 'gd32' ? 'gd32' : 'esp32';
persistFlashTarget(window.flashTarget);
applySharedModeUi();
/* 等 processFile(含 HEX→BIN)完成后再写入 shared 并 sync */
var done = (typeof window.handleFileSelect === 'function')
? Promise.resolve(window.handleFileSelect(event, chip))
: Promise.resolve();
done.then(function () {
var prepared = chip === 'gd32' ? window.gd32File : window.esp32File;
if (prepared) {
window.sharedFirmwareFiles[chip] = { file: prepared, handle: null };
}
syncBleFileFromSelectedTarget();
}).catch(function (err) {
if (typeof window.addLog === 'function') {
window.addLog('W', '文件', chip.toUpperCase() + ' 选择失败: ' + (err && err.message ? err.message : err));
}
});
};
window.pickSharedFirmware = function (chip) {
window.flashTarget = chip === 'gd32' ? 'gd32' : 'esp32';
persistFlashTarget(window.flashTarget);
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 () {
/* 用 processFile 后的结果(GD32 HEX→BIN 后的 prepared),不要再塞回原始 .hex */
var prepared = chip === 'gd32' ? window.gd32File : window.esp32File;
var handle = (chip === 'gd32' ? window.gd32FileHandle : window.esp32FileHandle) || capturedHandle;
if (prepared) {
window.sharedFirmwareFiles[chip] = { file: prepared, handle: handle || null };
} else if (capturedHandle) {
window.sharedFirmwareFiles[chip] = {
file: window.sharedFirmwareFiles[chip] && window.sharedFirmwareFiles[chip].file,
handle: capturedHandle
};
}
syncBleFileFromSelectedTarget();
}).catch(function (err) {
if (err && err.name !== 'AbortError' && typeof window.addLog === 'function') {
window.addLog('W', '文件', 'FSAPI 选择失败: ' + (err.message || err));
}
}).then(function () {
/* 无论成败都还原原生 picker,避免二次选择失效 */
window.showOpenFilePicker = nativePicker;
});
} 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.
}
};
/**
* 按 flashTarget 把「该芯片一路」固件绑到 BLE 会话(复刻 WiFi:两路独立、切换不串文件)。
* 优先 window.esp32File/gd32File(含 HEX→BIN 结果),其次 sharedFirmwareFiles / input。
* 当前目标无文件时清空 BLE 会话,禁止继续显示/上传另一芯片固件。
*/
window.syncBleFileFromSelectedTarget = function () {
if (window.connectionMode !== 'ble' || !window.bleOta) {
return;
}
if (window.bleOta.isUploading && window.bleOta.isUploading()) {
return;
}
var chip = window.flashTarget === 'gd32' ? 'gd32' : 'esp32';
var file = chip === 'gd32' ? window.gd32File : window.esp32File;
var handle = chip === 'gd32' ? window.gd32FileHandle : window.esp32FileHandle;
var stored = window.sharedFirmwareFiles && window.sharedFirmwareFiles[chip]
? window.sharedFirmwareFiles[chip]
: null;
if (!handle && stored && stored.handle) handle = stored.handle;
if (!file && stored && stored.file) file = stored.file;
function bindBle(f, h) {
setBleFirmwareInputFile(f);
if (typeof window.bleOta.notifyFileChange === 'function') {
window.bleOta.notifyFileChange(f, h || null);
}
/* 会话 SHA 与对应芯片槽对齐(不跨芯片) */
var slot = chip === 'gd32' ? window._gd32FileSha256 : window._esp32FileSha256;
if (window.bleOta.state) {
if (slot) window.bleOta.state.fileSha256 = slot;
}
}
function clearBleSession() {
if (typeof window.bleOta.clearFile === 'function') {
window.bleOta.clearFile();
} else if (typeof window.bleOta.notifyFileChange === 'function') {
window.bleOta.notifyFileChange(null, null);
}
}
if (file) {
bindBle(file, handle);
return;
}
if (handle && typeof handle.getFile === 'function') {
handle.getFile().then(function (fresh) {
if (chip === 'gd32' && fresh.name.toLowerCase().endsWith('.hex') &&
typeof window.processFile === 'function') {
return Promise.resolve(window.processFile(fresh, 'gd32', {
silent: true,
skipHandlePersist: true
})).then(function () {
if (window.gd32File) bindBle(window.gd32File, handle);
else bindBle(fresh, handle);
});
}
if (chip === 'esp32') window.esp32File = fresh;
else window.gd32File = fresh;
if (stored) stored.file = chip === 'gd32' ? window.gd32File : window.esp32File;
bindBle(chip === 'gd32' ? window.gd32File : window.esp32File, handle);
}).catch(function () {
if (stored && stored.file) bindBle(stored.file, handle);
else clearBleSession();
});
return;
}
var suffix = chip === 'gd32' ? 'Gd32' : 'Esp32';
var input = document.getElementById('fileInput' + suffix);
var inputFile = input && input.files && input.files[0] ? input.files[0] : null;
if (inputFile) {
if (window.sharedFirmwareFiles) {
window.sharedFirmwareFiles[chip] = { file: inputFile, handle: null };
}
bindBle(inputFile, null);
return;
}
clearBleSession();
};
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');
var autoBtn = document.getElementById('autoFlashBtn');
if (autoBtn && isBle) {
autoBtn.textContent = isGd32 ? '一键刷写 GD32' : '一键刷写 ESP32';
}
if (hint) {
if (isBle) {
hint.innerHTML = isGd32
? 'BLE 模式 · 目标 GD32:连接设备 → 选择 GD32 固件 → 一键刷写。'
: 'BLE 模式 · 目标 ESP32:连接设备 → 选择 ESP32 固件 → 一键刷写。';
} else if (isGd32) {
hint.innerHTML = 'WiFi 模式:上传 GD32 固件后自动执行 SPI OTA。';
} else {
hint.innerHTML = 'WiFi 模式:上传 ESP32 固件后自动执行 OTA 并重启。';
}
}
};
applySharedModeUi();
/* 刷新后恢复上次固件与目标(FSAPI handle + localStorage 目标) */
restorePersistedFirmwareSelection().then(function () {
if (typeof window.switchStageFlow === 'function' && window.connectionMode !== 'ble') {
window.switchStageFlow(window.flashTarget || 'esp32');
}
}).catch(function () {});
})();
/* HTML onclick 用的 API 已在 IIFE 内挂到 window.*,无需再二次赋值 */
+734
View File
@@ -0,0 +1,734 @@
/**
* file-handle.js - 文件处理 + Intel HEX → BIN 转换
*
* 包含:HEX 解析、文件选择、FSAPI、SHA-256 计算、文件信息 UI、清除。
*
* 共享状态(通过 window.* 引用):
* window.esp32File / window.gd32File / window.esp32FileHandle / window.gd32FileHandle
* window._esp32FileSha256 / window._gd32FileSha256
*
* 复用的函数(通过 window.* 引用):
* window.updateUploadButtonLabel / window.showToast / window.addLog
*/
import { formatBytes, bytesToHex } from '../common/utils.js';
import { showToast } from '../common/toast.js';
import { addLog } from '../common/log.js';
// ==================== 文件处理 ====================
// 文件状态(模块级,但被 upload-esp32/upload-gd32 通过 window.* 读写)
window.esp32File = null;
window.gd32File = null;
window.esp32FileHandle = null; // FileSystemFileHandle for hot reload
window.gd32FileHandle = null; // FileSystemFileHandle for hot reload
/** 两路各自一份 SHA:选文件时写入,上传时读取;互不切换、不共享 */
window._esp32FileSha256 = null;
window._gd32FileSha256 = null;
/** localStorage:升级目标等轻量偏好(刷新后恢复) */
var LS_FLASH_TARGET = 'dcu_ota_flash_target';
/** localStorage:上次文件名/大小(无权限时仍可展示,提示一点恢复) */
var LS_FILE_META = 'dcu_ota_fw_meta';
/** IndexedDB:FileSystemFileHandle 可结构化克隆,刷新后可恢复同一文件 */
var IDB_NAME = 'dcu_ota_fw_v1';
var IDB_STORE = 'file_handles';
/**
* 刷新后权限常为 prompt,requestPermission 必须在用户手势里才成功。
* 此处暂存待授权 handle,点击上传区时再 requestPermission 并直接 getFile。
*/
window._pendingFwHandles = { esp32: null, gd32: null };
/** 模块级 IndexedDB 连接缓存,避免每次 put/get 都重开 */
var _idbDbPromise = null;
function openHandleDb() {
if (!_idbDbPromise) {
_idbDbPromise = new Promise(function (resolve, reject) {
if (!window.indexedDB) {
reject(new Error('indexedDB unavailable'));
return;
}
var req = indexedDB.open(IDB_NAME, 1);
req.onupgradeneeded = function () {
var db = req.result;
if (!db.objectStoreNames.contains(IDB_STORE)) {
db.createObjectStore(IDB_STORE);
}
};
req.onsuccess = function () { resolve(req.result); };
req.onerror = function () { reject(req.error || new Error('idb open failed')); };
});
// 失败时清空缓存,下次允许重试
_idbDbPromise.catch(function () { _idbDbPromise = null; });
}
return _idbDbPromise;
}
function idbPutHandle(chip, handle) {
return openHandleDb().then(function (db) {
return new Promise(function (resolve, reject) {
var tx = db.transaction(IDB_STORE, 'readwrite');
if (handle) {
tx.objectStore(IDB_STORE).put(handle, chip);
} else {
tx.objectStore(IDB_STORE).delete(chip);
}
tx.oncomplete = function () { resolve(); };
tx.onerror = function () { reject(tx.error); };
});
}).catch(function () { /* 无痕/禁用存储时静默 */ });
}
function idbGetHandle(chip) {
return openHandleDb().then(function (db) {
return new Promise(function (resolve, reject) {
var tx = db.transaction(IDB_STORE, 'readonly');
var req = tx.objectStore(IDB_STORE).get(chip);
req.onsuccess = function () { resolve(req.result || null); };
req.onerror = function () { reject(req.error); };
});
}).catch(function () { return null; });
}
/** 持久化升级目标(esp32/gd32) */
export function persistFlashTarget(chip) {
try {
if (chip === 'esp32' || chip === 'gd32') {
localStorage.setItem(LS_FLASH_TARGET, chip);
}
} catch (_) {}
}
export function loadPersistedFlashTarget() {
try {
var t = localStorage.getItem(LS_FLASH_TARGET);
return (t === 'gd32' || t === 'esp32') ? t : null;
} catch (_) {
return null;
}
}
/** 保存 FSAPI handle,刷新后可默认恢复该文件(仅模块内使用,不导出) */
function persistFileHandle(chip, handle) {
if (chip !== 'esp32' && chip !== 'gd32') return Promise.resolve();
return idbPutHandle(chip, handle || null);
}
function readFileMetaMap() {
try {
var raw = localStorage.getItem(LS_FILE_META);
if (!raw) return {};
var o = JSON.parse(raw);
return o && typeof o === 'object' ? o : {};
} catch (_) {
return {};
}
}
function persistFileMeta(chip, file) {
if (chip !== 'esp32' && chip !== 'gd32') return;
try {
var map = readFileMetaMap();
if (file && file.name) {
map[chip] = {
name: file.name,
size: file.size || 0,
lastModified: file.lastModified || 0
};
} else {
delete map[chip];
}
localStorage.setItem(LS_FILE_META, JSON.stringify(map));
} catch (_) {}
}
function loadFileMeta(chip) {
var map = readFileMetaMap();
return map[chip] || null;
}
/** 安全调用 window.updateUploadButtonLabel(恢复路径可能在 app.js 初始化前触发) */
function safeUpdateUploadButtonLabel() {
if (typeof window.updateUploadButtonLabel === 'function') {
window.updateUploadButtonLabel();
}
}
/** 仅 query,不 request(刷新无用户手势时 request 会失败) */
async function queryHandlePermission(handle) {
if (!handle || typeof handle.getFile !== 'function') return 'denied';
try {
if (typeof handle.queryPermission === 'function') {
return await handle.queryPermission({ mode: 'read' });
}
/* 无 query API 时尝试直接 getFile */
await handle.getFile();
return 'granted';
} catch (_) {
return 'denied';
}
}
/**
* 在用户点击手势中:对上次 handle 重新授权并恢复文件(无需再走选文件对话框)。
* @returns {Promise<boolean>} 是否已成功恢复
*/
export async function resumePersistedFirmwareWithGesture(chip) {
if (chip !== 'esp32' && chip !== 'gd32') return false;
var handle = (window._pendingFwHandles[chip]) ||
(chip === 'esp32' ? window.esp32FileHandle : window.gd32FileHandle) ||
null;
if (!handle) {
try {
handle = await idbGetHandle(chip);
} catch (_) {
handle = null;
}
}
if (!handle || typeof handle.getFile !== 'function') return false;
try {
var perm = 'granted';
if (typeof handle.queryPermission === 'function') {
perm = await handle.queryPermission({ mode: 'read' });
}
if (perm !== 'granted' && typeof handle.requestPermission === 'function') {
perm = await handle.requestPermission({ mode: 'read' });
}
if (perm !== 'granted') {
addLog('W', '文件', chip.toUpperCase() + ' 未授予读取权限,请在弹窗中允许,或重新选择文件');
return false;
}
var file = await handle.getFile();
await applyRestoredFile(chip, handle, file, { silent: true });
window._pendingFwHandles[chip] = null;
addLog('I', '文件', chip.toUpperCase() + ' 已重新授权并恢复: ' + file.name);
showToast(chip.toUpperCase() + ' 已恢复上次固件', 'success');
return true;
} catch (e) {
addLog('W', '文件', chip.toUpperCase() + ' 恢复失败: ' + (e && e.message ? e.message : e));
return false;
}
}
function applyRestoredFile(chip, handle, file, options) {
if (chip === 'esp32') {
window.esp32FileHandle = handle;
window.esp32File = file;
} else {
window.gd32FileHandle = handle;
window.gd32File = file;
}
if (window.sharedFirmwareFiles) {
window.sharedFirmwareFiles[chip] = { file: file, handle: handle };
}
/* 等 SHA 写入对应槽后再 sync BLE,避免二次重算 */
return Promise.resolve(processFile(file, chip, Object.assign({ skipHandlePersist: true }, options || {})))
.then(function () {
if (typeof window.syncBleFileFromSelectedTarget === 'function') {
window.syncBleFileFromSelectedTarget();
}
safeUpdateUploadButtonLabel();
});
}
/** 无权限时仍展示上次文件名,提示点上传区一键恢复 */
function showPendingReauthUi(chip, handle, meta) {
if (chip === 'esp32') window.esp32FileHandle = handle;
else window.gd32FileHandle = handle;
window._pendingFwHandles[chip] = handle;
if (window.sharedFirmwareFiles) {
window.sharedFirmwareFiles[chip] = { file: null, handle: handle };
}
var suffix = chip === 'esp32' ? 'Esp32' : 'Gd32';
var info = document.getElementById('fileInfo' + suffix);
var nameEl = document.getElementById('fileName' + suffix);
var typeEl = document.getElementById('fileType' + suffix);
var sizeEl = document.getElementById('fileSize' + suffix);
var name = (meta && meta.name) || (handle && handle.name) || '上次固件';
if (info) info.classList.add('show');
if (nameEl) nameEl.textContent = name + '(点击上传区重新授权)';
if (typeEl) typeEl.textContent = '待授权';
if (sizeEl) sizeEl.textContent = meta && meta.size ? formatBytes(meta.size) : '--';
var shaEl = document.getElementById('fileSha256' + suffix);
if (shaEl) shaEl.textContent = '刷新后需点击授权';
safeUpdateUploadButtonLabel();
}
/**
* 页面刷新后:恢复升级目标 + 上次 FSAPI 固件。
* 权限仍 granted → 静默恢复;否则保留 handle,等用户点击上传区再授权。
*/
export async function restorePersistedFirmwareSelection() {
var target = loadPersistedFlashTarget();
if (target) {
window.flashTarget = target;
}
var chips = ['esp32', 'gd32'];
for (var i = 0; i < chips.length; i++) {
var chip = chips[i];
try {
var handle = await idbGetHandle(chip);
if (!handle) continue;
var meta = loadFileMeta(chip);
var perm = await queryHandlePermission(handle);
if (perm === 'granted') {
var file = await handle.getFile();
await applyRestoredFile(chip, handle, file, { silent: true, skipHandlePersist: true });
window._pendingFwHandles[chip] = null;
addLog('I', '文件', chip.toUpperCase() + ' 已恢复刷新前的固件: ' + file.name);
continue;
}
/* prompt/denied:不在此处 requestPermission(无用户手势会失败) */
showPendingReauthUi(chip, handle, meta);
addLog('I', '文件',
chip.toUpperCase() + ' 上次固件「' +
((meta && meta.name) || handle.name || '未知') +
'」需点击上传区重新授权(浏览器安全限制)');
} catch (e) {
addLog('W', '文件', chip.toUpperCase() + ' 恢复上次固件失败: ' + (e && e.message ? e.message : e));
}
}
if (typeof window.applySharedModeUi === 'function') {
window.applySharedModeUi();
}
if (typeof window.syncBleFileFromSelectedTarget === 'function') {
window.syncBleFileFromSelectedTarget();
}
safeUpdateUploadButtonLabel();
}
export function handleFileSelect(event, chip) {
var file = event.target.files[0];
if (!file) return Promise.resolve();
/* 传统 input 无 handle:清掉旧 FSAPI 句柄,避免热重载读到过期路径 */
if (chip === 'esp32') window.esp32FileHandle = null;
else window.gd32FileHandle = null;
window._pendingFwHandles[chip] = null;
persistFileHandle(chip, null);
return processFile(file, chip);
}
// 通过 File System Access API 选择文件(支持热重载)
export async function pickFileViaFSAPI(chip) {
/*
* 仅在「刷新后待授权」或「尚无已加载 File」时尝试一键恢复。
* 已有可用文件时再点上传区 → 打开选文件对话框(可换文件)。
*/
var pending = window._pendingFwHandles[chip];
var hasLoadedFile = !!(chip === 'esp32' ? window.esp32File : window.gd32File);
if (pending || !hasLoadedFile) {
var resumed = await resumePersistedFirmwareWithGesture(chip);
if (resumed) {
return true;
}
}
if (!window.showOpenFilePicker) {
addLog('W', '文件', '当前浏览器不支持 File System Access API,请手动重新选择文件');
return false;
}
try {
/* GD32 常为 .hex:同时声明 octet-stream / text,避免对话框里灰掉选不了 */
var acceptMap = chip === 'esp32'
? { 'application/octet-stream': ['.bin'] }
: {
'application/octet-stream': ['.bin', '.hex'],
'text/plain': ['.hex'],
'application/x-intel-hex': ['.hex']
};
var prevHandle = chip === 'esp32' ? window.esp32FileHandle : window.gd32FileHandle;
if (!prevHandle) {
prevHandle = window._pendingFwHandles[chip] || null;
}
var opts = {
/* 记住该 id 上次目录;再配合 startIn 定位到刷新前的文件 */
id: chip === 'esp32' ? 'dcu-fw-esp32' : 'dcu-fw-gd32',
types: [{ description: '固件文件', accept: acceptMap }],
multiple: false
};
if (prevHandle) {
opts.startIn = prevHandle;
}
var handles;
try {
handles = await window.showOpenFilePicker(opts);
} catch (pickErr) {
/* 失效 handle 作 startIn 时部分浏览器直接抛错,去掉后重试 */
if (opts.startIn && pickErr && pickErr.name !== 'AbortError') {
delete opts.startIn;
handles = await window.showOpenFilePicker(opts);
} else {
throw pickErr;
}
}
if (handles && handles.length > 0) {
var handle = handles[0];
var file = await handle.getFile();
if (chip === 'esp32') window.esp32FileHandle = handle;
else window.gd32FileHandle = handle;
window._pendingFwHandles[chip] = null;
await processFile(file, chip);
persistFileHandle(chip, handle);
persistFileMeta(chip, file);
persistFlashTarget(chip);
addLog('I', '文件', '已通过 FSAPI 选择文件(支持热重载): ' + file.name);
return true;
}
} catch (e) {
if (e && e.name !== 'AbortError') {
addLog('W', '文件', 'FSAPI 选择失败: ' + (e.message || e));
}
}
return false;
}
export function setupDropZone(zoneId, chip) {
var zone = document.getElementById(zoneId);
if (!zone) return;
zone.addEventListener('dragover', function (e) {
e.preventDefault(); e.currentTarget.classList.add('dragover');
});
zone.addEventListener('dragleave', function (e) {
e.currentTarget.classList.remove('dragover');
});
zone.addEventListener('drop', function (e) {
e.preventDefault(); e.currentTarget.classList.remove('dragover');
var file = e.dataTransfer.files[0];
if (!file) return;
/* 拖拽拿不到 FSAPI 句柄:必须清掉旧句柄,否则热重载会把上一个文件读回来
覆盖本次拖入的文件(表现为"刷错固件"),并同步删除 IDB 持久化句柄 */
if (chip === 'esp32') window.esp32FileHandle = null;
else window.gd32FileHandle = null;
window._pendingFwHandles[chip] = null;
persistFileHandle(chip, null);
processFile(file, chip);
});
}
// ==================== Intel HEX → BIN 转换 ====================
/**
* 将 Intel HEX 字符串解析为原始 BIN 字节数组。
*
* 支持记录类型: 00(数据), 01(EOF), 02(扩展段地址), 04(扩展线性地址)。
* 地址间隙填充 0xFF(Flash 擦除态)。
*
* @param {string} hexText Intel HEX 文件的完整文本内容。
* @returns {{ data: Uint8Array, baseAddr: number, endAddr: number }}
*/
export function parseIntelHex(hexText) {
var baseAddr = 0; // 当前扩展地址(uint32)
var segments = []; // { offset, data } 列表
var minAddr = 0xFFFFFFFF;
var maxAddr = 0;
var lines = hexText.split(/\r?\n/);
for (var i = 0; i < lines.length; i++) {
var line = lines[i].trim();
if (!line || line[0] !== ':') continue;
// 解析行: :LLAAAATT[DD...]CC
var byteCount = parseInt(line.slice(1, 3), 16);
var addr16 = parseInt(line.slice(3, 7), 16);
var recType = parseInt(line.slice(7, 9), 16);
// 校验和验证
var sum = 0;
for (var j = 1; j < line.length; j += 2) {
sum += parseInt(line.slice(j, j + 2), 16);
}
if ((sum & 0xFF) !== 0) {
throw new Error('HEX 行 ' + (i + 1) + ' 校验和错误: ' + line);
}
if (recType === 0x01) {
// EOF
break;
} else if (recType === 0x04) {
// 扩展线性地址 (upper 16 bits),>>>0 归整为 uint32
baseAddr = (parseInt(line.slice(9, 13), 16) << 16) >>> 0;
} else if (recType === 0x02) {
// 扩展段地址:seg << 4,不加掩码(避免截断 0x10FFEF 等合法地址)
baseAddr = (parseInt(line.slice(9, 13), 16) << 4) >>> 0;
} else if (recType === 0x00) {
// 数据记录
var absAddr = (baseAddr + addr16) >>> 0;
var data = new Uint8Array(byteCount);
for (var k = 0; k < byteCount; k++) {
data[k] = parseInt(line.slice(9 + k * 2, 11 + k * 2), 16);
}
segments.push({ offset: absAddr, data: data });
if (absAddr < minAddr) minAddr = absAddr;
var endAddr = absAddr + byteCount;
if (endAddr > maxAddr) maxAddr = endAddr;
}
// 其他记录类型 (03, 05 等) 忽略
}
if (segments.length === 0) {
throw new Error('HEX 文件中没有有效数据记录');
}
// 合并为连续 bin,间隙填 0xFF
var totalSize = maxAddr - minAddr;
var bin = new Uint8Array(totalSize);
// 先填充 0xFF
for (var m = 0; m < totalSize; m++) bin[m] = 0xFF;
for (var s = 0; s < segments.length; s++) {
var seg = segments[s];
var off = seg.offset - minAddr;
bin.set(seg.data, off);
}
return { data: bin, baseAddr: minAddr, endAddr: maxAddr };
}
/**
* 检测文件内容是否为 Intel HEX 格式。
* 看前几个非空行是否以 ':' 开头。
*/
export function isIntelHexText(text) {
var lines = text.split(/\r?\n/);
for (var i = 0; i < Math.min(lines.length, 5); i++) {
var line = lines[i].trim();
if (!line) continue;
return line[0] === ':';
}
return false;
}
/**
* 公共 HEX→BIN 转换:如果文件是 .hex 格式,自动解析为 bin。
* BLE / WiFi 共用此函数,避免转换逻辑重复。
* @param {File} file 原始文件
* @returns {Promise<{file: File, converted: boolean, baseAddr: number|null, endAddr: number|null}>}
*/
export async function convertHexFileToBin(file) {
if (!file || !file.name.toLowerCase().endsWith('.hex')) {
return { file: file, converted: false, baseAddr: null, endAddr: null };
}
var text = await file.text();
if (!isIntelHexText(text)) {
return { file: file, converted: false, baseAddr: null, endAddr: null };
}
var result = parseIntelHex(text);
var binName = file.name.replace(/\.hex$/i, '.bin');
var binBlob = new Blob([result.data], { type: 'application/octet-stream' });
var binFile = new File([binBlob], binName, { type: 'application/octet-stream', lastModified: file.lastModified });
return { file: binFile, converted: true, baseAddr: result.baseAddr, endAddr: result.endAddr };
}
// ==================== GD32 包身份判定(前端早期防呆) ====================
/**
* 判定 GD32 镜像字节是否为合法 APP 包。
* 与设备端 Gd32FlashStore::validateStoredImageIdentity 同判据:
* 向量表初始 MSP 须落在 SRAM(0x20000000~0x2001FFFF),
* 复位向量须落在 APP 区(0x08048000~0x0807DFFB);
* 落在 BOOT 区(0x08000000~0x08011FFF) 即 Bootloader 包。
* @param {Uint8Array} bytes 至少 8 字节
* @returns {{ok: boolean, kind: 'app'|'bootloader'|'invalid'}}
*/
export function classifyGd32ImageBytes(bytes) {
if (!bytes || bytes.length < 8) return { ok: false, kind: 'invalid' };
var dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
var msp = dv.getUint32(0, true);
var reset = (dv.getUint32(4, true) & 0xFFFFFFFE) >>> 0;
var mspOk = msp >= 0x20000000 && msp < 0x20020000;
var inApp = reset >= 0x08048000 && reset < 0x0807DFFC;
var inBoot = reset >= 0x08000000 && reset < 0x08012000;
if (mspOk && inApp) return { ok: true, kind: 'app' };
if (inBoot) return { ok: false, kind: 'bootloader' };
return { ok: false, kind: 'invalid' };
}
/**
* 读取文件前 8 字节并判定 GD32 包身份。
* 无文件返回 kind='none'("没有选文件"≠"固件非法",调用方不应据此报身份错误)。
*/
export function checkGd32ImageFile(file) {
if (!file) return Promise.resolve({ ok: false, kind: 'none' });
return file.slice(0, 8).arrayBuffer().then(function (buf) {
return classifyGd32ImageBytes(new Uint8Array(buf));
}).catch(function () {
return { ok: false, kind: 'invalid' };
});
}
/** 身份不符:清空已选文件 + 明确报错(不抛异常,避免各调用点未捕获 rejection)。 */
function rejectGd32Identity(fileName, kind) {
var msg = kind === 'bootloader'
? '检测到 Bootloader 固件,不能刷入 APP 区,请选择 APP 固件'
: kind === 'none'
? '未选择固件文件'
: '文件不是合法的 GD32 APP 固件(向量表非法),已拒绝';
addLog('E', '文件', fileName + ': ' + msg);
showToast(msg, 'error');
window.gd32File = null;
window._gd32FileSha256 = null;
var info = document.getElementById('fileInfoGd32');
if (info) info.classList.remove('show');
safeUpdateUploadButtonLabel();
}
// ==================== SHA-256:ESP32 / GD32 各一份,互不共用、不互切 ====================
function paintShaUi(chip, hashHex, statusText) {
var t = statusText != null ? statusText : (hashHex || '-');
if (chip === 'esp32') {
var e = document.getElementById('fileSha256Esp32');
if (e) e.textContent = t;
} else if (chip === 'gd32') {
var g = document.getElementById('fileSha256Gd32');
if (g) g.textContent = t;
}
/* BLE 面板只显示当前目标槽,避免算 ESP32 时盖掉 GD32 的展示 */
var cur = (window.flashTarget === 'gd32') ? 'gd32' : 'esp32';
if (chip === cur) {
var ble = document.getElementById('fileSha256');
if (ble) ble.textContent = t;
}
}
/**
* 选文件时算一次,写入对应芯片槽;上传时直接读槽。
* 不做跨芯片复用、不与另一路互切。
*/
export function computeFileSha(file, chip, optArrayBuffer) {
if (!file || (chip !== 'esp32' && chip !== 'gd32')) {
return Promise.resolve(null);
}
paintShaUi(chip, null, '计算中...');
var bufPromise = optArrayBuffer ? Promise.resolve(optArrayBuffer) : file.arrayBuffer();
return bufPromise.then(function (buf) {
return crypto.subtle.digest('SHA-256', buf);
}).then(function (hashBuffer) {
var hashHex = bytesToHex(new Uint8Array(hashBuffer)).toUpperCase();
if (chip === 'esp32') window._esp32FileSha256 = hashHex;
else window._gd32FileSha256 = hashHex;
paintShaUi(chip, hashHex);
addLog('I', '文件', chip.toUpperCase() + ' 固件 SHA-256: ' + hashHex);
return hashHex;
}).catch(function (err) {
paintShaUi(chip, null, '计算失败: ' + err.message);
if (chip === 'esp32') window._esp32FileSha256 = null;
else window._gd32FileSha256 = null;
addLog('W', '文件', chip.toUpperCase() + ' SHA-256 计算失败: ' + err.message);
return null;
});
}
// ==================== 文件处理 ====================
/**
* @description 处理用户选择的固件文件(HEX→BIN、算 SHA 写入对应芯片槽)。
* @param {File} file
* @param {'esp32'|'gd32'} chip
* @param {{silent?: boolean, skipHandlePersist?: boolean}=} options
* @returns {Promise<void>}
*/
export function processFile(file, chip, options) {
var opts = options || {};
var silent = !!opts.silent;
var suffix = chip === 'esp32' ? 'Esp32' : 'Gd32';
var isHexByName = file.name.toLowerCase().endsWith('.hex');
if (!opts.skipHandlePersist) {
persistFlashTarget(chip);
}
if (file && file.name) {
persistFileMeta(chip, file);
}
function paintFileInfo(name, type, sizeText) {
var info = document.getElementById('fileInfo' + suffix);
var nameEl = document.getElementById('fileName' + suffix);
var typeEl = document.getElementById('fileType' + suffix);
var sizeEl = document.getElementById('fileSize' + suffix);
if (info) info.classList.add('show');
if (nameEl) nameEl.textContent = name;
if (typeEl) typeEl.textContent = type;
if (sizeEl) sizeEl.textContent = sizeText;
}
if (chip === 'gd32' && isHexByName) {
return convertHexFileToBin(file).then(function (result) {
if (!result.converted) {
addLog('W', '文件', file.name + ' 扩展名为 .hex 但内容不像 HEX 格式,按原始文件处理');
return checkGd32ImageFile(file).then(function (id) {
if (!id.ok) { rejectGd32Identity(file.name, id.kind); return; }
window.gd32File = file;
paintFileInfo(file.name, 'Binary(?)', formatBytes(file.size));
safeUpdateUploadButtonLabel();
return computeFileSha(file, 'gd32');
});
}
return checkGd32ImageFile(result.file).then(function (id) {
if (!id.ok) { rejectGd32Identity(file.name, id.kind); return; }
window.gd32File = result.file;
var baseHex = '0x' + result.baseAddr.toString(16).toUpperCase().padStart(8, '0');
addLog('I', '文件', 'HEX → BIN 转换完成: ' + file.name + ' → ' + result.file.name);
addLog('I', '文件', '基地址: ' + baseHex + ', 数据大小: ' + formatBytes(result.file.size));
if (!silent) showToast('HEX 已自动转换为 BIN', 'success');
paintFileInfo(
result.file.name + ' (由 ' + file.name + ' 转换)',
'Binary (HEX转换)',
formatBytes(result.file.size)
);
safeUpdateUploadButtonLabel();
return computeFileSha(result.file, 'gd32');
});
}).catch(function (err) {
addLog('E', '文件', 'HEX 解析失败: ' + (err && err.message ? err.message : err));
if (!silent) showToast('HEX 文件解析失败: ' + (err && err.message ? err.message : err), 'error');
window.gd32File = null;
window._gd32FileSha256 = null;
var info = document.getElementById('fileInfo' + suffix);
if (info) info.classList.remove('show');
safeUpdateUploadButtonLabel();
throw err;
});
}
function finalizeSelect() {
if (chip === 'esp32') window.esp32File = file;
else window.gd32File = file;
paintFileInfo(
file.name,
isHexByName ? 'Intel HEX' : 'Binary',
formatBytes(file.size)
);
safeUpdateUploadButtonLabel();
if (silent) {
addLog('I', '文件', chip.toUpperCase() + ' 已恢复: ' + file.name + ' (' + formatBytes(file.size) + ')');
} else {
addLog('I', '文件', chip.toUpperCase() + ' 已选择: ' + file.name + ' (' + formatBytes(file.size) + ')');
showToast(chip.toUpperCase() + ' 文件已选择', 'success');
}
return computeFileSha(file, chip);
}
/* GD32 原始 BIN:选文件时即判定包身份,BOOT 包不进上传队列 */
if (chip === 'gd32') {
return checkGd32ImageFile(file).then(function (id) {
if (!id.ok) { rejectGd32Identity(file.name, id.kind); return null; }
return finalizeSelect();
});
}
return Promise.resolve(finalizeSelect());
}
/** @description 清除已选固件文件并重置对应 UI 状态。 */
export function clearFile(chip) {
var suffix = chip === 'esp32' ? 'Esp32' : 'Gd32';
if (chip === 'esp32') {
window.esp32File = null;
window.esp32FileHandle = null;
window._esp32FileSha256 = null;
} else {
window.gd32File = null;
window.gd32FileHandle = null;
window._gd32FileSha256 = null;
}
window._pendingFwHandles[chip] = null;
persistFileHandle(chip, null);
persistFileMeta(chip, null);
document.getElementById('fileInput' + suffix).value = '';
document.getElementById('fileInfo' + suffix).classList.remove('show');
var shaEl = document.getElementById('fileSha256' + suffix);
if (shaEl) shaEl.textContent = '-';
safeUpdateUploadButtonLabel();
addLog('I', '文件', chip.toUpperCase() + ' 文件已清除');
}
+257
View File
@@ -0,0 +1,257 @@
/**
* progress.js - 折叠面板 + 刷写进度 + 阶段节点 UI
*
* 操作 DOM: #esp32Stages / #gd32Stages / #stageStatusBar / #progressFill / #progressTxt / #progressPct
*
* 共享状态(通过 window.* 引用):
* window.activePhase / window.currentOtaTarget / window.esp32OtaPhase
*
* 复用的函数(通过 window.* 引用):
* window.updateStagesFromRelayState / window.cleanupAfterOtaFailure
* window.resumePollingAfterUpload / window.addLog
*/
import { addLog } from '../common/log.js';
// ==================== 常量 ====================
// ESP32 OTA 阶段 DOM 节点 id(es1..es6 + esd1..esd6)
var ESP32_STAGE_IDS = ['es1', 'es2', 'es3', 'es4', 'es5', 'es6'];
var ESP32_DETAIL_IDS = ['esd1', 'esd2', 'esd3', 'esd4', 'esd5', 'esd6'];
// 顶部步骤 pill → phase 名映射
var STEP_MAP = { esp32Ota: 'ppEsp32', gd32Ota: 'ppGd32Ota' };
// 进度条 DOM 元素懒缓存(首次访问后复用,DOM 未就绪时回退到 getElementById)
var progressFillEl = null;
var progressTxtEl = null;
var progressPctEl = null;
// ==================== 折叠面板 ====================
export function toggleCollapse(headEl) {
headEl.closest('.card').classList.toggle('collapsed');
}
// ==================== 刷写进度 ====================
window.activePhase = null; // 'esp32Ota' | 'gd32Ota' | null
window.currentOtaTarget = null; // 'esp32' | 'gd32' | null — 当前 OTA 目标,决定显示哪套阶段
// 切换 OTA 阶段面板显示(原始版本,会被 console-mode.js 覆盖以支持 BLE)
export function switchStageFlow(target) {
window.currentOtaTarget = target;
var esp32El = document.getElementById('esp32Stages');
var gd32El = document.getElementById('gd32Stages');
if (target === 'esp32') {
if (esp32El) esp32El.style.display = '';
if (gd32El) gd32El.style.display = 'none';
} else if (target === 'gd32') {
if (esp32El) esp32El.style.display = 'none';
if (gd32El) gd32El.style.display = '';
} else {
// null / 'ble' / 未知:隐藏两套面板(reset 语义,避免残留显示)
if (esp32El) esp32El.style.display = 'none';
if (gd32El) gd32El.style.display = 'none';
}
resetEsp32StageNodes();
resetGd32StageNodes();
}
/** @description 把 ESP32 阶段节点(es1..es6 / esd1..esd6)复位到初始态。 */
function resetEsp32StageNodes() {
ESP32_STAGE_IDS.forEach(function (id) {
var el = document.getElementById(id);
if (el) el.className = 'stage-node';
});
ESP32_DETAIL_IDS.forEach(function (id) {
var el = document.getElementById(id);
if (el) el.textContent = '';
});
}
/** @description 把 GD32 阶段节点(s1..s6 / sd1..sd6)复位到初始态。 */
function resetGd32StageNodes() {
['s1', 's2', 's3', 's4', 's5', 's6'].forEach(function (id) {
var el = document.getElementById(id);
if (el) el.className = 'stage-node';
});
['sd1', 'sd2', 'sd3', 'sd4', 'sd5', 'sd6'].forEach(function (id) {
var el = document.getElementById(id);
if (el) el.textContent = '';
});
}
// ESP32 OTA 阶段状态: 0=待机, 1=上传中, 2=OTA写入, 3=SHA-256校验, 4=应用固件, 5=重启中, 6=启动完成
window.esp32OtaPhase = 0;
export const esp32PhaseLabels = ['', '上传中', '写入中', '校验中', '应用中', '重启中', '已完成'];
export function updateEsp32StagesFromPhase(phase) {
window.esp32OtaPhase = phase;
var all = ESP32_STAGE_IDS.map(function (id) { return document.getElementById(id); });
var details = ESP32_DETAIL_IDS.map(function (id) { return document.getElementById(id); });
all.forEach(function (s) { if (s) s.className = 'stage-node'; });
details.forEach(function (d) { if (d) d.textContent = ''; });
var statusBar = document.getElementById('stageStatusBar');
var statusText = document.getElementById('stageStatusText');
if (phase <= 0) {
if (statusBar) statusBar.className = 'stage-status-bar';
if (statusText) statusText.textContent = '待机';
return;
}
/* phase=N:前 N-1 步 done;第 N 步进行中 live。
* phase=6(启动完成/已完成)是终态:全部 done(绿),最后一步不要 live(蓝)。 */
for (var i = 0; i < 6; i++) {
if (!all[i]) continue;
if (i < phase - 1) {
all[i].className = 'stage-node done';
} else if (i === phase - 1) {
all[i].className = (phase >= 6) ? 'stage-node done' : 'stage-node live';
if (details[i]) details[i].textContent = esp32PhaseLabels[phase] || '';
}
}
if (statusBar && statusText) {
if (phase >= 6) {
statusBar.className = 'stage-status-bar ok';
statusText.textContent = 'ESP32 刷写完成';
} else {
statusBar.className = 'stage-status-bar active';
statusText.textContent = (esp32PhaseLabels[phase] || '进行中') + ' (' + phase + '/6)';
}
}
}
export function updateEsp32StagesError() {
var all = ESP32_STAGE_IDS.map(function (id) { return document.getElementById(id); });
var details = ESP32_DETAIL_IDS.map(function (id) { return document.getElementById(id); });
all.forEach(function (s) { if (s) s.className = 'stage-node'; });
// ★ 失败态:阶段下方文案也要清空,避免残留"上传中/校验中"与红色样式并存
details.forEach(function (d) { if (d) d.textContent = ''; });
var statusBar = document.getElementById('stageStatusBar');
var statusText = document.getElementById('stageStatusText');
if (statusBar) statusBar.className = 'stage-status-bar err';
if (statusText) statusText.textContent = '校验/应用失败';
}
/**
* @description OTA 失败后的统一清理函数。
* 所有上传失败、校验失败、中继触发失败等异常路径都应调用此函数,
* 确保 UI 状态完整重置,不会残留"上传中"或中间态。
*
* ★ 状态机改造:转入 FAILED 态,置位 otaFailReason。
* 之后轮询(pollStatus/updateSystemStatusUi)不得覆盖此失败态,
* 直到用户发起新上传(otaSessionReset)或检测到设备已回 IDLE。
* @param {string} reason 失败原因描述
*/
export function cleanupAfterOtaFailure(reason) {
// 幂等:已在 FAILED 且无活跃 XHR 时只刷新文案,避免 abort 事件二次递归清状态
var alreadyFailed = window.otaSessionState === window.OtaSessionState.FAILED;
/* chip 判定优先级(权威来源顺序):
* activeUploadChip — XHR 上传开始时由 startUpload 写入,WiFi 路径权威
* otaSessionTarget — OTA 会话状态机目标(仅 WiFi 走 OtaSessionState,BLE 不走)
* currentOtaTarget — switchStageFlow 设置的当前面板目标
* flashTarget — 用户选择的目标(BLE 路径未设上述两者时兜底)
* 'esp32' — 最终默认值 */
var chip = window.activeUploadChip || window.otaSessionTarget ||
window.currentOtaTarget || window.flashTarget || 'esp32';
// 0. 状态机:转入 FAILED(先于标志重置)
window.otaSessionTransition(window.OtaSessionState.FAILED, reason);
// 1. 清掉本会话全部残留标志 / 定时器 / 校验面板 / 旧 progress 语义
window.clearOtaSessionResiduals({
keepErrorStages: true,
statusText: reason || '失败',
errorStyle: true
});
// 2. 阶段指示器标错
if (chip === 'esp32') {
updateEsp32StagesError();
} else {
window.updateStagesFromRelayState(255);
}
// 3. 进度条失败文案(clear 已把 fill 的 done 去掉)
setProgress(0, reason || '刷写失败');
setActivePhase(null);
window.currentOtaTarget = null;
// 4. 恢复上传按钮(两枚都解禁并恢复各自原文案,避免 chip 判错或一侧残留"上传中")
// ★ 按钮文案是固定的,绝不能用共享的 uploadBtnOrigText 回写两枚,否则最后
// 刷 GD32 后 ESP32 按钮会被错写成 "GD32 OTA"(反之亦然)。
var uploadBtnLabels = { uploadEsp32Btn: '上传并升级 ESP32', uploadGd32Btn: 'GD32 OTA' };
Object.keys(uploadBtnLabels).forEach(function (btnId) {
var btn = document.getElementById(btnId);
if (!btn) return;
btn.disabled = false;
btn.textContent = uploadBtnLabels[btnId];
});
window.activeUploadChip = null;
window.otaSessionTarget = null;
window.uploadBtnOrigText = '';
if (typeof window.updateUploadButtonLabel === 'function') {
window.updateUploadButtonLabel();
}
// 5. 恢复轮询(之后 status 不得覆盖 FAILED UI,直至用户新上传或设备 idle 复位)
if (typeof window.resumePollingAfterUpload === 'function') {
window.resumePollingAfterUpload();
}
if (!alreadyFailed) {
addLog('E', 'OTA', '刷写流程已中止,已清理残留状态: ' + (reason || '未知原因'));
}
}
/** @description 设置当前活跃 OTA 阶段并更新 UI 步骤指示器。 */
export function setActivePhase(phase) {
if (phase === 'gd32Upload' || phase === 'gd32Relay') phase = 'gd32Ota';
window.activePhase = phase;
Object.keys(STEP_MAP).forEach(function (p) {
var el = document.getElementById(STEP_MAP[p]);
if (!el) return;
el.classList.remove('active', 'done');
if (p === phase) el.classList.add('active');
});
// Reset progress fill color
var fill = document.getElementById('progressFill');
if (fill) fill.classList.remove('done');
}
/** @description 标记指定阶段为完成,更新进度条颜色。 */
export function markPhaseDone(phase) {
if (phase === 'gd32Upload' || phase === 'gd32Relay') phase = 'gd32Ota';
var el = document.getElementById(STEP_MAP[phase]);
if (el) { el.classList.remove('active'); el.classList.add('done'); }
// All done? turn progress bar green
if (phase === 'gd32Ota' || phase === 'esp32Ota') {
var fill = document.getElementById('progressFill');
if (fill) fill.classList.add('done');
}
}
/** @description 更新进度条百分比和文本显示。对 pct 做边界校验,过滤 NaN/undefined。 */
export function setProgress(pct, text) {
// 数值校验:BLE ACK 高频调用路径偶尔传入 undefined/NaN,需兜底避免出现 "undefined%"
var n = Number(pct);
if (!Number.isFinite(n)) n = 0;
if (n < 0) n = 0;
else if (n > 100) n = 100;
// 懒缓存 DOM 元素引用;DOM 未就绪时回退到 getElementById(兼容早期调用)
if (!progressFillEl || !progressFillEl.isConnected) progressFillEl = document.getElementById('progressFill');
if (!progressTxtEl || !progressTxtEl.isConnected) progressTxtEl = document.getElementById('progressTxt');
if (!progressPctEl || !progressPctEl.isConnected) progressPctEl = document.getElementById('progressPct');
if (progressFillEl) progressFillEl.style.width = n + '%';
if (progressTxtEl) progressTxtEl.textContent = text;
if (progressPctEl) progressPctEl.textContent = n + '%';
}
/** @description 重置进度条和阶段状态到初始值。 */
export function resetProgress() {
setProgress(0, '等待操作...');
window.activePhase = null;
window.esp32OtaPhase = 0;
switchStageFlow(null);
['ppEsp32', 'ppGd32Ota'].forEach(function (id) {
var el = document.getElementById(id);
if (el) el.classList.remove('active', 'done');
});
var fill = document.getElementById('progressFill');
if (fill) fill.classList.remove('done');
}
+250
View File
@@ -0,0 +1,250 @@
/**
* session.js - 前端 OTA 会话状态机
*
* 统一管理一次 OTA 会话的完整生命周期,避免散布的布尔标志
* (isUploading / currentOtaTarget / activeUploadChip / isEsp32Rebooting)
* 在网络错误后被轮询错误覆盖、状态混乱的问题。
*
* 状态流转:
* IDLE → UPLOADING → (ESP32: VERIFYING → REBOOTING) → DONE
* IDLE → UPLOADING → (GD32: RELAYING) → DONE
* 任意活跃态 ──(failure/abort)──→ FAILED
* FAILED/DONE ──(startUpload)──→ UPLOADING
* FAILED/DONE ──(device idle detected by poll)──→ IDLE
*
* 共享状态(通过 window.* 引用,入口模块统一挂载):
* window.isUploading / window.isEsp32Rebooting / window.rebootStartTime
* window.rebootObservedDown / window.activeUploadChip / window.currentOtaTarget
* window.esp32OtaPhase / window.activePhase
* window.gd32CompleteAnnounced / window.relayWatchLastSeq / window.gd32RelaySessionLive
* window.lastSysStateCode / window.lastRelayState
*
* 复用的函数(通过 window.* 引用):
* window.stopRelayProgressWatch / window.setProgress / window.addLog
*/
import { addLog } from '../common/log.js';
// ==================== 全局状态 ====================
// 文件句柄与上传进度等状态保留为模块级,被 file-handle / upload-esp32 等模块通过 window.* 读写
window.isUploading = false;
window.uploadProgress = 0;
window.isEsp32Rebooting = false;
window.rebootStartTime = 0;
window.rebootObservedDown = false;
window.pollingPausedForUpload = false;
window.uploadPollChip = null;
window.activeXhr = null;
window.uploadBtnOrigText = '';
window.activeUploadChip = null;
// 心跳定时器(模块级,仅本模块使用)
let uploadWaitHeartbeatId = null;
let uploadWaitStartMs = 0;
/** @description 停止「等待设备 HTTP 响应」心跳,避免残留定时器改进度条。 */
export function stopUploadWaitHeartbeat() {
if (uploadWaitHeartbeatId) {
clearInterval(uploadWaitHeartbeatId);
uploadWaitHeartbeatId = null;
}
uploadWaitStartMs = 0;
}
/**
* @description 浏览器 body 已发完但尚未收到 HTTP 响应时启动心跳。
* 上传期轮询已暂停,若无此反馈会像“卡在 100%”。
*/
export function startUploadWaitHeartbeat() {
stopUploadWaitHeartbeat();
uploadWaitStartMs = Date.now();
uploadWaitHeartbeatId = setInterval(function () {
if (!window.activeXhr) {
stopUploadWaitHeartbeat();
return;
}
var sec = Math.round((Date.now() - uploadWaitStartMs) / 1000);
window.setProgress(100, '数据已发完,设备处理中… 已等待 ' + sec + 's');
// 每 10s 打一条日志,便于区分“仍在处理”与“真死锁”
if (sec > 0 && sec % 10 === 0) {
addLog('I', '上传', '仍在等待设备 HTTP 响应(已 ' + sec +
's;设备可能仍在写 Flash / 算 SHA,或连接异常)');
}
}, 5000);
}
// ==================== 前端 OTA 会话状态机 ====================
export const OtaSessionState = {
IDLE: 'idle', // 无会话
UPLOADING: 'uploading', // 固件上传中(XHR 上传阶段)
VERIFYING: 'verifying', // ESP32 SHA-256 校验 + 应用中
RELAYING: 'relaying', // GD32 中继转发中(triggerGd32Relay 发起后)
REBOOTING: 'rebooting', // ESP32 重启等待中
DONE: 'done', // 会话成功完成
FAILED: 'failed' // 会话失败/中止
};
window.OtaSessionState = OtaSessionState;
let otaSessionState = OtaSessionState.IDLE;
let otaSessionTarget = null; // 'esp32' | 'gd32' | null — 本会话的目标芯片
let otaFailReason = ''; // FAILED 时的失败原因(用于 UI 显示)
// 同步到 window 供 polling.js / stages.js / upload-gd32.js 等通过 window.* 读取
window.otaSessionState = otaSessionState;
window.otaSessionTarget = otaSessionTarget;
export { otaSessionState, otaSessionTarget, otaFailReason };
/** @description 查询当前 OTA 会话是否处于活跃态(非 IDLE/DONE/FAILED)。 */
export function otaSessionActive() {
return otaSessionState === OtaSessionState.UPLOADING ||
otaSessionState === OtaSessionState.VERIFYING ||
otaSessionState === OtaSessionState.RELAYING ||
otaSessionState === OtaSessionState.REBOOTING;
}
/** @description 查询当前 OTA 会话是否已终结(DONE 或 FAILED)。 */
export function otaSessionTerminated() {
return otaSessionState === OtaSessionState.DONE ||
otaSessionState === OtaSessionState.FAILED;
}
/**
* @description 转换 OTA 会话状态。
* 统一在此处维护状态机一致性,同时同步旧标志位
* (isUploading / currentOtaTarget),保证渐进式迁移期间两套标志不冲突。
* @param {string} newState 目标状态。
* @param {string} [reason] 失败原因(仅转入 FAILED 时使用)。
*/
export function otaSessionTransition(newState, reason) {
var prev = otaSessionState;
otaSessionState = newState;
window.otaSessionState = newState;
if (newState === OtaSessionState.FAILED) {
otaFailReason = reason || '未知原因';
} else if (newState === OtaSessionState.UPLOADING) {
otaFailReason = '';
}
// isUploading 仅表示 XHR 上传中;VERIFYING/RELAYING/REBOOTING 必须清掉,
// 否则 updateUploadButtonLabel 会把「上传并升级」一直 disabled。
window.isUploading = (newState === OtaSessionState.UPLOADING);
// REBOOTING 复用 isEsp32Rebooting(pollStatus 重启检测依赖)
// VERIFYING 保持 isEsp32Rebooting=false;FAILED/IDLE/DONE 必须清掉,避免 residual 假「重启中」
if (newState === OtaSessionState.REBOOTING) {
window.isEsp32Rebooting = true;
} else if (newState === OtaSessionState.VERIFYING) {
/* 校验阶段不改 reboot 标志 */
} else {
window.isEsp32Rebooting = false;
}
if (prev !== newState && typeof addLog === 'function') {
addLog('D', 'OTA', '会话 ' + prev + ' → ' + newState +
(reason ? ' (' + reason + ')' : ''));
}
// 终态/离开上传态后刷新按钮(避免卡在 disabled)
if (typeof window.updateUploadButtonLabel === 'function' &&
(newState === OtaSessionState.IDLE ||
newState === OtaSessionState.DONE ||
newState === OtaSessionState.FAILED ||
newState === OtaSessionState.VERIFYING ||
newState === OtaSessionState.RELAYING ||
newState === OtaSessionState.REBOOTING)) {
window.updateUploadButtonLabel();
}
}
/**
* @description 清掉本会话残留 UI/标志(失败、超时、中止、新会话开始前都要调)。
* 不改变 otaSessionState(由调用方负责 FAILED/IDLE 转换)。
* @param {object} [opts]
* @param {boolean} [opts.keepErrorStages] true=保留错误阶段样式;false=节点复位到待机
* @param {string} [opts.statusText] 状态栏文案;null 表示「待机」
* @param {boolean} [opts.errorStyle] 状态栏是否红色
*/
export function clearOtaSessionResiduals(opts) {
opts = opts || {};
window.stopRelayProgressWatch();
stopUploadWaitHeartbeat();
window.gd32CompleteAnnounced = false;
window.relayWatchLastSeq = -1;
window.gd32RelaySessionLive = false;
window.lastSysStateCode = -1;
window.lastRelayState = 0;
window.isEsp32Rebooting = false;
window.rebootObservedDown = false;
window.rebootStartTime = 0;
window.isUploading = false;
/* 先摘掉引用再 abort,避免 abort 事件回调再次进入 cleanup 递归 */
var xh = window.activeXhr;
window.activeXhr = null;
if (xh) {
try { xh.abort(); } catch (_) {}
}
// 校验面板
var verifyInfo = document.getElementById('stageVerifyInfo');
if (verifyInfo) verifyInfo.style.display = 'none';
var sviFront = document.getElementById('sviFrontSha');
var sviDevice = document.getElementById('sviDeviceSha');
var sviResult = document.getElementById('sviResult');
if (sviFront) sviFront.textContent = '-';
if (sviDevice) sviDevice.textContent = '-';
if (sviResult) {
sviResult.textContent = '-';
sviResult.className = 'svi-val';
}
// 阶段节点
if (!opts.keepErrorStages) {
['es1','es2','es3','es4','es5','es6','s1','s2','s3','s4','s5','s6'].forEach(function(id) {
var el = document.getElementById(id);
if (el) el.className = 'stage-node';
});
['esd1','esd2','esd3','esd4','esd5','esd6','sd1','sd2','sd3','sd4','sd5','sd6'].forEach(function(id) {
var el = document.getElementById(id);
if (el) el.textContent = '';
});
window.esp32OtaPhase = 0;
}
// 顶部步骤 pill
['ppEsp32', 'ppGd32Ota'].forEach(function(id) {
var el = document.getElementById(id);
if (el) el.classList.remove('active', 'done');
});
var fill = document.getElementById('progressFill');
if (fill) fill.classList.remove('done');
window.activePhase = null;
// 状态栏
var sb = document.getElementById('stageStatusBar');
var st = document.getElementById('stageStatusText');
if (sb) {
sb.className = opts.errorStyle ? 'stage-status-bar err' : 'stage-status-bar';
}
if (st) {
st.textContent = (opts.statusText !== undefined && opts.statusText !== null)
? opts.statusText
: '待机';
}
}
/** @description 将会话从 FAILED/DONE 复位到 IDLE(用户发起新上传或设备空闲时调用)。 */
export function otaSessionReset() {
clearOtaSessionResiduals({
keepErrorStages: false,
statusText: '待机',
errorStyle: false
});
if (otaSessionState !== OtaSessionState.IDLE) {
otaSessionState = OtaSessionState.IDLE;
otaSessionTarget = null;
otaFailReason = '';
window.otaSessionState = OtaSessionState.IDLE;
window.otaSessionTarget = null;
window.currentOtaTarget = null;
window.isUploading = false;
}
window.activeUploadChip = null;
}
+512
View File
@@ -0,0 +1,512 @@
/**
* stages.js - OTA 阶段驱动 (真实状态)
*
* 将设备端 relay_state / sys_state 映射到 GD32 阶段节点 UI,
* 并驱动 updateSystemStatusUi 综合 UI 更新。
*
* 结构:
* applyDashboardUi — 堆/连接/版本/温湿度等常驻面板
* applyGd32OtaSessionUi — GD32 会话进度/完成/失败(唯一 UI 应用路径)
* updateSystemStatusUi — 入口:仪表盘 →(非 BLE/非 ESP32-OTA)会话
*
* 共享状态(通过 window.* 引用):
* window.latestSystemStatus / window.currentOtaTarget / window.esp32OtaPhase
* window.otaSessionState / window.otaSessionTarget / window.isUploading
* window.gd32CompleteAnnounced / window.gd32RelaySessionLive
*
* 复用的函数(通过 window.* 引用):
* window.setActivePhase / window.setProgress
* window.markGd32OtaUiComplete / window.cleanupAfterOtaFailure / window.otaSessionReset
* window.stopRelayProgressWatch / window.pollLogs / window.emitRelayProgress
*/
import { formatUptime } from '../common/utils.js';
import { updateConnectionUi, updateHealthUi, updateHeapAndTaskStatusUi } from '../common/connection.js';
import { setActivePhase, setProgress } from './progress.js';
import { otaSessionReset, OtaSessionState } from './session.js';
// Relay state comes from relay_state_t in main/relay/ota_relay.h:
// 0=IDLE, 1=HANDSHAKING, 2=SIZE_NEG, 3=TRANSFERRING,
// 4=FINISHING, 5=DONE, 255=ERROR.
// 与 index.html #gd32Stages s1..s6 一一对应(rs→节点序号):
// 1→s1, 2→s2, 3→s3, 4→s4, 5→s1..s6 全 done。
const relayStateDetailMap = {
0: '待机',
1: '认证握手',
2: '传输协商',
3: '数据转发',
4: '收尾验证',
5: 'OTA完成',
255: '错误'
};
/** 上次刷到阶段条的 displayRelayState,未变则跳过 DOM 重建 */
var lastDisplayedRelayState = undefined;
/**
* 将 relay_state 映射到 GD32 阶段节点 (s1..s6)。
* @param {number|null|undefined} rs
*/
export function updateStagesFromRelayState(rs) {
var ids = ['s1', 's2', 's3', 's4', 's5', 's6'];
var detailIds = ['sd1', 'sd2', 'sd3', 'sd4', 'sd5', 'sd6'];
var all = ids.map(function(id) { return document.getElementById(id); });
var details = detailIds.map(function(id) { return document.getElementById(id); });
all.forEach(function (s) { if (s) s.className = 'stage-node'; });
details.forEach(function (d) { if (d) d.textContent = ''; });
var statusBar = document.getElementById('stageStatusBar');
var statusText = document.getElementById('stageStatusText');
if (rs === undefined || rs === null) {
if (statusBar) statusBar.className = 'stage-status-bar';
if (statusText) statusText.textContent = '待机';
lastDisplayedRelayState = rs;
return;
}
if (rs === 255) {
all.forEach(function (s) { if (s) s.className = 'stage-node err'; });
if (statusBar) statusBar.className = 'stage-status-bar err';
if (statusText) statusText.textContent = '错误';
lastDisplayedRelayState = 255;
return;
}
/* rs → 节点:1..4 直接对应 s1..s4;rs=5 DONE → 点亮完成链(s1..s6 全 done) */
var activeStage = 0;
if (rs === 1) activeStage = 1;
else if (rs === 2) activeStage = 2;
else if (rs === 3) activeStage = 3;
else if (rs === 4) activeStage = 4;
else if (rs === 5) activeStage = 6;
for (var i = 0; i < activeStage - 1; i++) {
if (all[i]) all[i].className = 'stage-node done';
}
if (rs === 5) {
/* DONE:s1..s5 已在上方标 done,s5/s6 一并完成 */
if (all[4]) all[4].className = 'stage-node done';
if (all[5]) all[5].className = 'stage-node done';
if (details[5]) details[5].textContent = relayStateDetailMap[5];
} else if (activeStage >= 1 && activeStage <= 6) {
if (all[activeStage - 1]) all[activeStage - 1].className = 'stage-node live';
if (details[activeStage - 1]) {
details[activeStage - 1].textContent = relayStateDetailMap[rs] || '';
}
}
if (statusBar && statusText) {
if (rs === 0) {
statusBar.className = 'stage-status-bar';
statusText.textContent = '待机';
} else {
statusBar.className = 'stage-status-bar active';
statusText.textContent = (relayStateDetailMap[rs] || '未知') + ' (' + rs + ')';
}
}
lastDisplayedRelayState = rs;
}
function setDisplayedRelayState(rs) {
if (lastDisplayedRelayState === rs) return;
updateStagesFromRelayState(rs);
}
/* 写前比较小工具:每秒轮询里大部分仪表盘字段不变,
* 无条件写 textContent/className/title 会白白触发样式失效。 */
function setNodeText(el, txt) {
if (el && el.textContent !== txt) el.textContent = txt;
}
function setNodeClass(el, cls) {
if (el && el.className !== cls) el.className = cls;
}
function setNodeTitle(el, title) {
if (el && el.title !== title) el.title = title;
}
/**
* 常驻仪表盘:堆、连接、健康、版本、温度、IP/RSSI。
* @param {object} data /system/status JSON
* @param {object} ota data.ota
* @param {object} wifi data.wifi
*/
function applyDashboardUi(data, ota, wifi) {
updateHeapAndTaskStatusUi(data);
var connected = !!wifi.connected;
var connText = connected ? (wifi.mode || 'STA') + ' 已连接' : (wifi.mode || 'STA') + ' 未连接';
updateConnectionUi(connected, connText);
updateHealthUi(data.health, data.free_heap);
if (data.version) {
var pillVerEl = document.getElementById('pillVer');
if (pillVerEl) {
setNodeText(pillVerEl, 'v' + data.version);
var verTip = data.build_time ? ('编译: ' + data.build_time) : '';
if (pillVerEl.dataset.tooltip !== verTip) pillVerEl.dataset.tooltip = verTip;
}
var mGd32App = document.getElementById('mGd32App');
var mGd32Boot = document.getElementById('mGd32Boot');
if (mGd32App) {
if (ota.gd32_app && ota.gd32_app !== 'v0.0.0.0') {
setNodeText(mGd32App, ota.gd32_app);
setNodeClass(mGd32App, 'v ok');
} else {
setNodeText(mGd32App, ota.gd32_app_alive ? '未知' : '离线');
setNodeClass(mGd32App, 'v');
}
}
if (mGd32Boot) {
if (ota.gd32_boot && ota.gd32_boot !== 'v0.0.0.0') {
setNodeText(mGd32Boot, ota.gd32_boot);
setNodeClass(mGd32Boot, 'v ok');
} else {
/* BOOT 版本与 APP alive 无直接关系,无版本时统一「未知」 */
setNodeText(mGd32Boot, '未知');
setNodeClass(mGd32Boot, 'v');
}
}
}
/* GD32 APP 上电自检(GD32 1.5.34+ /system/status gd32_init):
* state 0=正常 1=降级(非关键模块失败) 2=关键模块失败;详情经 SPI 0xA3 惰性查询。
* 1.5.36+ fault_codes[] 给出每模块具体原因,直接显示无需悬停。 */
var mGd32InitEl = document.getElementById('mGd32Init');
if (mGd32InitEl) {
var gi = data.gd32_init || null;
/* 与 GD32 sys/initial.h AppInitializer::ModuleBit 位序一致 */
var kModNames = ['nvic','systick','esp32_hold','usart0','adc','canfd',
'ota_boot_req','app_spi','task_manager','scheduler',
'ethercat','ethercat_sync','watchdog'];
var kRstNames = ['上电','外部引脚','独立看门狗','窗口看门狗','软件','低功耗'];
/* 故障码 → 中文原因(码值定义见 GD32 sys/initial.h 与各模块头文件) */
var kFaultText = {
nvic: { 1: '优先级分组回读不一致' },
systick: { 1: 'SysTick 配置失败', 2: '1ms 节拍未前进' },
esp32_hold: { 1: 'PC9 被外部钳低' },
usart0: { 1: 'USART 使能未生效' },
adc: { 1: '校准复位超时(ADC 无响应)', 2: '校准超时', 3: 'VDDA 采样无效(供电/DMA)' },
canfd: { 1: '控制器初始化失败', 2: '卡在初始化模式', 3: '收发器 STB 配置未生效' },
ota_boot_req: {},
app_spi: {},
task_manager: {},
scheduler: { 1: '定时器未使能', 2: 'PSC 回读不一致', 3: 'CAR 回读不一致', 4: '100us 节拍未前进(中断异常)' },
ethercat: { 1: 'EXMC 总线未就绪', 2: 'SSC 协议栈初始化失败' },
ethercat_sync: {},
watchdog: { 1: '预分频写入未确认', 2: '重载值写入未确认', 3: '配置更新挂起', 4: 'LSI(IRC40K) 未就绪' }
};
/* canfd 故障码 = 原因码 | (通道号<<4),返回 'CHn 原因' */
function decodeFault(modName, code) {
code = Number(code) & 0xFF;
if (code === 0) return null;
if (modName === 'canfd') {
var ch = (code >> 4) & 0x7;
var reason = kFaultText.canfd[code & 0xF] || ('未知原因 0x' + (code & 0xF).toString(16));
return 'canfd CH' + ch + ' ' + reason;
}
var tbl = kFaultText[modName] || {};
return modName + ' ' + (tbl[code] || ('故障码 0x' + code.toString(16)));
}
if (!gi || !gi.state_known) {
setNodeText(mGd32InitEl, '--');
setNodeClass(mGd32InitEl, 'v');
setNodeTitle(mGd32InitEl, 'GD32 APP 上电初始化状态(未收到身份块或固件 < 1.5.34)');
} else {
/* 失败明细:优先用 fault_codes(含具体原因),否则退到位图模块名 */
var critMask = Number(gi.critical_fail_mask) >>> 0;
var modMask = Number(gi.module_mask) >>> 0;
var failList = [];
var i;
if (gi.detail_known && gi.fault_codes && gi.fault_codes.length >= kModNames.length) {
for (i = 0; i < kModNames.length; i++) {
var txt = decodeFault(kModNames[i], gi.fault_codes[i]);
if (txt) failList.push(txt + ((critMask & (1 << i)) !== 0 ? '(关键)' : ''));
}
} else if (gi.detail_known) {
for (i = 0; i < kModNames.length; i++) {
if ((modMask & (1 << i)) === 0) {
failList.push(kModNames[i] + ((critMask & (1 << i)) !== 0 ? '(关键)' : ''));
}
}
}
var tip = '';
if (gi.detail_known) {
tip = failList.length > 0 ? ('失败模块: ' + failList.join(',')) : '全部模块初始化成功';
var rstList = [];
var rc = Number(gi.reset_cause) & 0xFF;
for (var rb = 0; rb < kRstNames.length; rb++) {
if ((rc & (1 << rb)) !== 0) rstList.push(kRstNames[rb]);
}
if (rstList.length > 0) tip += '\n本次复位源: ' + rstList.join('+');
} else {
tip = '模块级详情未查询(SPI 0xA3 惰性获取中)';
}
if (gi.state === 0) {
setNodeText(mGd32InitEl, '正常');
setNodeClass(mGd32InitEl, 'v ok');
} else {
/* 失败/降级:值直接带首要原因,不用悬停 */
var head = (gi.state === 1) ? '降级' : '失败';
setNodeText(mGd32InitEl, failList.length > 0 ? (head + ': ' + failList[0]) : head);
setNodeClass(mGd32InitEl, (gi.state === 1) ? 'v warn' : 'v err');
}
setNodeTitle(mGd32InitEl, tip);
}
}
var mTempEl = document.getElementById('mTemp');
if (mTempEl) {
/* 与固件 app_config:TEMP_WARNING=80 / TEMP_CRITICAL=100 对齐 */
if (Number.isFinite(data.temperature)) {
var tC = data.temperature;
setNodeText(mTempEl, tC.toFixed(1) + '\u00B0C');
if (tC >= 100) {
setNodeClass(mTempEl, 'v err');
} else if (tC >= 80) {
setNodeClass(mTempEl, 'v warn');
} else {
setNodeClass(mTempEl, 'v ok');
}
setNodeTitle(mTempEl, 'ESP32 片内温度传感器(警告≥80°C,危险≥100°C)');
} else {
setNodeText(mTempEl, '--');
setNodeClass(mTempEl, 'v');
setNodeTitle(mTempEl, '温度无效或未采样');
}
}
/* GD32 ADC:enabled 来自 HELLO_ACK 功能位(GD32 编译开关),ESP32 无本地开关 */
var ga = data.gd32_adc || null;
var feat = data.gd32_features || null;
var mGd32TempEl = document.getElementById('mGd32Temp');
var mVccEl = document.getElementById('mVcc');
var mVddaEl = document.getElementById('mVdda');
function setMetricUi(el, text, cls, title) {
if (!el) return;
setNodeText(el, text);
setNodeClass(el, cls || 'v');
if (title) setNodeTitle(el, title);
}
if (mGd32TempEl || mVccEl || mVddaEl) {
if (ga && ga.enabled === false) {
var offTitle = 'GD32 CONFIG_SPI_ADC_FEEDBACK_ENABLE=0(HELLO/PING 身份)';
setMetricUi(mGd32TempEl, '关闭', 'v', offTitle);
setMetricUi(mVccEl, '关闭', 'v', offTitle);
setMetricUi(mVddaEl, '关闭', 'v', offTitle);
} else if (ga && Number.isFinite(ga.temperature) && ga.fresh) {
setMetricUi(mGd32TempEl, ga.temperature.toFixed(1) + '\u00B0C',
ga.valid ? 'v ok' : 'v warn',
'片内温度 · seq=' + (ga.seq || 0) + ' age=' + (ga.age_ms || 0) + 'ms'
+ (ga.valid ? '' : ' (valid=0)'));
/* VCC:status 已带 vcc;旧固件仅 vin_adc 时本地 *11(47k/4.7k) */
var vcc = Number.isFinite(ga.vcc) ? ga.vcc
: (Number.isFinite(ga.vin_adc) ? ga.vin_adc * 11.0 : NaN);
if (Number.isFinite(vcc) && vcc > 0.5) {
setMetricUi(mVccEl, vcc.toFixed(1) + 'V', ga.valid ? 'v ok' : 'v warn',
'输入母线 VCC(PA6 分压反推)· vin_adc='
+ (Number.isFinite(ga.vin_adc) ? ga.vin_adc.toFixed(3) + 'V' : '--'));
} else {
setMetricUi(mVccEl, '--', 'v', '无 VIN 采样');
}
if (Number.isFinite(ga.vdda) && ga.vdda > 0.5) {
setMetricUi(mVddaEl, ga.vdda.toFixed(2) + 'V', ga.valid ? 'v ok' : 'v warn',
'MCU 模拟供电 VDDA(片内 VREFINT 反推)');
} else {
setMetricUi(mVddaEl, '--', 'v', '无 VDDA 采样');
}
} else if (ga && Number.isFinite(ga.temperature)) {
setMetricUi(mGd32TempEl, ga.temperature.toFixed(1) + '\u00B0C (旧)', 'v warn', '超过 5s 未更新');
var vccOld = Number.isFinite(ga.vcc) ? ga.vcc
: (Number.isFinite(ga.vin_adc) ? ga.vin_adc * 11.0 : NaN);
setMetricUi(mVccEl, Number.isFinite(vccOld) ? (vccOld.toFixed(1) + 'V (旧)') : '--',
'v warn', '超过 5s 未更新');
setMetricUi(mVddaEl,
Number.isFinite(ga.vdda) ? (ga.vdda.toFixed(2) + 'V (旧)') : '--',
'v warn', '超过 5s 未更新');
} else if (ga && ga.enabled === true) {
var waitTitle = 'GD32 已开 0xC4,等待首帧';
setMetricUi(mGd32TempEl, '等待…', 'v', waitTitle);
setMetricUi(mVccEl, '等待…', 'v', waitTitle);
setMetricUi(mVddaEl, '等待…', 'v', waitTitle);
} else {
var idleTitle = feat && feat.known === false
? '尚未 HELLO 身份或旧 GD32(无 feat_map)'
: '等待 HELLO / 0xC4';
setMetricUi(mGd32TempEl, '--', 'v', idleTitle);
setMetricUi(mVccEl, '--', 'v', idleTitle);
setMetricUi(mVddaEl, '--', 'v', idleTitle);
}
}
var uptimeEl = document.getElementById('pillUptime');
if (uptimeEl) setNodeText(uptimeEl, '\u23F1 ' + formatUptime(data.uptime_sec));
var pillIPEl = document.getElementById('pillIP');
if (pillIPEl) setNodeText(pillIPEl, wifi.ip || '--');
var rssi = (wifi.rssi !== undefined && wifi.rssi !== -127) ? wifi.rssi + ' dBm' : '--';
var pillRSSIEl = document.getElementById('pillRSSI');
if (pillRSSIEl) setNodeText(pillRSSIEl, rssi);
}
/**
* GD32 OTA 会话 UI:阶段节点 + 进度条 + 完成/失败。
* 门控逻辑保持与历史实现一致,勿随意放宽完成条件。
* @param {object} ota data.ota
*/
function applyGd32OtaSessionUi(ota) {
var rs = ota.relay_state;
var sysState = ota.sys_state || '';
var sysStateCode = Number.isFinite(ota.sys_state_code) ? ota.sys_state_code : -1;
var isGd32Relaying = sysState === 'GD32_RELAYING' || sysStateCode === 3;
var isGd32Complete = sysState === 'GD32_COMPLETE' || sysStateCode === 4;
var isGd32Error = sysState === 'ERROR' || sysStateCode === 8 || rs === 255;
/* 进度以 relay_seq/relay_total 为准;不再用 stored_size 做「OTA 缓存」展示 */
var relaySeq = ota.relay_seq || 0;
var relayTotal = ota.relay_total_packets || 0;
var relayProgress = relayTotal > 0 ? Math.min(100, Math.round(relaySeq * 100 / relayTotal)) : 0;
var gd32AppAlive = ota.gd32_app_alive;
/* ★ FAILED 守卫:禁止后端状态覆盖失败 UI;设备回 IDLE 才自动复位 */
if (window.otaSessionState === OtaSessionState.FAILED) {
var deviceIdle = (sysStateCode === 0 || sysStateCode === 2 || sysStateCode === 4) &&
(rs === 0 || rs === 5);
if (!deviceIdle) {
if ((rs === 3 || isGd32Relaying) && relayTotal > 0) {
setActivePhase('gd32Ota');
var failRelayOverall = 60 + Math.round(relayProgress * 0.30);
setProgress(failRelayOverall,
'已转发 ' + relaySeq + ' / ' + relayTotal + ' 包(设备仍在跑)');
setDisplayedRelayState(3);
} else {
setDisplayedRelayState(255);
}
return;
}
otaSessionReset();
window.stopRelayProgressWatch();
/* 落到下方正常分支刷新 UI */
}
var displayRelayState = isGd32Complete ? 5 : (isGd32Relaying && rs === 0 ? 1 : rs);
if (window.currentOtaTarget !== 'esp32') {
setDisplayedRelayState(displayRelayState);
}
var gd32Session =
window.otaSessionState === OtaSessionState.RELAYING ||
window.currentOtaTarget === 'gd32' ||
window.otaSessionTarget === 'gd32';
if (isGd32Error && gd32Session) {
var failDetail = 'GD32 OTA 失败';
if (relayTotal > 0) {
failDetail += '(已转发 ' + relaySeq + '/' + relayTotal +
',relay_state=' + rs + ',sys=' + (sysState || sysStateCode) + ')';
} else {
failDetail += '(relay_state=' + rs + ',sys=' + (sysState || sysStateCode) + ')';
}
window.cleanupAfterOtaFailure(failDetail);
window.pollLogs();
return;
}
if (isGd32Error) {
setDisplayedRelayState(255);
var sbErr = document.getElementById('stageStatusBar');
var stErr = document.getElementById('stageStatusText');
if (sbErr) sbErr.className = 'stage-status-bar err';
if (stErr) stErr.textContent = 'GD32 错误(待机)';
return;
}
if (window.otaSessionState === OtaSessionState.UPLOADING) {
/* 上传进行中:不要用旧缓存/旧完成态覆盖进度条 */
return;
}
if (window.otaSessionState === OtaSessionState.RELAYING) {
/* 必须先看到本会话真实中继,才允许 DONE */
if (isGd32Relaying || (rs >= 1 && rs <= 4)) {
window.gd32RelaySessionLive = true;
}
var deviceIdleAfterRelay =
(sysStateCode === 0 || sysStateCode === 2) &&
(rs === 0 || rs === 5) &&
window.gd32RelaySessionLive &&
!window.gd32CompleteAnnounced;
if (deviceIdleAfterRelay) {
window.cleanupAfterOtaFailure(
'GD32 中继已结束但未成功(设备已回 IDLE' +
(relayTotal > 0 ? ',已转发 ' + relaySeq + '/' + relayTotal : '') + ')'
);
window.pollLogs();
return;
}
/*
* 包进度/完成:统一走 emitRelayProgress + markGd32OtaUiComplete。
* FINISHING 时 force100 后只改文案,禁止 setProgress(95) 回跳。
*/
if (window.gd32RelaySessionLive && relayTotal > 0 &&
(relaySeq >= relayTotal || rs >= 4 || isGd32Complete)) {
if (typeof window.emitRelayProgress === 'function') {
window.emitRelayProgress(relayTotal, relayTotal, { force100: true });
}
} else if (window.gd32RelaySessionLive && relayTotal > 0 && relaySeq > 0 &&
typeof window.emitRelayProgress === 'function') {
window.emitRelayProgress(relaySeq, relayTotal);
}
if (window.gd32RelaySessionLive &&
(isGd32Complete || rs === 5 ||
(gd32AppAlive && relayTotal > 0 && relaySeq >= relayTotal && rs >= 3))) {
if (!window.gd32CompleteAnnounced) {
window.markGd32OtaUiComplete('GD32 刷写完成');
}
return;
}
if (rs === 1) {
setActivePhase('gd32Ota');
setProgress(50, '握手与认证中...');
} else if (rs === 4) {
/* 收尾:保持 100%,只更新文案(避免 100→95 回跳) */
setActivePhase('gd32Ota');
if (!window.gd32CompleteAnnounced) {
setProgress(100, gd32AppAlive ? '收尾中…' : '收尾中,等待 APP 启动...');
}
} else if (rs === 3 && relaySeq <= 0) {
setActivePhase('gd32Ota');
setProgress(60, '数据包传输中...');
} else if (rs === 2 || (isGd32Relaying && relaySeq === 0)) {
setActivePhase('gd32Ota');
setProgress(55, relayTotal > 0
? ('大小协商中(共 ' + relayTotal + ' 包)')
: '固件大小协商中');
} else if (!window.gd32RelaySessionLive) {
setActivePhase('gd32Ota');
setProgress(48, '已触发中继,等待设备进入握手…');
}
return;
}
/* 不再用 stored_size 做「OTA 缓存」展示;上传走 XHR,中继走 relay_seq。 */
}
/**
* /system/status → UI 总入口。
* @param {object} data
*/
export function updateSystemStatusUi(data) {
if (!data) return;
window.latestSystemStatus = data;
var wifi = data.wifi || {};
var ota = data.ota || {};
applyDashboardUi(data, ota, wifi);
/* BLE:仪表盘可更新,进度/阶段由 ble 路径独占 */
if (window.connectionMode === 'ble') {
return;
}
/* ESP32 OTA 进行中:勿用 GD32 relay 覆盖阶段条 */
if (window.currentOtaTarget === 'esp32' && window.esp32OtaPhase > 0) {
return;
}
applyGd32OtaSessionUi(ota);
}
+614
View File
@@ -0,0 +1,614 @@
/**
* upload-esp32.js - ESP32 本机 OTA 上传流程 + 完成升级
*
* 包含:startUpload 入口(处理 esp32 + gd32 共用互斥/重读逻辑)、
* XHR 上传、SHA 校验、verifyEsp32Firmware、restartEsp32。
*
* 共享状态(通过 window.* 引用):
* window.esp32File / window.gd32File / window.esp32FileHandle / window.gd32FileHandle
* window._esp32FileSha256 / window._gd32FileSha256 / window.activeXhr
* window.isUploading / window.uploadBtnOrigText / window.activeUploadChip
*
* 复用的函数(通过 window.* 引用):
* window.otaSessionActive / window.otaSessionTerminated / window.otaSessionReset
* window.otaSessionTransition / window.OtaSessionState / window.otaSessionTarget
* window.cleanupAfterOtaFailure / window.pausePollingForUpload
* window.resumePollingAfterUpload / window.updateEsp32StagesFromPhase
* window.switchStageFlow / window.setActivePhase / window.setProgress
* window.markPhaseDone / window.startUploadWaitHeartbeat / window.stopUploadWaitHeartbeat
* window.updateUploadButtonLabel / window.processFile / window.clearFile
* window.triggerGd32Relay / window.rebootObservedDown / window.rebootStartTime
* window.isEsp32Rebooting / window.getBaseUrl / window.formatBytes
* window.showToast / window.addLog
*/
import { formatBytes } from '../common/utils.js';
import { showToast } from '../common/toast.js';
import { addLog } from '../common/log.js';
import {
otaSessionActive, otaSessionTerminated, otaSessionReset,
otaSessionTransition, OtaSessionState, stopUploadWaitHeartbeat,
startUploadWaitHeartbeat
} from './session.js';
import { updateEsp32StagesFromPhase, switchStageFlow, setActivePhase, setProgress, markPhaseDone, cleanupAfterOtaFailure } from './progress.js';
import { computeFileSha } from './file-handle.js';
function isOtaBusyError(status, detail) {
var text = (detail || '').toLowerCase();
return status === 403 ||
status === 409 ||
status === 423 ||
text.indexOf('busy') >= 0 ||
text.indexOf('occupied') >= 0 ||
text.indexOf('another ota') >= 0 ||
text.indexOf('invalid state') >= 0 ||
text.indexOf('占用') >= 0 ||
text.indexOf('互斥') >= 0 ||
text.indexOf('系统忙') >= 0;
}
function formatHttpErrorDetail(text) {
var detail = (text || '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
if (detail.length > 180) detail = detail.slice(0, 177) + '...';
return detail;
}
/** @description 启动 ESP32 本机 OTA 上传流程(互斥获取→分块上传→SHA验证→重启)。 */
export async function startUpload(chip) {
var file = chip === 'esp32' ? window.esp32File : window.gd32File;
if (!file) return;
// ★ 状态机守卫:用统一的会话活跃判断替代裸 isUploading。
// FAILED/DONE 会话需要先复位才能发起新上传(下方 otaSessionReset)。
// RELAYING 可能因 ERROR 被设备软清为 IDLE 而前端未收尾,先探测再决定。
if (otaSessionActive()) {
var busyState = window.otaSessionState;
if (busyState === OtaSessionState.RELAYING || busyState === OtaSessionState.VERIFYING) {
try {
var staleProbe = await fetch(window.getBaseUrl() + '/system/status', {
signal: AbortSignal.timeout(2000)
});
if (staleProbe.ok) {
var staleSt = await staleProbe.json();
var so = staleSt.ota || {};
var ssc0 = Number.isFinite(so.sys_state_code) ? so.sys_state_code : -1;
var rs0 = Number.isFinite(so.relay_state) ? so.relay_state : -1;
/* 设备已空闲/完成/错误残留:前端会话是陈旧活跃态,强制清掉 */
var deviceFree = (ssc0 === 0 || ssc0 === 2 || ssc0 === 4 || ssc0 === 8) &&
(rs0 === 0 || rs0 === 5 || rs0 === 255 || rs0 < 0);
if (deviceFree) {
addLog('W', '上传', '检测到陈旧会话 ' + busyState +
'(设备已 ' + (so.sys_state || ssc0) + '),自动清理后继续');
otaSessionReset();
}
}
} catch (_) { /* 探测失败则仍按占用拒绝 */ }
}
}
if (otaSessionActive()) {
var busyChip = (window.otaSessionTarget || window.activeUploadChip || window.flashTarget || chip || 'ota').toUpperCase();
addLog('W', '上传', '已有 ' + busyChip + ' OTA 会话进行中 (' + window.otaSessionState + '),拒绝新的 ' + chip.toUpperCase() + ' 上传');
showToast('OTA 通道正在占用,请等待当前刷写结束', 'error');
return;
}
// 上一次会话已终结(DONE/FAILED):发起新会话前先复位状态机
if (otaSessionTerminated()) otaSessionReset();
// 记录本会话目标
window.otaSessionTarget = chip;
window.pausePollingForUpload();
/* 先进入 UPLOADING(同步 isUploading),再 await 读文件,防止重复提交 */
otaSessionTransition(OtaSessionState.UPLOADING);
window.activeUploadChip = chip;
/* 新上传:清掉上次完成标志,避免立刻被 status 残留判成“又完成了” */
window.gd32CompleteAnnounced = false;
window.relayWatchLastSeq = -1;
window.gd32RelaySessionLive = false;
if (chip === 'esp32') {
updateEsp32StagesFromPhase(1);
}
// 跨通道互斥前置检查(含 BLE 取消后的瞬态重试)
var _probeReady = false;
for (var _pi = 0; _pi < 4; _pi++) {
if (_pi > 0) {
var _pd = [1500, 3000, 3000][_pi - 1];
addLog('I', '上传', '等待设备清理 OTA 状态 (' + _pd + 'ms)...');
await new Promise(function(r) { setTimeout(r, _pd); });
}
try {
var probe = await fetch(window.getBaseUrl() + '/system/status', {
signal: AbortSignal.timeout(2000)
});
if (probe.ok) {
var st = await probe.json();
var ota = st.ota || {};
var ssc = Number.isFinite(ota.sys_state_code) ? ota.sys_state_code : -1;
var otaChannel = Number.isFinite(ota.channel) ? ota.channel : -1;
var otaChannelName = ota.channel_name || (otaChannel === 1 ? 'BLE' : 'WIFI');
// ESP32 RECEIVING(5) 可由同一 WiFi 通道的新上传接管;后台/休眠可能让 /ota/update 没发出,
// 若这里提前拦截,后端 beginEsp32OtaWithRetry 的同通道清理分支永远没有机会执行。
// ERROR(8) 放行给后端清理;REBOOTING(7) 不可逆。
// FINALIZING(6):真收尾时后端会 403;SHA 失败后可能假占用(core 已空),
// status 自愈 / begin 残留清理需要请求触达设备,前端同通道不硬拦。
if ((ssc === 5 || ssc === 6) && otaChannel === 1 && _pi < 3) continue;
var esp32Busy = (ssc === 7 ||
(ssc === 6 && otaChannel !== 0) ||
(ssc === 5 && otaChannel !== 0));
// GD32 STORING(1) 可由同一 WiFi 通道的新上传接管;RELAYING(3) 不可中断。
var gd32Busy = (ssc === 3 || (ssc === 1 && otaChannel !== 0));
if ((esp32Busy || gd32Busy)) {
addLog('E', '上传', '设备已有 OTA 进行中 (sys_state=' + (ota.sys_state || ssc) + ', channel=' + otaChannelName + '),拒绝本次 ' + chip.toUpperCase() + ' 上传');
showToast('设备正在 OTA 中,请等待结束后再试', 'error');
// ★ 状态机:预检失败也需完整清理(转入 FAILED)
cleanupAfterOtaFailure('设备 OTA 占用中');
return;
}
}
_probeReady = true;
break;
} catch (e) {
// 探测失败:可能是设备瞬时忙或网络问题。保守继续走,由后端 403 兜底。
addLog('W', '上传', '跨通道互斥状态探测失败: ' + e.message + '(继续尝试,由设备端兜底)');
_probeReady = true;
break;
}
}
// 热重载:有 FSAPI handle 则重新读盘(本地编译会改同路径固件)
var handle = chip === 'esp32' ? window.esp32FileHandle : window.gd32FileHandle;
if (handle) {
try {
var freshFile = await handle.getFile();
if (chip === 'gd32' && freshFile.name.toLowerCase().endsWith('.hex')) {
/* HEX 必须按最新源文件再转 BIN,不能用选文件时的旧 prepared */
await window.processFile(freshFile, 'gd32', { silent: true, skipHandlePersist: true });
file = window.gd32File;
} else {
if (chip === 'esp32') window.esp32File = freshFile;
else window.gd32File = freshFile;
file = freshFile;
/* .bin 直读分支此前无任何日志,热重载是否生效不可观察,补一条 */
addLog('I', '文件', '热重载: 已重新读取 ' + freshFile.name +
' (' + formatBytes(freshFile.size) + ')');
}
} catch (e) {
addLog('W', '文件', '重新读取文件失败: ' + e.message + ',将使用缓存版本');
}
} else {
/* 无 FSAPI 句柄(拖拽/传统方式选择):无法读盘,只能用缓存。
GD32 缓存是内存中转换出的 Blob,arrayBuffer 校验永远通过,
若不显式告知,本地重新编译后点上传会静默刷入旧固件 */
var cachedAt = file.lastModified
? new Date(file.lastModified).toLocaleTimeString('zh-CN', { hour12: false })
: '未知时间';
addLog('W', '文件', '热重载不可用(无 FSAPI 句柄):本次上传缓存文件 ' + file.name +
'(磁盘修改于 ' + cachedAt + ')。如需自动读取最新编译产物,请点击上传区重新选择');
showToast('热重载不可用,上传的是缓存文件', 'warning');
try {
await file.arrayBuffer();
} catch (e) {
addLog('E', '文件', '本地文件已变更或失效,请重新选择文件');
showToast('文件已变更,请重新选择', 'error');
window.clearFile(chip);
cleanupAfterOtaFailure('本地文件失效');
return;
}
}
var uploadBtn = document.getElementById(chip === 'esp32' ? 'uploadEsp32Btn' : 'uploadGd32Btn');
window.uploadBtnOrigText = uploadBtn.textContent;
uploadBtn.disabled = true;
uploadBtn.textContent = '上传中...';
switchStageFlow(chip);
setActivePhase(chip === 'esp32' ? 'esp32Ota' : 'gd32Ota');
if (chip === 'esp32') updateEsp32StagesFromPhase(1);
// Reset verification panel
var verifyInfo = document.getElementById('stageVerifyInfo');
if (verifyInfo) verifyInfo.style.display = 'none';
var sviFront = document.getElementById('sviFrontSha');
var sviDevice = document.getElementById('sviDeviceSha');
var sviResult = document.getElementById('sviResult');
if (sviFront) sviFront.textContent = '-';
if (sviDevice) sviDevice.textContent = '-';
if (sviResult) { sviResult.textContent = '-'; sviResult.className = 'svi-val'; }
addLog('I', '上传', '开始上传: ' + file.name + ' \u2192 ' + chip.toUpperCase());
showToast('开始上传固件到 ' + chip.toUpperCase(), 'success');
try {
var arrayBuffer = await file.arrayBuffer();
/* 上传前始终按本次内容重算 SHA(本地固件会改,不能沿用选文件时的旧值) */
var savedSha = await computeFileSha(file, chip, arrayBuffer);
if (!savedSha) {
addLog('E', '文件', chip.toUpperCase() + ' SHA-256 计算失败');
showToast('SHA-256 计算失败', 'error');
cleanupAfterOtaFailure('SHA-256 计算失败');
return;
}
var fileSize = file.size;
var body, url = window.getBaseUrl() + '/ota/upload';
if (chip === 'esp32') {
var headerObj = { type: 'esp32', total_size: fileSize };
var payload = arrayBuffer;
addLog('I', '上传', '直传: ' + formatBytes(fileSize) +
'(设备边收边写 Flash;进度 100% 后仍需等 HTTP 响应)');
setProgress(1, '连接设备并开始写入…');
var headerJson = JSON.stringify(headerObj);
var encoder = new TextEncoder();
var headerBytes = encoder.encode(headerJson);
var combined = new Uint8Array(headerBytes.length + payload.byteLength);
combined.set(headerBytes, 0);
combined.set(new Uint8Array(payload), headerBytes.length);
body = combined.buffer;
} else {
var headerObj = { total_size: fileSize };
var payload = arrayBuffer;
addLog('I', '上传', '直传: ' + formatBytes(fileSize));
var gd32Header = JSON.stringify(headerObj);
var encoder = new TextEncoder();
var headerBytes = encoder.encode(gd32Header);
var combined = new Uint8Array(headerBytes.length + payload.byteLength);
combined.set(headerBytes, 0);
combined.set(new Uint8Array(payload), headerBytes.length);
body = combined.buffer;
}
window.activeXhr = new XMLHttpRequest();
var xhr = window.activeXhr;
xhr.upload.addEventListener('progress', function (e) {
if (e.lengthComputable) {
var pct = Math.round((e.loaded / e.total) * 100);
window.uploadProgress = pct;
if (chip === 'gd32') {
var overallPct = Math.min(45, Math.round(pct * 0.45));
setProgress(overallPct, '已上传 ' + formatBytes(e.loaded) + ' / ' + formatBytes(e.total));
} else if (pct >= 100) {
setProgress(100, '数据已发完,等待设备写 Flash 并响应…');
} else {
setProgress(pct, '已上传 ' + formatBytes(e.loaded) + ' / ' + formatBytes(e.total));
}
}
});
xhr.upload.addEventListener('load', function () {
// 请求体发送完毕 ≠ 服务端处理完毕
setProgress(100, '数据已发完,等待设备写 Flash 并响应…');
addLog('I', '上传', '浏览器已发完全部数据,等待 ESP32 HTTP 响应…');
startUploadWaitHeartbeat();
});
// 1MB+ 固件:服务端写 Flash 可能较久;禁止无限挂起无反馈
xhr.timeout = 300000;
xhr.addEventListener('load', function () {
stopUploadWaitHeartbeat();
/* 成功路径摘掉 activeXhr;失败路径留给 cleanupAfterOtaFailure 统一摘掉并 abort 幂等 */
if (window.activeXhr === xhr) window.activeXhr = null;
if (xhr.status === 200) {
/* 校验 body:避免空响应/网关误 200 被当成上传成功 */
var bodyTxt = (xhr.responseText || '').trim();
var bodyOk = false;
try {
var bj = bodyTxt ? JSON.parse(bodyTxt) : null;
bodyOk = !!(bj && (bj.status === 'ok' || bj.target === chip));
} catch (_pe) {
bodyOk = bodyTxt.indexOf('"status"') >= 0 && bodyTxt.indexOf('ok') >= 0;
}
if (!bodyOk) {
addLog('E', '上传', 'HTTP 200 但响应异常: ' + (bodyTxt.slice(0, 120) || '(空)'));
showToast('上传响应异常,请重试', 'error');
cleanupAfterOtaFailure('响应异常');
return;
}
/* 成功:恢复轮询/按钮(会话仍 VERIFYING/RELAYING,isUploading 由 transition 清) */
window.resumePollingAfterUpload();
uploadBtn.disabled = false;
uploadBtn.textContent = window.uploadBtnOrigText;
window.updateUploadButtonLabel();
// 成功路径只打一条「上传完成」日志,避免 [上传]+[OTA] 语义重复
if (chip === 'esp32') {
addLog('I', 'OTA', '上传完成,开始 SHA-256 校验…');
showToast('固件上传成功', 'success');
setProgress(100, '上传完成');
markPhaseDone('esp32Ota');
updateEsp32StagesFromPhase(2); // OTA 写入阶段
// ★ 状态机:ESP32 上传完成 → 进入 VERIFYING(verifyEsp32Firmware 内部继续推进)
otaSessionTransition(OtaSessionState.VERIFYING);
verifyEsp32Firmware();
} else {
addLog('I', 'OTA', '上传完成,校验 SHA 并触发 GD32 中继…');
showToast('上传完成,GD32 OTA 已自动开始', 'success');
switchStageFlow('gd32');
setActivePhase('gd32Ota');
setProgress(45, '上传完成,校验并触发 GD32 OTA…');
// ★ 状态机:GD32 上传完成 → 进入 RELAYING(triggerGd32Relay 内部驱动中继)
otaSessionTransition(OtaSessionState.RELAYING);
window.triggerGd32Relay();
}
} else if (isOtaBusyError(xhr.status, formatHttpErrorDetail(xhr.responseText))) {
var busyDetail = formatHttpErrorDetail(xhr.responseText);
addLog('E', '上传', chip.toUpperCase() + ' OTA 被其他通道占用(BLE/WiFi 互斥)' + (busyDetail ? ': ' + busyDetail : ''));
showToast('OTA 通道被占用,请稍后重试', 'error');
cleanupAfterOtaFailure('通道被占用');
} else {
var detail = formatHttpErrorDetail(xhr.responseText);
var message = '上传失败: HTTP ' + xhr.status + (detail ? ' - ' + detail : '');
addLog('E', '上传', message);
showToast(detail ? ('上传失败: ' + detail) : '上传失败', 'error');
cleanupAfterOtaFailure('HTTP ' + xhr.status);
}
});
xhr.addEventListener('error', function () {
stopUploadWaitHeartbeat();
// cleanup 会先摘 activeXhr 再 abort;仅当本 xhr 仍是活动请求或会话仍在 UPLOADING 时清理
if (window.activeXhr === xhr || window.otaSessionState === OtaSessionState.UPLOADING) {
showToast('上传过程发生网络错误', 'error');
cleanupAfterOtaFailure('网络错误');
}
});
xhr.addEventListener('abort', function () {
stopUploadWaitHeartbeat();
// clearOtaSessionResiduals 先置 activeXhr=null 再 abort → 此处勿二次 cleanup
if (window.activeXhr === xhr) {
showToast('上传已中止', 'error');
cleanupAfterOtaFailure('已中止');
}
});
xhr.addEventListener('timeout', function () {
stopUploadWaitHeartbeat();
if (window.activeXhr === xhr || window.otaSessionState === OtaSessionState.UPLOADING) {
showToast('上传超时:设备可能忙或连接被踢,请重试', 'error');
cleanupAfterOtaFailure('上传超时(设备未在 5 分钟内响应)');
}
});
xhr.open('POST', url);
xhr.send(body);
} catch (e) {
addLog('E', '上传', '上传失败: ' + e.message);
showToast('上传失败: ' + e.message, 'error');
cleanupAfterOtaFailure('上传异常');
}
}
// ==================== ESP32 OTA 完成升级 ====================
// 一次调用完成:校验 SHA-256 → 设置启动分区 → 自动重启(无需确认按钮)
//
// 竞态说明(易误报“应用失败”):
// 1) finalize 成功 → 回 JSON ok → 100ms 后 esp_restart
// 2) 响应丢失 / 浏览器只收到半截 / 请求迟到打到已重启的新固件
// → 正文可能是明文或 JSON 的 "OTA not started"
// 3) 此时 /system/status 已是 IDLE,uptime 往往很小
// 旧逻辑:resp.json() 抛错 → 探测到 IDLE → cleanupAfterOtaFailure
// 正确:上传已成功的 VERIFYING 会话里,优先按「已应用并重启」推进 REBOOTING。
/** @description 进入 ESP32 重启等待(校验成功或推断已应用)。 */
function enterEsp32RebootingUi(reason, opts) {
opts = opts || {};
var resultEl = document.getElementById('sviResult');
if (resultEl && !opts.keepResult) {
resultEl.textContent = opts.resultText || '已应用,等待重启';
resultEl.className = 'svi-val ok';
}
addLog('I', 'OTA', reason || '设备重启中…');
showToast(opts.toast || '设备重启中…', 'success');
updateEsp32StagesFromPhase(4);
updateEsp32StagesFromPhase(5);
window.rebootObservedDown = !!opts.observedDown;
window.rebootStartTime = Date.now();
otaSessionTransition(OtaSessionState.REBOOTING);
}
/**
* /ota/update 结果不明确时探测设备真实状态。
* @param {string} detail 原始错误摘要
* @param {{notStarted?: boolean}} flags
*/
async function resolveEsp32VerifyAmbiguous(detail, flags) {
flags = flags || {};
addLog('W', 'OTA', '应用结果不明确: ' + (detail || 'unknown') + ',探测设备状态…');
var sawOffline = false;
var delays = [0, 800, 1500, 2500, 4000];
for (var i = 0; i < delays.length; i++) {
if (delays[i] > 0) {
await new Promise(function (r) { setTimeout(r, delays[i]); });
}
try {
var probe = await fetch(window.getBaseUrl() + '/system/status', {
signal: AbortSignal.timeout(4000)
});
if (!probe.ok) {
sawOffline = true;
continue;
}
var st = await probe.json();
var o = st.ota || {};
var ssc = Number.isFinite(o.sys_state_code) ? o.sys_state_code : -1;
var uptime = Number(st.uptime_sec);
if (!Number.isFinite(uptime)) uptime = -1;
// 7=REBOOTING
if (ssc === 7) {
enterEsp32RebootingUi('探测到设备 REBOOTING,按重启中处理', {
toast: '设备正在重启…',
observedDown: false
});
return;
}
// 5=RECEIVING / 6=FINALIZING:仍在收尾,再等几轮
if (ssc === 5 || ssc === 6) {
addLog('I', 'OTA', '设备仍在 ESP32 OTA (sys=' + ssc + '),继续等待…');
continue;
}
// IDLE/其它:上传本会话已成功过;短 uptime 或曾离线 → 几乎一定是已 finalize 并重启
// 「OTA not started」在 VERIFYING 上下文下同理(新固件无会话)。
var freshBoot = uptime >= 0 && uptime < 120;
if (flags.notStarted || freshBoot || sawOffline) {
enterEsp32RebootingUi(
flags.notStarted
? ('设备回报 OTA not started(uptime=' + uptime +
's)— 上传已完成,按已应用并重启处理')
: ('探测到设备已在线 (sys=' + ssc + ', uptime=' + uptime +
's) — 按已应用并重启处理'),
{
toast: '固件可能已应用,等待启动完成…',
observedDown: sawOffline,
resultText: '已应用(响应未完整返回,按重启完成确认)'
}
);
return;
}
// 长 uptime + IDLE 且非 notStarted:更像请求失败、设备从未进入 finalize
addLog('E', 'OTA', '应用请求失败且设备非重启态 (sys=' + ssc +
', uptime=' + uptime + 's): ' + (detail || ''));
var resultElFail = document.getElementById('sviResult');
if (resultElFail) {
resultElFail.textContent = '✗ 请求失败: ' + (detail || 'unknown');
resultElFail.className = 'svi-val err';
}
showToast('应用请求失败: ' + (detail || 'unknown'), 'error');
cleanupAfterOtaFailure('应用请求失败');
return;
} catch (_probeErr) {
sawOffline = true;
}
}
// 多轮无响应,或一直停在 FINALIZING
if (sawOffline) {
enterEsp32RebootingUi('设备无响应,按重启中观察(可能 TCP 被 esp_restart 掐断)', {
toast: '设备无响应,等待重启恢复…',
observedDown: true,
resultText: '等待设备重启…'
});
return;
}
addLog('E', 'OTA', '应用阶段设备长时间仍忙或状态不明: ' + (detail || ''));
var resultElBusy = document.getElementById('sviResult');
if (resultElBusy) {
resultElBusy.textContent = '✗ 请求中断,设备仍忙';
resultElBusy.className = 'svi-val err';
}
cleanupAfterOtaFailure('应用请求中断(设备仍忙)');
}
/** @description 验证 ESP32 固件 SHA-256 是否与设备端计算值一致。 */
export async function verifyEsp32Firmware() {
updateEsp32StagesFromPhase(3); // 校验中
var sha256 = window._esp32FileSha256;
if (sha256) {
addLog('I', 'OTA', '前端文件 SHA-256: ' + sha256);
var verifyInfo = document.getElementById('stageVerifyInfo');
if (verifyInfo) verifyInfo.style.display = 'block';
var frontSha = document.getElementById('sviFrontSha');
if (frontSha) frontSha.textContent = sha256;
}
showToast('ESP32 正在校验固件并应用...', 'success');
try {
// 带 sha256 时设备端校验、set_boot 并自动重启;无需二次“应用”请求
var requestBody = {};
if (sha256) requestBody.sha256 = sha256;
var resp = await fetch(window.getBaseUrl() + '/ota/update', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody),
signal: AbortSignal.timeout(120000)
});
// 禁止裸 resp.json():失败体可能是 httpd 明文或半截响应
var bodyTxt = '';
try {
bodyTxt = await resp.text();
} catch (te) {
await resolveEsp32VerifyAmbiguous('读取响应失败: ' + te.message, {});
return;
}
var data = null;
if (bodyTxt && bodyTxt.trim()) {
try {
data = JSON.parse(bodyTxt);
} catch (_pe) {
data = null;
}
}
var notStarted =
(data && (data.code === 'ota_not_started' ||
/OTA not started/i.test(String(data.message || '')))) ||
/OTA not started/i.test(bodyTxt || '');
if (resp.ok && data && data.status === 'ok') {
addLog('I', 'OTA', '设备端 SHA-256: ' + (data.sha256 || '-'));
if (data.sha256) {
var deviceSha = document.getElementById('sviDeviceSha');
if (deviceSha) deviceSha.textContent = data.sha256;
}
var resultEl = document.getElementById('sviResult');
if (resultEl) {
if (sha256 && data.sha256 && sha256.toLowerCase() === data.sha256.toLowerCase()) {
resultEl.textContent = '验证通过';
resultEl.className = 'svi-val ok';
} else {
resultEl.textContent = '已应用(未比对 SHA)';
resultEl.className = 'svi-val ok';
}
}
enterEsp32RebootingUi('SHA-256 校验通过,设备自动重启中...', {
toast: '校验通过,设备重启中...',
keepResult: true,
observedDown: false
});
return;
}
// SHA 明确不匹配
if (data && (data.status === 'error' || !resp.ok) &&
/mismatch|不匹配/i.test(String(data.message || bodyTxt || ''))) {
var detail = data.message || 'SHA-256 不匹配';
addLog('E', 'OTA', '校验失败: ' + detail);
showToast('校验失败: ' + detail, 'error');
if (data.device) {
var dSha = document.getElementById('sviDeviceSha');
if (dSha) dSha.textContent = data.device;
}
var resultElMm = document.getElementById('sviResult');
if (resultElMm) {
resultElMm.textContent = '✗ ' + detail;
resultElMm.className = 'svi-val err';
}
cleanupAfterOtaFailure('校验失败');
return;
}
// 迟到请求打到已重启固件 / 非 JSON 响应:勿当硬失败
if (notStarted) {
await resolveEsp32VerifyAmbiguous(
bodyTxt.slice(0, 120) || 'OTA not started',
{ notStarted: true }
);
return;
}
var failDetail = (data && data.message) ||
formatHttpErrorDetail(bodyTxt) ||
('HTTP ' + resp.status);
await resolveEsp32VerifyAmbiguous(failDetail, {});
} catch (e) {
// fetch 异常 ≠ 一定失败:finalize 成功后 esp_restart 会掐断 TCP
await resolveEsp32VerifyAmbiguous(e && e.message ? e.message : String(e), {});
}
}
export async function restartEsp32() {
if (!confirm('确定要重启 ESP32 吗?')) return;
addLog('I', '系统', '=== 请求重启 ESP32 ===');
showToast('ESP32 正在重启...', 'success');
try {
var resp = await fetch(window.getBaseUrl() + '/restart', {
method: 'POST', signal: AbortSignal.timeout(5000)
});
if (resp.ok) {
var data = await resp.json();
addLog('I', '系统', '重启已触发: ' + data.message);
showToast('ESP32 重启中,页面将自动恢复...', 'success');
window.isEsp32Rebooting = true;
window.rebootObservedDown = false; // 重置:尚未观察到设备实际下线
window.rebootStartTime = Date.now();
}
} catch (e) {
addLog('E', '系统', '重启请求失败: ' + e.message);
showToast('重启失败', 'error');
}
}
+218
View File
@@ -0,0 +1,218 @@
/**
* upload-gd32.js - GD32 中继 OTA 触发 + 进度辅助
*
* 进度/完成/失败 UI 的唯一应用路径:pollStatus → updateSystemStatusUi(stages.js)。
* 本文件只负责:
* - 触发 /ota/relay
* - 中继期加速 status 轮询(400ms)
* - emitRelayProgress / markGd32OtaUiComplete 等被 stages 调用的辅助函数
*
* 共享状态(通过 window.* 引用):
* window.gd32CompleteAnnounced / window.relayWatchLastSeq / window.gd32RelaySessionLive
* window.pollingPausedForUpload / window.statusPollIntervalMs
* window.otaSessionState / window.lastSysStateCode / window.lastRelayState
*
* 复用的函数(通过 window.* 引用):
* window.otaSessionTransition / window.OtaSessionState
* window.markPhaseDone / window.setProgress / window.updateSystemStatusUi
* window.cleanupAfterOtaFailure / window.scheduleStatusPoll / window.pollLogs
* window.resumePollingAfterUpload / window.getBaseUrl / window.showToast / window.addLog
*/
import { showToast } from '../common/toast.js';
import { addLog } from '../common/log.js';
import {
otaSessionTransition, OtaSessionState
} from './session.js';
import { markPhaseDone, setProgress } from './progress.js';
/** 中继期 status 轮询间隔(ms);结束后由 stopRelayProgressWatch 恢复 1000 */
const RELAY_STATUS_POLL_MS = 400;
const NORMAL_STATUS_POLL_MS = 1000;
window.gd32CompleteAnnounced = false;
window.relayWatchLastSeq = -1;
/** 本会话是否已观察到真实中继态(1~4),避免旧 relay_state=5 残留导致一点击就“完成” */
window.gd32RelaySessionLive = false;
/**
* 包进度变更时更新进度条(同 seq 不重复)。
* 由 stages.js 在 status 轮询路径调用;勿在本文件再起独立定时器刷进度。
* @param {number} rseq
* @param {number} rtot
* @param {{force100?: boolean}} [opts]
* @returns {boolean} 是否实际输出了进度
*/
export function emitRelayProgress(rseq, rtot, opts) {
opts = opts || {};
rseq = Number(rseq) || 0;
rtot = Number(rtot) || 0;
if (rtot <= 0) return false;
if (opts.force100 || rseq >= rtot) {
rseq = rtot;
}
if (rseq <= 0) return false;
if (rseq === window.relayWatchLastSeq) return false;
window.relayWatchLastSeq = rseq;
var pct = Math.min(100, Math.round(rseq * 100 / rtot));
/* 进度只走进度条/阶段文案,不写调试日志(避免 ring/网页刷屏) */
setProgress(60 + Math.round(pct * 0.30),
'已转发 ' + rseq + ' / ' + rtot + ' 包 (' + pct + '%)');
return true;
}
/** 完成/收尾前:若尚未打出 100%,用 status 里的包数补一条 */
export function ensureRelayProgressComplete() {
var data = window.latestSystemStatus;
var o = (data && data.ota) || {};
var rtot = Number(o.relay_total_packets) || 0;
var rseq = Number(o.relay_seq) || 0;
if (rtot <= 0) return;
/* FINISHING/DONE 时后端 seq 已到 total;status 滞后时也强制 100% */
if (rseq < rtot && (Number(o.relay_state) >= 4 || Number(o.sys_state_code) === 4)) {
rseq = rtot;
}
if (rseq >= rtot) {
emitRelayProgress(rtot, rtot, { force100: true });
}
}
export function markGd32OtaUiComplete(msg) {
// 已公告过:禁止再改 className / setProgress / toast,否则 status 重触发动效会「疯狂跳 ✓」
if (window.gd32CompleteAnnounced) {
return;
}
/* 仅本会话 RELAYING/FAILED 可宣布完成;IDLE/UPLOADING 时 status 里的旧 relay 残留一律忽略 */
if (window.otaSessionState !== OtaSessionState.RELAYING &&
window.otaSessionState !== OtaSessionState.FAILED) {
return;
}
/* 先 100% 转发进度,再宣布刷写完成 —— 避免日志里「完成」早于「1089/1089」 */
ensureRelayProgressComplete();
window.gd32CompleteAnnounced = true;
stopRelayProgressWatch();
markPhaseDone('gd32Ota');
window.currentOtaTarget = null;
setProgress(100, msg || 'GD32 刷写完成');
var sbDone = document.getElementById('stageStatusBar');
var stDone = document.getElementById('stageStatusText');
if (sbDone) sbDone.className = 'stage-status-bar ok';
if (stDone) stDone.textContent = msg || 'GD32 刷写完成';
if (window.otaSessionState === OtaSessionState.RELAYING ||
window.otaSessionState === OtaSessionState.FAILED) {
otaSessionTransition(OtaSessionState.DONE);
}
if (typeof window.updateUploadButtonLabel === 'function') {
window.updateUploadButtonLabel();
}
/* 完成文案以设备 [I][OTA] GD32 OTA完成 为准,前端只 toast/进度,避免日志双份 */
showToast(msg || 'GD32 刷写完成', 'success');
}
/**
* 结束中继加速轮询,恢复常态 1s status 间隔。
* 不再持有独立 setInterval(进度/完成/失败统一由 stages 在 pollStatus 路径处理)。
*/
export function stopRelayProgressWatch() {
if (typeof window.setStatusPollIntervalMs === 'function') {
window.setStatusPollIntervalMs(NORMAL_STATUS_POLL_MS);
}
}
/**
* 中继进度跟踪:只加速 /system/status 轮询,不再另起 400ms 定时器。
* UI 真相源仍是 pollStatus → updateSystemStatusUi(stages.js)。
*/
export function startRelayProgressWatch() {
if (window.pollingPausedForUpload) {
window.resumePollingAfterUpload();
}
if (typeof window.setStatusPollIntervalMs === 'function') {
window.setStatusPollIntervalMs(RELAY_STATUS_POLL_MS);
}
if (typeof window.scheduleStatusPoll === 'function') {
window.scheduleStatusPoll(0);
}
if (typeof window.pollLogs === 'function') {
window.pollLogs();
}
}
/** @description 触发 GD32 中继 OTA 流程(通过 HTTP 接口)。 */
export async function triggerGd32Relay() {
var sha256 = window._gd32FileSha256;
if (!sha256) {
addLog('E', 'OTA', '缺少 GD32 文件 SHA-256,请先上传文件');
showToast('SHA-256 缺失', 'error');
window.cleanupAfterOtaFailure('SHA-256 缺失');
return;
}
addLog('I', 'OTA', '触发 GD32 中继…');
setProgress(48, '校验 SHA 并触发 GD32 中继…');
try {
// 仅触发中继(设备端异步跑),不阻塞到刷写结束;SHA 读分区可能数秒
var resp = await fetch(window.getBaseUrl() + '/ota/relay', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sha256: sha256 }),
signal: AbortSignal.timeout(120000)
});
var text = await resp.text();
if (resp.ok) {
addLog('I', 'OTA', 'GD32 OTA 已触发;进度看状态栏包数');
showToast('GD32 OTA 已触发', 'success');
setProgress(50, '中继已启动,同步进度…');
window.gd32CompleteAnnounced = false;
window.relayWatchLastSeq = -1;
startRelayProgressWatch();
} else {
// 尝试解析错误响应中的 device SHA / 身份校验错误
try {
var err = JSON.parse(text);
if (err.code === 'firmware_identity_mismatch') {
var idMsg = err.message || '固件身份校验失败,已拒绝刷写';
addLog('E', 'OTA', 'GD32 包身份校验失败: ' + idMsg);
showToast(idMsg, 'error');
window.cleanupAfterOtaFailure('固件身份不符');
return;
}
if (err.device) {
addLog('E', 'OTA', 'GD32 SHA-256 不匹配: 设备=' + err.device +
' 前端=' + sha256);
showToast('SHA-256 不匹配', 'error');
window.cleanupAfterOtaFailure('SHA-256 不匹配');
return;
}
} catch (_) {}
addLog('E', 'OTA', 'GD32 触发失败: HTTP ' + resp.status + ' ' + text);
showToast('GD32 触发失败', 'error');
window.cleanupAfterOtaFailure('触发失败 HTTP ' + resp.status);
}
} catch (e) {
// 请求超时/断连 ≠ 设备未启动中继:先探测 status 再决定是否标失败
try {
var probe = await fetch(window.getBaseUrl() + '/system/status', {
signal: AbortSignal.timeout(3000)
});
if (probe.ok) {
var st = await probe.json();
var o = st.ota || {};
var code = Number(o.sys_state_code);
var rstate = Number(o.relay_state);
if (code === 3 || (rstate >= 1 && rstate <= 4)) {
addLog('W', 'OTA', '触发请求中断(' + e.message + '),但设备已在中继,继续跟踪进度');
showToast('设备已在刷写,同步进度中', 'success');
if (window.otaSessionState !== OtaSessionState.RELAYING) {
otaSessionTransition(OtaSessionState.RELAYING);
}
try { window.updateSystemStatusUi(st); } catch (_) {}
startRelayProgressWatch();
return;
}
}
} catch (_) { /* probe 失败再走失败清理 */ }
addLog('E', 'OTA', 'GD32 触发失败: ' + e.message);
showToast('GD32 触发失败', 'error');
window.cleanupAfterOtaFailure('触发异常');
}
}
+393
View File
@@ -0,0 +1,393 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>电机控制台 — Motor CAN FD Console</title>
<!-- 空 favicon:阻止浏览器自动请求 /favicon.ico(本地静态服务无此文件会 404) -->
<link rel="icon" href="data:,">
<link rel="stylesheet" href="css/motor.css?v=20260816a">
</head>
<body>
<!-- ═══════════ TOOLBAR ═══════════ -->
<header class="toolbar">
<div class="toolbar-brand">
<svg width="26" height="26" viewBox="0 0 26 26" fill="none" aria-hidden="true">
<rect x="1" y="1" width="24" height="24" rx="6" stroke="var(--primary)" stroke-width="1.5" opacity=".6"/>
<circle cx="13" cy="13" r="5.5" stroke="var(--primary)" stroke-width="1.8"/>
<path d="M13 4v3.5M13 18.5V22M4 13h3.5M18.5 13H22" stroke="var(--primary)" stroke-width="1.5" stroke-linecap="round"/>
<circle cx="13" cy="13" r="1.8" fill="var(--primary)"/>
</svg>
<span>电机控制台</span>
<span class="sub">Motor CAN FD</span>
</div>
<div class="toolbar-sep"></div>
<div class="conn-group" title="与 OTA 主页共用设备 IP,本页不提供连接配置">
<span class="led" id="connLed"></span>
<span class="conn-ip-label" id="ipLabel">IP --</span>
<span style="font-size:.72rem;color:var(--fg-dim);font-family:var(--font-mono)" id="devStatus">探测中…</span>
</div>
<div class="toolbar-sep"></div>
<div class="conn-group">
<label for="chSelect" style="font-size:.72rem;color:var(--fg-muted);font-family:var(--font-mono);letter-spacing:.06em">通道</label>
<select class="ch-select" id="chSelect">
<option value="0">CH0</option>
<option value="1">CH1</option>
<option value="2">CH2</option>
</select>
</div>
<div class="toolbar-sep"></div>
<div class="conn-group" title="电机状态刷新间隔(ms,50~5000)。同时驱动前端 HTTP 轮询与 GD32 0xC5 SPI 主动推送周期,端到端节拍一致">
<label for="refreshRate" class="refresh-hint">刷新速度</label>
<input type="number" id="refreshRate" class="refresh-input" value="250" min="50" max="5000" step="50" aria-label="电机状态刷新间隔(毫秒)" />
<span class="refresh-hint">ms</span>
</div>
<div class="toolbar-right">
<a class="btn btn-sm" href="index.html" title="返回 OTA 主页配置连接">返回 OTA 控制台</a>
<span class="node-badge" id="nodeBadge">NODE -</span>
</div>
</header>
<div class="net-banner" id="netBanner">
<span class="led led-err led-pulse" style="width:7px;height:7px"></span>
<span id="netBannerText">未连接 — 使用主页共享 IP 自动探测中… 请先在 OTA 主页配置并测试连接</span>
</div>
<!-- ═══════════ MAIN 3-ZONE GRID ═══════════ -->
<main class="main-grid">
<!-- LEFT: NODE OVERVIEW -->
<aside class="panel nodes-panel">
<div class="panel-head">
<span class="panel-title">节点总览</span>
<span style="margin-left:auto;font-size:.68rem;color:var(--fg-dim);font-family:var(--font-mono)" id="nodeSummary">0/9 在线 · CAN FD</span>
</div>
<div class="panel-body">
<!-- 独立 BOOT 状态:总线检测到 0x103 心跳时显示,不归属任何 Nx
显示/隐藏只靠 .boot-badge / .boot-badge.show,禁止内联 display -->
<div id="bootBadge" class="boot-badge" aria-hidden="true">
<span class="boot-dot" aria-hidden="true"></span>
<span class="boot-txt">电机在 BOOT</span>
<span class="boot-sub" id="bootBadgeSub">0x103</span>
</div>
<div class="node-list" id="nodeList" role="listbox" aria-label="电机节点列表 N1-N9(离线可选)">
<!-- N1–N9 由 JS 固定渲染,离线也可选中刷写 -->
</div>
</div>
</aside>
<!-- CENTER: CONTROL -->
<section class="panel center-panel">
<div class="panel-head">
<span class="panel-title">CMD 控制</span>
<span style="font-size:.68rem;color:var(--fg-dim);font-family:var(--font-mono);margin-left:auto" id="cmdIdLabel">ID 0x000-0200 · DLC 32</span>
</div>
<div class="cmd-section">
<div class="cmd-grid">
<div class="field">
<span class="field-label">Control_Mode <span class="field-unit">控制模式</span></span>
<select id="modeSelect" aria-label="控制模式">
<option value="0">0 — Disable 关闭</option>
<option value="1">1 — Angle 位置控制</option>
<option value="2">2 — Velocity 速度控制</option>
<option value="3" selected>3 — Velocity_Angle 速度+位置</option>
<option value="4">4 — MIT Mode</option>
<option value="5">5 — Current 电流/力矩</option>
<option value="6">6 — 开环速度</option>
<option value="7">7 — 开环速度 (2)</option>
</select>
<span class="mode-desc" id="modeDesc">速度+位置环联合控制,目标速度与目标角度同时生效</span>
</div>
<div class="field">
<span class="field-label">Limit_Voltage <span class="field-unit">V · 0~65.535</span></span>
<input type="number" id="inVoltage" value="24.000" step="0.5" min="0" max="65.535" aria-label="电压限制" />
</div>
<div class="field" id="fldCurrent">
<span class="field-label">Target_Current <span class="field-unit">mA</span></span>
<input type="number" id="inCurrent" value="0" step="100" aria-label="目标电流" />
</div>
<div class="field" id="fldVelocity">
<span class="field-label">Target_Velocity <span class="field-unit">deg/s</span></span>
<input type="number" id="inVelocity" value="360" step="10" aria-label="目标速度" />
</div>
<div class="field" id="fldAngle">
<span class="field-label">Target_Angle <span class="field-unit">deg</span></span>
<input type="number" id="inAngle" value="180.0" step="1" aria-label="目标角度" />
</div>
</div>
<div class="field" style="margin-top:var(--sp-4)">
<span class="field-label">PID 增益 <span class="field-unit">P × 3 环</span></span>
<div class="pid-group">
<div class="field"><span class="field-label" style="font-size:.62rem">电流环 P</span><input type="number" id="pidCurP" value="100" step="1" /></div>
<div class="field"><span class="field-label" style="font-size:.62rem">速度环 P</span><input type="number" id="pidVelP" value="100" step="1" /></div>
<div class="field"><span class="field-label" style="font-size:.62rem">角度环 P</span><input type="number" id="pidAngP" value="100" step="1" /></div>
</div>
</div>
<div class="quick-run" id="quickRun" title="把值写入当前模式的主目标字段并立即发送 CMD">
<span class="qr-label">快捷运行 <span class="qr-target" id="qrTargetHint">→ 目标角度 deg</span></span>
<div class="qr-btns">
<button class="qr-btn" data-val="-9999">-9999</button>
<button class="qr-btn" data-val="-200">-200</button>
<button class="qr-btn" data-val="-100">-100</button>
<button class="qr-btn" data-val="0">0</button>
<button class="qr-btn" data-val="50">50</button>
<button class="qr-btn" data-val="100">100</button>
<button class="qr-btn" data-val="200">200</button>
<button class="qr-btn" data-val="1000">1000</button>
<button class="qr-btn" data-val="9999">9999</button>
</div>
</div>
<div class="cmd-actions">
<button class="btn btn-primary" id="sendCmdBtn">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" aria-hidden="true"><path d="M1.5 7h9M8 3.5L11.5 7 8 10.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/></svg>
发送 CMD
</button>
<button class="btn" id="sendRepeatBtn">周期发送</button>
<label class="repeat-period" title="周期发送间隔(最小 80ms)">
<input type="number" id="repeatPeriod" value="100" min="80" max="10000" step="10" />
<span>ms</span>
</label>
<span class="send-hint" id="sendHint">POST /can/send_batch · CH0</span>
</div>
</div>
<!-- TABS -->
<nav class="tabs-bar" aria-label="辅助功能">
<button class="tab-btn active" data-tab="config">配置操作</button>
<button class="tab-btn" data-tab="version">版本信息</button>
<button class="tab-btn" data-tab="nodeid">节点 ID</button>
<button class="tab-btn" data-tab="ota">电机 OTA</button>
<button class="tab-btn" data-tab="seq">命令序列</button>
</nav>
<div class="tab-content">
<!-- CONFIG TAB -->
<div class="tab-page active" id="tab-config">
<div class="config-grid">
<button class="config-btn danger-action" data-action="motReset">
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" aria-hidden="true"><path d="M4 11a7 7 0 1 1 2 4.9M4 16v-5h5" stroke="var(--danger)" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/></svg>
<span class="cb-name">电机复位</span><span class="cb-sig">motReset · bit0</span>
</button>
<button class="config-btn" data-action="saveConfig">
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" aria-hidden="true"><path d="M5 3h9l4 4v12H5V3z" stroke="var(--primary)" stroke-width="1.6" stroke-linejoin="round"/><path d="M8 3v5h6V3M8 18v-5h7v5" stroke="var(--primary)" stroke-width="1.4"/></svg>
<span class="cb-name">保存配置</span><span class="cb-sig">saveConfig · bit2</span>
</button>
<button class="config-btn" data-action="angleReset">
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" aria-hidden="true"><circle cx="11" cy="11" r="7.5" stroke="var(--primary)" stroke-width="1.6"/><path d="M11 6v5l3.5 2" stroke="var(--primary)" stroke-width="1.5" stroke-linecap="round"/></svg>
<span class="cb-name">角度置零</span><span class="cb-sig">angle_reset · bit3</span>
</button>
<button class="config-btn" data-action="zeroCalib">
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" aria-hidden="true"><circle cx="11" cy="11" r="7" stroke="var(--primary)" stroke-width="1.6"/><path d="M11 2v4M11 16v4M2 11h4M16 11h4" stroke="var(--primary)" stroke-width="1.5" stroke-linecap="round"/><circle cx="11" cy="11" r="1.5" fill="var(--primary)"/></svg>
<span class="cb-name">零点校准</span><span class="cb-sig">zero_calibration · bit5</span>
</button>
<button class="config-btn" data-action="sleep">
<svg width="22" height="22" viewBox="0 0 22 22" fill="none" aria-hidden="true"><path d="M17 13.5A7 7 0 0 1 8.5 5a7 7 0 1 0 8.5 8.5z" stroke="var(--warn)" stroke-width="1.6" stroke-linejoin="round"/></svg>
<span class="cb-name">休眠</span><span class="cb-sig">SLEEP · bit56</span>
</button>
</div>
<p style="margin-top:var(--sp-4);font-size:.72rem;color:var(--fg-dim);line-height:1.6">
CMD_CONFIG (0x000<span class="cfgNodeNibble">-</span>0301) · DLC 24 · 每个操作发送单帧脉冲信号,操作前需确认
</p>
</div>
<!-- VERSION TAB -->
<div class="tab-page" id="tab-version">
<button class="btn btn-sm" id="queryVerBtn">
<svg width="13" height="13" viewBox="0 0 13 13" fill="none" aria-hidden="true"><circle cx="6.5" cy="6.5" r="5" stroke="currentColor" stroke-width="1.3"/><path d="M6.5 4.2v.1M6.5 6v3" stroke="currentColor" stroke-width="1.4" stroke-linecap="round"/></svg>
查询版本 (GET_VERSION)
</button>
<div class="version-grid" style="margin-top:var(--sp-4)" id="versionGrid">
<div class="ver-card"><div class="ver-type">软件版本</div><div class="ver-date ver-empty" id="verSoft">--</div></div>
<div class="ver-card"><div class="ver-type">硬件版本</div><div class="ver-date ver-empty" id="verHard">--</div></div>
<div class="ver-card"><div class="ver-type">Bootloader</div><div class="ver-date ver-empty" id="verBoot">--</div></div>
</div>
<p style="margin-top:var(--sp-3);font-size:.72rem;color:var(--fg-dim)">请求/回报共用 0x000<span class="cfgNodeNibble">-</span>00FF(同 ID)· DLC 24 · major.minor.patch.build ×3 (soft/hard/boot,DBC v0.71) · GD32 解析后 0xC6 同步</p>
</div>
<!-- NODE ID TAB -->
<div class="tab-page" id="tab-nodeid">
<div class="nodeid-row">
<div class="field" style="min-width:140px">
<span class="field-label">目标 Node ID</span>
<input type="number" id="nodeIdInput" min="1" max="9" value="3" />
</div>
<button class="btn btn-primary btn-sm" id="setNodeIdBtn">设置 ID (0xN0400)</button>
<button class="btn btn-sm" id="getNodeIdBtn">读取 ID (0xN0401)</button>
</div>
<div class="nodeid-result" id="nodeIdResult">连接设备后读取节点 ID</div>
<p style="margin-top:var(--sp-3);font-size:.72rem;color:var(--fg-dim);line-height:1.6">
SET_NODE_ID (0x000N0400 DLC2) / GET_NODE_ID (0x000N0401 DLC2)。写入后 CAN ID 前缀变为 0x000Nxxxx。
</p>
</div>
<!-- OTA TAB -->
<div class="tab-page" id="tab-ota">
<div class="ota-stages" id="otaStages" aria-label="OTA阶段">
<div class="ota-stage" data-stage="upload"><span class="st-dot">1</span>上传固件</div>
<div class="ota-stage" data-stage="boot"><span class="st-dot">2</span>进入BOOT</div>
<div class="ota-stage" data-stage="handshake"><span class="st-dot">3</span>转发握手</div>
<div class="ota-stage" data-stage="erase"><span class="st-dot">4</span>擦除Flash</div>
<div class="ota-stage" data-stage="transfer"><span class="st-dot">5</span>数据传输</div>
<div class="ota-stage" data-stage="app"><span class="st-dot">6</span>完成</div>
</div>
<div class="ota-file-row">
<button class="btn btn-sm" id="otaFileBtn">选择固件 (.bin)</button>
<input type="file" id="otaFileInput" accept=".bin" hidden />
<span class="ota-file-name" id="otaFileName">未选择固件</span>
<button class="btn btn-primary btn-sm" id="otaStartBtn">开始 OTA</button>
<button class="btn btn-danger btn-sm" id="otaCancelBtn" disabled>取消</button>
</div>
<div class="ota-progress-wrap">
<div class="ota-progress-bar"><div class="ota-progress-fill" id="otaProgressFill"></div></div>
<div class="ota-stats">
<span>包 <span class="hl" id="otaPktNum">0</span> / <span id="otaPktTotal">0</span></span>
<span>速率 <span class="hl" id="otaSpeed">—</span></span>
<span>状态 <span id="otaCrc">—</span></span>
</div>
</div>
<div class="ota-log-toolbar">
<button id="otaLogCopyBtn" title="复制全部日志">复制</button>
<button id="otaLogClearBtn" title="清空日志">清空</button>
</div>
<div class="ota-log" id="otaLog" aria-live="polite">
<div class="log-info">[就绪] 电机 Bootloader OTA · 先上传固件到 ESP32 → 发 0x000N0000 进 BOOT → 触发转发 → GD32 本地 relay(AES-128 + CAN stop-and-wait)</div>
<div>[信息] 选固件 → 上传并比对 flash SHA-256(失败重传≤2次)→ 发进BOOT命令 → 触发 /motor/ota/relay → GD32 完成握手/加密/传输 · 每包 48 B · 48 KB 上限</div>
</div>
</div>
<!-- 命令序列 TAB -->
<div class="tab-page" id="tab-seq">
<div class="seq-toolbar">
<label class="repeat-period" title="整序列循环次数,0 = 无限循环">
<span>循环</span>
<input type="number" id="seqLoops" value="0" min="0" max="9999" step="1" />
<span>次(0=无限)</span>
</label>
<button class="btn btn-sm" id="seqAddBtn">+ 添加步骤</button>
<button class="btn btn-sm" id="seqClearBtn">清空</button>
<button class="btn" id="seqStartBtn">▶ 开始序列</button>
<span class="seq-status" id="seqStatus">未运行</span>
</div>
<div class="seq-list" id="seqList"></div>
<p class="seq-hint">
命令序列:每步为一个「同步动作组」,组内每个动作 = 一台电机目标(通道/节点/模式/电压/电流/速度/角度/PID);
执行本步时组内全部动作经一次突发请求近同时下发(同组偏差微秒级;超过单次容量自动分组,组间约 1-2ms),
发送成功后保持「保持 ms」再进入下一步。每步可「+」添加多个电机动作,支持多通道、多电机混合搭配,独立于左侧选中节点。
循环 0 = 无限循环,与「周期发送」互斥。「+ 添加步骤」会复制当前 CMD 表单已提交的值及当前通道/选中节点。
</p>
</div>
</div>
</section>
<!-- RIGHT: STATUS FEEDBACK -->
<section class="panel status-panel">
<div class="panel-head">
<span class="panel-title">STATUS 实时反馈</span>
<span style="font-size:.68rem;color:var(--fg-dim);font-family:var(--font-mono);margin-left:auto" id="statusIdLabel">0x000-0100 · 1 Hz</span>
</div>
<div class="panel-body">
<div class="offline-overlay show" id="offlineOverlay">
<svg class="off-icon" width="40" height="40" viewBox="0 0 40 40" fill="none" aria-hidden="true"><circle cx="20" cy="20" r="16" stroke="var(--fg-dim)" stroke-width="1.5" stroke-dasharray="4 4"/><path d="M14 26l12-12M26 26L14 14" stroke="var(--fg-dim)" stroke-width="1.5" stroke-linecap="round"/></svg>
<span style="font-size:.85rem" id="offlineTitle">节点离线 — 未收到 STATUS 帧</span>
<span style="font-size:.72rem" id="offlineSub">检查电机供电与 CAN 总线连接</span>
</div>
<div class="status-live hide" id="statusLive">
<div class="big-readings">
<div class="big-reading" data-signal="angle" title="点击展开/收起角度波形">
<div class="br-val" id="rtAngle">--</div>
<div class="br-unit">deg</div>
<div class="br-lbl">RT_Angle <span class="br-toggle">▾ 波形</span></div>
<div class="br-target">目标 <span id="tgtAngle">--</span> deg</div>
<div class="br-scope"><canvas id="miniAngle" aria-label="角度波形" role="img"></canvas></div>
</div>
<div class="big-reading" data-signal="velocity" title="点击展开/收起速度波形">
<div class="br-val" id="rtVelocity">--</div>
<div class="br-unit">deg/s</div>
<div class="br-lbl">RT_Velocity <span class="br-toggle">▾ 波形</span></div>
<div class="br-target">目标 <span id="tgtVelocity">--</span> deg/s</div>
<div class="br-scope"><canvas id="miniVelocity" aria-label="速度波形" role="img"></canvas></div>
</div>
<div class="big-reading" data-signal="current" title="点击展开/收起电流波形">
<div class="br-val" id="rtCurrent">--</div>
<div class="br-unit">mA</div>
<div class="br-lbl">RT_Current <span class="br-toggle">▾ 波形</span></div>
<div class="br-target">目标 <span id="tgtCurrent">--</span> mA</div>
<div class="br-scope"><canvas id="miniCurrent" aria-label="电流波形" role="img"></canvas></div>
</div>
</div>
<div class="status-row">
<div class="temp-block" data-signal="mcuTemp" title="点击展开/收起 MCU 温度曲线">
<div class="temp-head"><span class="temp-name">MCU_Temp<span class="temp-toggle">▾ 曲线</span></span><span class="temp-val" id="mcuTemp">-- °C</span></div>
<div class="temp-bar"><div class="temp-fill" id="mcuTempBar" style="width:0%"></div></div>
<div class="br-scope"><canvas id="miniMcuTemp" aria-label="MCU温度曲线" role="img"></canvas></div>
</div>
<div class="temp-block" data-signal="motTemp" title="点击展开/收起电机温度曲线">
<div class="temp-head"><span class="temp-name">Motor_Temp<span class="temp-toggle">▾ 曲线</span></span><span class="temp-val" id="motTemp">-- °C</span></div>
<div class="temp-bar"><div class="temp-fill" id="motTempBar" style="width:0%"></div></div>
<div class="br-scope"><canvas id="miniMotTemp" aria-label="电机温度曲线" role="img"></canvas></div>
</div>
</div>
<div class="phase-currents">
<div class="pc-head">
<span class="pc-title">三相电流 U / V / W</span>
<div class="pc-live-vals">
<span class="pc-chip" style="--ch-color:var(--primary)">U <b id="valU">+0</b> mA</span>
<span class="pc-chip" style="--ch-color:var(--ok)">V <b id="valV">+0</b> mA</span>
<span class="pc-chip" style="--ch-color:var(--warn)">W <b id="valW">+0</b> mA</span>
</div>
</div>
<div class="scope-wrap">
<canvas id="scopeCanvas" aria-label="三相电流实时波形" role="img"></canvas>
<div class="scope-grid-labels"><span>+5A</span><span>0</span><span>-5A</span></div>
</div>
</div>
<div class="misc-grid">
<div class="misc-item"><span class="mi-lbl">Power_Voltage</span><span class="mi-val" id="pwrVolt">-- V</span></div>
<div class="misc-item"><span class="mi-lbl">test_float</span><span class="mi-val" id="testFloat">--</span></div>
<div class="misc-item"><span class="mi-lbl">test_U16</span><span class="mi-val" id="testU16">--</span></div>
<div class="cpu-ring-wrap" style="grid-column:2;grid-row:2">
<div class="cpu-ring">
<svg width="52" height="52" viewBox="0 0 52 52" role="img" aria-label="CPU占用率">
<circle class="ring-bg" cx="26" cy="26" r="21"/>
<circle class="ring-fg" id="cpuRingFg" cx="26" cy="26" r="21" stroke-dasharray="131.9" stroke-dashoffset="131.9"/>
</svg>
<span class="cpu-pct" id="cpuPct">--%</span>
</div>
<div class="cpu-info"><span class="mi-lbl">CPU_Usage</span><span class="mi-sub" id="cpuSub">-- </span></div>
</div>
</div>
<div class="flag-lights">
<div class="flag-item" id="flagItemMot"><span class="flag-led" id="flagMot"></span>motStatus 使能<span class="flag-state" id="flagMotState">—</span></div>
<div class="flag-item" id="flagItemMag"><span class="flag-led" id="flagMag"></span>magStatus 磁编码<span class="flag-state" id="flagMagState">—</span></div>
<div class="flag-item" id="flagItemThermal"><span class="flag-led" id="flagThermal"></span>thermal 过温<span class="flag-state" id="flagThermalState">—</span></div>
<div class="flag-item" id="flagItemEeprom"><span class="flag-led" id="flagEeprom"></span>EEPROM 存储<span class="flag-state" id="flagEepromState">—</span></div>
</div>
</div>
</div>
</section>
</main>
<!-- ═══════════ STATUS BAR ═══════════ -->
<footer class="statusbar">
<div class="sb-item"><span class="led" id="sbLed" style="width:7px;height:7px"></span> <span id="sbCh">CH0</span> <span class="sb-val" id="sbRx">RX --</span></div>
<div class="sb-sep"></div>
<div class="sb-item">TX <span class="sb-val" id="sbTx">--</span></div>
<div class="sb-sep"></div>
<div class="sb-item">ERR <span class="sb-val" id="sbErr">--</span> · TEC <span id="sbTec">--</span> / REC <span id="sbRec">--</span></div>
<div class="sb-sep"></div>
<div class="sb-item">BUS <span class="sb-val" id="sbBus">--</span></div>
<span class="sb-last-send" id="sbLastSend">尚未发送</span>
</footer>
<!-- TOAST & MODAL -->
<div class="toast-container" id="toastContainer" aria-live="polite"></div>
<div class="modal-mask" id="modalMask" role="dialog" aria-modal="true">
<div class="modal-box">
<div class="modal-title" id="modalTitle">确认操作</div>
<div class="modal-desc" id="modalDesc"></div>
<div class="modal-actions">
<button class="btn" id="modalCancel">取消</button>
<button class="btn btn-primary" id="modalConfirm">确认执行</button>
</div>
</div>
</div>
<script type="module" src="js/motor/main.js?v=20260816c"></script>
</body>
</html>
+25
View File
@@ -0,0 +1,25 @@
@echo off
:: ESP32 debug console - console/debug launcher (shows server log in this window).
:: No-black-box launchers: esp32_app.vbs (desktop window) / esp32_web.vbs (browser tab)
set PORT=8080
chcp 65001 >nul
set PYTHONUTF8=1
echo ========================================
echo ESP32 Debug Console (console mode)
echo PORT: %PORT%
echo ========================================
echo.
echo OTA Console: http://localhost:%PORT%/index.html
echo.
echo Switch WiFi / BLE mode in the "Device Connect" card.
echo No-black-box: double-click esp32_app.vbs instead.
echo.
echo Press Ctrl+C to stop
echo ========================================
echo.
cd /d "%~dp0"
python esp32_server.py --port %PORT%
pause
+175
View File
@@ -0,0 +1,175 @@
<!DOCTYPE html>
<!--
【临时功能 SPI-SPEEDTEST】SPI 链路测速独立页面
─────────────────────────────────────────────
完全自包含(无外部 css/js 依赖),不被任何其他页面引用。
删除方法:直接删掉本文件即可(固件端点删除方法见
main/monitor/spi_speed_test.cpp 文件头)。
用法:浏览器直接打开本文件,或经 serve.bat 访问
http://localhost:8080/spi_test.html
-->
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SPI 测速(临时工具)</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: "Segoe UI", "Microsoft YaHei", sans-serif;
background: #14171c; color: #d8dee6;
max-width: 760px; margin: 0 auto; padding: 24px 16px;
}
h1 { font-size: 20px; margin-bottom: 4px; }
.sub { color: #7a8494; font-size: 12px; margin-bottom: 20px; }
.card {
background: #1c2129; border: 1px solid #2a313c; border-radius: 8px;
padding: 16px; margin-bottom: 16px;
}
label { font-size: 13px; color: #9aa5b3; margin-right: 6px; }
input {
background: #12151a; color: #d8dee6; border: 1px solid #2a313c;
border-radius: 4px; padding: 6px 8px; font-size: 14px; width: 130px;
}
input.narrow { width: 70px; }
.row { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; margin-bottom: 12px; }
button {
background: #2563eb; color: #fff; border: 0; border-radius: 6px;
padding: 8px 18px; font-size: 14px; cursor: pointer;
}
button:hover { background: #1d4ed8; }
button:disabled { background: #3a4250; cursor: wait; }
button.alt { background: #374151; }
button.alt:hover { background: #4b5563; }
pre {
background: #12151a; border: 1px solid #2a313c; border-radius: 6px;
padding: 12px; font-size: 14px; line-height: 1.7; white-space: pre-wrap;
min-height: 120px;
}
pre.fail { border-color: #b91c1c; color: #fca5a5; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th, td { text-align: left; padding: 5px 8px; border-bottom: 1px solid #2a313c; }
th { color: #7a8494; font-weight: normal; }
td.bad { color: #f87171; }
.hint { color: #7a8494; font-size: 12px; line-height: 1.7; margin-top: 8px; }
</style>
</head>
<body>
<h1>SPI 链路测速</h1>
<div class="sub">临时诊断工具 [SPI-SPEEDTEST] — 探针 0xA1 APP PING,删除时直接删掉本文件</div>
<div class="card">
<div class="row">
<label>设备 IP</label><input id="ip" value="192.168.31.127">
<label>帧数</label><input id="frames" class="narrow" value="200">
<label>载荷 B</label><input id="payload" class="narrow" value="236">
</div>
<div class="row">
<button id="btnRun">开始测速</button>
<button id="btnRtt" class="alt">空载 RTT(500 帧)</button>
<button id="btnStress" class="alt">压力测试(2000 帧满载)</button>
</div>
<div class="hint">
满帧 236B = 等效电机 OTA 0xD1 数据节奏;载荷 0 = 纯握手往返。<br>
判读:失败必须为 0;「最快」是链路物理极限周期;「平均-最快」差值是软件调度开销。
</div>
</div>
<div class="card">
<pre id="out">等待测试…</pre>
</div>
<div class="card">
<table>
<thead><tr><th>时间</th><th>帧数</th><th>载荷</th><th>失败</th><th>平均</th><th>最快</th><th>吞吐</th></tr></thead>
<tbody id="hist"></tbody>
</table>
<div class="hint">本页历史仅存内存,刷新即清空——方便改参数前后对比。</div>
</div>
<script>
"use strict";
(function () {
var out = document.getElementById("out");
var hist = document.getElementById("hist");
var btns = [
document.getElementById("btnRun"),
document.getElementById("btnRtt"),
document.getElementById("btnStress")
];
var ipInput = document.getElementById("ip");
var savedIp = null;
try { savedIp = window.localStorage.getItem("spi_test_ip"); } catch (e) {}
if (savedIp) { ipInput.value = savedIp; }
function setBusy(busy) {
for (var i = 0; i < btns.length; i++) { btns[i].disabled = busy; }
}
function pad2(n) { return (n < 10 ? "0" : "") + n; }
function addHistory(frames, payload, text) {
/* 从设备中文文本里提取关键数字做历史行 */
var fail = (text.match(/失败:\s*(\d+)/) || [])[1] || "?";
var avg = (text.match(/平均\s*([\d.]+)\s*µs/) || [])[1] || "?";
var min = (text.match(/最快\s*([\d.]+)\s*µs/) || [])[1] || "?";
var tput = (text.match(/等效吞吐:\s*([\d.]+)\s*KB\/s/) || [])[1] || "-";
var d = new Date();
var tr = document.createElement("tr");
var cells = [
pad2(d.getHours()) + ":" + pad2(d.getMinutes()) + ":" + pad2(d.getSeconds()),
String(frames), String(payload) + "B", fail,
avg + "µs", min + "µs", tput === "-" ? "-" : tput + "KB/s"
];
for (var i = 0; i < cells.length; i++) {
var td = document.createElement("td");
td.textContent = cells[i];
if (i === 3 && fail !== "0") { td.className = "bad"; }
tr.appendChild(td);
}
hist.insertBefore(tr, hist.firstChild);
}
function run(frames, payload) {
var ip = ipInput.value.trim();
try { window.localStorage.setItem("spi_test_ip", ip); } catch (e) {}
var url = "http://" + ip + "/spi/speedtest?frames=" + frames + "&payload=" + payload;
setBusy(true);
out.className = "";
out.textContent = "测试中… (" + frames + " 帧 × " + payload + "B)";
fetch(url)
.then(function (r) {
if (!r.ok) { throw new Error("HTTP " + r.status); }
return r.text();
})
.then(function (text) {
var failed = /失败:\s*[1-9]/.test(text);
out.className = failed ? "fail" : "";
out.textContent = text;
addHistory(frames, payload, text);
})
.catch(function (err) {
out.className = "fail";
out.textContent = "请求失败: " + err.message + "\n请检查设备 IP 与网络连通性。";
})
.then(function () { setBusy(false); });
}
function clampInt(v, lo, hi, dflt) {
var n = parseInt(v, 10);
if (isNaN(n)) { return dflt; }
return Math.min(hi, Math.max(lo, n));
}
btns[0].onclick = function () {
run(clampInt(document.getElementById("frames").value, 1, 2000, 200),
clampInt(document.getElementById("payload").value, 0, 236, 236));
};
btns[1].onclick = function () { run(500, 0); };
btns[2].onclick = function () { run(2000, 236); };
})();
</script>
</body>
</html>
+124
View File
@@ -0,0 +1,124 @@
<!--
tabs.html - ESP32 调试控制台「单窗口多标签」外壳
顶部一排标签, 下方 iframe 承载各功能页(index/can/motor/...)。
各页面原样复用, 互不干扰; 页面内的"新窗口打开"/普通链接会在 iframe 内跳转,
外壳监听 iframe 的 load 事件同步高亮当前标签。
激活标签存 localStorage(dcu_tab), 下次打开自动恢复。
-->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ESP32 调试控制台 — DCU</title>
<link rel="icon" href="data:,">
<link rel="stylesheet" href="css/base.css?v=857f83a5">
<style>
html, body { height: 100%; margin: 0; }
body {
background: var(--bg-root); color: var(--text);
font-family: "Segoe UI", "Microsoft YaHei", system-ui, sans-serif;
display: flex; flex-direction: column; overflow: hidden;
}
.tabbar {
display: flex; align-items: stretch; gap: 2px;
background: var(--bg-panel); border-bottom: 1px solid var(--border);
padding: 0 8px; height: 42px; flex: 0 0 auto;
user-select: none; -webkit-user-select: none;
}
.tabbar .brand {
display: flex; align-items: center; gap: 8px;
padding: 0 12px 0 4px; margin-right: 6px;
border-right: 1px solid var(--border);
font-weight: 600; font-size: .86rem; color: var(--text-dim);
letter-spacing: .02em;
}
.tabbar .brand .dot { width: 9px; height: 9px; border-radius: 50%;
background: var(--esp32); box-shadow: 0 0 8px var(--esp32); }
.tab {
appearance: none; border: none; background: transparent; cursor: pointer;
color: var(--text-dim); font-size: .84rem; font-family: inherit;
padding: 0 16px; display: flex; align-items: center; gap: 7px;
border-bottom: 2px solid transparent; transition: color .12s, background .12s;
white-space: nowrap;
}
.tab:hover { color: var(--text); background: var(--bg-hover); }
.tab.active { color: var(--text); border-bottom-color: var(--esp32); background: var(--bg-card); }
.tab .ico { font-size: .95rem; opacity: .9; }
.tab .accent { width: 7px; height: 7px; border-radius: 50%; opacity: .55; }
.tab.active .accent { opacity: 1; }
.tabbar .spacer { flex: 1 1 auto; }
.tabbar .hint { display: flex; align-items: center; color: var(--text-label);
font-size: .72rem; padding: 0 8px; }
#view { flex: 1 1 auto; width: 100%; border: none; background: var(--bg-root); display: block; }
</style>
</head>
<body>
<nav class="tabbar" id="tabbar">
<div class="brand"><span class="dot"></span>ESP32 调试控制台</div>
<!-- 标签由 TABS 数组渲染 -->
<div class="spacer"></div>
<div class="hint">⟳ 刷新当前页 · F5</div>
</nav>
<iframe id="view" title="功能页" src="about:blank"></iframe>
<script>
(function(){
var TABS = [
{ page: 'index.html', label: 'OTA 中继', ico: '⬆', color: 'var(--esp32)' },
{ page: 'can.html', label: 'CAN 通信', ico: '⇄', color: 'var(--can)' },
{ page: 'can_monitor.html', label: 'CAN 监控', ico: '👁', color: 'var(--can)' },
{ page: 'motor.html', label: '电机控制', ico: '⚙', color: 'var(--motor)' },
{ page: 'continuity_test.html',label: '通断测试', ico: '🧰', color: 'var(--gd32)' },
{ page: 'spi_test.html', label: 'SPI 测速', ico: '⚡', color: 'var(--ble)' }
];
var bar = document.getElementById('tabbar');
var view = document.getElementById('view');
var spacer = bar.querySelector('.spacer');
var LS = 'dcu_tab';
function pageToTab(path){
path = (path || '').replace(/^.*\//, '').split('?')[0];
if (path === '' ) return 'index.html';
for (var i=0;i<TABS.length;i++) if (TABS[i].page === path) return path;
return null; // 未知页(如从页面内跳到别的 html)
}
function setActive(path){
var tabs = bar.querySelectorAll('.tab');
for (var i=0;i<tabs.length;i++){
tabs[i].classList.toggle('active', tabs[i].getAttribute('data-page') === path);
}
}
function go(path, save){
view.src = path;
setActive(path);
if (save !== false) { try { localStorage.setItem(LS, path); } catch(e){} }
}
// 渲染标签
TABS.forEach(function(t){
var b = document.createElement('button');
b.className = 'tab'; b.type = 'button';
b.setAttribute('data-page', t.page);
b.innerHTML = '<span class="accent" style="background:'+t.color+'"></span>'
+ '<span class="ico">'+t.ico+'</span>' + t.label;
b.onclick = function(){ go(t.page); };
bar.insertBefore(b, spacer);
});
// iframe 内导航(页面里的链接/新窗口打开覆写)后, 同步高亮对应标签
view.addEventListener('load', function(){
var p = null;
try { p = view.contentWindow.location.pathname; } catch(e){ return; }
var tab = pageToTab(p);
if (tab) { setActive(tab); try { localStorage.setItem(LS, tab); } catch(e){} }
});
// 初始标签: 上次记忆, 否则 OTA 主页
var init = 'index.html';
try { var s = localStorage.getItem(LS); if (s && pageToTab(s)) init = s; } catch(e){}
go(init, false);
})();
</script>
</body>
</html>