commit d2c86c425aee0e71e1ac54f53b646601d50bbc3a Author: cbb <2625274797@qq.com> Date: Thu Sep 3 21:30:32 2026 +0800 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 换行原样) diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..3b3ecb8 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +# 该工具集在 Windows 下开发运行,保持文件换行符原样,避免 checkout/commit 时被改写 +* -text diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..96b7e06 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..f4d40bf --- /dev/null +++ b/README.md @@ -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 主站工具集(同类结构的姊妹工具)。 diff --git a/build_mem.bat b/build_mem.bat new file mode 100644 index 0000000..847f972 --- /dev/null +++ b/build_mem.bat @@ -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% \ No newline at end of file diff --git a/can.html b/can.html new file mode 100644 index 0000000..36ab440 --- /dev/null +++ b/can.html @@ -0,0 +1,334 @@ + + + + + +ESP32 CAN 通信控制台 + + + + + + + + + +
+ +
+
+ +
+ ESP32 CAN 通信控制台 + DCU · Robot_v2 · CAN FD +
+
+
+ 未连接 + IP -- + RSSI -- + 运行 -- + v-- + CH0 + 未导入 DBC + 0 报文 + 模式: -- + 轮询: -- +
+ +
+ + + + +
+
+
+
+ 📚 + 报文列表 + + +
+ + +
+
+ +
+ +
+ +
+
+ 已勾选 0 +
+ + +
+ + + + +
+
+
+
+
+
📥
+

导入 DBC 开始编辑报文

+

点击此处或拖拽 .dbc 文件到列表区域

+
+
+ +
+
+ +
+
🕘 最近发送
+
+
+
尚未发送任何 CAN 帧。
+
+
+
+
+ + +
+
+ + + + +
+ ✓ + 操作成功 +
+ + + + diff --git a/can_monitor.html b/can_monitor.html new file mode 100644 index 0000000..7518b57 --- /dev/null +++ b/can_monitor.html @@ -0,0 +1,189 @@ + + + + + +ESP32 CAN 接收监控 + + + + + + + + + +
+ +
+
+ + ESP32 CAN 接收监控 +
+
+ 未连接 + 模式: -- + 轮询: 待连接 +
+ +
+ + +
+
🧰 连接设置
+
+
+
+ +
+ + +
+
+
+ +
+ + + +
+
+
+
+
+ + 等待连接测试 +
+
+ 页面每 1 秒通过 /can/feedback 拉取三路 CAN 通道快照。无 RX 的通道显示 空(无 RX)。
桥接模式:CAN0 收到的每一帧(11/29 位、FD、BRS)自动由 CAN1 与 CAN2 转发出去。 +
+
+
+
+ + +
+
📊 三路 CAN 通道反馈(1Hz)
+
+ +
+
+ + +
+
🔬 每 ID 快照表(1Hz 刷新) + +
+
+
+ + + + + + + + + + +
#CAN IDDLCFDBRSESI数据 (HEX)
等待数据...
+
+
+
+ +
+ +
+ ✓ + 操作成功 +
+ + + + \ No newline at end of file diff --git a/continuity_test.html b/continuity_test.html new file mode 100644 index 0000000..22d621f --- /dev/null +++ b/continuity_test.html @@ -0,0 +1,754 @@ + + + + + +LAN9252 通断测试 + + + + + +
+ +

LAN9252 通断测试

+
通过 ESP32 /spi/send → SPI 0xA2 → GD32 驱动 EXMC 引脚,万用表测 LAN9252 端电压
+ +
+ ⚠️ + 不点按钮时 GD32 引脚为高阻输入态(安全)。点击模式后先预览引脚图,点"确认应用"才实际驱动引脚。测量完毕请点"停止/恢复"释放。 +
+ + +
+
+ 设备 IP + 读取中... + 共用主页"设备连接"配置的 IP +
+
+ + +
+

LAN9252 64-QFN 引脚图

+

俯视,Pin1 右上角。点击下方模式按钮预览引脚电压,点"确认应用"发送命令。绿色=3.3V,红色=0V,橙色=控制线测试中。

+
+ + + + + + + + + + + + +
+
+ HIGH 3.3V + LOW 0V + 控制线测试 + 数据线空闲 + 控制线空闲 + 电源/其他 + Pin1 +
+
+ + +
+

模式选择

+

点击预览引脚图,再点"确认应用"发送命令

+ + +
+ + + + +
+ + +
+ + + + + +
+ + +
+ +
+

+ 数据线切为高阻输入、CS#+RD# 持续拉低(读 IDX_0)。用万用表在 LAN9252 芯片引脚 上量数据线: + 芯片活着 → D2(Pin12)、D4(Pin49)、D5(Pin50)、D9(Pin19)、D12(Pin21) ≈ 3.3V,其余数据脚 ≈ 0V(全部确定电平,即 0x1234); + 全部中电平/漂移 → 芯片未驱动总线。图上预览色 = 芯片应答的期望值。 +

+
+ + +
+

操作

+
+ + + +
+

+ 确认应用 · 发送当前选中的模式到 GD32
+ 循环切换 · 每5s在 0x0000 ↔ 当前选中模式 之间切换(需先选数据线 pattern)
+ 停止 / 恢复 · 恢复 EXMC 正常模式 +

+
+ + +
+
+ 操作日志 +
+ + + + +
+
+
+
+ 共 0 条 + SPI 0xA2 · UDP 6666 +
+
+ +
+ + + + diff --git a/css/base.css b/css/base.css new file mode 100644 index 0000000..28c07c5 --- /dev/null +++ b/css/base.css @@ -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; +} diff --git a/css/can.css b/css/can.css new file mode 100644 index 0000000..3484997 --- /dev/null +++ b/css/can.css @@ -0,0 +1,1712 @@ +/* === can.css — CAN 面板/工作台/1Hz反馈/ID快照/报文行展开/节点过滤 等 CAN 专用样式 === */ +/* === CAN PANEL === */ +.can-grid { + display: grid; grid-template-columns: 70px 1fr 60px 1fr; gap: 10px; +} +.can-launch-card { + background: + radial-gradient(circle at top right, rgba(67,217,123,.16), transparent 28%), + linear-gradient(138deg, rgba(18,29,41,.96), rgba(20,29,40,.92) 48%, rgba(14,21,31,.98) 100%); + border: 1px solid rgba(67,217,123,.16); + border-radius: 14px; + padding: 20px; + box-shadow: + inset 0 0 0 1px rgba(255,255,255,.02), + 0 18px 40px rgba(0,0,0,.18); + position: relative; + overflow: hidden; +} +.can-launch-card::before { + content: ""; + position: absolute; + inset: 0 auto 0 0; + width: 4px; + background: linear-gradient(180deg, rgba(67,217,123,.9), rgba(67,217,123,.18)); +} +.can-launch-copy { + position: relative; + z-index: 1; +} +.can-launch-top { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 10px; +} +.can-launch-copy h3 { + font-size: 22px; + line-height: 1.2; + margin: 6px 0 0; +} +.can-launch-desc { + color: var(--text-dim); + font-size: 13px; + line-height: 1.7; + max-width: 700px; +} +.can-launch-kicker { + color: #8ef0b4; + font-size: 10px; + letter-spacing: .18em; + text-transform: uppercase; + opacity: .92; +} +.can-launch-badge { + display: inline-flex; + align-items: center; + padding: 7px 12px; + border-radius: 999px; + background: rgba(67,217,123,.09); + border: 1px solid rgba(67,217,123,.22); + color: #a9f5c8; + font-size: 11px; + font-weight: 700; + letter-spacing: .12em; + text-transform: uppercase; + white-space: nowrap; +} +.can-launch-points { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 14px; +} +.can-launch-point { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 7px 11px; + border-radius: 999px; + background: rgba(255,255,255,.03); + border: 1px solid rgba(255,255,255,.06); + color: #c6d7e8; + font-size: 12px; +} +.can-launch-point::before { + content: ""; + width: 6px; + height: 6px; + border-radius: 50%; + background: var(--can); + box-shadow: 0 0 10px rgba(67,217,123,.35); +} +.can-launch-actions { + display: grid; + grid-template-columns: minmax(0, 1.15fr) minmax(180px, .85fr); + gap: 10px; + margin-top: 18px; + position: relative; + z-index: 1; +} +.can-launch-primary, +.can-launch-secondary { + min-height: 42px; +} +.can-launch-primary { + background: linear-gradient(135deg, var(--can) 0%, #22b45f 100%); + color: #04240f; + box-shadow: 0 10px 24px rgba(67,217,123,.2); +} +.can-launch-secondary { + color: #d5e4f4; + border-color: rgba(255,255,255,.08); + background: rgba(255,255,255,.015); +} +.can-launch-secondary:hover:not(:disabled) { + background: rgba(255,255,255,.05); + border-color: rgba(67,217,123,.25); +} +.can-launch-foot { + display: flex; + gap: 10px; + align-items: flex-start; + margin-top: 16px; + padding-top: 14px; + border-top: 1px solid rgba(255,255,255,.06); + color: #91a6bc; + font-size: 12px; + line-height: 1.7; + position: relative; + z-index: 1; +} +/* 电机入口:复用 can-launch 布局,换青色强调 */ +.motor-launch-card { + background: + radial-gradient(circle at top right, rgba(34,211,238,.16), transparent 28%), + linear-gradient(138deg, rgba(18,29,41,.96), rgba(20,29,40,.92) 48%, rgba(14,21,31,.98) 100%); + border-color: rgba(34,211,238,.16); +} +.motor-launch-card::before { + background: linear-gradient(180deg, rgba(34,211,238,.9), rgba(34,211,238,.18)); +} +.motor-launch-card .can-launch-kicker { color: #67e8f9; } +.motor-launch-card .can-launch-badge { + background: rgba(34,211,238,.09); + border-color: rgba(34,211,238,.22); + color: #a5f3fc; +} +.motor-launch-card .can-launch-point::before { + background: var(--motor, #22d3ee); + box-shadow: 0 0 10px rgba(34,211,238,.35); +} +.motor-launch-primary { + background: linear-gradient(135deg, var(--motor, #22d3ee) 0%, #0891b2 100%) !important; + color: #042f2e !important; + box-shadow: 0 10px 24px rgba(34,211,238,.2); +} +.motor-launch-card .can-launch-secondary:hover:not(:disabled) { + border-color: rgba(34,211,238,.25); +} + +.can-launch-foot-label { + flex: none; + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 40px; + padding: 4px 8px; + border-radius: 999px; + background: rgba(255,255,255,.04); + color: #d8e6f4; + font-size: 10px; + letter-spacing: .12em; + text-transform: uppercase; +} +/* === CAN WORKSPACE PAGE === */ +/* 右侧监控抽屉 translate 出屏时,防止 body 被撑出横向滚动条 */ +body.can-page { + overflow-x: hidden; + max-width: 100%; +} +.can-page::after { + content: ""; + position: fixed; + inset: 0; + pointer-events: none; + background: + radial-gradient(circle at 10% 18%, rgba(34,211,238,.08), transparent 26%), + radial-gradient(circle at 85% 14%, rgba(245,158,11,.05), transparent 24%); +} +.can-container { + max-width: min(1700px, 100%); + width: 100%; + box-sizing: border-box; +} +.can-header { + position: sticky; + top: 16px; + z-index: 5; + backdrop-filter: blur(14px); + background: rgba(15,22,31,.86); +} +/* sticky+backdrop-filter 合成层冲突致 fadeInUp 卡首帧(translateY(10px)+opacity:0), + * 禁用入场动画。选择器需 .header-bar.can-header 以胜过 responsive.css 的 .header-bar 规则 */ +.header-bar.can-header { animation: none; } +.can-header-actions { + margin-left: auto; +} +.can-link-pill { + text-decoration: none; + color: var(--text); +} +.can-toolbar-card, +.can-table-card, +.can-editor-layout { + margin-top: 18px; +} +.can-toolbar-grid { + display: grid; + grid-template-columns: minmax(220px, 1.1fr) 140px minmax(200px, 1fr); + gap: 14px; + align-items: start; +} +.can-toolbar-grid-compact { + grid-template-columns: 140px minmax(220px, 1fr); +} +.can-toolbar-grid-dbc-only { + grid-template-columns: 1fr; + max-width: 420px; +} + +/* DBC 并入报文列表卡 */ +.can-table-card.dbc-dropzone { + position: relative; + transition: box-shadow .15s, border-color .15s; +} +.can-table-card.dbc-dropzone.dragover { + border-color: var(--accent); + box-shadow: 0 0 0 2px var(--accent-dim); +} +.can-table-card.dbc-dropzone.dragover::after { + content: '松开以导入 DBC'; + position: absolute; + inset: 0; + z-index: 5; + display: flex; + align-items: center; + justify-content: center; + background: rgba(9, 14, 21, 0.72); + color: var(--accent); + font-weight: 700; + font-size: 15px; + pointer-events: none; + border-radius: inherit; +} +/* 标题栏内:统计 + 文件 chip 同一行;chip 先铺满横轴,真不够再折行 */ +.dbc-summary-inline { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + align-items: center; + gap: 8px 10px; + margin: 0 0 0 8px; + flex: 1 1 auto; + min-width: 0; + max-width: none; + padding: 2px 6px; + white-space: nowrap; + overflow: hidden; +} +.dbc-summary-inline .dbc-summary-meta { + flex: 0 0 auto; +} +.dbc-file-chips { + display: flex; + flex-direction: row; + flex-wrap: wrap; /* 仅在占满标题栏剩余宽度后折行 */ + align-items: center; + align-content: flex-start; + gap: 4px 6px; + flex: 1 1 auto; + min-width: 0; + max-width: none; /* 去掉 40vw/360px 上限,吃满横轴 */ + overflow: hidden; +} +.dbc-file-chip { + display: inline-flex; + align-items: center; + gap: 2px; + max-width: 140px; + padding: 1px 2px 1px 6px; + border-radius: 999px; + background: rgba(34, 211, 238, 0.1); + border: 1px solid rgba(34, 211, 238, 0.25); + font-size: 10px; + color: var(--text); + flex: 0 0 auto; +} +.dbc-file-chip-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 110px; +} +.dbc-file-chip-remove { + border: none; + background: transparent; + color: var(--text-dim); + cursor: pointer; + font-size: 14px; + line-height: 1; + padding: 0 4px; + border-radius: 50%; +} +.dbc-file-chip-remove:hover { + color: var(--red); + background: rgba(239, 68, 68, 0.12); +} +.dbc-empty-drop { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + min-height: 280px; + padding: 32px 16px; + text-align: center; + color: var(--text-dim); + cursor: pointer; + border: 1px dashed var(--border); + border-radius: var(--radius-sm); + margin: 12px; + background: rgba(255, 255, 255, 0.02); +} +.dbc-empty-drop:hover { + border-color: var(--accent); + color: var(--text); +} +.dbc-empty-drop h4 { + margin: 0; + font-size: 15px; + color: var(--text); + font-weight: 600; +} +.dbc-empty-drop p { + margin: 0; + font-size: 12px; +} +.can-table-card-body .helper-note { + margin: 0 12px 12px; +} +.can-toolbar-block { + min-width: 0; +} +.can-toolbar-upload .upload-zone { + min-height: 72px; + padding: 12px 12px; +} +.can-toolbar-upload .upload-zone h4 { font-size: 13px; margin: 4px 0 2px; } +.can-toolbar-upload .upload-zone p { font-size: 11px; margin: 0; } +/* DBC 解析摘要:强制单行 */ +.dbc-summary-strip { + display: flex; + flex-wrap: nowrap; + align-items: center; + gap: 6px 8px; + margin-top: 8px; + padding: 6px 8px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: rgba(255, 255, 255, 0.02); + font-size: 11px; + color: var(--text-dim); + min-width: 0; + white-space: nowrap; +} +.dbc-summary-strip[hidden] { display: none !important; } +.dbc-summary-name { + flex: 0 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + color: var(--text); + font-weight: 600; +} +.dbc-summary-meta { + display: inline-flex; + flex-wrap: nowrap; + align-items: center; + gap: 0 8px; + flex: 0 0 auto; + white-space: nowrap; + font-variant-numeric: tabular-nums; +} +.dbc-summary-meta > span { + white-space: nowrap; + flex: 0 0 auto; +} +.dbc-summary-meta b { + color: var(--text); + font-weight: 600; + margin-right: 1px; +} +.dbc-summary-strip .btn { flex: 0 0 auto; padding: 2px 8px; white-space: nowrap; } +.can-toolbar-foot { + display: flex; + flex-wrap: wrap; + gap: 10px 16px; + margin-top: 12px; + align-items: center; +} +.can-toolbar-foot .helper-note { + flex: 1 1 160px; + margin: 0; + font-size: 11px; +} + +/* 报文列表标题栏:标题 | 摘要+chip(吃满中间) | 清除/添加 */ +.can-msg-list-head { + flex-wrap: nowrap; + row-gap: 8px; + min-width: 0; +} +.can-msg-list-head > .dbc-summary-inline { + margin-left: 8px; + flex: 1 1 auto; + min-width: 0; +} +.can-msg-head-actions { + display: inline-flex; + align-items: center; + gap: 6px; + flex: 0 0 auto; + margin-left: 8px; +} +.can-msg-list-head > .dbc-summary-inline + .can-msg-head-actions { + margin-left: 8px; +} +.can-msg-head-actions #clearDbcBtn[hidden] { + display: none !important; +} +/* 通道 / 搜索 / 勾选 / 节点 / 批量 — 同一行工具栏 */ +.can-msg-toolbar { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px 10px; + padding: 8px 14px; + border-bottom: 1px solid var(--border); + background: rgba(15, 23, 42, 0.35); + min-width: 0; + /* 不可用 overflow-x:auto:会把 overflow-y 也变成 clip,节点下拉被裁切 */ + overflow: visible; + position: relative; + z-index: 40; /* 高于 .message-table-head / 列表,避免下拉沉底 */ +} +.can-msg-channel { + display: inline-flex; + align-items: center; + gap: 6px; + flex: 0 0 auto; + font-size: 11px; + color: var(--text-dim); + text-transform: none; + letter-spacing: 0; + font-weight: 500; + cursor: pointer; + margin: 0; +} +.can-msg-channel-label { + white-space: nowrap; +} +.can-msg-channel-select { + width: 72px; + min-width: 72px; + height: 30px; + padding: 2px 6px; + font-size: 12px; +} +.can-msg-search-wrap { + flex: 1 1 140px; + min-width: 120px; + max-width: 260px; +} +.can-msg-search { + width: 100%; + padding: 5px 10px; + font-size: 12px; + height: 30px; +} +.can-msg-toolbar .can-batch-actions { + flex: 0 1 auto; + margin-left: auto; + flex-wrap: nowrap; + gap: 6px; + min-width: 0; +} +.can-msg-toolbar .pill { + white-space: nowrap; + flex-shrink: 0; +} +.can-msg-toolbar .btn-sm { + white-space: nowrap; + flex-shrink: 0; +} +.can-msg-toolbar .can-batch-all-label { + white-space: nowrap; + flex-shrink: 0; +} +.can-table-card-body { + padding: 0; +} +.can-editor-layout { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 340px); + gap: 18px; + align-items: start; + min-width: 0; + width: 100%; + max-width: 100%; +} +.can-editor-layout > .can-main-col, +.can-editor-layout > .can-sidepanel { + min-width: 0; + max-width: 100%; +} +.can-sidepanel { + display: flex; + flex-direction: column; + gap: 10px; /* 两卡间距略紧,仍保持独立卡片 */ + width: 100%; +} +.can-sidepanel > .card { + margin-top: 0; + min-width: 0; + max-width: 100%; +} +.can-preview-card .card-body, +.can-raw-card .card-body { + padding-top: 12px; + padding-bottom: 12px; +} +.dbc-dropzone { + position: relative; + overflow: hidden; +} +.dbc-dropzone::after { + content: ""; + position: absolute; + inset: -40% auto auto -10%; + width: 180px; + height: 180px; + background: radial-gradient(circle, rgba(34,211,238,.18), transparent 68%); + pointer-events: none; +} +.message-list { + max-height: 390px; + overflow-x: hidden !important; + overflow-y: auto; + overscroll-behavior-x: none; +} +.message-table { + /* 列宽随左栏宽度自适应压缩,禁止撑出横向滚动 */ + min-width: 0 !important; + width: 100%; + max-width: 100%; + table-layout: fixed; +} +.message-table-body { + min-width: 0; + width: 100%; + max-width: 100%; + overflow-x: hidden; +} +.message-table-head, +.message-row { + display: grid; + /* 报文名称勿抢宽;标识符/DLC/信号/节点给固定下限,避免被挤没 */ + grid-template-columns: + 34px /* 勾选 */ + 34px /* 展开 */ + 28px /* # */ + minmax(0, 0.55fr) /* DBC名称 */ + minmax(0, 1fr) /* 报文名称(收窄,长名省略) */ + minmax(7.2em, 0.65fr)/* 标识符 */ + 0fr /* 通道 */ + 0fr /* 类型 */ + 2.8em /* DLC */ + 2.8em /* 信号 */ + minmax(5.5em, 0.8fr) /* 发送节点 */ + minmax(0, 0.65fr); /* 注释 */ + align-items: center; + min-width: 0; + width: 100%; + max-width: 100%; + box-sizing: border-box; +} +.message-col-name { + /* 名称过长只省略,不撑开其它列 */ + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.message-col-id, +.message-col-dlc, +.message-col-signal, +.message-col-node { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.message-table-head { + position: sticky; + top: 0; + z-index: 2; + background: #182330; + border-bottom: 1px solid var(--border); +} +.message-row { + width: 100%; + border: 0; + background: transparent; + color: var(--text); + text-align: left; + cursor: pointer; + border-bottom: 1px solid rgba(255,255,255,.04); + transition: background .12s ease, border-color .12s ease; +} +.message-row:hover { + background: rgba(255,255,255,.03); +} +.message-row.active { + background: linear-gradient(90deg, rgba(34,211,238,.2), rgba(34,211,238,.07)); + box-shadow: inset 3px 0 0 0 var(--accent); +} +.message-col { + padding: 8px 6px; + font-size: 12px; + line-height: 1.45; + border-right: 1px solid rgba(255,255,255,.03); + min-width: 0; + box-sizing: border-box; +} +.message-table-head .message-col { + font-size: 11px; + font-weight: 700; + letter-spacing: .08em; + color: #c8d6e7; + text-transform: uppercase; +} +.message-col:last-child { + border-right: 0; +} +.message-col-index, +.message-col-dlc, +.message-col-signal, +.message-col-channel, +.message-col-type { + text-align: center; +} +.message-col-id, +.history-item-hex, +.can-frame-hex, +.can-title, +.can-subtitle, +.byte-pill, +.history-item-main, +.history-item-foot, +.signal-table, +.signal-cell-meta, +.signal-cell-range { + font-family: var(--font-mono); +} +.message-col-id { + color: var(--accent); + font-size: 12px; +} +.message-col-dbc { + color: var(--accent); + font-size: 11px; + font-weight: 600; + opacity: .9; +} +.can-empty-state { + display: grid; + place-items: center; + min-height: 300px; + text-align: center; + border: 1px dashed rgba(34,211,238,.2); + border-radius: var(--radius); + background: linear-gradient(180deg, rgba(26,36,50,.7), rgba(20,29,40,.96)); + padding: 28px; +} +.can-empty-icon { + width: 70px; + height: 70px; + border-radius: 18px; + display: grid; + place-items: center; + margin-bottom: 14px; + background: rgba(34,211,238,.12); + font-size: 32px; + box-shadow: inset 0 0 0 1px rgba(34,211,238,.16); +} +.can-empty-state h3 { + font-size: 22px; + margin-bottom: 8px; +} +.can-empty-state p { + max-width: 560px; + color: var(--text-dim); + line-height: 1.75; +} +.can-title { + font-size: 24px; + font-weight: 700; + letter-spacing: .02em; +} +.can-subtitle { + margin-top: 6px; + color: var(--text-dim); + font-size: 13px; +} +.can-selection-bar { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; +} +.can-selection-actions { + display: flex; + gap: 10px; + flex-wrap: wrap; +} +.can-meta-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; + margin-top: 16px; +} +.can-meta-card { + background: var(--bg-elevated); + border: 1px solid var(--border); + border-radius: 10px; + padding: 12px; +} +.can-meta-card span { + display: block; + font-size: 10px; + color: var(--text-label); + letter-spacing: .08em; + text-transform: uppercase; + margin-bottom: 4px; +} +.can-meta-card b { + font-size: 14px; +} +.can-comment-note { + margin-top: 16px; +} +.can-frame-panel { + background: linear-gradient(180deg, rgba(26,36,50,.95), rgba(13,19,27,.98)); + border: 1px solid rgba(34,211,238,.12); + border-radius: 16px; + padding: 18px; + box-shadow: inset 0 0 0 1px rgba(255,255,255,.02); +} +.can-frame-panel-compact { + padding: 16px; +} +.can-panel-kicker { + color: var(--text-label); + font-size: 10px; + letter-spacing: .12em; + text-transform: uppercase; +} +.can-panel-title { + margin-top: 6px; + font-size: 16px; + font-weight: 700; +} +.can-frame-hex { + margin-top: 14px; + padding: 16px 18px; + border-radius: 12px; + background: #081019; + color: #7ef3ff; + font-size: 18px; + line-height: 1.6; + word-break: break-all; + border: 1px solid rgba(34,211,238,.16); +} +.can-byte-strip { + display: flex; + flex-wrap: wrap; + gap: 4px; + margin-top: 8px; +} +.byte-pill { + min-width: 0; + width: 36px; + padding: 3px 2px 4px; + border-radius: 6px; + background: rgba(34,211,238,.08); + border: 1px solid rgba(34,211,238,.16); + text-align: center; + font-size: 11px; + line-height: 1.2; + font-family: var(--font-mono); + flex: 0 0 auto; +} +.byte-pill b { + display: block; + color: var(--text-label); + font-size: 9px; + font-weight: 600; + margin-bottom: 1px; + letter-spacing: 0; + font-family: var(--font); +} +.byte-pill > span { + font-variant-numeric: tabular-nums; +} +.signal-table-host { + display: block; + margin-top: 18px; + min-width: 0; + max-width: 100%; +} +.signal-table { + width: 100%; + max-width: 100%; + min-width: 0; + border: 1px solid var(--border); + border-radius: 12px; + overflow: hidden; +} +.signal-table-head, +.signal-row { + display: grid; + /* 全部可收缩,禁止固定 min 像素把容器撑宽 */ + grid-template-columns: + 2.2em + minmax(0, 1.15fr) + minmax(0, 0.9fr) + minmax(0, 0.95fr) + minmax(0, 0.85fr) + minmax(0, 0.9fr); + align-items: stretch; + min-width: 0; + width: 100%; + max-width: 100%; + box-sizing: border-box; +} +.signal-table-inline .signal-cell { + padding: 8px 8px; +} +.signal-table-inline .signal-cell b, +.signal-table-inline .signal-cell small { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 100%; +} +.signal-table-head { + background: #182330; + border-bottom: 1px solid var(--border); +} +.signal-table-body { + max-height: 520px; + overflow-x: hidden; + overflow-y: auto; +} +.signal-row { + border-bottom: 1px solid rgba(255,255,255,.04); + background: rgba(255,255,255,.01); +} +.signal-row:nth-child(even) { + background: rgba(255,255,255,.025); +} +.signal-cell { + padding: 11px 12px; + border-right: 1px solid rgba(255,255,255,.03); + font-size: 12px; + line-height: 1.5; + min-width: 0; +} +.signal-cell:last-child { + border-right: 0; +} +.signal-table-head .signal-cell { + font-size: 11px; + font-weight: 700; + letter-spacing: .08em; + color: #c8d6e7; + text-transform: uppercase; +} +.signal-cell-index { + text-align: center; +} +.signal-cell-name b, +.signal-cell-range b, +.signal-cell-meta b { + display: block; + font-size: 12px; + color: var(--text); +} +.signal-cell-name small, +.signal-cell-range small, +.signal-cell-meta small, +.signal-cell-comment { + display: block; + margin-top: 4px; + color: var(--text-dim); + font-size: 11px; + line-height: 1.45; +} +.signal-input-compact { + min-width: 0; + padding: 8px 10px; + font-size: 12px; +} +.can-response { + margin-top: 14px; + padding: 12px 14px; + border-radius: 12px; + background: var(--bg-elevated); + border: 1px solid var(--border); + font-family: var(--font-mono); + font-size: 12px; + word-break: break-all; +} +.can-response.ok { + border-color: rgba(34,197,94,.25); + color: #c7f9d5; +} +.can-response.err { + border-color: rgba(239,68,68,.25); + color: #fecaca; +} +.can-response-label { + color: var(--text-label); + font-size: 11px; + margin-bottom: 6px; + font-family: var(--font); +} +.history-list { + display: flex; + flex-direction: column; + gap: 4px; + max-height: 620px; + overflow-x: hidden; + overflow-y: auto; + padding-right: 4px; + min-width: 0; + max-width: 100%; +} +.history-list-compact { + max-height: 420px; +} +/* 单行紧凑历史:充分利用横轴,减少行高 */ +.history-item { + padding: 5px 10px; + border-radius: 8px; + background: var(--bg-elevated); + border: 1px solid var(--border); + min-width: 0; + max-width: 100%; + box-sizing: border-box; +} +.history-item-row { + display: grid; + grid-template-columns: + auto + 56px + minmax(0, 0.7fr) + minmax(0, 0.8fr) + minmax(0, 1.6fr); + align-items: center; + gap: 8px 10px; + min-width: 0; + max-width: 100%; +} +.history-item-row.is-err { + border-color: rgba(239, 68, 68, 0.35); +} +.history-item-time { + color: var(--text-label); + font-size: 11px; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} +.history-item-tag { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 2px 7px; + border-radius: 999px; + font-size: 10px; + font-weight: 700; + letter-spacing: .06em; + white-space: nowrap; + line-height: 1.3; +} +.history-item-tag.ok { + background: rgba(34,197,94,.12); + color: #b8f5cb; +} +.history-item-tag.err { + background: rgba(239,68,68,.12); + color: #fecaca; +} +.history-item-main { + margin-top: 0; + font-size: 12px; + color: var(--text); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} +.history-item-hex { + margin-top: 0; + font-size: 11px; + color: #7ef3ff; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; + word-break: normal; +} +.history-item-foot { + margin-top: 0; + font-size: 11px; + color: var(--text-dim); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; + text-align: right; +} +/* Click-to-Copy 样式 */ +.history-item-row .copyable { + cursor: pointer; + border-radius: 4px; + padding: 1px 4px; + margin: -1px -4px; + transition: background .15s, color .15s; +} +.history-item-row .copyable:hover { + background: rgba(255,255,255,.08); +} +.history-item-row .copyable:active { + background: rgba(255,255,255,.14); +} +.history-item-row .copyable.copied { + background: rgba(34,197,94,.18); + color: #86efac; +} + +/* === CAN 1Hz FEEDBACK === */ +.can-feedback-card { margin-top: 18px; } +.can-feedback-card .card-head { + display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 10px; +} +.feedback-controls { display: flex; align-items: center; gap: 8px; } +.feedback-grid { + display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; +} +/* can.html 右侧监控抽屉:竖向排三路,窄宽也能读 */ +.feedback-grid-drawer { + grid-template-columns: 1fr; + gap: 12px; +} +.can-monitor-drawer { + /* 用 100% 视口减量,避免 100vw 含滚动条宽度把页面撑出横条 */ + --sys-drawer-panel-w: min(440px, calc(100% - 48px)); + max-width: 100%; +} +.can-monitor-drawer .sys-drawer-body .status-section-title:first-child { + margin-top: 0; +} +.can-monitor-drawer .conn-indicator { + display: inline-flex; + align-items: center; + padding: 2px 8px; + margin: 0; + background: transparent; + border: none; +} +.feedback-channel.ch-disabled { + opacity: 0.45; + filter: grayscale(0.35); +} +.feedback-channel.ch-disabled .feedback-ch-head { + cursor: default; +} +.feedback-channel { + background: rgba(255,255,255,.03); border: 1px solid rgba(255,255,255,.07); + border-radius: 10px; padding: 14px 16px; +} +.feedback-ch-head { + font-weight: 700; font-size: 15px; margin-bottom: 10px; + display: flex; align-items: center; gap: 8px; + cursor: pointer; + user-select: none; +} +.feedback-expand-caret { + margin-left: auto; + font-size: 11px; + color: var(--text-dim); + transition: transform .15s ease; + opacity: .7; +} +.feedback-channel.collapsed .feedback-expand-caret { + transform: rotate(-90deg); +} +.feedback-channel.collapsed .feedback-detail { + display: none; +} +.feedback-channel:not(.collapsed) .feedback-ch-head { + margin-bottom: 8px; +} +.feedback-primary .feedback-row:last-child { + border-bottom: 1px solid rgba(255,255,255,.04); +} +.feedback-channel:not(.collapsed) .feedback-primary .feedback-row:last-child { + border-bottom: 1px solid rgba(255,255,255,.04); +} +.feedback-detail { + margin-top: 2px; + padding-top: 2px; + border-top: 1px dashed rgba(255,255,255,.06); +} +.ch-dot { + width: 10px; height: 10px; border-radius: 50%; display: inline-block; +} +.ch-dot.ch0 { background: #22d3ee; } +.ch-dot.ch1 { background: #a78bfa; } +.ch-dot.ch2 { background: #f59e0b; } +.feedback-row { + display: flex; justify-content: space-between; padding: 3px 0; + font-size: 13px; border-bottom: 1px solid rgba(255,255,255,.04); +} +.feedback-row:last-child { border-bottom: none; } +.fb-label { color: var(--muted, #8892a4); } +.fb-val { font-weight: 600; font-variant-numeric: tabular-nums; cursor: pointer; border-radius: 4px; padding: 1px 4px; margin: -1px -4px; transition: background .15s; } +.fb-val:hover { background: rgba(255,255,255,.08); } +.fb-val:active { background: rgba(255,255,255,.14); } +.fb-val.copied { background: rgba(34,197,94,.18); color: #86efac; } +.fb-val.mono { font-family: monospace; } +.fb-val.ok { color: #34d399; } +.fb-val.warn { color: #f59e0b; } +.fb-val.err { color: #f87171; } +.fb-val.zero { color: #64748b; } +@media (max-width: 900px) { + .feedback-grid { grid-template-columns: 1fr; } +} + +/* === ID SNAPSHOT TABLE === */ +.idsnap-table-wrap { overflow-x: auto; } +.idsnap-table { + width: 100%; border-collapse: collapse; font-size: 13px; + font-variant-numeric: tabular-nums; +} +.idsnap-table th { + text-align: left; padding: 6px 10px; font-weight: 600; + border-bottom: 2px solid rgba(255,255,255,.12); color: var(--muted, #8892a4); + white-space: nowrap; +} +.idsnap-table td { + padding: 5px 10px; border-bottom: 1px solid rgba(255,255,255,.05); + white-space: nowrap; +} +.idsnap-table td.mono { font-family: monospace; } +.idsnap-table tr:hover td { background: rgba(255,255,255,.03); } +.idsnap-table .flag-on { color: #34d399; font-weight: 600; } +.idsnap-table .flag-off { color: #475569; } +.idsnap-data { font-family: monospace; max-width: 320px; overflow: hidden; text-overflow: ellipsis; } + +.fb-data-cell { + max-width: 200px; overflow: hidden; text-overflow: ellipsis; + font-size: 11px; word-break: break-all; line-height: 1.4; +} + +/* === CAN message row expand / batch additions === */ +.message-col-check, +.message-col-toggle { + text-align: center; + padding: 6px 2px; + /* 勿被全局单元格 overflow:hidden 裁切勾选/展开 */ + overflow: visible !important; + text-overflow: clip; +} +/* 统一:报文行 / 全选 / 节点过滤 勾选框外观 */ +.can-page input[type="checkbox"], +.message-check, +.checkbox-inline input[type="checkbox"], +.can-node-filter-item input[type="checkbox"] { + -webkit-appearance: none; + appearance: none; + width: 15px; + height: 15px; + margin: 0; + padding: 0; + flex-shrink: 0; + box-sizing: border-box; + border: 1.5px solid rgba(148, 163, 184, 0.55); + border-radius: 4px; + background: rgba(15, 23, 42, 0.9); + cursor: pointer; + vertical-align: middle; + position: relative; + display: inline-block; + transition: border-color .12s ease, background .12s ease, box-shadow .12s ease; +} +.can-page input[type="checkbox"]:hover, +.message-check:hover, +.checkbox-inline input[type="checkbox"]:hover, +.can-node-filter-item input[type="checkbox"]:hover { + border-color: rgba(34, 211, 238, 0.65); +} +.can-page input[type="checkbox"]:checked, +.message-check:checked, +.checkbox-inline input[type="checkbox"]:checked, +.can-node-filter-item input[type="checkbox"]:checked { + background: var(--accent, #22d3ee); + border-color: var(--accent, #22d3ee); + box-shadow: 0 0 0 2px rgba(34, 211, 238, 0.18); +} +.can-page input[type="checkbox"]:checked::after, +.message-check:checked::after, +.checkbox-inline input[type="checkbox"]:checked::after, +.can-node-filter-item input[type="checkbox"]:checked::after { + content: ""; + position: absolute; + left: 4px; + top: 1px; + width: 4px; + height: 8px; + border: solid #0b1220; + border-width: 0 2px 2px 0; + transform: rotate(45deg); +} +.can-page input[type="checkbox"]:focus-visible, +.message-check:focus-visible, +.checkbox-inline input[type="checkbox"]:focus-visible, +.can-node-filter-item input[type="checkbox"]:focus-visible { + outline: none; + box-shadow: 0 0 0 3px rgba(34, 211, 238, 0.28); +} +.message-toggle { + width: 26px; + height: 26px; + min-width: 26px; + border-radius: 6px; + border: 1px solid rgba(255,255,255,.14); + background: rgba(255,255,255,.04); + color: #c8d6e7; + cursor: pointer; + font-size: 12px; + line-height: 1; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0; + box-sizing: border-box; + transition: background .12s ease, border-color .12s ease, color .12s ease; +} +.message-toggle:hover { + background: rgba(34,211,238,.12); + border-color: rgba(34,211,238,.4); + color: #e6fbff; +} +.message-toggle.expanded { + background: rgba(34,211,238,.18); + border-color: rgba(34,211,238,.55); + color: #e6fbff; +} + +.message-row-expand { + grid-column: 1 / -1; + background: rgba(255,255,255,.02); + border-top: 1px dashed rgba(255,255,255,.06); + border-bottom: 1px solid rgba(255,255,255,.04); + padding: 10px 12px 12px; + width: 100%; + max-width: 100%; + min-width: 0; + box-sizing: border-box; + /* 禁止展开区出现横向滑条;内容省略/折行 */ + overflow-x: hidden; +} +.message-row-expand[hidden] { + display: none; +} +.message-row-expand .signal-table-host { + margin-top: 0; + min-width: 0; + max-width: 100%; + overflow-x: hidden; +} +.message-row-expand .signal-table { + border-radius: 10px; + min-width: 0 !important; + max-width: 100%; +} +.message-row-expand .signal-table-body { + max-height: 360px; + overflow-x: hidden; + overflow-y: auto; +} +.message-row-expand .signal-cell { + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; +} +.message-row-expand .signal-input, +.message-row-expand .signal-input-compact, +.message-row-expand select.input, +.message-row-expand input.input { + width: 100%; + min-width: 0 !important; + max-width: 100%; + box-sizing: border-box; +} + +.can-batch-actions { + display: inline-flex; + align-items: center; + gap: 8px; + margin-left: auto; + flex-wrap: wrap; +} +.checkbox-inline { + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 12px; + color: var(--text-dim, #93a4bf); + cursor: pointer; + user-select: none; +} +.checkbox-inline input { + cursor: pointer; +} +.card-head-actions { + display: inline-flex; + align-items: center; + gap: 8px; +} +.btn.btn-sm { + padding: 4px 10px; + font-size: 12px; +} +.btn[disabled] { + opacity: .55; + cursor: not-allowed; +} + +/* === 2026-06-30c: 报文列表与右侧操作栏并列布局调整 === */ +/* 左列:报文列表 + 最近发送;右列:帧预览等 */ +.can-editor-layout > .can-main-col { + margin-top: 0; + display: flex; + flex-direction: column; + gap: 18px; + min-width: 0; + max-width: 100%; +} +.can-main-col > .can-table-card { + margin-top: 0; + min-width: 0; + max-width: 100%; + /* 保持 visible,避免裁切「发送节点」下拉;横向由内部 message-list 锁死 */ + overflow: visible; +} +.can-main-col > .can-history-card { + margin-top: 0; + align-self: stretch; + min-width: 0; + max-width: 100%; + overflow-x: hidden; +} +.can-table-card-body, +.can-msg-list-head, +.can-msg-toolbar { + min-width: 0; + max-width: 100%; +} +.can-frame-actions { + display: flex; + gap: 8px; + margin-top: 10px; + flex-wrap: wrap; +} +.can-frame-actions .btn { flex: 1 1 auto; min-width: 120px; } +.can-frame-panel-compact .can-frame-hex { + word-break: break-all; +} + + +/* === 2026-06-30c: 报文列表压缩列宽 + 纵向滚动美化 === */ +.message-list { + /* 提高可视高度,并改用美化的纵向滚动条 */ + max-height: calc(100vh - 300px); + min-height: 420px; + overflow-x: hidden !important; + overflow-y: auto; + overscroll-behavior-x: none; + min-width: 0; + width: 100%; + max-width: 100%; + scrollbar-width: thin; + scrollbar-color: rgba(34,211,238,.35) transparent; +} +.message-list::-webkit-scrollbar { width: 8px; } +.message-list::-webkit-scrollbar-track { + background: transparent; + margin: 4px 0; +} +.message-list::-webkit-scrollbar-thumb { + background: linear-gradient(180deg, rgba(34,211,238,.45), rgba(34,211,238,.18)); + border-radius: 6px; + border: 2px solid transparent; + background-clip: padding-box; +} +.message-list::-webkit-scrollbar-thumb:hover { + background: linear-gradient(180deg, rgba(34,211,238,.65), rgba(34,211,238,.28)); + background-clip: padding-box; + border: 2px solid transparent; +} + +.msg-src { + display: inline-block; + max-width: 4.5em; + overflow: hidden; + text-overflow: ellipsis; + vertical-align: bottom; + font-size: 10px; + font-weight: 600; + color: var(--accent); + opacity: .85; + margin-right: 2px; +} +/* 单元格统一省略号;切断 grid 项默认 min-content 最小宽 */ +.message-row .message-col, +.message-table-head .message-col { + min-width: 0 !important; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + box-sizing: border-box; +} +.message-col { + padding: 8px 6px; +} +/* 紧凑列的内边距收一点,腾出空间给名称/注释 */ +.message-col-index, +.message-col-channel, +.message-col-type, +.message-col-dlc, +.message-col-signal { + padding-left: 6px; + padding-right: 6px; +} +.message-col-check, +.message-col-toggle { + padding-left: 2px; + padding-right: 2px; + display: inline-flex; + align-items: center; + justify-content: center; +} +/* 注释列允许浅色降权显示 */ +.message-row .message-col-comment { + color: var(--muted); +} + +/* === 窄屏报文表降级(0fr 隐藏列,保持 track 数量) === */ +/* 列序:check toggle # dbc name id channel type dlc signal node comment */ +/* 隐藏列必须把 padding/border/内容贡献压到 0,否则仍会撑出横向滚动 */ +.message-col-channel, +.message-col-type { + padding: 0 !important; + border: 0 !important; + margin: 0 !important; + overflow: hidden !important; + min-width: 0 !important; + max-width: 0 !important; + font-size: 0 !important; + line-height: 0 !important; + visibility: hidden; + pointer-events: none; +} +@media (max-width: 1200px) { + .message-table-head, + .message-row { + grid-template-columns: + 34px 34px 28px + minmax(0, 0.5fr) /* dbc */ + minmax(0, 1fr) /* name */ + minmax(6.8em, 0.6fr)/* id */ + 0fr 0fr + 2.6em /* dlc */ + 0fr /* signal */ + minmax(5em, 0.7fr) /* node */ + minmax(0, 0.55fr); /* comment */ + } + .message-col-signal { + padding: 0 !important; + border: 0 !important; + overflow: hidden !important; + min-width: 0 !important; + max-width: 0 !important; + font-size: 0 !important; + visibility: hidden; + pointer-events: none; + } +} +@media (max-width: 1024px) { + .message-table-head, + .message-row { + grid-template-columns: + 34px 34px 26px + minmax(0, 0.5fr) + minmax(0, 1fr) + minmax(6.5em, 0.55fr) + 0fr 0fr + 2.6em + 0fr + 0fr /* node */ + minmax(0, 0.55fr); + } + .message-col-node { + padding: 0 !important; + border: 0 !important; + overflow: hidden !important; + min-width: 0 !important; + max-width: 0 !important; + font-size: 0 !important; + visibility: hidden; + pointer-events: none; + } +} +@media (max-width: 900px) { + .message-table-head, + .message-row { + grid-template-columns: + 34px 34px 26px + 0fr /* dbc */ + minmax(0, 1fr) + minmax(6.2em, 0.55fr) + 0fr 0fr + 2.6em + 0fr 0fr + minmax(0, 0.5fr); + } + .message-col-dbc { + padding: 0 !important; + border: 0 !important; + overflow: hidden !important; + min-width: 0 !important; + max-width: 0 !important; + font-size: 0 !important; + visibility: hidden; + pointer-events: none; + } +} + + +/* 工作台单独一行 */ +.can-top-layout { + margin-top: 18px; + display: block; +} +.can-top-layout > .can-toolbar-card { + margin-top: 0; +} +/* 历史卡:报文列表下方,单行紧凑约 8~10 条可见 */ +.can-history-card .card-body { + display: flex; + flex-direction: column; + padding-top: 10px; + padding-bottom: 10px; +} +.can-history-card .history-list { + max-height: min(42vh, 420px); + overflow-y: auto; + padding-right: 4px; + scrollbar-width: thin; + scrollbar-color: rgba(34,211,238,.35) transparent; +} +.can-history-card .history-list::-webkit-scrollbar { width: 8px; } +.can-history-card .history-list::-webkit-scrollbar-track { background: transparent; margin: 4px 0; } +.can-history-card .history-list::-webkit-scrollbar-thumb { + background: linear-gradient(180deg, rgba(34,211,238,.45), rgba(34,211,238,.18)); + border-radius: 6px; + border: 2px solid transparent; + background-clip: padding-box; +} +.can-history-card .history-list::-webkit-scrollbar-thumb:hover { + background: linear-gradient(180deg, rgba(34,211,238,.65), rgba(34,211,238,.28)); + background-clip: padding-box; +} + + +/* === 2026-07-01a: 发送节点过滤下拉 === */ +.can-node-filter { + position: relative; + display: inline-flex; + z-index: 1; +} +/* 面板打开时整块提到最前 */ +.can-node-filter.is-open { + z-index: 50; +} +.can-node-filter-btn { + display: inline-flex; + align-items: center; + gap: 6px; + white-space: nowrap; +} +.can-node-filter-btn.active { + border-color: rgba(34,211,238,.65); + color: var(--accent); + background: rgba(34,211,238,.08); +} +.can-node-filter-caret { + font-size: 10px; + opacity: .75; +} +.can-node-filter-panel { + position: absolute; + top: calc(100% + 6px); + right: 0; + z-index: 60; + min-width: 260px; + max-width: 320px; + background: #182330; + border: 1px solid var(--border); + border-radius: 12px; + box-shadow: 0 18px 38px rgba(0,0,0,.45); + padding: 10px; +} +.can-node-filter-panel[hidden] { display: none; } +.can-node-filter-head { + display: flex; + flex-direction: column; + gap: 8px; + margin-bottom: 8px; +} +.can-node-filter-search { + width: 100%; + padding: 6px 8px; + font-size: 12px; +} +.can-node-filter-actions { + display: flex; + gap: 6px; + justify-content: flex-end; +} +.can-node-filter-list { + max-height: 260px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 2px; + scrollbar-width: thin; + scrollbar-color: rgba(34,211,238,.35) transparent; +} +.can-node-filter-list::-webkit-scrollbar { width: 8px; } +.can-node-filter-list::-webkit-scrollbar-thumb { + background: rgba(34,211,238,.35); + border-radius: 6px; +} +.can-node-filter-item { + display: grid; + grid-template-columns: 18px 1fr auto; + align-items: center; + gap: 8px; + padding: 6px 8px; + border-radius: 8px; + cursor: pointer; + font-size: 12px; + color: var(--text); + transition: background .12s; +} +.can-node-filter-item:hover { + background: rgba(255,255,255,.04); +} +.can-node-filter-item input[type="checkbox"] { + margin: 0; +} +.can-node-filter-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-family: var(--font-mono); +} +.can-node-filter-count { + font-size: 11px; + color: var(--muted); + background: rgba(255,255,255,.05); + border-radius: 999px; + padding: 1px 8px; + min-width: 26px; + text-align: center; +} diff --git a/css/components.css b/css/components.css new file mode 100644 index 0000000..c913767 --- /dev/null +++ b/css/components.css @@ -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); } diff --git a/css/layout.css b/css/layout.css new file mode 100644 index 0000000..5fe2922 --- /dev/null +++ b/css/layout.css @@ -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; } diff --git a/css/motor.css b/css/motor.css new file mode 100644 index 0000000..ba4b0ed --- /dev/null +++ b/css/motor.css @@ -0,0 +1,797 @@ +/* motor.css - 电机控制台页面样式 + * 由 motor.html 内联 +
↻
+ + +""" +_RELOAD_SNIPPET = _RELOAD_SNIPPET.replace("__HOME__", "/tabs.html") +_RELOAD_MARK = "" + +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() diff --git a/esp32_web.vbs b/esp32_web.vbs new file mode 100644 index 0000000..79e8975 --- /dev/null +++ b/esp32_web.vbs @@ -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 diff --git a/index.html b/index.html new file mode 100644 index 0000000..6ec1868 --- /dev/null +++ b/index.html @@ -0,0 +1,521 @@ + + + + + + +ESP32 OTA 中继控制台 + + + + + + + + + +
+ + +
+
+ +
+ ESP32 OTA 中继控制台 + DCU · Robot_v2 · Relay +
+
+
+ 未连接 + v-- + -- + -- + 运行 -- +
+
+ 设备名: - + 设备ID: - +
+
+ + +
+ + +
+ + +
+
🔗 设备连接
+
+
+ + +
+
+
+ +
+ + +
+
+
+ + + +
+
+ + +
+
🎯 升级目标、固件文件与执行操作
+
+
+ + +
+
+
+
+
📤
+

点击或拖拽 ESP32 固件

+
+ +
+
+ 📄 - + +
+
+
类型-
+
大小-
+
+
+ SHA-256 + - +
+
+
+ + + + +
+
+ +
+ + +
+
+
+ + +
+
⚡ 刷写进度与流程阶段
+
+ +
+
📦ESP32 OTA
+
📤GD32 OTA
+
+ +
+
+
+ 等待操作... + 0% +
+
+
+ + + + + +
+
1
认证握手
+
2
传输协商
+
3
数据转发
+
4
收尾验证
+
5
APP 启动
+
6
OTA完成
+
+
+ + 待机 +
+ +
+ +
+
+
+ +
+ + +
+ + +
+
📡 设备状态
+
+
+
+
离线
+
连接状态
+
+ +
+
offline
+
BLE 连接
+
+
+
未就绪
+
GATT 状态
+
+
+ + +
+
+ + +
+
📊 运行指标
+
+
+
ESP32 温度
--
+
GD32 温度
--
+
输入 VCC
--
+
VDDA
--
+
GD32 APP
--
+
GD32 BOOT
--
+
GD32 自检
--
+
+
+
+ + + + +
+
+ 🖥️ 调试日志 + UDP :6666 +
+
+
+
+ + + + +
+ +
+
+
[--:--:--]I[系统]等待上位机连接...
+
+
+ 共 0 条 + +
+
+
+ +
+
+
+ + + + + +
+ ✓ + 操作成功 +
+ + + + + + + + diff --git a/js/app.js b/js/app.js new file mode 100644 index 0000000..58ce23c --- /dev/null +++ b/js/app.js @@ -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); \ No newline at end of file diff --git a/js/ble/constants.js b/js/ble/constants.js new file mode 100644 index 0000000..d2777f5 --- /dev/null +++ b/js/ble/constants.js @@ -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: '当前目标:GD32。页面会使用 cmd=0x00 发送数据,写入完成后自动触发 SPI OTA 中继。', + verifyStage: 6, + finalizeStage: 5, + stage5Label: '完成触发', + stage6Label: '校验比对' + }, + esp32: { + label: 'ESP32', + packetCmd: 0x01, + ctrlHex: 'F8', + recommendedPayload: 480, + finishText: '自动完成 OTA', + hint: '当前目标:ESP32。页面会使用 cmd=0x01 发送数据,写入完成后发 0xF8 + 32 字节 SHA-256 触发设备端收尾:设备端读 OTA 分区算 SHA 并比对,一致则 end+set_boot+reboot;不一致上报 0x85,不重启。', + verifyStage: 5, + finalizeStage: 6, + stage5Label: '校验比对', + stage6Label: '完成触发' + } +}; + +export const MAX_LOG_LINES = 1000; diff --git a/js/ble/file.js b/js/ble/file.js new file mode 100644 index 0000000..45ce3cb --- /dev/null +++ b/js/ble/file.js @@ -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); + } +} diff --git a/js/ble/gatt.js b/js/ble/gatt.js new file mode 100644 index 0000000..d8e104c --- /dev/null +++ b/js/ble/gatt.js @@ -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'); +} diff --git a/js/ble/session.js b/js/ble/session.js new file mode 100644 index 0000000..a39be76 --- /dev/null +++ b/js/ble/session.js @@ -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)); +} diff --git a/js/ble/stages.js b/js/ble/stages.js new file mode 100644 index 0000000..5b3e6f7 --- /dev/null +++ b/js/ble/stages.js @@ -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 || '未知错误')); +} \ No newline at end of file diff --git a/js/ble/state.js b/js/ble/state.js new file mode 100644 index 0000000..2e4bfb9 --- /dev/null +++ b/js/ble/state.js @@ -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'; + } +} diff --git a/js/ble/transfer.js b/js/ble/transfer.js new file mode 100644 index 0000000..dc46bc0 --- /dev/null +++ b/js/ble/transfer.js @@ -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); + } +} diff --git a/js/ble/verify.js b/js/ble/verify.js new file mode 100644 index 0000000..6b364d6 --- /dev/null +++ b/js/ble/verify.js @@ -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'; } +} \ No newline at end of file diff --git a/js/ble_ota.js b/js/ble_ota.js new file mode 100644 index 0000000..80ca292 --- /dev/null +++ b/js/ble_ota.js @@ -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(); \ No newline at end of file diff --git a/js/can.js b/js/can.js new file mode 100644 index 0000000..c3c3ee0 --- /dev/null +++ b/js/can.js @@ -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(); \ No newline at end of file diff --git a/js/can/dbc.js b/js/can/dbc.js new file mode 100644 index 0000000..da70de7 --- /dev/null +++ b/js/can/dbc.js @@ -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://)。 */ +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_ ; + * 注意该值是物理值(已应用 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 + ' 报文'; + } +} diff --git a/js/can/messages.js b/js/can/messages.js new file mode 100644 index 0000000..8c05d39 --- /dev/null +++ b/js/can/messages.js @@ -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 '' + + '' + + '' + escapeHtml(s.name) + '' + + '' + + ''; + }).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 = '
无匹配节点
'; + 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 ''; + }).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 = + '
' + + '
📥
' + + '

导入 DBC 开始编辑报文

' + + '

支持一次选择或拖入 多个 .dbc 文件

' + + '
'; + updateBatchUi(); + return; + } + if (!state.filteredMessages.length) { + el.messageList.innerHTML = '
没有匹配的报文。
'; + 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 = '' + + '
' + + '' + + '' + + '' + (index + 1) + '' + + '' + + escapeHtml(msg.sourceName ? String(msg.sourceName).replace(/\.dbc$/i, '') : '-') + '' + + '' + + escapeHtml(msg.name) + '' + + '' + escapeHtml(idHex) + '' + + 'CH' + escapeHtml(channelTxt) + '' + + '' + escapeHtml(msg.isExtended ? '扩展' : '标准') + '' + + '' + escapeHtml(msg.dlc) + '' + + '' + escapeHtml(msg.signals.length) + '' + + '' + escapeHtml(msg.transmitter || 'Vector__XXX') + '' + + '' + escapeHtml(comment || '-') + '' + + '
'; + var expandHtml = '
' + + '
' + + '
'; + return rowHtml + expandHtml; + }).join(''); + el.messageList.innerHTML = + '
' + + '
' + + '' + + '' + + '#' + + 'DBC名称' + + '报文名称' + + '标识符' + + '通道' + + '类型' + + 'DLC' + + '信号' + + '发送节点' + + '注释' + + '
' + + '
' + rows + '
' + + '
'; + + 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(); +} diff --git a/js/can/persist.js b/js/can/persist.js new file mode 100644 index 0000000..851f96b --- /dev/null +++ b/js/can/persist.js @@ -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(); + }); +} diff --git a/js/can/send.js b/js/can/send.js new file mode 100644 index 0000000..c131446 --- /dev/null +++ b/js/can/send.js @@ -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} 与 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' + ? '原始帧已发送到设备,若有响应则显示在下方。' + : '设备返回错误码 ' + escapeHtml(result.code) + '。'; + } +} + +// ==================== 发送历史 ==================== +/** + * 构造一条历史条目。集中字段,避免多处重复拼对象。 + */ +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 = '
尚未发送任何 CAN 帧。
'; + return; + } + el.historyList.innerHTML = state.history.map(function (item) { + var meta = 'CH' + item.channel + ' · ' + item.idHex + ' · DLC ' + item.dlc; + return '' + + '
' + + '' + escapeHtml(item.mode) + '' + + '' + escapeHtml(item.time) + '' + + '' + escapeHtml(meta) + '' + + '' + escapeHtml(item.dataHex || '--') + '' + + '' + escapeHtml(item.resultShort || item.resultText) + '' + + '
'; + }).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 合法,当前长度 ' + (dataHex.length / 2) + ' 字节。'; + } 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'); +} diff --git a/js/can/signals.js b/js/can/signals.js new file mode 100644 index 0000000..28c83f1 --- /dev/null +++ b/js/can/signals.js @@ -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 = '
当前报文没有信号定义。
'; + return; + } + var rows = activeSignals.map(function (signal, index) { + return renderSignalRow(message, signal, index); + }).join(''); + host.innerHTML = + '
' + + '
' + + '#' + + '信号名称' + + '输入值' + + '范围 / 枚举' + + '位定义' + + '注释' + + '
' + + '
' + rows + '
' + + '
'; + 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 = ''; + } else { + inputHtml = ''; + } + return '' + + '
' + + '' + (index + 1) + '' + + '' + + '' + sanitizeHtml(signal.name) + '' + + '' + sanitizeHtml((signal.receivers || []).join(', ') || '-') + '' + + '' + + '' + inputHtml + '' + + '' + + '' + sanitizeHtml(rangeText) + '' + + (enumCount ? '' + sanitizeHtml(enumKeys.slice(0, 3).map(function (key) { return key + ':' + signal.enumMap[key]; }).join(' / ')) + (enumCount > 3 ? ' ...' : '') + '' : '' + sanitizeHtml(factorText) + '') + + '' + + '' + + '' + sanitizeHtml(bitRange) + '' + + '' + sanitizeHtml(typeText) + '' + + '' + + '' + sanitizeHtml(comment) + '' + + '
'; +} + +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 ' + message.dlc + ',当前编码 ' + state.generatedData.length + + ' 字节,发送通道 CH' + (el.defaultChannel ? el.defaultChannel.value : '0') + '。'; + 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 '
无数据
'; + var html = []; + for (var i = 0; i < bytes.length; i++) { + html.push('B' + i + '' + padHex(bytes[i], 2) + ''); + } + 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); +} diff --git a/js/can_monitor.js b/js/can_monitor.js new file mode 100644 index 0000000..48d9ee2 --- /dev/null +++ b/js/can_monitor.js @@ -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 = '无数据(CAN 未收到帧)'; + return; + } + var frag = document.createDocumentFragment(); + for (var i = 0; i < entries.length; i++) { + var e = entries[i]; + var tr = document.createElement('tr'); + tr.innerHTML = + '' + (i + 1) + '' + + '' + escapeHtml(e.id) + '' + + '' + escapeHtml(e.dlc) + '' + + '' + (e.fd ? 'Y' : '-') + '' + + '' + (e.brs ? 'Y' : '-') + '' + + '' + (e.esi ? 'Y' : '-') + '' + + '' + escapeHtml(e.data || '--') + ''; + frag.appendChild(tr); + } + tbody.innerHTML = ''; + tbody.appendChild(frag); +} + +function renderIdTableEmpty() { + var tbody = el.idsnapBody; + if (tbody) tbody.innerHTML = '等待数据...'; + 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(); +} diff --git a/js/common/base-url.js b/js/common/base-url.js new file mode 100644 index 0000000..a0386b8 --- /dev/null +++ b/js/common/base-url.js @@ -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://)。 */ +export function getBaseUrl() { + return 'http://' + getDeviceIp(); +} diff --git a/js/common/connection.js b/js/common/connection.js new file mode 100644 index 0000000..7795986 --- /dev/null +++ b/js/common/connection.js @@ -0,0 +1,1167 @@ +/** + * connection.js - 连接管理 + 系统状态显示 + * + * 操作 DOM 元素 #pillConn / #pillConnTxt / #bConn 等(连接文案仅顶栏)。 + * + * 共享状态(通过 window.* 引用): + * window.latestSystemStatus — 最近一次 /system/status 响应 + * window.lastSysStateCode — 上次成功的 sys_state_code(polling.js 读取) + * window.lastRelayState — 上次成功的 relay_state(polling.js 读取) + * window.consecutivePollFailures — 连续轮询失败计数(polling.js 读取) + * + * 复用的函数(通过 window.* 引用): + * window.getBaseUrl / window.updateSystemStatusUi / window.formatPercent + */ +import { formatBytes, formatTime, formatUptime, escapeHtml } from './utils.js'; +import { addLog } from './log.js'; +import { showToast } from './toast.js'; +import { + appendHeapLowRecords, loadHeapLowHistory, clearHeapLowHistory, HEAP_HISTORY_MAX +} from './heap-low-history.js'; + +// ==================== 渲染缓存(性能) ==================== +// /system/status 每 1s(中继期 400ms)刷一次 UI。此前每轮全量 innerHTML 重建 +// CPU/任务栈列表 + 无条件写几十个节点,引发周期性样式重算/重排/重绘掉帧。 +// 这里统一三条策略:静态元素只查一次、文本/宽度/类名写前比较、列表增量更新。 +var elCache = {}; // id -> element(页面静态节点,仅首次 getElementById) +function getEl(id) { + var el = elCache[id]; + if (el === undefined || el === null) { + el = document.getElementById(id); + if (el) elCache[id] = el; // 未找到不缓存,等节点出现后再查 + } + return el || null; +} +/** 写前比较的 textContent 更新:值未变则不触碰 DOM,避免无谓样式失效。 */ +function setTextById(id, value) { + var el = getEl(id); + if (!el) return; + var v = String(value); + if (el.textContent !== v) el.textContent = v; +} +function setClassIfChanged(el, cls) { + if (el && el.className !== cls) el.className = cls; +} +function setTextIfChanged(el, txt) { + if (el && el.textContent !== txt) el.textContent = txt; +} +/** 写前比较的宽度更新:用元素上的 _lastW 缓存避免读回 style 字符串。 */ +function setWidthIfChanged(el, widthStr) { + if (el && el._lastW !== widthStr) { + el._lastW = widthStr; + el.style.width = widthStr; + } +} + +// ==================== 连接管理 ==================== +export function updateConnectionUi(connected, text) { + var label = text || (connected ? '已连接' : '未连接'); + // 仅顶栏连接胶囊(下方连接卡不再重复显示文案) + var pc = getEl('pillConn'); + var pct = getEl('pillConnTxt'); + if (pc) { + setClassIfChanged(pc, connected ? 'pill online' : 'pill offline'); + if (pct) setTextIfChanged(pct, label); + } + // 系统状态卡徽章 + var bc = getEl('bConn'); + if (bc) { + setTextIfChanged(bc, connected ? '在线' : '离线'); + setClassIfChanged(bc, 'v ' + (connected ? 'ok' : 'err')); + } +} +export function updateHealthUi(healthCode, freeHeap) { + var healthNames = ['正常', '警告', '危险', '关机']; + var health = healthCode !== undefined ? (healthNames[healthCode] || '未知') : '未知'; + /* 连接文案由 updateConnectionUi / 顶栏 pill 负责,不在此拼「设备在线 · 系统正常」 */ + if (freeHeap !== undefined) { + setTextById('mHeap', formatBytes(freeHeap)); + } + var mh = getEl('mHealth'); + if (mh) { + setTextIfChanged(mh, health); + setClassIfChanged(mh, 'v ' + (healthCode === 0 ? 'ok' : healthCode === 1 ? 'warn' : 'err')); + } + ensureHealthBootToggleBound(); +} + +/** 健康状态 badge 点击展开/收起启动自检面板(只绑一次)。 */ +function ensureHealthBootToggleBound() { + var badge = document.getElementById('healthBadge'); + var panel = document.getElementById('healthBootPanel'); + if (!badge || !panel || badge.dataset.bootToggleBound === '1') return; + badge.dataset.bootToggleBound = '1'; + + function toggle() { + var open = panel.classList.contains('collapsed') || panel.hasAttribute('hidden'); + if (open) { + panel.hidden = false; + panel.classList.remove('collapsed'); + badge.classList.add('is-open'); + badge.setAttribute('aria-expanded', 'true'); + } else { + panel.hidden = true; + panel.classList.add('collapsed'); + badge.classList.remove('is-open'); + badge.setAttribute('aria-expanded', 'false'); + } + } + badge.addEventListener('click', function (e) { + e.preventDefault(); + toggle(); + }); + badge.addEventListener('keydown', function (e) { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + toggle(); + } + }); +} + +/** 启动自检内容(/system/status.boot);挂在健康状态展开区,非常驻。 */ +/** 启动自检渲染签名:boot 数据启动后不变,签名相同就跳过整段 DOM 重建。 */ +var bootUiSig = null; + +function updateBootInitUi(data) { + var boot = data && data.boot ? data.boot : null; + /* 签名 = 影响渲染的全部字段;每秒轮询里 boot 几乎永远不变,直接短路 */ + var sig = boot + ? [boot.mask, boot.critical_ok, boot.all_ok, boot.summary, boot.failed, + Array.isArray(boot.modules) ? boot.modules.length : 0].join('|') + : 'none'; + if (sig === bootUiSig) return; + bootUiSig = sig; + + var resultEl = getEl('mBootResult'); + var failedEl = getEl('mBootFailed'); + var listEl = getEl('bootModuleList'); + if (!boot) { + if (resultEl) { + resultEl.textContent = '--'; + resultEl.className = 'health-boot-summary'; + } + if (failedEl) { + failedEl.textContent = '--'; + failedEl.className = ''; + } + if (listEl) listEl.innerHTML = ''; + return; + } + + var criticalOk = !!boot.critical_ok; + var allOk = !!boot.all_ok; + var summary = boot.summary || (allOk ? 'OK (all modules)' : (criticalOk ? 'OK (critical)' : 'FAIL')); + var failed = (boot.failed && String(boot.failed).trim()) ? String(boot.failed) : ''; + var tone = allOk ? 'ok' : (criticalOk ? 'warn' : 'err'); + + if (resultEl) { + resultEl.textContent = summary; + resultEl.className = 'health-boot-summary ' + tone; + resultEl.title = 'mask=0x' + (Number(boot.mask) || 0).toString(16); + } + if (failedEl) { + failedEl.textContent = failed || (allOk ? '无' : '--'); + failedEl.className = failed ? tone : 'ok'; + } + + if (listEl) { + var modules = Array.isArray(boot.modules) ? boot.modules : []; + if (!modules.length) { + listEl.innerHTML = ''; + } else { + listEl.innerHTML = modules.map(function (m) { + var ok = !!m.ok; + var critical = !!m.critical; + var cls = 'boot-mod ' + (ok ? 'ok' : (critical ? 'fail' : 'fail optional')); + var tip = (m.name || '') + (ok ? ' 成功' : ' 失败') + (critical ? ' · 关键' : ' · 非关键'); + return '' + + '' + + escapeHtml(m.name || '?') + + ''; + }).join(''); + } + } + + if (!window._bootInitLogged) { + window._bootInitLogged = true; + var logLine = '启动自检: ' + summary + (failed ? ' · 失败/跳过: ' + failed : ''); + addLog(allOk ? 'I' : (criticalOk ? 'W' : 'E'), 'BOOT', logLine); + } + ensureHealthBootToggleBound(); +} + +/* ==================== 状态任务列表:增量渲染 ==================== + * innerHTML 全量重建会重解析 HTML、重建每条 .status-task-bar(其 ::after 微光 + * 动画随之重启、合成层反复创建销毁),是主页每秒掉帧的主要来源之一。 + * 这里按 key(任务名)缓存行节点:集合不变时只改数字/宽度,排序变了仅更新 CSS order。 + * 行结构统一:head(名称/占比) + bar + 3 项 meta。 + * items: [{ key, rowCls, pctText, width, fillCls, metaLabels[3], metas[3] }] + */ +function buildStatusTaskRowHtml(it) { + return '' + + '
' + + '
' + + '
' + escapeHtml(it.key) + '
' + + '
' + escapeHtml(it.pctText) + '
' + + '
' + + '
' + + '
' + + '
' + it.metaLabels[0] + '' + escapeHtml(it.metas[0]) + '
' + + '
' + it.metaLabels[1] + '' + escapeHtml(it.metas[1]) + '
' + + '
' + it.metaLabels[2] + '' + escapeHtml(it.metas[2]) + '
' + + '
' + + '
'; +} +function updateStatusTaskRow(row, it) { + var refs = row._refs; + if (!refs) { + refs = row._refs = { + ratio: row.querySelector('.status-task-ratio'), + fill: row.querySelector('.status-task-bar > div'), + metas: row.querySelectorAll('.status-task-meta b') + }; + } + setTextIfChanged(refs.ratio, it.pctText); + setClassIfChanged(refs.fill, it.fillCls); + setWidthIfChanged(refs.fill, it.width); + setTextIfChanged(refs.metas[0], it.metas[0]); + setTextIfChanged(refs.metas[1], it.metas[1]); + setTextIfChanged(refs.metas[2], it.metas[2]); +} +/** 空态提示:内容未变不重写,并清掉行缓存让下次有数据时重建。 */ +function setListEmpty(listEl, html) { + var cache = listEl._rowCache; + if (cache) { cache.rows = {}; cache.keys = null; } + if (listEl._emptyHtml !== html) { + listEl._emptyHtml = html; + listEl.innerHTML = html; + } +} +function renderStatusTaskList(listEl, items) { + listEl._emptyHtml = null; + var cache = listEl._rowCache || (listEl._rowCache = { keys: null, rows: {} }); + var keys = []; + var seen = {}; + var hasDup = false; + for (var i = 0; i < items.length; i++) { + var k = items[i].key; + if (seen[k]) hasDup = true; + seen[k] = true; + keys.push(k); + } + var keyStr = keys.join('\u0001'); + var sameSet = !hasDup && keys.length === Object.keys(cache.rows).length && + keys.every(function (k2) { return !!cache.rows[k2]; }); + if (!sameSet) { + /* 集合变化(或同名任务无法索引):一次性重建并缓存行引用 */ + listEl.innerHTML = items.map(buildStatusTaskRowHtml).join(''); + cache.rows = {}; + cache.keys = null; + if (!hasDup) { + for (var c = 0; c < listEl.children.length; c++) cache.rows[keys[c]] = listEl.children[c]; + cache.keys = keyStr; + } + return; + } + if (keyStr !== cache.keys) { + /* 集合相同仅排序变化:保留节点位置,避免带动画的光泽伪元素重启 */ + cache.keys = keyStr; + } + for (var u = 0; u < items.length; u++) { + var row = cache.rows[keys[u]]; + var order = String(u); + if (row.style.order !== order) row.style.order = order; + updateStatusTaskRow(row, items[u]); + } +} + +/* 原始 JSON 折叠面板:收起时跳过 JSON.stringify + 大段文本重排; + * 展开瞬间用 window.latestSystemStatus 补渲染,行为与之前一致。 */ +var statusJsonBound = false; +function renderStatusJsonIfVisible(data) { + var statusJson = getEl('statusJson'); + if (!statusJson) return; + var fold = getEl('statusJsonFold'); + if (!statusJsonBound && fold) { + statusJsonBound = true; + var head = fold.querySelector('.status-subfold-head'); + var renderLatest = function () { + /* 内联 onclick/onkeydown 已先切换 collapsed,这里只补展开后的内容 */ + if (!fold.classList.contains('collapsed') && window.latestSystemStatus) { + statusJson.textContent = JSON.stringify(window.latestSystemStatus, null, 2); + } + }; + if (head) { + head.addEventListener('click', renderLatest); + head.addEventListener('keydown', function (e) { + if (e.key === 'Enter' || e.key === ' ') renderLatest(); + }); + } + } + if (fold && fold.classList.contains('collapsed')) return; + statusJson.textContent = JSON.stringify(data, null, 2); +} + +export function updateHeapAndTaskStatusUi(data) { + if (!data) return; + var formatPercent = window.formatPercent; + var ota = data.ota || {}; + var setText = function (id, value) { setTextById(id, value); }; + + updateBootInitUi(data); + + // ---- 堆概况:已用 / 总计 + 占用条 ---- + function setFill(id, pct) { + var el = getEl(id); + if (!el) return; + setWidthIfChanged(el, Math.max(0, Math.min(100, pct)) + '%'); + setClassIfChanged(el, 'heap-bar-fill' + (pct >= 85 ? ' danger' : pct >= 70 ? ' warn' : '')); + } + function setBar(usageId, fillId, pctId, free, total) { + if (free === undefined || total === undefined) { + setText(usageId, '--'); setFill(fillId, 0); setText(pctId, '--'); + return; + } + var used = total - free; + var pct = total > 0 ? (used * 100 / total) : 0; + setText(usageId, '已用 ' + formatBytes(used) + ' / 总计 ' + formatBytes(total)); + setFill(fillId, pct); + setText(pctId, pct.toFixed(1) + '%'); + } + function setCpuFill(pct) { + var el = getEl('cpuUsageFill'); + if (!el) return; + var safePct = Math.max(0, Math.min(100, Number(pct) || 0)); + setWidthIfChanged(el, safePct + '%'); + setClassIfChanged(el, 'heap-bar-fill cpu-fill' + (safePct >= 85 ? ' danger' : safePct >= 70 ? ' warn' : '')); + } + function updateCpuUsageUi(cpuData) { + cpuData = cpuData || {}; + var taskList = getEl('cpuTaskList'); + if (!cpuData.available) { + setText('cpuUsageText', '未启用 FreeRTOS 统计'); + setText('cpuUsagePct', '--'); + setText('cpuIdlePct', '--'); + setText('cpuSampleMs', '--'); + setText('cpuTaskCount', '--'); + setCpuFill(0); + if (taskList) setListEmpty(taskList, '
固件未启用 CPU runtime stats
'); + return; + } + if (!cpuData.valid) { + setText('cpuUsageText', '等待下一次采样...'); + setText('cpuUsagePct', '--'); + setText('cpuIdlePct', '--'); + setText('cpuSampleMs', '--'); + setText('cpuTaskCount', '--'); + setCpuFill(0); + if (taskList) setListEmpty(taskList, '
首次刷新只建立基线,下一次刷新后显示 CPU 占用
'); + return; + } + var usagePct = Number(cpuData.usage_pct) || 0; + var idlePct = Number(cpuData.idle_pct) || 0; + var sampleMs = Number(cpuData.sample_ms) || 0; + var tasks = Array.isArray(cpuData.tasks) ? cpuData.tasks.slice() : []; + /* 按占用率降序;占比相同再按任务号,保证排序稳定。 + * renderStatusTaskList 在集合不变时只更新 CSS order,不移动或重建 DOM。 */ + tasks.sort(function (a, b) { + var d = (Number(b.usage_pct) || 0) - (Number(a.usage_pct) || 0); + if (d !== 0) return d; + return (Number(a.task_number) || 0) - (Number(b.task_number) || 0); + }); + + setText('cpuUsageText', 'CPU 已用 ' + formatPercent(usagePct)); + setText('cpuUsagePct', formatPercent(usagePct)); + setText('cpuIdlePct', formatPercent(idlePct)); + setText('cpuSampleMs', sampleMs > 0 ? sampleMs + ' ms' : '--'); + setText('cpuTaskCount', String(tasks.length)); + setCpuFill(usagePct); + + if (taskList) { + /* 按占用降序渲染全部任务;列表容器 CSS 默认露出约 3 条,其余滚动。 + * 增量更新:集合不变时只改数字/宽度,避免每秒 innerHTML 全量重建 */ + if (!tasks.length) { + setListEmpty(taskList, '
当前采样没有任务增量
'); + } else { + renderStatusTaskList(taskList, tasks.map(function (task) { + var pct = Number(task.usage_pct) || 0; + var delta = Number(task.runtime_delta) || 0; + return { + key: String(task.name || 'task'), + rowCls: 'status-task cpu-task', + pctText: formatPercent(pct), + width: Math.max(0, Math.min(100, pct)) + '%', + fillCls: 'status-task-bar-fill' + (pct >= 85 ? ' err' : pct >= 70 ? ' warn' : ''), + metaLabels: ['任务号', '增量', '占比'], + metas: [String(task.task_number), String(delta), formatPercent(pct)] + }; + })); + } + } + } + updateCpuUsageUi(data.cpu); + + /* 合并 memory:layout(?layout=1,缓存)+ 动态 heap_*(每次轮询) */ + function mergeMemoryView(rawMem) { + rawMem = rawMem || {}; + if (rawMem.layout && typeof rawMem.layout === 'object') { + window._memLayout = rawMem.layout; + } + var L = window._memLayout || {}; + /* 兼容旧固件:静态字段仍在 memory 根上 */ + function pick(key) { + if (rawMem[key] !== undefined) return rawMem[key]; + if (L[key] !== undefined) return L[key]; + return undefined; + } + return { + chip: pick('chip'), + sram_total: pick('sram_total'), + sram_soc: pick('sram_soc'), + static_reserved: pick('static_reserved'), + reserved_tail: pick('reserved_tail'), + iram: pick('iram'), + data: pick('data'), + bss: pick('bss'), + heap_total: pick('heap_total'), + dma_total: pick('dma_total'), + rtc_total: pick('rtc_total'), + task_stack_total: pick('task_stack_total'), + default_heap_total: pick('default_heap_total'), + heap_used: rawMem.heap_used, + heap_free: rawMem.heap_free, + heap_min_free: rawMem.heap_min_free, + heap_largest_free: rawMem.heap_largest_free, + alloc_blocks: rawMem.alloc_blocks, + free_blocks: rawMem.free_blocks, + dma_free: rawMem.dma_free, + rtc_free: rawMem.rtc_free, + default_heap_free: rawMem.default_heap_free + }; + } + + /* 芯片 SRAM 全景:IRAM + .data + .bss + 堆已用 + 堆空闲(链接符号拆分静态段) */ + function updateSramOverview(mem, fallback) { + mem = mergeMemoryView(mem); + var sramTotal = Number(mem.sram_total); + var sramSoc = Number(mem.sram_soc); + var staticRes = Number(mem.static_reserved); + var iram = Number(mem.iram); + var dataSz = Number(mem.data); + var bss = Number(mem.bss); + var heapTotal = Number(mem.heap_total); + var heapUsed = Number(mem.heap_used); + var heapFree = Number(mem.heap_free); + var reservedTail = Number(mem.reserved_tail); + var hasSections = Number.isFinite(iram) || Number.isFinite(dataSz) || Number.isFinite(bss); + + /* 旧固件无 memory 字段时,用 internal_* 兜底 */ + if (!Number.isFinite(sramTotal) || sramTotal <= 0) { + heapFree = Number(fallback.internal_free_heap); + heapTotal = Number(fallback.internal_heap_total); + if (!Number.isFinite(heapTotal) || heapTotal <= 0) { + setText('memSramUsage', '--'); + setText('memSramPct', '--'); + setText('mIram', '--'); + setText('mData', '--'); + setText('mBss', '--'); + setText('mHeapUsed', '--'); + setText('mHeapFree', '--'); + setText('mSramSoc', '--'); + setText('mReservedTail', '--'); + setText('mTaskStackTotal', '--'); + setText('mDmaFree', '--'); + setText('mRtcFree', '--'); + setText('mAllocBlocks', '--'); + ['memSegIram', 'memSegData', 'memSegBss', 'memSegUsed', 'memSegFree'].forEach(function (id) { + var el = getEl(id); + if (el) setWidthIfChanged(el, '0%'); + }); + return; + } + heapUsed = heapTotal - heapFree; + sramTotal = heapTotal; + staticRes = 0; + iram = dataSz = bss = 0; + hasSections = false; + } + if (!Number.isFinite(heapUsed) || heapUsed < 0) { + heapUsed = Math.max(0, (Number.isFinite(heapTotal) ? heapTotal : 0) - (Number.isFinite(heapFree) ? heapFree : 0)); + } + if (!Number.isFinite(heapFree) || heapFree < 0) heapFree = 0; + if (!Number.isFinite(staticRes) || staticRes < 0) staticRes = 0; + if (!Number.isFinite(heapTotal) || heapTotal < 0) heapTotal = heapUsed + heapFree; + if (!Number.isFinite(iram) || iram < 0) iram = 0; + if (!Number.isFinite(dataSz) || dataSz < 0) dataSz = 0; + if (!Number.isFinite(bss) || bss < 0) bss = 0; + /* 无分段字段时,整块静态灰条仍可用 static_reserved */ + if (!hasSections && staticRes > 0) { + bss = staticRes; + } + + var usedAll = staticRes + heapUsed; + if (!Number.isFinite(staticRes) || staticRes <= 0) { + usedAll = iram + dataSz + bss + heapUsed; + staticRes = iram + dataSz + bss; + } + var pctAll = sramTotal > 0 ? (usedAll * 100 / sramTotal) : 0; + setText('memSramUsage', '已占 ' + formatBytes(usedAll) + ' / 应用 ' + formatBytes(sramTotal)); + setText('memSramPct', pctAll.toFixed(1) + '%'); + setText('mIram', formatBytes(iram)); + setText('mData', formatBytes(dataSz)); + setText('mBss', formatBytes(bss)); + setText('mHeapUsed', formatBytes(heapUsed)); + setText('mHeapFree', formatBytes(heapFree)); + setText('mSramSoc', Number.isFinite(sramSoc) && sramSoc > 0 ? formatBytes(sramSoc) : '--'); + setText('mReservedTail', Number.isFinite(reservedTail) ? formatBytes(reservedTail) : '--'); + setText('mTaskStackTotal', Number.isFinite(Number(mem.task_stack_total)) + ? formatBytes(Number(mem.task_stack_total)) : '--'); + + function setSeg(id, bytes) { + var el = getEl(id); + if (!el) return; + var pct = sramTotal > 0 ? (bytes * 100 / sramTotal) : 0; + setWidthIfChanged(el, Math.max(0, Math.min(100, pct)) + '%'); + } + setSeg('memSegIram', iram); + setSeg('memSegData', dataSz); + setSeg('memSegBss', bss); + setSeg('memSegUsed', heapUsed); + setSeg('memSegFree', heapFree); + + if (Number.isFinite(Number(mem.dma_free)) && Number.isFinite(Number(mem.dma_total))) { + setText('mDmaFree', formatBytes(Number(mem.dma_free)) + ' / ' + formatBytes(Number(mem.dma_total))); + } else { + setText('mDmaFree', '--'); + } + if (Number.isFinite(Number(mem.rtc_free)) && Number.isFinite(Number(mem.rtc_total))) { + var rt = Number(mem.rtc_total); + setText('mRtcFree', rt > 0 + ? (formatBytes(Number(mem.rtc_free)) + ' / ' + formatBytes(rt)) + : '未并入堆'); + } else { + setText('mRtcFree', '--'); + } + setText('mAllocBlocks', Number.isFinite(Number(mem.alloc_blocks)) ? String(Number(mem.alloc_blocks)) : '--'); + } + updateSramOverview(data.memory, data); + + setText('mInternalHeap', data.internal_free_heap !== undefined ? formatBytes(data.internal_free_heap) : '--'); + setText('mInternalMinHeap', data.internal_min_free_heap !== undefined ? formatBytes(data.internal_min_free_heap) : '--'); + setText('mInternalLargestBlock', data.internal_largest_free_block !== undefined ? formatBytes(data.internal_largest_free_block) : '--'); + setBar('memInternalUsage', 'memInternalFill', 'memInternalPct', data.internal_free_heap, data.internal_heap_total); + /* 最低空闲再创新低 → 尽快把设备 1 槽快照拉进 IndexedDB(多条历史靠浏览器攒) */ + onStatusHeapMinMaybeChanged(data); + + setText('mLargestBlock', data.largest_free_block !== undefined ? formatBytes(data.largest_free_block) : '--'); + + bindHeapLowTooltip(); + + setText('sOtaState', (ota.sys_state || '--') + (Number.isFinite(ota.sys_state_code) ? ' (' + ota.sys_state_code + ')' : '')); + setText('sRelayState', Number.isFinite(ota.relay_state) ? String(ota.relay_state) : '--'); + if (Number.isFinite(ota.sys_state_code)) window.lastSysStateCode = ota.sys_state_code; + if (Number.isFinite(ota.relay_state)) window.lastRelayState = ota.relay_state; + setText('sStatusUpdated', formatTime(new Date())); + + var taskList = getEl('taskStackList'); + if (taskList) { + var taskStack = data.task_stack || {}; + var names = Object.keys(taskStack); + if (!names.length) { + setListEmpty(taskList, '
当前状态未包含任务栈信息
'); + } else { + /* 峰值占用比例降序;容器默认露出最高 3 条,其余滚动 */ + names.sort(function (a, b) { + var ia = taskStack[a] || {}; + var ib = taskStack[b] || {}; + var sa = Number(ia.stack_size_bytes) || 0; + var sb = Number(ib.stack_size_bytes) || 0; + var pa = sa > 0 ? ((Number(ia.peak_used_bytes) || 0) * 100 / sa) : 0; + var pb = sb > 0 ? ((Number(ib.peak_used_bytes) || 0) * 100 / sb) : 0; + return pb - pa; + }); + /* 增量更新:任务集合稳定时仅改变化的数值,不重建 DOM */ + renderStatusTaskList(taskList, names.map(function (name) { + var item = taskStack[name] || {}; + var stackSize = Number(item.stack_size_bytes) || 0; + var watermark = Number(item.high_watermark_bytes) || 0; + var peakUsed = Number(item.peak_used_bytes) || 0; + var usagePct = stackSize > 0 ? (peakUsed * 100 / stackSize) : 0; + return { + key: name, + rowCls: 'status-task', + pctText: formatPercent(usagePct), + width: Math.max(0, Math.min(100, usagePct)) + '%', + fillCls: 'status-task-bar-fill' + (usagePct >= 85 ? ' err' : usagePct >= 70 ? ' warn' : ''), + metaLabels: ['栈大小', '剩余水位', '峰值占用'], + metas: [formatBytes(stackSize), formatBytes(watermark), formatBytes(peakUsed)] + }; + })); + } + } + + /* 原始 JSON:折叠时跳过序列化与大段文本写入(展开瞬间自动补渲染) */ + renderStatusJsonIfVisible(data); +} +/** 从设备 IP 推导网关 IP(取前三段 + .1) */ +function getGatewayIp() { + var parts = (window.getEspIp ? window.getEspIp() : '192.168.31.127').split('.'); + if (parts.length === 4) return parts[0] + '.' + parts[1] + '.' + parts[2] + '.1'; + return '192.168.31.1'; +} + +/** + * 网络诊断:区分浏览器离线 / 网关不通 / 仅设备不通。 + * 放在 connection.js,避免与 polling.js 形成循环 import。 + */ +export async function diagnoseNetwork() { + var result = []; + if (navigator.onLine === false) { + result.push('浏览器无网络连接'); + return result.join(';'); + } + var gwIp = getGatewayIp(); + var gwOk = false; + try { + await fetch('http://' + gwIp + '/', { mode: 'no-cors', signal: AbortSignal.timeout(2000) }); + gwOk = true; + } catch (_) {} + if (!gwOk) { + try { + await fetch('http://' + gwIp + '/favicon.ico', { mode: 'no-cors', signal: AbortSignal.timeout(1500) }); + gwOk = true; + } catch (_) {} + } + if (!gwOk) { + result.push('网关 ' + gwIp + ' 不可达(本地网络不通)'); + } else { + result.push('网关 ' + gwIp + ' 可达'); + var espIp = window.getEspIp ? window.getEspIp() : '设备'; + result.push('设备 ' + espIp + ' 不响应(可能断电、重启中或 WiFi 断开)'); + } + return result.join(';'); +} + +/** + * /system/status URL:尚无缓存布局时带 ?layout=1(静态 IRAM/.data/.bss/堆容量只取一次)。 + * 设备重启/换固件后清 window._memLayout 再拉。 + */ +export function systemStatusUrl(forceLayout) { + var base = (window.getBaseUrl ? window.getBaseUrl() : '') + '/system/status'; + if (forceLayout || !window._memLayout) { + return base + (base.indexOf('?') >= 0 ? '&' : '?') + 'layout=1'; + } + return base; +} + +/** + * 统一的 /system/status 抓取器:独立 AbortController,超时后中断, + * 避免与递归调度的 pollStatus 互相干扰。 + * 成功返回 data,HTTP 非 2xx 抛 Error(含 status),网络失败抛原 Error。 + */ +async function fetchSystemStatus(timeoutMs, forceLayout) { + var ac = new AbortController(); + var timer = setTimeout(function () { ac.abort(); }, timeoutMs || 3000); + try { + var resp = await fetch(systemStatusUrl(!!forceLayout), { signal: ac.signal }); + if (!resp.ok) { + var err = new Error('HTTP ' + resp.status); + err.status = resp.status; + throw err; + } + return await resp.json(); + } finally { + clearTimeout(timer); + } +} + +/** @description 测试与 ESP32 的连接(GET /status)。 */ +export async function testConnection() { + try { + var data = await fetchSystemStatus(3000); + window.latestSystemStatus = data; + window.updateSystemStatusUi(data); + var wifi = data.wifi || {}; + addLog('I', '连接', wifi.connected + ? '已连接:' + (wifi.mode || 'STA') + ' ' + (wifi.ip || '') + : '未连接:' + (wifi.mode || 'STA')); + showToast(wifi.connected ? 'ESP32 当前在线' : 'ESP32 当前未连接', + wifi.connected ? 'success' : 'warning'); + } catch (e) { + if (e && e.status) { + addLog('E', '连接', 'HTTP ' + e.status); + showToast('设备响应异常: HTTP ' + e.status, 'error'); + return; + } + addLog('E', '连接', '连接失败: ' + e.message); + // 连接失败时自动诊断 + var diag = await diagnoseNetwork(); + addLog('W', '诊断', diag); + showToast('无法连接: ' + diag, 'error'); + } +} +export async function refreshSystemStatusNow(event) { + if (event) { + event.preventDefault(); + event.stopPropagation(); + } + try { + /* 手动刷新强制带 layout,便于换固件后更新静态段 */ + var data = await fetchSystemStatus(3000, true); + window.latestSystemStatus = data; + window.updateSystemStatusUi(data); + window.consecutivePollFailures = 0; + addLog('I', '状态', '已手动刷新 /system/status'); + } catch (e) { + if (e && e.status) { + addLog('E', '状态', '刷新失败: HTTP ' + e.status); + showToast('状态刷新失败: HTTP ' + e.status, 'error'); + return; + } + addLog('E', '状态', '刷新失败: ' + e.message); + showToast('状态刷新失败', 'error'); + } +} + +// ============================================================ +// 最低空闲 hover 弹窗:拉 /system/heap_low_log(设备 1 条)+ IndexedDB 历史 +// ============================================================ +// 行为:鼠标移到 #mInternalMinHeap 上时浮现 tooltip,离开时关闭。 +// 首次进入懒加载一次接口,1s 内复用缓存防鼠标抖动反复请求。 +// 弹窗固定在元素右下,越界时自动翻到左侧/上侧。 + +var heapLowLogCache = null; // null=未加载,[]/data=已加载 +var heapLowLogLoading = false; +var heapLowLogLoadAt = 0; // 上次成功加载时间戳(ms) +var heapLowTooltipEl = null; // 当前显示中的 tooltip 元素 + +var HEAP_LOW_STATE_NAMES = ['IDLE','GD32_STORING','GD32_STORED','GD32_RELAYING','GD32_COMPLETE','ESP32_RECEIVING','ESP32_FINALIZING','ESP32_REBOOTING','ERROR']; +var HEAP_LOW_CHANNEL_NAMES = ['WIFI','BLE']; + +function fmtUptime(ms) { + if (!Number.isFinite(ms) || ms <= 0) return '--'; + var s = Math.floor(ms / 1000); + var h = Math.floor(s / 3600); + var m = Math.floor((s % 3600) / 60); + var sec = s % 60; + if (h > 0) return h + 'h' + String(m).padStart(2,'0') + 'm' + String(sec).padStart(2,'0') + 's'; + if (m > 0) return m + 'm' + String(sec).padStart(2,'0') + 's'; + return sec + 's'; +} + +/** OTA 忙态:HTTP socket/堆紧张,勿后台拉 heap_low_log(与 status 抢缓冲/超时 abort)。 */ +function isOtaBusyForHeapLowPoll() { + if (window.pollingPausedForUpload) return true; + var c = Number(window.lastSysStateCode); + /* STORING=1 RELAYING=3 RECEIVING=5 FINALIZING=6 REBOOTING=7 */ + return c === 1 || c === 3 || c === 5 || c === 6 || c === 7; +} + +function isFetchAbortError(e) { + if (!e) return false; + if (e.name === 'AbortError') return true; + var msg = String(e.message || e); + return /aborted|AbortError/i.test(msg); +} + +/** 拉取 /system/heap_low_log。点击触发频率低,不缓存,保证每次看到最新数据 + * (与每秒轮询的 #mInternalMinHeap 表格保持一致)。仅对"请求中"去重避免重复并发。 + * 拉到后增量写入 IndexedDB 持久化,突破设备仅 1 条槽位的限制。 */ +async function loadHeapLowLog(force) { + if (heapLowLogLoading) return heapLowLogCache; + /* 用户点开 tooltip 时 force=true 仍允许;后台/创新低轮询勿在 OTA 中抢 HTTP */ + if (!force && isOtaBusyForHeapLowPoll()) { + return heapLowLogCache; + } + heapLowLogLoading = true; + try { + var ac = new AbortController(); + var timer = setTimeout(function(){ ac.abort(); }, 3000); + var resp; + try { + resp = await fetch(window.getBaseUrl() + '/system/heap_low_log', { signal: ac.signal }); + } finally { clearTimeout(timer); } + if (!resp.ok) throw new Error('HTTP ' + resp.status); + heapLowLogCache = await resp.json(); + heapLowLogLoadAt = Date.now(); + // 增量持久化:把设备当前记录与 IndexedDB 已有的去重后写入。 + // 失败/不可用静默降级(appendHeapLowRecords 内部已 catch)。 + if (heapLowLogCache && Array.isArray(heapLowLogCache.records) && heapLowLogCache.records.length) { + appendHeapLowRecords(heapLowLogCache.records).then(function (n) { + if (n > 0) { + addLog('I', '内存', '堆低水位历史已存 ' + n + ' 条新记录'); + } + }); + } + } catch (e) { + /* 超时 abort / 导航取消:OTA 或 socket 紧时常见,不刷 WARN */ + if (!isFetchAbortError(e)) { + addLog('W', '内存', '拉取 /system/heap_low_log 失败: ' + (e && e.message ? e.message : e)); + } + // 失败时不清缓存,避免下次再次进入就重打接口 + } finally { + heapLowLogLoading = false; + } + return heapLowLogCache; +} + +function buildHeapLowRowHtml(r, idx, segTag) { + var ts = fmtUptime(r.ts_ms); + var state = HEAP_LOW_STATE_NAMES[r.sys_state] || escapeHtml('#' + r.sys_state); + var conn = ''; + if (r.ble_connected) conn += 'BLE '; + if (r.wifi_connected) conn += 'WiFi'; + if (!conn) conn = '--'; + var otaProgress = '--'; + if (r.ota_total_size > 0) { + otaProgress = formatBytes(r.ota_stored_size) + '/' + formatBytes(r.ota_total_size); + } + var curTask = escapeHtml(r.current_task || 'health_mon'); + // 排除法诊断:OTA 资源优先;否则按 hook 最后一击类别(勿把「连接=WiFi」当触发任务) + var diag = '--'; + var diagCls = ''; + var suspects = []; + if (Number(r.ble_data_queue_depth) > 0) suspects.push('BLE队列:' + r.ble_data_queue_depth); + if (Number(r.ble_reorder_cached) > 0) suspects.push('reorder:' + r.ble_reorder_cached); + if (Number(r.spi_pending_jobs) > 0) suspects.push('SPI队列:' + r.spi_pending_jobs); + var hookCls = (r.hook_task || '').toString(); + var hookSz = Number(r.hook_alloc_size) || 0; + if (suspects.length) { + diag = escapeHtml(suspects.join(' ')); + diagCls = 'has-suspect'; + } else if (Number(r.hook_fired) === 1 && (hookCls === 'net?' || (hookSz >= 48 && hookSz < 2048))) { + // 最后一击是小/中块:HTTP 回包 / UDP 日志 send 前 lwip 会再 alloc pbuf,常被误当成 WiFi 主因 + diag = '最后一击≈网络发送'; + diagCls = 'idle-suspect'; + } else if (Number(r.hook_fired) === 1 && (hookCls === 'bulk' || hookSz >= 2048)) { + diag = '最后一击≈大块alloc'; + diagCls = 'has-suspect'; + } else { + var stateName = HEAP_LOW_STATE_NAMES[r.sys_state] || ('#' + r.sys_state); + if (stateName === 'IDLE' || stateName === 'GD32_COMPLETE' || stateName === 'GD32_STORED' || stateName === 'ERROR') { + diag = '空闲期(主因未定位)'; + diagCls = 'idle-suspect'; + } else { + diag = '非数据路径'; + diagCls = 'idle-suspect'; + } + } + // 任务栈子表(默认隐藏,点击主行展开) + var stacks = Array.isArray(r.task_stacks) ? r.task_stacks : []; + var stackRows = stacks.map(function(s) { + var nm = escapeHtml(s.name || '?'); + var rem = Number.isFinite(s.remaining) ? formatBytes(s.remaining) : '--'; + var cls = (Number(s.remaining) < 1024) ? 'crit' : + (Number(s.remaining) < 2048) ? 'warn' : ''; + return '' + + nm + '' + rem + ''; + }).join(''); + var stackSummary = stacks.length + ? (escapeHtml(stacks[0].name || '?') + ' ' + formatBytes(stacks[0].remaining) + ' ▾' + stacks.length + '') + : '--'; + // ★ 行 id 用 segTag 前缀避免设备当前段与历史段 idx 冲突 + var rowId = segTag + '_' + idx; + return '' + + '' + ts + '' + + '' + formatBytes(r.min_free_heap) + '' + + '' + formatBytes(r.free_heap) + '' + + '' + formatBytes(r.largest_free_block) + '' + + '' + state + '' + + '' + conn + '' + + '' + otaProgress + '' + + '' + formatBytes(r.default_free_heap) + '' + + '' + diag + '' + + '' + stackSummary + '' + + '' + + '' + + '
' + + '采样任务: ' + curTask + ' (巡检发现者,不是 alloc 触发者)' + + '内部堆分配数: ' + (Number.isFinite(r.alloc_count) ? r.alloc_count : '--') + '' + + 'IP事件计数: ' + (Number.isFinite(r.ip_event_count) ? r.ip_event_count : '--') + '' + + (r.saved_at_ms ? '保存于: ' + formatTime(new Date(r.saved_at_ms)) + '' : '') + + '
' + + '
' + + '
heap hook:破新低时的「最后一击」alloc' + + (Number(r.hook_fired) === 1 + ? '' + : ' (hook 未命中)') + + '
' + + '分配类别: ' + escapeHtml(r.hook_task || '--') + ' net?=小中块/疑网络 · bulk=大块 · tiny=更小' + + '本次alloc: ' + (Number.isFinite(r.hook_alloc_size) && Number(r.hook_alloc_size) > 0 ? formatBytes(r.hook_alloc_size) : '--') + '' + + 'alloc后est空闲: ' + (Number.isFinite(r.hook_est_free_after) && Number(r.hook_est_free_after) > 0 ? formatBytes(r.hook_est_free_after) : (Number.isFinite(r.hook_free_after) && Number(r.hook_free_after) > 0 ? formatBytes(r.hook_free_after) : '--')) + '' + (r.hook_confidence === 'est_low_watermark' ? ' (estimated 低水位近似)' : '') + '' + + '调用者地址: 0x' + (Number.isFinite(r.hook_caller_addr) && Number(r.hook_caller_addr) > 0 ? r.hook_caller_addr.toString(16).toUpperCase() : '--') + '' + + ' (addr2line)' + + '
' + + '' + + '' + (stackRows || '') + '' + + '
任务剩余栈
无
' + + ''; +} + +function buildHeapLowSectionHtml(records, segTag, title, totalCount) { + if (!records || !records.length) return ''; + var rows = records.map(function (r, i) { return buildHeapLowRowHtml(r, i, segTag); }).join(''); + return '
' + + '
' + title + + ' ' + records.length + ' 条' + + (totalCount !== undefined && totalCount > records.length ? ' / 共 ' + totalCount : '') + + '' + + '
' + + '' + + '' + + '' + + '' + + '' + + '' + + '' + rows + '' + + '
启动后最低空闲当时空闲最大块OTA状态连接OTA进度默认堆诊断最危险栈 ▾
' + + '
'; +} + +function buildHeapLowTooltipHtml(data, history) { + var live = (data && Array.isArray(data.records)) ? data.records : []; + var hist = Array.isArray(history) ? history : []; + var bodyHtml; + if (!live.length && !hist.length) { + bodyHtml = '
尚无"最低空闲创新低"记录
'; + } else { + // 设备当前段:live 已是最新在前 + var liveHtml = buildHeapLowSectionHtml(live, 'live', '设备当前(仅最新 1 条)', hist.length + live.length); + // 历史段:hist 升序(旧→新),显示时反转成新的在前 + var histDesc = hist.slice().reverse(); + var liveKeys = {}; + live.forEach(function (r) { + liveKeys[r.ts_ms + '|' + r.min_free_heap + '|' + r.free_heap + '|' + (r.alloc_count || 0)] = true; + }); + var histOnly = histDesc.filter(function (r) { + return !liveKeys[r.ts_ms + '|' + r.min_free_heap + '|' + r.free_heap + '|' + (r.alloc_count || 0)]; + }); + var histHtml = histOnly.length + ? buildHeapLowSectionHtml(histOnly, 'hist', '前端累积历史', hist.length) + : ''; + bodyHtml = liveHtml + histHtml; + } + return '' + + '
堆"最低空闲"创新低记录' + + '设备 ' + live.length + ' 条 + 历史 ' + hist.length + ' 条' + + '' + + '' + + '
' + + '
' + bodyHtml + '
' + + '
设备只保留最新 1 条;多条历史在浏览器 IndexedDB(最多 ' + + HEAP_HISTORY_MAX + + ' 条,约 5s 拉取 + status 新低时立即拉取)。「清空」只清本地历史,不访问设备。状态栏「最低空闲」需重启才清。
'; +} + +function positionHeapLowTooltip(anchorEl, tipEl) { + // 默认放右下;越界时翻左侧/上侧。tip 先 measure 再定位。 + tipEl.style.visibility = 'hidden'; + tipEl.style.left = '0px'; + tipEl.style.top = '0px'; + document.body.appendChild(tipEl); + var r = anchorEl.getBoundingClientRect(); + var tw = tipEl.offsetWidth; + var th = tipEl.offsetHeight; + var pad = 8; + var left = r.right + pad; + if (left + tw > window.innerWidth - 8) left = r.left - tw - pad; + if (left < 8) left = Math.max(8, r.right - tw); + var top = r.bottom + pad; + if (top + th > window.innerHeight - 8) top = r.top - th - pad; + if (top < 8) top = 8; + tipEl.style.left = left + 'px'; + tipEl.style.top = top + 'px'; + tipEl.style.visibility = 'visible'; +} + +function showHeapLowTooltip(anchorEl) { + // 并行加载:设备当前 1 条 + 浏览器 IndexedDB 累积历史 + // force=true:用户主动查看,OTA 期也允许试一次(失败静默) + Promise.all([ + loadHeapLowLog(true).catch(function () { return { records: [] }; }), + loadHeapLowHistory().catch(function () { return []; }) + ]).then(function (results) { + var data = results[0] || { records: [] }; + var history = results[1] || []; + if (heapLowTooltipEl) heapLowTooltipEl.remove(); + var el = document.createElement('div'); + el.className = 'heap-low-tooltip'; + el.innerHTML = buildHeapLowTooltipHtml(data, history); + heapLowTooltipEl = el; + positionHeapLowTooltip(anchorEl, el); + // 关闭按钮:显式关闭并阻止 click 冒泡触发"点击外部关闭" + var closeBtn = el.querySelector('.heap-low-close'); + if (closeBtn) { + closeBtn.addEventListener('click', function(e) { + e.preventDefault(); + e.stopPropagation(); + hideHeapLowTooltip(); + }); + } + var clearBtn = el.querySelector('.heap-low-clear'); + if (clearBtn) { + clearBtn.addEventListener('click', function(e) { + e.preventDefault(); + e.stopPropagation(); + clearHeapLowRecordsUi(clearBtn, anchorEl); + }); + } + // 点击主行展开/收起对应任务栈详情子表 + var mainRows = el.querySelectorAll('.heap-low-main-row'); + mainRows.forEach(function(row) { + row.addEventListener('click', function(e) { + e.preventDefault(); + e.stopPropagation(); + var idx = row.getAttribute('data-idx'); + if (idx === null) return; + var detail = el.querySelector('.heap-low-stack-detail[data-idx="' + idx + '"]'); + if (detail) detail.hidden = !detail.hidden; + }); + }); + }); +} + +/** + * 清空浏览器侧堆低水位历史(IndexedDB)。 + * 不再 POST 设备:设备仅 1 槽,多条历史全在本地;状态栏 min_free 仍要重启才清。 + */ +async function clearHeapLowRecordsUi(btn, anchorEl) { + if (btn && btn.disabled) return; + if (!window.confirm( + '清空浏览器中的堆低水位历史(IndexedDB,最多 ' + + HEAP_HISTORY_MAX + + ' 条)?\n\n不会访问设备。状态栏「最低空闲」芯片累计需重启才清零。' + )) { + return; + } + if (btn) { + btn.disabled = true; + btn.textContent = '清空中…'; + } + var localOk = false; + try { + localOk = !!(await clearHeapLowHistory()); + } catch (e2) { + localOk = false; + } + heapLowLogCache = null; + heapLowLogLoadAt = 0; + lastSeenInternalMinHeap = null; + + if (localOk) { + addLog('I', '内存', '已清空堆低水位本地历史'); + showToast('已清空本地历史', 'success'); + } else { + addLog('E', '内存', '清空本地历史失败'); + showToast('清空失败', 'error'); + if (btn) { + btn.disabled = false; + btn.textContent = '清空'; + } + return; + } + if (anchorEl) { + showHeapLowTooltip(anchorEl); + } else { + hideHeapLowTooltip(); + } +} + +function hideHeapLowTooltip() { + if (heapLowTooltipEl) { heapLowTooltipEl.remove(); heapLowTooltipEl = null; } +} + +/** 绑定到 #mInternalMinHeap 元素(仅绑一次)。click 切换显示/隐藏。 */ +export function bindHeapLowTooltip() { + var el = document.getElementById('mInternalMinHeap'); + if (!el || el.dataset.heapLowBound === '1') return; + el.dataset.heapLowBound = '1'; + el.style.cursor = 'pointer'; + el.title = '点击查看堆低水位创新低记录'; + + // 点击触发:已显示则关闭,未显示则拉接口显示 + el.addEventListener('click', function(e) { + e.preventDefault(); + e.stopPropagation(); + if (heapLowTooltipEl) { + hideHeapLowTooltip(); + } else { + showHeapLowTooltip(el); + } + }); + + // 点击 tooltip 外部关闭(点击 tooltip 自身或其子元素不关闭) + document.addEventListener('click', function(e) { + if (!heapLowTooltipEl) return; + if (e.target === el) return; // 触发元素自身已单独处理 + var node = e.target; + while (node && node !== document) { + if (node === heapLowTooltipEl) return; + node = node.parentNode; + } + hideHeapLowTooltip(); + }, true); + + // 页面整体滚动/resize 时关闭(tooltip 内部滚动不触发,由 e.target 父链判断) + window.addEventListener('scroll', function(e) { + if (!heapLowTooltipEl) return; + var node = e.target; + while (node && node !== document) { + if (node === heapLowTooltipEl) return; + node = node.parentNode; + } + hideHeapLowTooltip(); + }, true); + window.addEventListener('resize', function() { + if (heapLowTooltipEl) hideHeapLowTooltip(); + }); + + // 后台拉取 heap_low_log → IndexedDB 多条历史(设备仅 1 槽) + startHeapLowBackgroundPoll(); +} + +// ==================== 堆低水位历史:后台拉取 + 弹窗合并显示 ==================== +var heapLowBgTimerId = null; +/** 与 health_mon 5s 巡检对齐,尽量在下一次创新低覆盖前把当前槽拉进 IDB */ +var HEAP_LOW_BG_INTERVAL_MS = 5000; +/** 上次从 /system/status 见到的 min_free,用于检测新低并立刻拉 log */ +var lastSeenInternalMinHeap = null; + +/** + * status 轮询里调用:若 internal_min_free_heap 再创新低,立刻拉设备槽写入 IDB。 + * 这样不必等 5s 后台定时,连续创新低时也能多攒几条(每条仍须设备侧先记一次)。 + */ +export function onStatusHeapMinMaybeChanged(data) { + if (!data) return; + var minH = data.internal_min_free_heap; + if (minH === undefined) minH = data.min_free_heap; + minH = Number(minH); + if (!Number.isFinite(minH)) return; + if (lastSeenInternalMinHeap === null) { + lastSeenInternalMinHeap = minH; + return; + } + if (minH < lastSeenInternalMinHeap) { + lastSeenInternalMinHeap = minH; + /* force=false:OTA 忙态跳过,避免与上传/status 抢 socket 后 abort 刷屏 */ + if (!document.hidden && !isOtaBusyForHeapLowPoll()) { + loadHeapLowLog(false).catch(function () {}); + } + } else if (minH > lastSeenInternalMinHeap) { + /* 设备重启后 min 回升:同步基线,不强制拉 */ + lastSeenInternalMinHeap = minH; + } +} + +/** 启动后台拉取。多次调用幂等。 */ +function startHeapLowBackgroundPoll() { + if (heapLowBgTimerId) return; + function tick() { + heapLowBgTimerId = null; + if (!document.hidden && !isOtaBusyForHeapLowPoll()) { + loadHeapLowLog(false).catch(function () {}); + } + heapLowBgTimerId = setTimeout(tick, HEAP_LOW_BG_INTERVAL_MS); + } + heapLowBgTimerId = setTimeout(tick, 3000); +} diff --git a/js/common/heap-low-history.js b/js/common/heap-low-history.js new file mode 100644 index 0000000..f43549d --- /dev/null +++ b/js/common/heap-low-history.js @@ -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:本次实际新增的条数。 */ +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。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。 */ +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。 */ +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; +} \ No newline at end of file diff --git a/js/common/log.js b/js/common/log.js new file mode 100644 index 0000000..8ae8cef --- /dev/null +++ b/js/common/log.js @@ -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 = + '[' + escapeHtml(entry.time) + ']' + + '' + escapeHtml(entry.level) + '' + + '[' + escapeHtml(entry.tag) + ']' + + '' + escapeHtml(entry.message) + ''; + 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 暂停滚动'; +} diff --git a/js/common/polling.js b/js/common/polling.js new file mode 100644 index 0000000..67b3ec4 --- /dev/null +++ b/js/common/polling.js @@ -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 启动(勿写死 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); diff --git a/js/common/toast.js b/js/common/toast.js new file mode 100644 index 0000000..8464446 --- /dev/null +++ b/js/common/toast.js @@ -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); +} diff --git a/js/common/utils.js b/js/common/utils.js new file mode 100644 index 0000000..bfee6bc --- /dev/null +++ b/js/common/utils.js @@ -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, '''); +} +/** + * @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; +} diff --git a/js/motor/main.js b/js/motor/main.js new file mode 100644 index 0000000..0e218ba --- /dev/null +++ b/js/motor/main.js @@ -0,0 +1,3428 @@ +/** + * motor/main.js - 电机控制台页面逻辑(ES Module 入口) + * 由 motor.html 内联 + + \ No newline at end of file diff --git a/serve.bat b/serve.bat new file mode 100644 index 0000000..26b9349 --- /dev/null +++ b/serve.bat @@ -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 diff --git a/spi_test.html b/spi_test.html new file mode 100644 index 0000000..146e841 --- /dev/null +++ b/spi_test.html @@ -0,0 +1,175 @@ + + + + + + +SPI 测速(临时工具) + + + +

SPI 链路测速

+
临时诊断工具 [SPI-SPEEDTEST] — 探针 0xA1 APP PING,删除时直接删掉本文件
+ +
+
+ + + +
+
+ + + +
+
+ 满帧 236B = 等效电机 OTA 0xD1 数据节奏;载荷 0 = 纯握手往返。
+ 判读:失败必须为 0;「最快」是链路物理极限周期;「平均-最快」差值是软件调度开销。 +
+
+ +
+
等待测试…
+
+ +
+ + + +
时间帧数载荷失败平均最快吞吐
+
本页历史仅存内存,刷新即清空——方便改参数前后对比。
+
+ + + + diff --git a/tabs.html b/tabs.html new file mode 100644 index 0000000..18d65ca --- /dev/null +++ b/tabs.html @@ -0,0 +1,124 @@ + + + + + + +ESP32 调试控制台 — DCU + + + + + + + + + + +