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:
@@ -0,0 +1,2 @@
|
||||
# 该工具集在 Windows 下开发运行,保持文件换行符原样,避免 checkout/commit 时被改写
|
||||
* -text
|
||||
+23
@@ -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
|
||||
@@ -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 主站工具集(同类结构的姊妹工具)。
|
||||
@@ -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%␍
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""").replace(/'/g,"'");}
|
||||
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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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
@@ -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; }
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)">↻</div>
|
||||
<div id="__home" title="返回标签主页" style="display:none">⌂</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()
|
||||
@@ -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
@@ -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>
|
||||
@@ -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);
|
||||
@@ -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
@@ -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
@@ -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');
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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'; }
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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
@@ -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 + ' 报文';
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
@@ -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');
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
@@ -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 暂停滚动';
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
/**
|
||||
* @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
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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.*,无需再二次赋值 */
|
||||
@@ -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() + ' 文件已清除');
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
@@ -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
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user