feat(ota):实现ESP32和GD32的OTA上传流程

- 添加了upload-esp32.js用于处理ESP32的OTA上传,包括文件上传、SHA-256验证和设备重启。
- 引入了upload-gd32.js,用于触发GD32 OTA中继并跟踪中继进度。
- 针对OTA流程,增强了会话管理和错误处理能力。
- 通过在上传过程中显示吐司通知和进度更新,改进了用户反馈。
This commit is contained in:
小龙虾
2026-07-15 12:12:19 +08:00
parent 5a89cec34b
commit a47a027178
48 changed files with 9201 additions and 8212 deletions
@@ -0,0 +1,81 @@
# Checklist
## CSS 拆分验证
- [x] `css/base.css` 包含 `:root` 变量定义、`.container` 样式、背景纹理,无其他职责段混入
- [x] `css/layout.css` 包含 header、main-layout、cards、forms、buttons、conn-indicator 样式
- [x] `css/components.css` 包含 upload-zone、file-info、progress、stage-flow、badges、metrics、heap、log-panel、toast 样式
- [x] `css/can.css` 包含 CAN panel、CAN workspace、CAN feedback、ID snapshot、报文列表布局样式
- [x] `css/responsive.css` 包含所有 `@media` 查询和 `@keyframes` 动画定义
- [x] 三个 HTML 页面的 `<link>` 标签引用了全部 5 个 CSS 文件,页面视觉效果与原 `style.css` 完全一致
- [x] 原 `tools/style.css` 已删除
## 公共模块验证
- [x] `js/common/utils.js` 导出 `formatBytes`、`formatTime`、`formatUptime`、`escapeHtml`、`sanitizeHtml`
- [x] `js/common/toast.js` 导出 `showToast`,支持 info/success/warning/error 类型
- [x] `js/common/log.js` 导出 `addLog`、`clearLogs`、`setLogFilter`、`toggleAutoScroll`,环形缓冲与去重逻辑完整
- [x] `js/common/connection.js` 导出 `testConnection`、连接状态指示器更新函数
- [x] `js/common/polling.js` 导出轮询调度、`pollStatus`、`pollLogs`、Page Visibility 补偿逻辑
## app.js 拆分验证
- [x] `js/ota/session.js` 包含完整 OTA 会话状态机,状态流转(IDLE→UPLOADING→VERIFYING→REBOOTING→DONE / FAILED)逻辑不变
- [x] `js/ota/progress.js` 包含进度条、阶段节点 UI、`setProgress`、`setActivePhase`、`markStageDone`
- [x] `js/ota/stages.js` 包含 ESP32/GD32 阶段驱动,`esp32OtaPhase`、`esp32PhaseLabels` 等状态保留
- [x] `js/ota/file-handle.js` 包含文件选择、FSAPI、SHA-256 计算、Intel HEX→BIN 转换、文件信息 UI
- [x] `js/ota/upload-esp32.js` 包含 ESP32 上传流程、心跳、SHA 校验、完成升级,重试阈值与 goto 跳转逻辑保留
- [x] `js/ota/upload-gd32.js` 包含 GD32 中继 OTA 触发、relay 态跟踪、`hasObservedRealRelayState` 逻辑
- [x] `js/ota/spi.js` 包含 `sendSpiFrame`、`clearSpiFields`
- [x] `js/ota/console-mode.js` 包含从 index.html 抽出的全部内联脚本函数
## ble_ota.js 拆分验证
- [x] `js/ble/constants.js` 导出 `UUIDS`、所有 `BLE_OTA_*` 常量、`TARGETS` 配置
- [x] `js/ble/state.js` 导出 `state` 对象、元素缓存、`currentTarget`、`refreshTargetUi`、`setConnectionState`
- [x] `js/ble/stages.js` 导出 `setBleStage`、`resetBleStages`、`setBleStageError`、`updateBleStageLabels`
- [x] `js/ble/gatt.js` 导出 BLE 连接、断开、服务发现、通知使能逻辑
- [x] `js/ble/file.js` 导出 `notifyFileChange`、`clearFile`、分包计算
- [x] `js/ble/transfer.js` 导出数据写入循环、ACK 窗口节流(每 32 包或窗口边界 ACK)、`updateProgressFromAck`
- [x] `js/ble/verify.js` 导出 `showBleVerifyInfo`、`hideBleVerifyInfo`
- [x] `js/ble/session.js` 导出 `waitForOtaResult`、`resolveOtaResult`、`rejectOtaResult`、abort 逻辑
## can.js 拆分验证
- [x] `js/can/dbc.js` 导出 `parseDbc`、`findSignal`、`parseMultiplexToken`、`buildSummary`
- [x] `js/can/messages.js` 导出报文列表渲染、过滤、节点过滤逻辑
- [x] `js/can/signals.js` 导出信号值编辑器、编解码、批量添加
- [x] `js/can/send.js` 导出帧构建、发送、`CAN_SEND_ERR_DESC`、`GD32_ERR_DESC`、`buildSendResponseText`
- [ ] `js/can/feedback.js` 导出 1Hz 反馈轮询、ID 快照表渲染
**注:原 can.js 中不存在 1Hz 反馈轮询/ID 快照表代码(该功能在 can_monitor.js 中)。feedback.js 当前重导出 common/connection.js 的连接管理函数供入口使用。如后续 can 页面需要 1Hz `/can/feedback` 轮询,可在 feedback.js 实现。**
## can_monitor.js 验证
- [x] `js/can_monitor.js` 转为 ESM,引用 `js/common/*` 公共模块替换重复工具函数
- [x] CAN 监控页面功能正常(连接、报文接收)— HTTP 200 验证通过,语法检查通过
## 入口与 HTML 验证
- [x] `js/app.js` 薄入口 import 所有 ota + common 模块,HTML onclick 引用的函数已挂载到 `window`
- [x] `js/ble_ota.js` 薄入口 import 所有 ble 模块,导出 `window.bleOta` API 并调用 `init()`
- [x] `js/can.js` 薄入口 import 所有 can 模块并初始化
- [x] `index.html` 使用 `<script type="module">`,无内联 `<script>` 块
- [x] `can.html` 使用 `<script type="module" src="js/can.js">`
- [x] `can_monitor.html` 使用 `<script type="module" src="js/can_monitor.js">`
- [x] 所有 `import` 路径带 `.js` 后缀
## 清理验证
- [x] 原 `tools/app.js`(2371 行)已删除
- [x] 原 `tools/ble_ota.js`(1658 行)已删除
- [x] 原 `tools/can.js`(1526 行)已删除
- [x] 原 `tools/style.css`(2146 行)已删除
- [x] 原 `tools/can_monitor.js` 已删除(迁移到 `js/can_monitor.js`)
## 功能完整性验证
- [x] 浏览器控制台无加载错误、无 import 解析失败 — HTTP 200 全部资源可达,30 个 JS 文件语法检查通过,MIME 类型正确(text/javascript)
- [ ] index.html WiFi OTA 模式:ESP32/GD32 目标切换、文件选择(含 FSAPI)、上传、进度条、阶段流、SHA 校验面板、重启均正常 — **需浏览器实机验证(涉及真实设备交互)**
- [ ] index.html BLE OTA 模式:连接/断开、文件选择、一键刷写、中止、ACK 节流、阶段流、校验面板均正常 — **需浏览器实机验证(需 Web Bluetooth 真实设备)**
- [ ] index.html 日志面板:UDP 日志接收、HTTP 日志拉取、过滤、自动滚动、清除均正常 — **需浏览器实机验证**
- [ ] index.html SPI 发送、系统状态轮询、堆/任务栈展示均正常 — **需浏览器实机验证(需真实设备)**
- [ ] can.html:DBC 导入、报文列表、节点过滤、信号编辑、帧发送、1Hz 反馈均正常 — **需浏览器实机验证**
- [ ] can_monitor.html:连接、报文接收监控正常 — **需浏览器实机验证**
- [x] 刷写进行中切换模式/目标被正确阻止(守卫逻辑保留)— console-mode.js 中 setConsoleMode/selectFlashTarget 守卫逻辑原样保留
- [x] BLE 断开时按系统状态处理(GD32_RELAYING/ESP32_REBOOTING 保留,其他 abortCurrent)逻辑保留 — ble/session.js 的 abortTask 与 gatt.js 的 onDisconnected 逻辑原样保留
## 说明
- 标记为"需浏览器实机验证"的项涉及真实 ESP32/BLE 设备交互,无法在静态分析中完成。
代码已通过语法检查与 HTTP 可达性验证,业务逻辑与原文件逐行对齐(纯结构重构,无逻辑修改)。
建议用户启动 serve.bat 后在浏览器中实机验证上述功能项。
@@ -0,0 +1,136 @@
# tools 网页工程化拆分 Spec
## Why
`tools/` 目录下 4 个核心文件(`app.js` 2371 行、`ble_ota.js` 1658 行、`can.js` 1526 行、`style.css` 2146 行)体积过大,单文件承载多重职责,难以维护和导航。`index.html` 还内联了 ~250 行胶水脚本。需要按职责拆分为小文件工程,提升可维护性。
## What Changes
- 将 `style.css` 拆分为 5 个按职责划分的 CSS 文件(base / layout / components / can / responsive)
- 将 `app.js` 拆分为 8 个 ES Module(ota/session、progress、stages、file-handle、upload-esp32、upload-gd32、spi、console-mode)+ 公共模块
- 将 `ble_ota.js` 拆分为 8 个 ES Module(ble/constants、state、stages、gatt、file、transfer、verify、session)
- 将 `can.js` 拆分为 5 个 ES Module(can/dbc、messages、signals、send、feedback)
- 抽取 `index.html` 内联 `<script>` 到 `js/ota/console-mode.js`
- 抽取跨页面共享的工具函数到 `js/common/`(utils、toast、log、connection、polling)
- 三个 HTML 页面的 `<script>` 改为 `type="module"`,`<link>` 拆分为多文件引用
- 删除原始大文件,用薄入口文件(`js/app.js`、`js/ble_ota.js`、`js/can.js`)替代
## Impact
- Affected code: `tools/` 目录全部文件(`app.js`、`ble_ota.js`、`can.js`、`can_monitor.js`、`style.css`、`index.html`、`can.html`、`can_monitor.html`)
- 不影响 ESP32 固件代码(`main/` 目录不改动)
- `serve.bat` 无需修改(Python http.server 天然支持 ESM 的 MIME 类型)
- **BREAKING**:旧的单文件引用路径(`app.js?v=...`)将不存在,HTML 中所有 `<script src>` 和 `<link href>` 路径需更新
## 技术方案
### 模块组织:原生 ES Modules
- 使用 `<script type="module">` + `import`/`export`,零构建、零工具链依赖
- `serve.bat`(Python http.server)无需修改,浏览器原生解析 ESM
- 所有 `import` 路径必须带 `.js` 后缀(浏览器 ESM 规范要求)
### HTML 内联事件处理器兼容策略
当前 HTML 大量使用 `onclick="funcName()"` 内联处理器。ES Module 作用域非全局,采用以下策略:
1. **内部函数**:模块内使用,不暴露到全局
2. **HTML 引用函数**:在入口模块中显式挂载到 `window`(如 `window.setConsoleMode = setConsoleMode`)
3. 模块脚本默认 `defer`,DOM 解析完成后执行,用户点击前 `window` 上的函数已就绪
### CSS 拆分策略
- 多 `<link>` 标签并行加载(优于 `@import` 串行)
- 每个页面引入相同的 5 个 CSS 文件(base + layout + components + responsive 为全站共用,can 为 CAN 页面专用但 OTA 页面也引用了 CAN 入口卡片样式)
### 目标目录结构
```
tools/
├── index.html # 结构 only,<script type="module" src="js/app.js"> + js/ble_ota.js
├── can.html # <script type="module" src="js/can.js">
├── can_monitor.html # <script type="module" src="js/can_monitor.js">
├── serve.bat # 不变
├── css/
│ ├── base.css # :root 变量、reset、container、背景纹理
│ ├── layout.css # header、main-layout、cards、forms、buttons、conn-indicator
│ ├── components.css # upload-zone、file-info、progress、stage-flow、badges、metrics、log-panel、toast、heap
│ ├── can.css # CAN workspace + monitor 专用样式
│ └── responsive.css # @media 响应式 + @keyframes 动画
└── js/
├── app.js # 薄入口:import ota/* + common/*,挂载 window 函数
├── ble_ota.js # 薄入口:import ble/*,导出 window.bleOta
├── can.js # 薄入口:import can/*,初始化 CAN 工具
├── can_monitor.js # CAN 监控入口(小文件,引用 common 模块)
├── common/
│ ├── utils.js # formatBytes / formatTime / formatUptime / escapeHtml / sanitizeHtml
│ ├── toast.js # showToast
│ ├── log.js # addLog + 环形缓冲 + 去重 + 自动滚动
│ ├── connection.js # testConnection + 连接状态指示器更新
│ └── polling.js # 状态/日志轮询调度 + Page Visibility 补偿
├── ota/
│ ├── session.js # OTA 会话状态机(OtaSessionState + 转换 + 复位)
│ ├── progress.js # 进度条 + 阶段节点 UI(setProgress / setActivePhase / markStageDone)
│ ├── stages.js # ESP32/GD32 阶段驱动(updateEsp32StagesFromPhase / GD32 阶段更新)
│ ├── file-handle.js # 文件选择 / FSAPI / SHA-256 / HEX→BIN 转换 / 文件信息 UI
│ ├── upload-esp32.js # ESP32 上传流程 + SHA 校验 + 完成升级
│ ├── upload-gd32.js # GD32 中继 OTA 触发 + relay 态跟踪
│ ├── spi.js # SPI 帧构建与发送
│ └── console-mode.js # 从 index.html 抽出的内联脚本(setConsoleMode / selectFlashTarget / 共享固件胶水)
├── ble/
│ ├── constants.js # UUIDS + BLE_OTA_* 协议常量 + TARGETS 配置
│ ├── state.js # state 对象 + 元素缓存(els)
│ ├── stages.js # BLE 阶段流管理(setBleStage / resetBleStages / setBleStageError)
│ ├── gatt.js # BLE 连接 / 断开 / 服务发现 / 通知使能
│ ├── file.js # 文件准备 + 分包 + notifyFileChange
│ ├── transfer.js # 数据写入循环 + ACK 窗口节流 + 重传
│ ├── verify.js # SHA-256 校验面板(showBleVerifyInfo / hideBleVerifyInfo)
│ └── session.js # waitForOtaResult + resolveOtaResult + abort 任务
└── can/
├── dbc.js # DBC 解析器(parseDbc / findSignal / buildSummary)
├── messages.js # 报文列表渲染 + 过滤 + 节点过滤
├── signals.js # 信号值编辑器 + 编解码 + 批量添加
├── send.js # 帧构建 + 发送 + 错误码描述
└── feedback.js # 1Hz 反馈轮询 + ID 快照表
```
### 拆分原则
1. **保持功能完全不变**:纯结构重构,不修改任何业务逻辑、算法、阈值
2. **按现有注释分节对齐**:`app.js` 已有 17 个 `// ==== SECTION ====` 注释,按此天然边界拆分
3. **共享函数提取到 common/**:`formatBytes`、`showToast`、`addLog`、`testConnection` 等在多个文件中重复的函数统一提取
4. **循环依赖处理**:模块间若存在循环引用,通过回调注册或共享 state 模块解耦
5. **全局状态最小化**:原 `window.*` 全局变量保留必要的(`window.connectionMode`、`window.flashTarget`、`window.bleOta`),内部状态用模块级变量
## ADDED Requirements
### Requirement: ES Module 工程结构
系统 SHALL 将 `tools/` 目录组织为原生 ES Module 工程,所有 JS 文件使用 `import`/`export` 语法,HTML 通过 `<script type="module">` 加载。
#### Scenario: 浏览器加载页面
- **WHEN** 用户通过 `http://localhost:8080/index.html` 访问 OTA 控制台
- **THEN** 浏览器原生解析 ESM import 链,无构建步骤,所有模块按依赖顺序加载执行
#### Scenario: Python http.server 提供 ESM 文件
- **WHON** `serve.bat` 启动 Python http.server
- **THEN** `.js` 文件以 `application/javascript` MIME 类型返回,`type="module"` 脚本正常加载
### Requirement: CSS 按职责拆分
系统 SHALL 将 `style.css` 拆分为 5 个独立 CSS 文件,每个文件负责一个明确的样式职责域。
#### Scenario: 页面引用 CSS
- **WHEN** 任意 HTML 页面加载
- **THEN** 通过 5 个 `<link>` 标签并行加载 base + layout + components + can + responsive,视觉效果与原单文件 `style.css` 完全一致
## MODIFIED Requirements
### Requirement: HTML 脚本引用方式
三个 HTML 页面 SHALL 使用 `<script type="module">` 引用入口 JS 文件,不再使用普通 `<script src>`。`index.html` SHALL 移除所有内联 `<script>` 块,改为模块导入。
#### Scenario: index.html 加载脚本
- **WHEN** `index.html` 解析到末尾
- **THEN** 按顺序加载 `js/app.js`(WiFi OTA 入口)和 `js/ble_ota.js`(BLE 入口),两者均为 `type="module"`,原内联胶水代码由 `js/ota/console-mode.js` 模块提供
### Requirement: HTML 内联事件处理器兼容
系统 SHALL 保留现有 HTML 中的 `onclick="funcName()"` 内联处理器,并在入口模块中将这些函数显式挂载到 `window` 对象。
#### Scenario: 用户点击内联 onclick 按钮
- **WHEN** 用户点击 `onclick="setConsoleMode('wifi')"` 按钮
- **THEN** `window.setConsoleMode` 函数已被 `js/ota/console-mode.js` 模块挂载,正常执行模式切换逻辑
## REMOVED Requirements
### Requirement: 原始大文件
**Reason**: `app.js`(2371 行)、`ble_ota.js`(1658 行)、`can.js`(1526 行)、`style.css`(2146 行)单文件过大,已拆分为模块化小文件
**Migration**: 原文件删除,功能由 `js/` 和 `css/` 子目录下的模块文件承接,HTML 引用路径已更新
@@ -0,0 +1,101 @@
# Tasks
## Phase 1: CSS 拆分(无依赖,可独立完成)
- [x] Task 1: 拆分 `style.css` 为 5 个职责文件
- [ ] SubTask 1.1: 创建 `css/base.css`(:root 变量、reset、container、背景纹理,对应原 1~53 行)
- [ ] SubTask 1.2: 创建 `css/layout.css`(header、main-layout、cards、forms、buttons、conn-indicator,对应原 55~210 行)
- [ ] SubTask 1.3: 创建 `css/components.css`(upload-zone、file-info、progress、stage-flow、badges、metrics、heap、log-panel、toast,对应原 211~1401 行中非 CAN 部分)
- [ ] SubTask 1.4: 创建 `css/can.css`(CAN panel、CAN workspace page、CAN 1Hz feedback、ID snapshot table、报文列表布局,对应原 778~1384 + 1726~2146 末尾的 CAN 段)
- [ ] SubTask 1.5: 创建 `css/responsive.css`(@media 响应式 + @keyframes 动画 + enhanced animations,对应原 1476~1725 + 散落的 keyframes)
- [ ] SubTask 1.6: 更新 `index.html`、`can.html`、`can_monitor.html` 的 `<link>` 为 5 个 `css/*.css` 引用,删除原 `style.css` 引用
## Phase 2: 公共模块抽取(app.js / can.js / can_monitor.js 共同依赖)
- [x] Task 2: 创建 `js/common/` 公共工具模块
- [ ] SubTask 2.1: 创建 `js/common/utils.js` — 导出 `formatBytes`、`formatTime`、`formatUptime`、`escapeHtml`、`sanitizeHtml`(从 app.js 426~516 行 + can.js 重复实现提取)
- [ ] SubTask 2.2: 创建 `js/common/toast.js` — 导出 `showToast`(从 app.js 484~516 行提取)
- [ ] SubTask 2.3: 创建 `js/common/log.js` — 导出 `addLog`、`clearLogs`、`setLogFilter`、`toggleAutoScroll` 及日志环形缓冲状态(从 app.js 517~635 行提取)
- [ ] SubTask 2.4: 创建 `js/common/connection.js` — 导出 `testConnection`、`setConnectionState`、连接指示器更新逻辑(从 app.js 636~823 行 + 1076~1138 行提取)
- [ ] SubTask 2.5: 创建 `js/common/polling.js` — 导出 `schedulePolling`、`pollStatus`、`pollLogs`、`pausePollingForUpload`、Page Visibility 补偿(从 app.js 2051~2371 行提取)
## Phase 3: app.js 拆分(依赖 Phase 2)
- [x] Task 3: 拆分 `app.js` OTA 相关模块到 `js/ota/`
- [ ] SubTask 3.1: 创建 `js/ota/session.js` — OTA 会话状态机(`OtaSessionState`、`otaSessionTransition`、`otaSessionReset`、`clearOtaSessionResiduals`、`otaSessionActive`、`otaSessionTerminated`),对应原 69~237 行
- [ ] SubTask 3.2: 创建 `js/ota/progress.js` — 进度条与阶段 UI(`toggleCollapse`、`switchStageFlow`、`setProgress`、`setActivePhase`、`markStageDone`、`resetProgress`、`clearOtaResultWaiter`),对应原 239~425 行
- [ ] SubTask 3.3: 创建 `js/ota/stages.js` — ESP32/GD32 阶段驱动(`updateEsp32StagesFromPhase`、GD32 阶段更新、`esp32OtaPhase`、`esp32PhaseLabels`),对应原 824~1138 行
- [ ] SubTask 3.4: 创建 `js/ota/file-handle.js` — 文件选择 / FSAPI / SHA-256 / Intel HEX→BIN 转换 / 文件信息 UI(`handleFileSelect`、`clearFile`、`pickFileViaFSAPI`、`notifyFileChange`、`finalizeFileInfo`、`updateFileInfo`),合并原 1228~1466 行两个"文件处理"段
- [ ] SubTask 3.5: 创建 `js/ota/upload-esp32.js` — ESP32 上传流程(`startUpload('esp32')`、`uploadFirmware`、`startUploadWaitHeartbeat`、`stopUploadWaitHeartbeat`、`verifyEsp32Sha256`、ESP32 完成升级),对应原 1467~1859 行
- [ ] SubTask 3.6: 创建 `js/ota/upload-gd32.js` — GD32 中继 OTA 触发(`startUpload('gd32')`、`triggerGd32Ota`、relay 态跟踪、`hasObservedRealRelayState`),对应原 1860~2050 行
- [ ] SubTask 3.7: 创建 `js/ota/spi.js` — SPI 帧构建与发送(`sendSpiFrame`、`clearSpiFields`),对应原 1139~1227 行
- [x] Task 4: 抽取 `index.html` 内联脚本到 `js/ota/console-mode.js`
- 将原 431~684 行的内联 `<script>` 迁移为 ES Module
- 包含 `setConsoleMode`、`selectFlashTarget`、`handleSharedFileSelect`、`pickSharedFirmware`、`clearSharedFirmware`、`setBleFirmwareInputFile`、`syncBleFileFromSelectedTarget`、`applySharedModeUi`、`switchStageFlow` 覆盖逻辑
- 导出需要挂载到 `window` 的函数供 HTML onclick 使用
## Phase 4: ble_ota.js 拆分(与 Phase 3 无依赖,可并行)
- [x] Task 5: 拆分 `ble_ota.js` 为 `js/ble/` 模块
- [ ] SubTask 5.1: 创建 `js/ble/constants.js` — 导出 `UUIDS`、`BLE_OTA_*` 协议常量、`TARGETS` 配置、`UI_BUILD`(原 5~55 行)
- [ ] SubTask 5.2: 创建 `js/ble/state.js` — 导出 `state` 对象、`$` 元素查询辅助、`els` 元素缓存、`currentTarget`、`currentPayloadSize`、`flowTarget`、`refreshTargetUi`、`setConnectionState`、`resetUiAfterDisconnect`(原 57~430 行)
- [ ] SubTask 5.3: 创建 `js/ble/stages.js` — 导出 `updateBleStageLabels`、`resetBleStages`、`setBleStage`、`setBleStageText`、`setBleStageError`、`bleStageIds`(原 141~206 行)
- [ ] SubTask 5.4: 创建 `js/ble/gatt.js` — 导出 `connectBle`、`disconnect`、`onDisconnected`、服务发现、通知使能(原 431~560 行附近 connect/disconnect 段)
- [ ] SubTask 5.5: 创建 `js/ble/file.js` — 导出 `notifyFileChange`、`clearFile`、文件准备、分包计算、`setReadyHint`(原文件准备相关段)
- [ ] SubTask 5.6: 创建 `js/ble/transfer.js` — 导出数据写入循环、ACK 窗口节流、`updateProgressFromAck`、重传逻辑(原数据写入段)
- [ ] SubTask 5.7: 创建 `js/ble/verify.js` — 导出 `showBleVerifyInfo`、`hideBleVerifyInfo`、SHA-256 校验比对逻辑(原 276~302 行)
- [ ] SubTask 5.8: 创建 `js/ble/session.js` — 导出 `waitForOtaResult`、`resolveOtaResult`、`rejectOtaResult`、`abortCurrent`、`nextOtaSessionId`、`finishBelongsToWaiter`(原 207~261 + 1568~1657 行)
## Phase 5: can.js 拆分(与 Phase 3/4 无依赖,可并行)
- [x] Task 6: 拆分 `can.js` 为 `js/can/` 模块
- [ ] SubTask 6.1: 创建 `js/can/dbc.js` — 导出 `parseDbc`、`findSignal`、`parseMultiplexToken`、`buildSummary`、`normalizeWhitespace`(原 182~335 行)
- [ ] SubTask 6.2: 创建 `js/can/messages.js` — 导出 `filterMessages`、`renderMessageList`、`collectTransmitters`、`renderNodeFilterList`、`bindNodeFilterEvents`、`updateDbcFileInfo`(原 357~700 行附近)
- [ ] SubTask 6.3: 创建 `js/can/signals.js` — 导出信号值编辑器、编解码、`renderSignalEditor`、批量添加逻辑、旧版 shim(原 700~1100 行附近)
- [ ] SubTask 6.4: 创建 `js/can/send.js` — 导出 `buildSendResponseText`、`CAN_SEND_ERR_DESC`、`GD32_ERR_DESC`、帧构建与发送(原发送相关段)
- [ ] SubTask 6.5: 创建 `js/can/feedback.js` — 导出 1Hz 反馈轮询、ID 快照表渲染(原反馈轮询段)
## Phase 6: can_monitor.js ESM 化(依赖 Phase 2)
- [x] Task 7: 将 `can_monitor.js` 转为 ES Module
- 创建 `js/can_monitor.js` 入口,引用 `js/common/utils.js`、`js/common/connection.js` 等公共模块替换内部重复实现
- 原文件仅 244 行,内部逻辑保持不变,仅替换工具函数为 import
## Phase 7: 入口组装与 HTML 更新
- [x] Task 8: 创建薄入口文件并更新 HTML 引用
- [ ] SubTask 8.1: 创建 `js/app.js` 入口 — import 所有 `js/ota/*` + `js/common/*` 模块,将 HTML onclick 引用的函数挂载到 `window`(`setConsoleMode`、`selectFlashTarget`、`handleSharedFileSelect`、`pickSharedFirmware`、`clearSharedFirmware`、`startUpload`、`restartEsp32`、`sendSpiFrame`、`clearSpiFields`、`testConnection`、`refreshSystemStatusNow`、`setLogFilter`、`toggleAutoScroll`、`clearLogs`、`toggleCollapse` 等)
- [ ] SubTask 8.2: 创建 `js/ble_ota.js` 入口 — import 所有 `js/ble/*` 模块,组装并导出 `window.bleOta` API 对象(`init`、`isUploading`、`notifyFileChange`、`clearFile`、`refreshButtons`、`refreshTargetUi` 等),调用 `init()`
- [ ] SubTask 8.3: 创建 `js/can.js` 入口 — import 所有 `js/can/*` 模块,执行初始化
- [ ] SubTask 8.4: 更新 `index.html` — `<script>` 改为 `type="module" src="js/app.js"` + `type="module" src="js/ble_ota.js"`,删除内联 `<script>` 块
- [ ] SubTask 8.5: 更新 `can.html` — `<script>` 改为 `type="module" src="js/can.js"`
- [ ] SubTask 8.6: 更新 `can_monitor.html` — `<script>` 改为 `type="module" src="js/can_monitor.js"`
## Phase 8: 清理与验证
- [x] Task 9: 删除原始大文件
- 删除 `tools/app.js`(原 2371 行,已被 `js/app.js` + `js/ota/*` 替代)
- 删除 `tools/ble_ota.js`(原 1658 行,已被 `js/ble_ota.js` + `js/ble/*` 替代)
- 删除 `tools/can.js`(原 1526 行,已被 `js/can.js` + `js/can/*` 替代)
- 删除 `tools/style.css`(原 2146 行,已被 `css/*` 替代)
- 删除原 `tools/can_monitor.js`(已迁移到 `js/can_monitor.js`)
- [x] Task 10: 全功能验证
- 启动 `serve.bat`,浏览器打开三个页面验证无控制台错误
- 验证 index.html:WiFi 模式切换、ESP32/GD32 目标切换、文件选择、上传按钮状态、进度条、阶段流、日志面板、SPI 发送、系统状态轮询
- 验证 BLE 模式:连接/断开、文件选择、一键刷写、中止、阶段流、校验面板
- 验证 can.html:DBC 导入、报文列表、节点过滤、信号编辑、帧发送、1Hz 反馈
- 验证 can_monitor.html:连接、报文接收监控
# Task Dependencies
- [Task 2] 依赖 [Task 1] 完成(CSS 拆分先行,避免干扰)
- [Task 3] 依赖 [Task 2] 完成(ota 模块引用 common 公共函数)
- [Task 4] 依赖 [Task 3] 完成(console-mode 引用 ota 模块导出)
- [Task 5] 依赖 [Task 2] 完成(ble 模块可能引用 common 工具)
- [Task 6] 依赖 [Task 2] 完成(can 模块引用 common 工具)
- [Task 7] 依赖 [Task 2] 完成(can_monitor 引用 common)
- [Task 5]、[Task 6] 与 [Task 3] 无依赖,可并行执行
- [Task 8] 依赖 [Task 3]、[Task 4]、[Task 5]、[Task 6]、[Task 7] 全部完成
- [Task 9] 依赖 [Task 8] 完成
- [Task 10] 依赖 [Task 9] 完成
+4 -4
View File
@@ -4,11 +4,11 @@
// 固件版本
#define FW_VERSION_MAJOR 0
#define FW_VERSION_MINOR 3
#define FW_VERSION_PATCH 45
#define FW_VERSION_STRING "0.3.45"
#define FW_VERSION_PATCH 46
#define FW_VERSION_STRING "0.3.46"
// 编译时间
#define BUILD_TIME "2026-07-15 02:19:05"
#define BUILD_TIME "2026-07-15 12:08:48"
// Git 信息
#define GIT_HASH "164855a"
#define GIT_HASH "5a89cec"
+2 -2
View File
@@ -4,8 +4,8 @@ CONFIG_ESP_WIFI_TX_BA_WIN=6
CONFIG_DCU_OTA_POLL_BACKOFF_MS=5
CONFIG_DCU_OTA_FINISH_TIMEOUT_MS=10000
# DCU SPI clock -- 1 MHz 优先保证 BOOT 从机可靠(RBNE/DMA 武装窗口);OTA 稳后再提速
CONFIG_DCU_SPI_CLOCK_HZ=1000000
# DCU SPI clock -- 4 MHz(GD32 SPI0 RX DMA 武装后可安全提速;8/12MHz 须零错误验证后再开)
CONFIG_DCU_SPI_CLOCK_HZ=4000000
# DCU OTA packet interval -- 给 GD32 主循环 AES/Flash drain 留时间(原 1ms 易在中后期卡死)
CONFIG_DCU_OTA_PACKET_INTERVAL_MS=3
-2371
View File
File diff suppressed because it is too large Load Diff
-1658
View File
File diff suppressed because it is too large Load Diff
+6 -2
View File
@@ -4,7 +4,11 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ESP32 CAN 通信控制台</title>
<link rel="stylesheet" href="style.css?v=20260701a">
<link rel="stylesheet" href="css/base.css?v=20260715a">
<link rel="stylesheet" href="css/layout.css?v=20260715a">
<link rel="stylesheet" href="css/components.css?v=20260715a">
<link rel="stylesheet" href="css/can.css?v=20260715a">
<link rel="stylesheet" href="css/responsive.css?v=20260715a">
</head>
<body class="can-page">
<div class="container can-container">
@@ -213,6 +217,6 @@
<span id="toastMsg">操作成功</span>
</div>
<script src="can.js?v=20260715c"></script>
<script type="module" src="js/can.js?v=20260715a"></script>
</body>
</html>
-1526
View File
File diff suppressed because it is too large Load Diff
+6 -2
View File
@@ -4,7 +4,11 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ESP32 CAN 接收监控</title>
<link rel="stylesheet" href="style.css?v=20260628a">
<link rel="stylesheet" href="css/base.css?v=20260715a">
<link rel="stylesheet" href="css/layout.css?v=20260715a">
<link rel="stylesheet" href="css/components.css?v=20260715a">
<link rel="stylesheet" href="css/can.css?v=20260715a">
<link rel="stylesheet" href="css/responsive.css?v=20260715a">
</head>
<body class="can-page">
<div class="container can-container">
@@ -158,6 +162,6 @@
<span id="toastMsg">操作成功</span>
</div>
<script src="can_monitor.js?v=20260629b"></script>
<script type="module" src="js/can_monitor.js?v=20260715a"></script>
</body>
</html>
-244
View File
@@ -1,244 +0,0 @@
(function () {
'use strict';
var state = { fbTimer: null };
var el = {};
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'
];
function cacheElements() {
ids.forEach(function (id) { el[id] = document.getElementById(id); });
}
function showToast(msg, type) {
type = type || 'success';
if (!el.toast) return;
el.toast.className = 'toast ' + (type === 'success' ? 'ok' : type === 'error' ? 'err' : 'warn') + ' show';
el.toastIco.textContent = type === 'success' ? '\u2713' : type === 'error' ? '\u2715' : '\u26A0';
el.toastMsg.textContent = msg;
clearTimeout(showToast._t);
showToast._t = setTimeout(function () { el.toast.classList.remove('show'); }, 3000);
}
function sanitize(text) {
return String(text == null ? '' : text)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function getBaseUrl() {
var ip = (el.monIp && el.monIp.value ? el.monIp.value.trim() : '') || '192.168.31.127';
return 'http://' + ip;
}
function setConnState(ok, text) {
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;
}
// ── 1Hz 轮询 /can/feedback ─────────────────────────────────
async function pollFeedback() {
// 取消上一次 in-flight 请求,防止 SPI 慢响应堆积导致 UI 瞬间刷新多次
if (state.fbAbortCtrl) { try { state.fbAbortCtrl.abort(); } catch(_e) {} }
state.fbAbortCtrl = new AbortController();
var fbPollTimer = setTimeout(function() { try { state.fbAbortCtrl.abort(); } catch(_e) {} }, 3000);
try {
var resp = await fetch(getBaseUrl() + '/can/feedback', { signal: state.fbAbortCtrl.signal });
if (!resp.ok) return;
var data = await resp.json();
var channels = data.channels || [];
var selectedCh = el.idsnapChannel ? parseInt(el.idsnapChannel.value) : 0;
for (var i = 0; i < 3; i++) {
var ch = channels[i];
if (!ch || !ch.valid) {
renderChannelEmpty(i);
continue;
}
renderChannel(i, ch);
}
// 渲染选中通道的 ID 快照表
var sel = channels[selectedCh];
if (sel && sel.valid && sel.entries) {
renderIdTable(sel.entries, sel.count || sel.entries.length);
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, '已连接');
clearTimeout(fbPollTimer);
} catch (e) {
clearTimeout(fbPollTimer);
// 自身 AbortController 触发的取消不算失败,静默返回
if (e && e.name === "AbortError") return;
if (el.fbPollStatus) {
el.fbPollStatus.textContent = '轮询: 离线';
el.fbPollStatus.className = 'pill offline';
}
setConnState(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'; }
}
function renderIdTable(entries, count) {
var tbody = el.idsnapBody;
if (!tbody) return;
if (!entries || !entries.length) {
tbody.innerHTML = '<tr><td colspan="7" class="status-empty">无数据(CAN 未收到帧)</td></tr>';
return;
}
var html = '';
for (var i = 0; i < entries.length; i++) {
var e = entries[i];
html += '<tr>';
html += '<td>' + (i + 1) + '</td>';
html += '<td class="mono">' + sanitize(e.id) + '</td>';
html += '<td>' + e.dlc + '</td>';
html += '<td class="' + (e.fd ? 'flag-on' : 'flag-off') + '">' + (e.fd ? 'Y' : '-') + '</td>';
html += '<td class="' + (e.brs ? 'flag-on' : 'flag-off') + '">' + (e.brs ? 'Y' : '-') + '</td>';
html += '<td class="' + (e.esi ? 'flag-on' : 'flag-off') + '">' + (e.esi ? 'Y' : '-') + '</td>';
html += '<td class="idsnap-data mono">' + sanitize(e.data || '--') + '</td>';
html += '</tr>';
}
tbody.innerHTML = html;
}
function renderIdTableEmpty() {
var tbody = el.idsnapBody;
if (tbody) tbody.innerHTML = '<tr><td colspan="7" class="status-empty">等待数据...</td></tr>';
if (el.idsnapStatus) { el.idsnapStatus.textContent = '--'; el.idsnapStatus.className = 'pill'; }
}
// ── 连接测试 ─────────────────────────────────────────────
async function testConnection() {
try {
var resp = await fetch(getBaseUrl() + '/can/feedback', { signal: AbortSignal.timeout(3000) });
if (!resp.ok) { setConnState(false, 'HTTP ' + resp.status); return; }
setConnState(true, '已连接');
showToast('ESP32 已连接', 'success');
} catch (e) {
setConnState(false, '连接失败');
showToast('连接失败: ' + e.message, 'error');
}
}
// ── 模式切换 ─────────────────────────────────────────────
async function setCanMode(mode) {
try {
var resp = await fetch(getBaseUrl() + '/can/mode', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode: mode }),
signal: AbortSignal.timeout(3000)
});
var data = await resp.json();
if (data.ok) {
showToast('CAN 模式已切换: ' + (data.actual === 2 ? '桥接(CAN0→1/2)' : (data.actual === 1 ? '过滤' : '全收')), 'success');
} else {
showToast('模式切换失败', 'error');
}
} catch (e) {
showToast('模式切换失败: ' + e.message, 'error');
}
}
// ── 初始化 ───────────────────────────────────────────────
function init() {
cacheElements();
setConnState(false, '未连接');
if (el.monTestBtn) el.monTestBtn.addEventListener('click', testConnection);
if (el.monIp) el.monIp.addEventListener('change', testConnection);
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);
setTimeout(testConnection, 150);
pollFeedback();
state.fbTimer = setInterval(pollFeedback, 1000);
}
init();
})();
+54
View File
@@ -0,0 +1,54 @@
/* === base.css — 基础变量与容器 === */
/* ===== ESP32 OTA 控制台 — 工业调试台风格 ===== */
:root {
--bg-root: #090e15;
--bg-panel: #0f161f;
--bg-card: #141d28;
--bg-elevated: #1a2432;
--bg-input: #0d131b;
--border: #1d2a3a;
--border-focus: #22d3ee;
--text: #e2e8f0;
--text-dim: #7b8fa8;
--text-label: #5a6d82;
--accent: #22d3ee;
--accent-dim: rgba(34,211,238,.15);
--green: #22c55e;
--amber: #f59e0b;
--red: #ef4444;
--radius-sm: 6px;
--radius: 10px;
--radius-lg: 14px;
--font: "PingFang SC","Microsoft YaHei","Segoe UI",system-ui,sans-serif;
--font-mono: "SF Mono","Fira Code","Cascadia Code","Consolas",monospace;
--gap: 16px;
}
* { 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.5;
-webkit-font-smoothing: antialiased;
}
/* subtle noise-grid background */
body::before {
content: "";
position: fixed; inset: 0; z-index: 0; pointer-events: none;
background-image:
linear-gradient(rgba(255,255,255,.015) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,.015) 1px, transparent 1px);
background-size: 40px 40px;
mask-image: radial-gradient(ellipse at 50% 0%, black 40%, transparent 70%);
}
.container {
position: relative; z-index: 1;
max-width: 1480px; margin: 0 auto; padding: 24px 32px 40px;
}
+1029
View File
File diff suppressed because it is too large Load Diff
+658
View File
@@ -0,0 +1,658 @@
/* === components.css — 上传/文件信息/进度/指标/堆概况/状态徽章/OTA流程/日志/Toast/Tooltip 等组件 === */
/* === UPLOAD ZONE === */
.upload-zone {
border: 2px dashed var(--border); border-radius: var(--radius);
padding: 28px 16px; text-align: center; cursor: pointer;
transition: border-color .2s, background .2s;
}
.upload-zone:hover,
.upload-zone.dragover { border-color: var(--accent); background: var(--accent-dim); }
.upload-zone .upload-ico { font-size: 32px; margin-bottom: 10px; }
.upload-zone h4 { font-size: 14px; margin-bottom: 4px; }
.upload-zone p { font-size: 12px; color: var(--text-dim); }
.upload-zone-sm { padding: 16px 12px; }
.upload-zone-sm .upload-ico { font-size: 24px; margin-bottom: 6px; }
.upload-zone-sm h4 { font-size: 13px; }
/* === FILE INFO === */
.file-info {
display: none; margin-top: 12px;
background: var(--bg-elevated); border-radius: var(--radius-sm); padding: 12px 14px;
}
.file-info.show { display: block; }
.file-info-row { display: flex; justify-content: space-between; align-items: center; }
.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); padding: 8px 10px; border-radius: 6px;
font-size: 12px;
}
.file-meta-item span { color: var(--text-label); display: block; font-size: 10px; margin-bottom: 2px; }
.file-meta-item b { font-weight: 600; }
/* === PROGRESS === */
.progress-phase-row {
display: flex; align-items: center; gap: 0; margin-bottom: 14px;
flex-wrap: nowrap;
background: var(--bg-input);
border-radius: 20px;
padding: 4px;
border: 1px solid var(--border);
}
.pp-step {
flex: 1;
display: flex; align-items: center; justify-content: center; gap: 5px;
padding: 7px 10px; border-radius: 16px;
font-size: 12px; font-weight: 600; color: var(--text-dim);
background: transparent;
transition: all .25s ease; white-space: nowrap;
cursor: default;
}
.pp-ico { font-size: 13px; opacity: .45; transition: all .25s ease; }
.pp-txt { transition: color .25s ease; }
.pp-sep { display: none; }
/* Active step — highlighted capsule */
.pp-step.active {
color: #07121c;
background: var(--accent);
box-shadow: 0 0 10px var(--accent-dim);
}
.pp-step.active .pp-ico { opacity: 1; }
.pp-step.active .pp-txt { color: #07121c; }
/* Done step — green outlined capsule */
.pp-step.done {
color: var(--green);
background: rgba(34,197,94,.12);
}
.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: 10px; border-radius: 20px;
background: var(--bg-input); overflow: hidden;
box-shadow: inset 0 1px 2px rgba(0,0,0,.3);
}
.progress-fill {
height: 100%; border-radius: 20px;
background: linear-gradient(90deg, #22d3ee, #06b6d4);
width: 0%; transition: width .3s ease;
position: relative;
box-shadow: 0 0 8px rgba(34,211,238,.3);
}
.progress-fill::after {
content: ""; position: absolute; inset: 0;
background: linear-gradient(90deg, transparent 0%, rgba(255,255,255,.3) 50%, transparent 100%);
animation: sweep 1.6s linear infinite;
}
.progress-fill.done {
background: linear-gradient(90deg, #22c55e, #16a34a);
box-shadow: 0 0 8px rgba(34,197,94,.3);
}
@keyframes sweep { 0% { transform: translateX(-100%); } 100% { transform: translateX(100%); } }
.progress-text {
display: flex; justify-content: space-between; margin-top: 7px;
font-size: 12px; color: var(--text-dim);
}
/* === BUTTON ROW === */
.btn-row { display: flex; gap: 10px; margin-top: 16px; }
.btn-row .btn { flex: 1; }
/* === METRIC GRID === */
.metric-grid {
display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px;
}
.metric {
background: var(--bg-elevated); border: 1px solid var(--border);
border-radius: var(--radius-sm); padding: 12px 14px;
}
.metric .k {
font-size: 10px; color: var(--text-label); text-transform: uppercase;
letter-spacing: .1em; margin-bottom: 4px;
}
.metric .v {
font-size: 20px; font-weight: 700; word-break: break-word;
}
.metric .v.highlight { color: var(--accent); }
.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 12px;
}
.status-kv .k {
display: block;
font-size: 10px;
color: var(--text-label);
text-transform: uppercase;
letter-spacing: .08em;
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);
}
.status-section-title { margin-top: 14px; margin-bottom: 8px; font-size: 11px; color: var(--text-label); text-transform: uppercase; letter-spacing: .08em; }
/* ===== 堆概况 ===== */
.heap-group {
background: rgba(255,255,255,0.02);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px 14px;
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,0.025);
}
.cpu-fill {
background: linear-gradient(90deg, #38bdf8, #22c55e);
}
.heap-group-title {
font-size: 11px;
font-weight: 600;
color: var(--text-label);
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 10px;
}
.heap-bar-row {
display: flex;
align-items: center;
gap: 10px;
}
.heap-bar-text {
font-size: 13px;
font-weight: 600;
white-space: nowrap;
min-width: 165px;
color: var(--text);
}
.heap-bar-track {
flex: 1;
height: 14px;
background: rgba(255,255,255,0.06);
border-radius: 7px;
overflow: hidden;
position: relative;
}
.heap-bar-fill {
display: block;
height: 100%;
border-radius: 7px;
background: linear-gradient(90deg, var(--accent), #5eead4);
transition: width .4s ease;
.heap-bar-fill.warn {
background: linear-gradient(90deg, #f59e0b, #d97706);
}
.heap-bar-fill.danger {
background: linear-gradient(90deg, #ef4444, #dc2626);
}
}
.heap-bar-pct {
font-size: 12px;
font-weight: 600;
min-width: 42px;
text-align: right;
font-variant-numeric: tabular-nums;
color: var(--text-dim);
}
.heap-detail-row {
display: flex;
gap: 16px;
font-size: 11px;
color: var(--text-dim);
margin-top: 6px;
padding-top: 6px;
border-top: 1px solid rgba(255,255,255,0.04);
flex-wrap: wrap;
}
.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;
}
.status-task-list {
display: grid;
gap: 8px;
}
.status-task {
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 10px 12px;
}
.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, #22d3ee, #06b6d4);
}
.status-task-bar-fill.warn {
background: linear-gradient(90deg, #f59e0b, #d97706);
}
.status-task-bar-fill.err {
background: linear-gradient(90deg, #ef4444, #dc2626);
}
.status-task-meta {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
}
.status-task-meta span {
display: block;
font-size: 10px;
color: var(--text-label);
text-transform: uppercase;
letter-spacing: .06em;
}
.status-task-meta b {
display: block;
margin-top: 2px;
color: var(--text);
font-size: 11px;
font-family: var(--font-mono);
}
.status-json {
margin-top: 0;
padding: 12px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--bg-elevated);
color: var(--text-dim);
font-size: 11px;
line-height: 1.6;
max-height: 280px;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
font-family: var(--font-mono);
}
.status-empty {
padding: 12px;
border: 1px dashed var(--border);
border-radius: var(--radius-sm);
color: var(--text-dim);
font-size: 12px;
}
/* === PRIMARY STATUS BADGES (large) === */
.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: 14px 16px; text-align: center;
}
.badge-stat .v {
font-size: 22px; font-weight: 700;
}
.badge-stat .v.accent { color: var(--accent); }
.badge-stat .v.ok { color: var(--green); }
.badge-stat .v.warn { color: var(--amber); }
.badge-stat .v.err { color: var(--red); }
.badge-stat .k {
font-size: 10px; color: var(--text-label); text-transform: uppercase;
letter-spacing: .1em; margin-top: 2px;
}
/* === OTA STAGE FLOW === */
.stage-flow {
display: flex; align-items: flex-start; gap: 0; padding: 8px 0;
}
.stage-node {
flex: 1; display: flex; flex-direction: column; align-items: center;
gap: 6px; position: relative;
}
.stage-node:not(:last-child)::after {
content: ""; position: absolute; top: 18px; left: calc(50% + 18px);
width: calc(100% - 36px); height: 2px;
background: var(--border); transition: background .3s;
}
.stage-node.done:not(:last-child)::after,
.stage-node.live:not(:last-child)::after { background: var(--accent); }
.stage-dot {
width: 36px; height: 36px; border-radius: 50%;
background: var(--bg-input); border: 2px solid var(--border);
display: flex; align-items: center; justify-content: center;
font-size: 16px; z-index: 1; transition: all .3s;
}
.stage-node.live .stage-dot {
background: var(--accent); border-color: var(--accent); color: #07121c;
box-shadow: 0 0 12px var(--accent-dim);
}
.stage-node.done .stage-dot {
background: var(--green); border-color: var(--green); color: #fff;
}
.stage-node.err .stage-dot {
background: var(--red); border-color: var(--red); color: #fff;
}
.stage-label {
font-size: 11px; color: var(--text-label); text-align: center; line-height: 1.3;
}
.stage-detail {
font-size: 10px; color: var(--accent); 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(--accent); }
.stage-node.done .stage-detail { color: var(--green); }
.stage-node.err .stage-detail { color: var(--red); }
/* Stage status bar */
.stage-status-bar {
display: flex; align-items: center; gap: 10px;
margin-top: 14px; padding: 10px 16px;
border-radius: 20px;
background: var(--bg-elevated); border: 1px solid var(--border);
font-size: 13px;
transition: all .3s;
}
.stage-status-bar .ssb-dot {
width: 8px; height: 8px; border-radius: 50%;
flex-shrink: 0;
background: var(--text-dim);
transition: all .3s;
}
.stage-status-bar .ssb-text {
color: var(--text-dim); font-size: 13px; font-weight: 500;
letter-spacing: .01em;
}
/* idle — muted, with subtle pulse */
.stage-status-bar .ssb-dot { animation: dotPulse 2.5s ease-in-out infinite; }
@keyframes dotPulse {
0%, 100% { opacity: .45; }
50% { opacity: .8; }
}
/* active — accent colored */
.stage-status-bar.active {
border-color: rgba(34,211,238,.25);
background: linear-gradient(135deg, rgba(34,211,238,.06), rgba(34,211,238,.02));
}
.stage-status-bar.active .ssb-dot {
background: var(--accent);
box-shadow: 0 0 6px var(--accent-dim);
animation: dotPulseLive 1.2s ease-in-out infinite;
}
.stage-status-bar.active .ssb-text { color: var(--accent); font-weight: 600; }
@keyframes dotPulseLive {
0%, 100% { opacity: .7; transform: scale(1); }
50% { opacity: 1; transform: scale(1.25); }
}
/* ok — green */
.stage-status-bar.ok {
border-color: rgba(34,197,94,.25);
background: linear-gradient(135deg, rgba(34,197,94,.06), rgba(34,197,94,.02));
}
.stage-status-bar.ok .ssb-dot {
background: var(--green);
box-shadow: 0 0 6px rgba(34,197,94,.3);
animation: none;
}
.stage-status-bar.ok .ssb-text { color: var(--green); font-weight: 600; }
/* err — red */
.stage-status-bar.err {
border-color: rgba(239,68,68,.25);
background: linear-gradient(135deg, rgba(239,68,68,.06), rgba(239,68,68,.02));
}
.stage-status-bar.err .ssb-dot {
background: var(--red);
box-shadow: 0 0 6px rgba(239,68,68,.3);
animation: none;
}
.stage-status-bar.err .ssb-text { color: var(--red); font-weight: 600; }
/* Stage verification info panel */
.stage-verify-info {
margin-top: 12px; padding: 12px 14px;
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: 6px 0; font-size: 12px;
border-bottom: 1px solid rgba(255,255,255,.03);
}
.svi-row:last-child { border-bottom: none; }
.svi-key {
color: var(--text-label); font-weight: 600;
text-transform: uppercase; letter-spacing: .06em; font-size: 10px;
min-width: 100px; 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 panel - 上位机确认 0x07/0x85 收尾 */
.stage-result {
margin-top: 12px; padding: 14px 16px;
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(34,197,94,.45); background: rgba(34,197,94,.08); }
.stage-result.err { border-color: rgba(239,68,68,.45); background: rgba(239,68,68,.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: #07121c;
}
.stage-result.ok .sr-ico { background: var(--green); }
.stage-result.err .sr-ico { background: var(--red); }
.stage-result .sr-title { 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.5; }
.stage-result .sr-actions { display: flex; justify-content: flex-end; gap: 8px; }
/* === LOG PANEL === */
.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: 10px 16px; border-bottom: 1px solid var(--border);
gap: 10px; flex-wrap: wrap;
}
.log-filters { display: flex; gap: 6px; }
.log-filter {
padding: 4px 10px; border-radius: 14px; font-size: 11px; font-weight: 500;
border: 1px solid var(--border); background: transparent; color: var(--text-dim);
cursor: pointer; transition: all .15s;
}
.log-filter.active { background: var(--accent); color: #07121c; border-color: var(--accent); }
.log-filter:hover:not(.active) { border-color: var(--text-dim); }
.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;
}
.log-scroll::-webkit-scrollbar { width: 5px; }
.log-scroll::-webkit-scrollbar-track { background: transparent; }
.log-scroll::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
.log-line {
display: flex; gap: 8px; padding: 3px 0;
border-bottom: 1px solid rgba(255,255,255,.02);
}
.log-line .lt { color: var(--text-label); flex-shrink: 0; }
.log-line .lv {
font-weight: 600; 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(--accent); }
.log-line .lv.D { color: var(--text-label); }
.log-line .lg { color: #a78bfa; flex-shrink: 0; }
.log-line .lm { color: var(--text); word-break: break-all; }
/* BLE log compat */
.log-line .t { color: var(--text-label); flex-shrink: 0; }
.log-line .tag { color: #a78bfa; flex-shrink: 0; }
.log-foot {
display: flex; justify-content: space-between; align-items: center;
padding: 8px 16px; border-top: 1px solid var(--border);
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: 10px;
padding: 14px 24px; border-radius: 10px;
font-size: 14px; font-weight: 600; color: #fff; pointer-events: none;
opacity: 0;
transition: all .35s cubic-bezier(.4,0,.2,1);
box-shadow: 0 8px 32px rgba(0,0,0,.35);
}
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); pointer-events: auto; }
.toast.ok { background: #16a34a; }
.toast.err { background: #dc2626; }
.toast.warn { background: #d97706; }
.toast .toast-ico { font-size: 18px; }
/* === GRID ROWS === */
.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 === */
.sub-actions { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 12px; }
/* === FOOTER NOTE === */
.footer-note { margin-top: 12px; font-size: 12px; line-height: 1.7; color: var(--text-dim); }
/* === CODE === */
code { color: #d3f9ff; font-family: var(--font-mono); }
/* === FILE INFO BORDER === */
.file-info { border: 1px solid var(--border); }
/* === PILL LIVE === */
.pill.live .indicator { background: var(--amber); animation: blink 1s infinite; }
/* ===== Header sub-row (设备名/设备ID) ===== */
.header-sub {
display: flex; gap: 12px;
margin-top: 0;
padding: 0;
}
.pill-val {
font-weight: 600;
color: var(--text);
}
/* ===== Custom Tooltip ===== */
.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: #1a2432;
color: #e2e8f0;
font-size: 11px;
font-weight: 400;
font-family: "SF Mono","Fira Code","Cascadia Code","Consolas",monospace;
padding: 6px 12px;
border-radius: 6px;
border: 1px solid #2a3a4e;
box-shadow: 0 4px 16px rgba(0,0,0,.45);
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: #2a3a4e;
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); }
}
+156
View File
@@ -0,0 +1,156 @@
/* === layout.css — 头部/主布局/卡片/表单/按钮/连接状态 === */
/* === HEADER — compact status bar === */
.header-bar {
display: flex; align-items: center; gap: 12px;
padding: 14px 20px; margin-bottom: 24px;
background: var(--bg-panel);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
flex-wrap: wrap;
}
.header-brand {
display: flex; align-items: center; gap: 10px;
padding-right: 20px; border-right: 1px solid var(--border);
white-space: nowrap;
}
.header-brand .dot-mark {
width: 10px; height: 10px; border-radius: 50%; background: var(--accent);
box-shadow: 0 0 8px var(--accent-dim);
flex-shrink: 0;
}
.header-brand strong { font-size: 15px; font-weight: 700; }
.header-status {
display: flex; align-items: center; gap: 8px;
flex-wrap: wrap;
}
.pill {
display: inline-flex; align-items: center; gap: 6px;
padding: 5px 12px; border-radius: 20px; font-size: 12px; font-weight: 500;
background: var(--bg-elevated); border: 1px solid var(--border);
white-space: nowrap;
}
.pill .indicator {
width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0;
}
.pill.online .indicator { background: var(--green); }
.pill.offline .indicator { background: var(--red); }
.pill.processing .indicator { background: var(--amber); animation: blink 1s infinite; }
.pill.idle .indicator { background: var(--text-dim); }
@keyframes blink { 50% { opacity: .35; } }
/* === MAIN LAYOUT === */
.main-layout {
display: grid;
grid-template-columns: 380px 1fr;
gap: var(--gap);
align-items: start;
}
/* === CARDS === */
.card {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
overflow: hidden;
}
.card + .card { margin-top: var(--gap); }
.card-head {
display: flex; align-items: center; gap: 8px;
padding: 14px 18px;
border-bottom: 1px solid var(--border);
font-size: 13px; font-weight: 600;
color: var(--text-dim);
text-transform: uppercase; letter-spacing: .06em;
}
.card-head .ico { font-size: 15px; }
.card-head-btn {
margin-left: auto;
padding: 4px 10px;
position: relative;
z-index: 2;
}
.card-body { padding: 16px 18px; }
.card.collapsed .card-body { display: none; }
.card-head { cursor: pointer; }
.card-head .collapse-arrow {
margin-left: auto; font-size: 10px; opacity: .45;
transition: transform .2s;
}
.card.collapsed .collapse-arrow { transform: rotate(-90deg); }
/* === FORM ELEMENTS === */
.form-row { margin-bottom: 14px; }
.form-row:last-child { margin-bottom: 0; }
.form-label {
display: block; font-size: 11px; color: var(--text-label);
text-transform: uppercase; letter-spacing: .08em; margin-bottom: 6px;
}
.input, select, textarea {
width: 100%; padding: 10px 12px; font-size: 13px; font-family: inherit;
background: var(--bg-input); color: var(--text);
border: 1px solid var(--border); border-radius: var(--radius-sm);
outline: none; transition: border-color .15s;
}
.input:focus, select:focus, textarea:focus {
border-color: var(--border-focus);
box-shadow: 0 0 0 3px var(--accent-dim);
}
select { cursor: pointer; }
select option { background: var(--bg-panel); color: var(--text); }
.input-group { display: flex; gap: 8px; }
.input-group .input { flex: 1; }
/* === BUTTONS === */
.btn {
display: inline-flex; align-items: center; justify-content: center; gap: 6px;
padding: 9px 16px; border: none; border-radius: var(--radius-sm);
font-size: 13px; font-weight: 600; cursor: pointer;
transition: opacity .15s, transform .15s;
font-family: inherit; white-space: nowrap;
}
.btn:hover:not(:disabled) { transform: translateY(-1px); }
.btn:disabled { opacity: .4; cursor: not-allowed; transform: none; }
.btn-primary { background: var(--accent); color: #07121c; }
.btn-primary:hover:not(:disabled) { opacity: .88; }
.btn-success { background: var(--green); color: #051a0c; }
.btn-danger { background: var(--red); color: #fff; }
.btn-outline {
background: transparent; color: var(--text-dim);
border: 1px solid var(--border);
}
.btn-outline:hover:not(:disabled) { background: var(--bg-elevated); color: var(--text); }
.btn-ghost {
background: transparent; color: var(--text-dim);
}
.btn-ghost:hover:not(:disabled) { background: var(--bg-elevated); color: var(--text); }
.btn-block { width: 100%; justify-content: center; }
.btn-sm { padding: 6px 12px; font-size: 12px; }
/* === CONNECTION STATUS ROW === */
.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); }
.conn-indicator.err .light { background: var(--red); }
.conn-indicator.warn .light { background: var(--amber); }
.helper-note {
margin-top: 10px;
padding: 10px 12px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--bg-elevated);
font-size: 12px;
line-height: 1.6;
color: var(--text-dim);
}
.helper-note.ok { border-color: rgba(34,197,94,.25); color: #b8f5cb; }
.helper-note.warn { border-color: rgba(245,158,11,.25); color: #fbd38d; }
.helper-note.err { border-color: rgba(239,68,68,.25); color: #fecaca; }
.helper-note code { color: #d7fbff; font-family: var(--font-mono); }
+250
View File
@@ -0,0 +1,250 @@
/* === responsive.css — 响应式断点 + 增强动画 === */
/* === RESPONSIVE === */
@media (max-width: 1100px) {
.main-layout { grid-template-columns: 1fr; }
.badge-grid { grid-template-columns: repeat(2, 1fr); }
.can-toolbar-grid,
.can-toolbar-foot,
.can-editor-layout { grid-template-columns: 1fr; }
.message-list {
max-height: 420px;
}
.can-meta-grid { grid-template-columns: 1fr 1fr; }
}
@media (max-width: 640px) {
.container { padding: 16px; }
.header-bar { flex-direction: column; align-items: flex-start; }
.badge-grid, .metric-grid { grid-template-columns: 1fr; }
.status-kv-grid, .status-task-meta { grid-template-columns: 1fr; }
.can-grid { grid-template-columns: 1fr 1fr; }
.btn-row { flex-direction: column; }
.can-meta-grid { grid-template-columns: 1fr; }
.can-launch-top,
.can-launch-actions,
.can-launch-foot {
grid-template-columns: 1fr;
flex-direction: column;
align-items: flex-start;
}
.can-selection-bar,
.can-selection-actions,
.history-item-top {
flex-direction: column;
align-items: flex-start;
}
.can-title {
font-size: 22px;
}
.can-frame-hex {
font-size: 15px;
}
.message-table,
.signal-table {
min-width: 980px;
}
}
/* ===== ENHANCED ANIMATIONS (v20260625b) ===== */
/* Global keyframes */
@keyframes fadeInUp {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes popIn {
0% { opacity: 0; transform: scale(.85); }
60% { opacity: 1; transform: scale(1.04); }
100% { opacity: 1; transform: scale(1); }
}
@keyframes shimmer {
0% { background-position: -200% 0; }
100% { background-position: 200% 0; }
}
@keyframes ringPulse {
0% { box-shadow: 0 0 0 0 rgba(34,211,238,.55); }
70% { box-shadow: 0 0 0 10px rgba(34,211,238,0); }
100% { box-shadow: 0 0 0 0 rgba(34,211,238,0); }
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
20% { transform: translateX(-4px); }
40% { transform: translateX(4px); }
60% { transform: translateX(-3px); }
80% { transform: translateX(3px); }
}
@keyframes spin {
to { transform: rotate(360deg); }
}
@keyframes floatY {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-4px); }
}
@keyframes glowBreath {
0%, 100% { box-shadow: 0 0 8px var(--accent-dim); }
50% { box-shadow: 0 0 18px rgba(34,211,238,.45); }
}
@keyframes flowDash {
to { background-position: 200% 0; }
}
/* Page entrance — header + cards staggered */
.header-bar { animation: fadeInUp .45s ease both; }
.main-layout > div > .card { animation: fadeInUp .5s ease both; }
.main-layout > div:nth-child(1) > .card:nth-child(1) { animation-delay: .05s; }
.main-layout > div:nth-child(1) > .card:nth-child(2) { animation-delay: .12s; }
.main-layout > div:nth-child(1) > .card:nth-child(3) { animation-delay: .19s; }
.main-layout > div:nth-child(1) > .card:nth-child(4) { animation-delay: .26s; }
.main-layout > div:nth-child(2) > .card:nth-child(1) { animation-delay: .08s; }
.main-layout > div:nth-child(2) > .card:nth-child(2) { animation-delay: .15s; }
.main-layout > div:nth-child(2) > .card:nth-child(3) { animation-delay: .22s; }
.main-layout > div:nth-child(2) > .card:nth-child(4) { animation-delay: .29s; }
/* Card hover — lift + glow */
.card {
transition: transform .25s ease, border-color .25s ease, box-shadow .25s ease;
}
.card:hover {
transform: translateY(-2px);
border-color: rgba(34,211,238,.28);
box-shadow: 0 10px 28px rgba(0,0,0,.35), 0 0 0 1px rgba(34,211,238,.08);
}
/* Brand dot — gentle ring pulse */
.header-brand .dot-mark {
animation: ringPulse 2.2s ease-out infinite, glowBreath 3.6s ease-in-out infinite;
}
/* Pills — subtle hover */
.pill { transition: transform .2s ease, border-color .2s ease, background .2s ease; }
.pill:hover { transform: translateY(-1px); border-color: rgba(34,211,238,.3); }
/* Button — press + sheen on hover for primary */
.btn { position: relative; overflow: hidden; }
.btn:active:not(:disabled) { transform: translateY(0) scale(.97); transition: transform .08s ease; }
.btn-primary::before,
.btn-success::before,
.btn-danger::before {
content: "";
position: absolute; top: 0; left: -120%;
width: 60%; height: 100%;
background: linear-gradient(90deg, transparent, rgba(255,255,255,.35), transparent);
transform: skewX(-20deg);
transition: left .55s ease;
pointer-events: none;
}
.btn-primary:hover:not(:disabled)::before,
.btn-success:hover:not(:disabled)::before,
.btn-danger:hover:not(:disabled)::before {
left: 130%;
}
/* Upload zone — drag pulse + icon float */
.upload-zone { transition: border-color .2s, background .2s, transform .2s; }
.upload-zone:hover { transform: translateY(-1px); }
.upload-zone .upload-ico {
display: inline-block;
transition: transform .25s ease;
}
.upload-zone:hover .upload-ico { animation: floatY 1.6s ease-in-out infinite; }
.upload-zone.dragover { animation: ringPulse 1.2s ease-out infinite; }
/* File info appearance */
.file-info.show { animation: fadeInUp .3s ease both; }
/* Progress bar — striped flow on active */
.progress-fill {
background-image:
linear-gradient(90deg, #22d3ee, #06b6d4),
repeating-linear-gradient(45deg,
rgba(255,255,255,.18) 0 10px,
rgba(255,255,255,0) 10px 20px);
background-size: 100% 100%, 200% 100%;
background-blend-mode: normal, overlay;
animation: flowDash 1.4s linear infinite;
}
.progress-fill.done { animation: none; }
/* Phase step capsule — pop when becoming active/done */
.pp-step.active,
.pp-step.done { animation: popIn .35s cubic-bezier(.4,1.4,.5,1) both; }
/* Stage flow — connector grow + dot pop */
.stage-node:not(:last-child)::after {
background: linear-gradient(90deg, var(--accent) 0%, var(--accent) 50%, var(--border) 50%, var(--border) 100%);
background-size: 200% 100%;
background-position: 100% 0;
transition: background-position .5s ease;
}
.stage-node.done:not(:last-child)::after,
.stage-node.live:not(:last-child)::after {
background-position: 0% 0;
}
.stage-dot { transition: transform .3s ease, background .3s, border-color .3s, color .3s, box-shadow .3s; }
.stage-node.live .stage-dot {
animation: ringPulse 1.6s ease-out infinite;
}
.stage-node.done .stage-dot { animation: popIn .35s cubic-bezier(.4,1.4,.5,1) both; }
.stage-node.err .stage-dot { animation: shake .4s ease both; }
/* Stage status bar — slide in when state changes */
.stage-status-bar.active,
.stage-status-bar.ok,
.stage-status-bar.err { animation: fadeInUp .3s ease both; }
/* Stage result panel */
.stage-result { animation: popIn .4s cubic-bezier(.4,1.4,.5,1) both; }
/* Metric / badge values — number tick highlight */
.metric .v,
.badge-stat .v {
transition: color .3s ease, transform .3s ease;
}
.metric:hover,
.badge-stat:hover {
border-color: rgba(34,211,238,.25);
transition: border-color .25s ease;
}
/* Conn indicator — light ring pulse when warn */
.conn-indicator { transition: background .25s ease, border-color .25s ease; }
.conn-indicator.warn .light { animation: ringPulse 1.4s ease-out infinite; }
.conn-indicator.ok .light { animation: glowBreath 3s ease-in-out infinite; }
/* Heap / task bars — sheen */
.heap-bar-track,
.status-task-bar { position: relative; overflow: hidden; }
.heap-bar-track::after,
.status-task-bar::after {
content: "";
position: absolute; inset: 0;
background: linear-gradient(90deg, transparent 0%, rgba(255,255,255,.12) 50%, transparent 100%);
transform: translateX(-100%);
animation: shimmer 2.6s linear infinite;
background-size: 200% 100%;
pointer-events: none;
}
.heap-bar-fill,
.status-task-bar-fill { transition: width .6s cubic-bezier(.4,0,.2,1); }
/* Log lines — fresh entry fade-in */
.log-line { animation: fadeIn .25s ease both; }
/* Toast — pop and float */
.toast.show { animation: popIn .35s cubic-bezier(.4,1.4,.5,1) both; }
/* Reduced motion — respect user preference */
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: .001ms !important;
animation-iteration-count: 1 !important;
transition-duration: .001ms !important;
}
}
+8 -257
View File
@@ -9,7 +9,11 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ESP32 OTA 中继控制台</title>
<link rel="stylesheet" href="style.css?v=20260625b">
<link rel="stylesheet" href="css/base.css?v=20260715a">
<link rel="stylesheet" href="css/layout.css?v=20260715a">
<link rel="stylesheet" href="css/components.css?v=20260715a">
<link rel="stylesheet" href="css/can.css?v=20260715a">
<link rel="stylesheet" href="css/responsive.css?v=20260715a">
</head>
<body>
<div class="container">
@@ -427,261 +431,8 @@
<span id="toastMsg">操作成功</span>
</div>
<script src="app.js?v=20260713m"></script>
<script>
window.connectionMode = window.connectionMode || 'wifi';
window.flashTarget = window.flashTarget || 'esp32';
window.sharedFirmwareFiles = window.sharedFirmwareFiles || {
esp32: { file: null, handle: null },
gd32: { file: null, handle: null }
};
(function () {
var originalSwitchStageFlow = window.switchStageFlow;
window.switchStageFlow = function (target) {
if (target === 'ble') {
window.currentOtaTarget = 'ble';
var esp32El = document.getElementById('esp32Stages');
var gd32El = document.getElementById('gd32Stages');
var bleEl = document.getElementById('bleStages');
if (esp32El) esp32El.style.display = 'none';
if (gd32El) gd32El.style.display = 'none';
if (bleEl) bleEl.style.display = '';
return;
}
if (typeof originalSwitchStageFlow === 'function') {
originalSwitchStageFlow(target);
}
var bleEl = document.getElementById('bleStages');
if (bleEl) bleEl.style.display = 'none';
};
window.setConsoleMode = function (mode) {
// ★ 刷写进行中禁止切换连接模式:setConsoleMode → syncBleFileFromSelectedTarget →
// notifyFileChange → updateFileInfo → finalizeFileInfo → setProgress(0) 会覆盖
// 进行中的 WiFi/BLE 上传进度(与 selectFlashTarget 同样的守卫策略)。
var bleBusy = window.bleOta && window.bleOta.isUploading && window.bleOta.isUploading();
if (window.isUploading || bleBusy) {
var active = bleBusy ? 'BLE' : ((window.activeUploadChip || 'OTA').toUpperCase());
if (typeof addLog === 'function') addLog('W', 'OTA', active + ' 刷写进行中,无法切换连接模式');
if (typeof showToast === 'function') showToast('当前有 ' + active + ' 任务在运行,请等待结束后再切换', 'error');
return;
}
var isBle = mode === 'ble';
window.connectionMode = isBle ? 'ble' : 'wifi';
var wifiCards = document.getElementById('wifiModeCards');
var bleCards = document.getElementById('bleModeCards');
var wifiBtn = document.getElementById('modeWifiBtn');
var bleBtn = document.getElementById('modeBleBtn');
if (wifiCards) wifiCards.style.display = isBle ? 'none' : '';
if (bleCards) bleCards.style.display = isBle ? '' : 'none';
var headBtns = document.getElementById('bleHeadActions');
if (headBtns) headBtns.style.display = isBle ? '' : 'none';
if (wifiBtn) wifiBtn.className = isBle ? 'btn btn-outline' : 'btn btn-primary';
if (bleBtn) bleBtn.className = isBle ? 'btn btn-primary' : 'btn btn-outline';
applySharedModeUi();
syncBleFileFromSelectedTarget();
if (isBle) {
window.switchStageFlow('ble');
if (typeof setActivePhase === 'function') {
setActivePhase(window.flashTarget === 'gd32' ? 'gd32Ota' : 'esp32Ota');
}
if (window.bleOta && typeof window.bleOta.refreshButtons === 'function') {
window.bleOta.refreshButtons();
}
} else {
window.switchStageFlow(window.flashTarget || (window.activePhase === 'esp32Ota' ? 'esp32' : 'gd32'));
}
};
window.selectFlashTarget = function (target) {
// OTA 上传进行中禁止切换目标:避免前端状态与后端 relay/upload 不同步导致卡死
// ★ 兼容 BLE 模式:BLE 上传用 bleOta.state.uploading,window.isUploading 仅 WiFi 通道使用。
var bleBusy = window.bleOta && window.bleOta.isUploading && window.bleOta.isUploading();
if (window.isUploading || bleBusy) {
var active = bleBusy ? 'BLE' : ((window.activeUploadChip || 'OTA').toUpperCase());
if (typeof addLog === 'function') addLog('W', 'OTA', active + ' 刷写进行中,无法切换目标');
if (typeof showToast === 'function') showToast('当前有 ' + active + ' 任务在运行,请等待结束后再切换', 'error');
return;
}
window.flashTarget = target === 'gd32' ? 'gd32' : 'esp32';
applySharedModeUi();
syncBleFileFromSelectedTarget();
if (window.connectionMode === 'ble') {
window.switchStageFlow('ble');
if (typeof setActivePhase === 'function') {
setActivePhase(target === 'gd32' ? 'gd32Ota' : 'esp32Ota');
}
} else {
window.switchStageFlow(window.flashTarget);
}
if (window.bleOta && typeof window.bleOta.refreshTargetUi === 'function') {
window.bleOta.refreshTargetUi();
}
};
window.handleSharedFileSelect = function (event, chip) {
var file = event && event.target && event.target.files && event.target.files[0] ? event.target.files[0] : null;
if (file) {
window.sharedFirmwareFiles[chip] = { file: file, handle: null };
}
if (typeof window.handleFileSelect === 'function') {
window.handleFileSelect(event, chip);
}
window.flashTarget = chip === 'gd32' ? 'gd32' : 'esp32';
applySharedModeUi();
setTimeout(syncBleFileFromSelectedTarget, 0);
};
window.pickSharedFirmware = function (chip) {
window.flashTarget = chip === 'gd32' ? 'gd32' : 'esp32';
applySharedModeUi();
if (window.showOpenFilePicker && typeof window.pickFileViaFSAPI === 'function') {
var nativePicker = window.showOpenFilePicker;
var capturedHandle = null;
window.showOpenFilePicker = function () {
return nativePicker.apply(this, arguments).then(function (handles) {
capturedHandle = handles && handles.length ? handles[0] : null;
return handles;
});
};
Promise.resolve(window.pickFileViaFSAPI(chip)).then(function () {
window.showOpenFilePicker = nativePicker;
if (!capturedHandle || typeof capturedHandle.getFile !== 'function') {
syncBleFileFromSelectedTarget();
return;
}
capturedHandle.getFile().then(function (file) {
window.sharedFirmwareFiles[chip] = { file: file, handle: capturedHandle };
syncBleFileFromSelectedTarget();
}).catch(function () {
syncBleFileFromSelectedTarget();
});
}).catch(function (err) {
window.showOpenFilePicker = nativePicker;
if (err && err.name !== 'AbortError' && typeof window.addLog === 'function') {
window.addLog('W', '文件', 'FSAPI 选择失败: ' + err.message);
}
});
} else {
var input = document.getElementById(chip === 'gd32' ? 'fileInputGd32' : 'fileInputEsp32');
if (input) input.click();
}
};
window.clearSharedFirmware = function (chip) {
window.sharedFirmwareFiles[chip] = { file: null, handle: null };
if (typeof window.clearFile === 'function') {
window.clearFile(chip);
}
if (window.connectionMode === 'ble' && window.flashTarget === chip && window.bleOta && typeof window.bleOta.clearFile === 'function') {
window.bleOta.clearFile();
}
};
window.setBleFirmwareInputFile = function (file) {
var input = document.getElementById('firmwareFile');
if (!input || !file || typeof window.DataTransfer !== 'function') {
return;
}
try {
var dt = new DataTransfer();
dt.items.add(file);
input.files = dt.files;
} catch (err) {
// Some browsers restrict assignment to file inputs; BLE state is still synced via notifyFileChange.
}
};
window.syncBleFileFromSelectedTarget = function () {
if (window.connectionMode !== 'ble' || !window.bleOta || typeof window.bleOta.notifyFileChange !== 'function') {
return;
}
var isGd32 = window.flashTarget === 'gd32';
var chip = isGd32 ? 'gd32' : 'esp32';
var suffix = isGd32 ? 'Gd32' : 'Esp32';
var stored = window.sharedFirmwareFiles[chip];
if (stored && stored.handle && typeof stored.handle.getFile === 'function') {
stored.handle.getFile().then(function (file) {
stored.file = file;
setBleFirmwareInputFile(file);
window.bleOta.notifyFileChange(file, stored.handle);
}).catch(function () {
if (stored.file) {
setBleFirmwareInputFile(stored.file);
window.bleOta.notifyFileChange(stored.file, stored.handle);
}
});
return;
}
if (stored && stored.file) {
setBleFirmwareInputFile(stored.file);
window.bleOta.notifyFileChange(stored.file, stored.handle || null);
return;
}
var input = document.getElementById('fileInput' + suffix);
var info = document.getElementById('fileInfo' + suffix);
var hasInfo = info && info.classList.contains('show');
var file = input && input.files && input.files[0] ? input.files[0] : null;
if (file) {
window.sharedFirmwareFiles[chip] = { file: file, handle: null };
setBleFirmwareInputFile(file);
window.bleOta.notifyFileChange(file, null);
return;
}
if (!hasInfo) {
return;
}
var nameEl = document.getElementById('fileName' + suffix);
var typeEl = document.getElementById('fileType' + suffix);
var sizeEl = document.getElementById('fileSize' + suffix);
var bleInfo = document.getElementById('fileInfo');
var bleName = document.getElementById('fileName');
var bleType = document.getElementById('fileType');
var bleSize = document.getElementById('fileSize');
if (bleInfo) bleInfo.classList.add('show');
if (bleName && nameEl) bleName.textContent = nameEl.textContent;
if (bleType && typeEl) bleType.textContent = typeEl.textContent;
if (bleSize && sizeEl) bleSize.textContent = sizeEl.textContent;
var packetCount = document.getElementById('packetCount');
var sentCount = document.getElementById('sentCount');
if (packetCount) packetCount.textContent = '-';
if (sentCount) sentCount.textContent = '0';
};
window.applySharedModeUi = function () {
var isBle = window.connectionMode === 'ble';
var isGd32 = window.flashTarget === 'gd32';
var targetEsp32 = document.getElementById('targetEsp32Tab');
var targetGd32 = document.getElementById('targetGd32Tab');
if (targetEsp32) targetEsp32.className = isGd32 ? 'btn btn-outline' : 'btn btn-primary';
if (targetGd32) targetGd32.className = isGd32 ? 'btn btn-primary' : 'btn btn-outline';
var ids = {
wifiEsp32UploadPane: !isGd32,
wifiGd32UploadPane: isGd32,
bleUploadPane: false,
wifiEsp32ActionPane: !isBle && !isGd32,
wifiGd32ActionPane: !isBle && isGd32,
bleActionPane: isBle
};
Object.keys(ids).forEach(function (id) {
var el = document.getElementById(id);
if (el) el.style.display = ids[id] ? '' : 'none';
});
var hint = document.getElementById('readyHint');
if (hint) {
if (isBle) {
hint.innerHTML = 'BLE 模式:连接设备 → 选择固件 → 一键刷写。';
} else if (isGd32) {
hint.innerHTML = 'WiFi 模式:上传 GD32 固件后自动执行 SPI OTA。';
} else {
hint.innerHTML = 'WiFi 模式:上传 ESP32 固件后自动执行 OTA 并重启。';
}
}
};
applySharedModeUi();
})();
</script>
<script src="ble_ota.js?v=20260715a"></script>
<script type="module" src="js/app.js?v=20260715a"></script>
<script type="module" src="js/ota/console-mode.js?v=20260715a"></script>
<script type="module" src="js/ble_ota.js?v=20260715a"></script>
</body>
</html>
+245
View File
@@ -0,0 +1,245 @@
/**
* 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
} from './common/connection.js';
import {
scheduleStatusPoll, startPolling, pausePollingForUpload,
resumePollingAfterUpload, pollStatus, pollLogs
} 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
} from './ota/file-handle.js';
import { sendSpiFrame, clearSpiFields, updateSpiHint } from './ota/spi.js';
import { startUpload, verifyEsp32Firmware, restartEsp32 } from './ota/upload-esp32.js';
import { triggerGd32Relay, markGd32OtaUiComplete, stopRelayProgressWatch, startRelayProgressWatch } from './ota/upload-gd32.js';
// ==================== 工具函数(部分未拆分的辅助函数) ====================
function formatPercent(value) {
if (!Number.isFinite(value)) return '--';
return value.toFixed(1).replace(/\.0$/, '') + '%';
}
function formatHttpErrorDetail(text) {
var detail = (text || '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
if (detail.length > 180) detail = detail.slice(0, 177) + '...';
return detail;
}
function isOtaBusyError(status, detail) {
var text = (detail || '').toLowerCase();
return status === 403 ||
status === 409 ||
status === 423 ||
text.indexOf('busy') >= 0 ||
text.indexOf('occupied') >= 0 ||
text.indexOf('another ota') >= 0 ||
text.indexOf('invalid state') >= 0 ||
text.indexOf('占用') >= 0 ||
text.indexOf('互斥') >= 0 ||
text.indexOf('系统忙') >= 0;
}
function getEspIp() {
return document.getElementById('espIp').value.trim() || '192.168.31.127';
}
function getBaseUrl() {
return 'http://' + getEspIp();
}
function updateUploadButtonLabel() {
var esp32Btn = document.getElementById('uploadEsp32Btn');
var gd32Btn = document.getElementById('uploadGd32Btn');
if (esp32Btn) esp32Btn.disabled = !window.esp32File || window.isUploading;
if (gd32Btn) gd32Btn.disabled = !window.gd32File || window.isUploading;
}
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');
pc.className = 'pill processing';
var elapsed = Math.round((Date.now() - window.rebootStartTime) / 1000);
pct.textContent = '重启中 ' + elapsed + 's';
var cs = document.getElementById('connStatus');
var cst = document.getElementById('connStatusTxt');
if (cs) cs.className = 'conn-indicator warn';
if (cst) cst.textContent = 'ESP32 正在重启...';
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');
pc.className = 'pill processing';
pct.textContent = '上传中';
var cs = document.getElementById('connStatus');
var cst = document.getElementById('connStatusTxt');
if (cs) cs.className = 'conn-indicator warn';
if (cst) cst.textContent = '固件上传中,设备忙...';
var bc = document.getElementById('bConn');
if (bc) bc.textContent = '上传中';
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.parseIntelHex = parseIntelHex;
window.isIntelHexText = isIntelHexText;
window.convertHexFileToBin = convertHexFileToBin;
window.processFile = processFile;
window.clearFile = clearFile;
window.sendSpiFrame = sendSpiFrame;
window.clearSpiFields = clearSpiFields;
window.updateSpiHint = updateSpiHint;
window.startUpload = startUpload;
window.verifyEsp32Firmware = verifyEsp32Firmware;
window.restartEsp32 = restartEsp32;
window.triggerGd32Relay = triggerGd32Relay;
window.markGd32OtaUiComplete = markGd32OtaUiComplete;
window.stopRelayProgressWatch = stopRelayProgressWatch;
window.startRelayProgressWatch = startRelayProgressWatch;
// ==================== 挂载 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.scheduleStatusPoll = scheduleStatusPoll;
window.startPolling = startPolling;
window.pausePollingForUpload = pausePollingForUpload;
window.resumePollingAfterUpload = resumePollingAfterUpload;
window.pollStatus = pollStatus;
window.pollLogs = pollLogs;
// ==================== 挂载本入口的辅助函数到 window ====================
window.getEspIp = getEspIp;
window.getBaseUrl = getBaseUrl;
window.updateUploadButtonLabel = updateUploadButtonLabel;
window.resetStageFlow = resetStageFlow;
window.applyDisconnectedRuntimeState = applyDisconnectedRuntimeState;
window.applyRebootingRuntimeState = applyRebootingRuntimeState;
window.applyUploadingRuntimeState = applyUploadingRuntimeState;
window.formatHttpErrorDetail = formatHttpErrorDetail;
window.isOtaBusyError = isOtaBusyError;
// ==================== 顶层副作用(原 app.js 末尾的初始化) ====================
setupDropZone('uploadZoneEsp32', 'esp32');
setupDropZone('uploadZoneGd32', 'gd32');
var spiTypeEl = document.getElementById('spiType');
var spiDataEl = document.getElementById('spiData');
if (spiTypeEl) spiTypeEl.addEventListener('input', updateSpiHint);
if (spiDataEl) spiDataEl.addEventListener('input', updateSpiHint);
// 启动轮询(必须在所有模块加载完成、window.OtaSessionState 已挂载后调用,
// 否则 polling.js 的 scheduleStatusPoll 会因 window.OtaSessionState 未定义而抛 TypeError)
startPolling();
// IP 输入变化时清零失败计数并立即刷新状态
var ipInput = document.getElementById('espIp');
if (ipInput) {
ipInput.addEventListener('change', function () {
window.consecutivePollFailures = 0;
addLog('I', '系统', 'IP 已修改,重新启动轮询...');
if (!window.statusPollInFlight) {
scheduleStatusPoll(0);
}
});
}
// 初始化按钮状态与提示
setTimeout(function () {
updateUploadButtonLabel();
addLog('I', '系统', '控制台初始化完成,等待固件文件...');
updateSpiHint();
}, 500);
+57
View File
@@ -0,0 +1,57 @@
// ==================== 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-12-ble-reorder-v6';
export const BLE_OTA_NOTIFY_ACK = 0xA1;
export const BLE_OTA_NOTIFY_FINISH = 0xB1;
export const BLE_OTA_NOTIFY_SHA256 = 0xC1;
export const BLE_OTA_ACK_WINDOW = 32;
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 TARGETS = {
gd32: {
label: 'GD32',
packetCmd: 0x00,
ctrlHex: 'F7',
recommendedPayload: 480,
finishText: '自动触发中继',
hint: '当前目标:<b>GD32</b>。页面会使用 <b>cmd=0x00</b> 发送数据,写入完成后自动触发 SPI OTA 中继。',
verifyStage: 6,
finalizeStage: 5,
stage5Label: '完成触发',
stage6Label: '校验比对'
},
esp32: {
label: 'ESP32',
packetCmd: 0x01,
ctrlHex: 'F8',
recommendedPayload: 480,
finishText: '自动完成 OTA',
hint: '当前目标:ESP32。页面会使用 <b>cmd=0x01</b> 发送数据,写入完成后发 <b>0xF8</b> + 32 字节 SHA-256 触发设备端收尾:设备端读 OTA 分区算 SHA 并比对,一致则 end+set_boot+reboot;不一致上报 0x85,不重启。',
verifyStage: 5,
finalizeStage: 6,
stage5Label: '校验比对',
stage6Label: '完成触发'
}
};
export const MAX_LOG_LINES = 1000;
+211
View File
@@ -0,0 +1,211 @@
// ==================== BLE OTA 文件准备与分包 ====================
import { state, els, bleLog, currentTarget, currentPayloadSize, setConnectionState } 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';
export function updateFileInfo(file) {
// ★ 上传进行中禁止重置进度/阶段:目标切换、模式切换、syncBleFileFromSelectedTarget
// 等事件会在上传期间调用本函数,若不拦截会触发 finalizeFileInfo → setProgress(0, ...)
// 把进度条从"已确认 N/M 包"错误地刷回"等待选择文件 0%"。
var isUploading = state.uploading || state.autoFlashing;
if (isUploading && 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 自动转换:复用 app.js 公共函数 convertHexFileToBin
if (file && file.name.toLowerCase().endsWith('.hex') && typeof convertHexFileToBin === 'function') {
return convertHexFileToBin(file).then(function(result) {
if (!result.converted) {
bleLog('FILE', file.name + ' 扩展名为 .hex 但内容不像 HEX 格式,按原始文件处理', 'W');
finalizeFileInfo(file);
computeFileSha256(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');
// 更新 UI 显示转换后的信息
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);
finalizeFileInfo(result.file);
computeFileSha256(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);
});
}
// 非 HEX 文件直接完成
finalizeFileInfo(file);
computeFileSha256(file);
return Promise.resolve();
}
function finalizeFileInfo(file) {
// ★ 上传进行中禁止重置进度条/阶段:防止目标切换等事件在上传期间
// 通过 finalizeFileInfo → setProgress(0, '等待选择文件') 覆盖上传进度。
var isUploading = state.uploading || state.autoFlashing;
if (isUploading) {
bleLog('FILE', '上传进行中,跳过 finalizeFileInfo 进度重置', 'D');
return;
}
setConnectionState(!!state.server);
if (file) {
setProgress(0, `已选择 ${file.name}`);
bleLog('FILE', `已选择: ${file.name} (${formatBytes(file.size)}), 目标=${currentTarget().label}`);
if (state.server) {
setBleStage(3, '计算哈希...', 'active');
setBleStageText('正在计算文件 SHA-256...');
}
} else {
setProgress(0, '等待选择文件');
if (state.server) {
resetBleStages();
setBleStage(3, '', null);
setBleStageText('BLE 连接就绪,请选择固件文件');
}
}
}
function computeFileSha256(file) {
var shaElem = document.getElementById('fileSha256');
if (file && shaElem) {
shaElem.textContent = '计算中...';
file.arrayBuffer().then(function(buf) {
return crypto.subtle.digest('SHA-256', buf);
}).then(function(hashBuffer) {
var hashArray = Array.from(new Uint8Array(hashBuffer));
var hashHex = hashArray.map(function(b) { return b.toString(16).padStart(2, '0'); }).join('');
shaElem.textContent = hashHex.toUpperCase();
state.fileSha256 = hashHex.toUpperCase();
bleLog('FILE', 'SHA-256: ' + hashHex.toUpperCase());
// If connected, advance to stage 3 done
if (state.server) {
setBleStage(3, 'SHA-256 已计算', null);
setBleStage(4, '', null);
setBleStageText('文件就绪,可以开始刷写');
}
}).catch(function(err) {
shaElem.textContent = '计算失败: ' + err.message;
state.fileSha256 = null;
});
} else if (shaElem) {
shaElem.textContent = '-';
state.fileSha256 = null;
}
}
export async function syncSelectedFileFromInput() {
// ★ 上传进行中禁止同步:window focus / file input change 事件会在上传期间
// 触发本函数,若不拦截会通过 updateFileInfo 把进度条重置为"等待选择文件 0%"。
// ★ 必须同时拦截 WiFi 通道上传 (app.js 全局 isUploading):focus 事件不分通道,
// WiFi 刷写时点击网页/切标签回来会触发本函数 → finalizeFileInfo → setProgress(0),
// 把进度"闪一下"归零,直到下一次 XHR progress 事件才恢复。
if (state.uploading || state.autoFlashing) return;
if (typeof isUploading !== 'undefined' && isUploading) return;
// FSAPI 选择的文件以 selectedFileHandle 为准,不从 input 元素同步,避免切标签页回来误清空
if (state.selectedFileHandle) return;
const file = (els.firmwareFile && els.firmwareFile.files && els.firmwareFile.files[0]) || null;
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 handles = await window.showOpenFilePicker({
types: [{ description: '固件文件', accept: { 'application/octet-stream': ['.bin', '.hex'] } }],
multiple: false
});
if (handles && handles.length > 0) {
const handle = handles[0];
const file = await handle.getFile();
state.selectedFileHandle = handle;
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 (state.uploading || state.autoFlashing) {
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);
hideBleVerifyInfo();
// Reset stages back to connection-ready state
if (state.server) {
resetBleStages();
setBleStage(3, '', null);
setBleStageText('BLE 连接就绪,请选择固件文件');
}
bleLog('FILE', '文件已清除');
}
// 别名导出,与 window.bleOta.clearFile 对齐
export { clearSelectedFile as clearFile };
export async function notifyFileChange(file, handle) {
// ★ 上传进行中拒绝外部文件变更通知:防止 index.html 的 syncBleFileFromSelectedTarget
// 在上传期间调用本函数 → updateFileInfo → setProgress(0, ...) 覆盖上传进度。
if (state.uploading || state.autoFlashing) {
bleLog('FILE', '上传进行中,忽略外部 notifyFileChange', 'D');
return;
}
// ★ WiFi 通道上传期间同样拒绝:setConsoleMode('ble') 等外部触发会覆盖 WiFi 进度
if (typeof isUploading !== 'undefined' && isUploading) {
bleLog('FILE', 'WiFi 上传进行中,忽略外部 notifyFileChange', 'D');
return;
}
if (file) {
if (handle) state.selectedFileHandle = handle;
await updateFileInfo(file);
}
}
+546
View File
@@ -0,0 +1,546 @@
// ==================== 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,
TARGETS,
} from './constants.js';
import {
state,
els,
bleLog,
toHex,
setConnectionState,
resetUiAfterDisconnect,
currentPayloadSize,
flowTarget,
currentTarget,
sleep,
} from './state.js';
import { setBleStage, setBleStageError, setBleStageText, 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 连接 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);
}
const ackText = `[ACK] cmd=0x${ackCmd.toString(16).padStart(2, '0').toUpperCase()} seq=${ackSeq} bytes=${state.lastAckBytes} (${formatBytes(state.lastAckBytes)}) raw=${hex}`;
bleLog('ACK', ackText);
// ★ 放宽匹配条件:只要 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}`,
};
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 });
var gd32Bar = document.getElementById('stageStatusBar');
var gd32Text = document.getElementById('stageStatusText');
if (gd32Bar) gd32Bar.className = 'stage-status-bar ok';
if (gd32Text) gd32Text.textContent = '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.abortRequested = true;
state.expectedReboot = false;
state.reconnecting = false;
state.reconnectAttempts = 0;
state.lastUploadStatus = 'aborted';
setProgress(0, 'OTA 已中止');
setBleStageError('OTA 已中止');
showToast('OTA 已中止(BLE 保持连接)', 'warning');
// 解除任何等待 ACK / OTA 结果的 Promise
if (state.ackWaiter) {
const waiter = state.ackWaiter;
state.ackWaiter = null;
clearTimeout(waiter.timer);
waiter.reject(new Error('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;
}
// 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 蓝
var rebootBar = document.getElementById('stageStatusBar');
var rebootBarTxt = document.getElementById('stageStatusText');
if (rebootBar) rebootBar.className = 'stage-status-bar ok';
if (rebootBarTxt) rebootBarTxt.textContent = '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) {
state.abortRequested = true; // ★ 阻止 fire-and-forget 继续发包
state.expectedReboot = false; // ★ 刷写失败,取消任何重连退避
state.reconnecting = false;
state.reconnectAttempts = 0;
if (state.ackWaiter) {
const waiter = state.ackWaiter;
state.ackWaiter = null;
clearTimeout(waiter.timer);
waiter.reject(new Error(`设备端 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);
// 设备端上报 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 标绿 + 顶部胶囊推进
var okBar = document.getElementById('stageStatusBar');
var okTxt = document.getElementById('stageStatusText');
if (okBar) okBar.className = 'stage-status-bar ok';
if (okTxt) okTxt.textContent = '刷写成功 — 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, 2s, 4s, 8s, 上限 10s
var backoff = Math.min(10000, 1000 * Math.pow(1.6, attempt - 1));
if (backoff > 200) await sleep(backoff);
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');
}
+123
View File
@@ -0,0 +1,123 @@
// ==================== 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() {
els.connectBtn.addEventListener('click', connectBle);
els.disconnectBtn.addEventListener('click', disconnectBle);
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));
}
+66
View File
@@ -0,0 +1,66 @@
// ==================== 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'];
export function updateBleStageLabels(cfg) {
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 = '';
});
var bar = document.getElementById('stageStatusBar');
if (bar) { bar.className = 'stage-status-bar'; bar.innerHTML = '<span class="ssb-dot"></span><span class="ssb-text" id="stageStatusText">待机</span>'; }
}
export function setBleStage(stageNum, detail, barState) {
// stageNum: 1-6, detail: sub-step text
for (var i = 0; i < bleStageIds.length; 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 = 'stage-node live';
else el.className = 'stage-node';
}
// Set detail text
if (stageNum >= 1 && stageNum <= 6) {
var detailEl = document.getElementById(bleStageDetailIds[stageNum - 1]);
if (detailEl && detail !== undefined) detailEl.textContent = detail;
}
// Update status bar
var bar = document.getElementById('stageStatusBar');
if (bar) {
bar.className = 'stage-status-bar' + (barState ? ' ' + barState : (stageNum > 0 ? ' active' : ''));
}
}
export function setBleStageText(text) {
var txt = document.getElementById('stageStatusText');
if (txt) txt.textContent = text;
}
export function setBleStageError(detail) {
// Mark current active stage as error
for (var i = 0; i < bleStageIds.length; i++) {
var el = document.getElementById(bleStageIds[i]);
if (el && el.className === 'stage-node live') {
el.className = 'stage-node err';
var detailEl = document.getElementById(bleStageDetailIds[i]);
if (detailEl && detail) detailEl.textContent = detail;
}
}
var bar = document.getElementById('stageStatusBar');
if (bar) bar.className = 'stage-status-bar err';
setBleStageText('错误: ' + (detail || ''));
}
+188
View File
@@ -0,0 +1,188 @@
// ==================== BLE OTA 状态与元素缓存 ====================
import { TARGETS } from './constants.js';
import { addLog } from '../common/log.js';
import { updateBleStageLabels, resetBleStages } from './stages.js';
import { hideBleVerifyInfo } from './verify.js';
import { updateFileInfo } from './file.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,
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 = {
connectBtn: $('connectBtn'),
disconnectBtn: $('disconnectBtn'),
autoFlashBtn: $('autoFlashBtn'),
abortBtn: $('abortBtn'),
uploadZone: $('uploadZone'),
firmwareFile: $('firmwareFile'),
connStatus: $('connStatus'), // 共享连接指示器
connStatusTxt: $('connStatusTxt'), // 共享连接指示器文本
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;
}
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 模式下更新共享连接指示器,避免覆盖 WiFi 轮询的状态
if (typeof connectionMode !== 'undefined' && connectionMode === 'ble') {
if (els.connStatus) els.connStatus.className = connected ? 'conn-indicator ok' : 'conn-indicator err';
if (els.connStatusTxt) els.connStatusTxt.textContent = connected ? 'BLE 已连接' : 'BLE 未连接';
}
// BLE 专用连接徽章 — 仅在 BLE 模式下更新,避免覆盖 WiFi 状态
if (typeof connectionMode !== 'undefined' && connectionMode === 'ble') {
if (els.connState) els.connState.textContent = connected ? '已连接' : '未连接';
if (els.connState && els.connState.parentElement) {
els.connState.parentElement.className = connected ? 'pill online' : 'pill offline';
}
els.connBadge.textContent = connected ? 'online' : 'offline';
els.connBadge.className = `v ${connected ? 'ok' : 'bad'}`;
els.gattState.textContent = connected ? 'GATT 已就绪' : '未就绪';
}
if (connected) {
els.connectBtn.disabled = true;
} else {
els.connectBtn.disabled = false;
}
els.disconnectBtn.disabled = !connected;
els.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);
// ★ 上传进行中不调用 updateFileInfo:避免目标切换时进度条被重置。
// 阶段标签更新(updateBleStageLabels)安全,不碰进度条。
if (state.selectedFile && !state.uploading && !state.autoFlashing) {
updateFileInfo(state.selectedFile);
}
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);
bleLog('CFG', `目标切换为 ${cfg.label}: packetCmd=0x${cfg.packetCmd.toString(16).padStart(2, '0').toUpperCase()}, payload=${cfg.recommendedPayload}`);
}
+489
View File
@@ -0,0 +1,489 @@
// ==================== 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 { 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;
}
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() {
// 每次刷写前尝试重新从本地读取文件
if (state.selectedFileHandle) {
try {
const freshFile = await state.selectedFileHandle.getFile();
await updateFileInfo(freshFile);
var actualFile = state.selectedFile || freshFile;
bleLog('FILE', '已重新读取本地文件: ' + actualFile.name + ' (' + formatBytes(actualFile.size) + ')');
} catch (e) {
bleLog('FILE', '重新读取文件失败: ' + e.message + ',将使用缓存版本', 'W');
}
} else {
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}`);
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}`);
if (totalSize !== file.size) {
bleLog('OTA', `⚠️ file.size(${file.size}) ≠ arrayBuffer(${totalSize}),以 arrayBuffer 为准,请确认页面已 Ctrl+F5 刷新`, 'W');
}
// SHA-256 已在文件选择时(updateFileInfo)计算并存入 state.fileSha256。
// 此处不再重复打日志(避免与 [FILE] SHA-256 重复),仅保证 fileSha256 变量有值。
var fileSha256;
if (state.fileSha256) {
fileSha256 = state.fileSha256.toLowerCase();
} else {
// 极端兜底:state.fileSha256 未初始化(理论上 updateFileInfo 总会先设置)
bleLog('OTA', '正在计算文件 SHA-256 哈希...');
const hashBuffer = await crypto.subtle.digest('SHA-256', fileBytes.buffer);
const hashArray = Array.from(new Uint8Array(hashBuffer));
fileSha256 = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
state.fileSha256 = fileSha256.toUpperCase();
}
// Update SHA display in file info
var shaElem = document.getElementById('fileSha256');
if (shaElem) shaElem.textContent = fileSha256.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}`);
const MAX_WINDOW_RETRIES = 3;
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 驱动
for (let seq = windowStart; seq <= endSeq; seq++) {
// ★ 中止检查放在循环内:abortTask 可能在本窗口发送过程中触发
// (windowStart 处的检查无法覆盖窗口中途的 abort)
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(带响应):
// seq=0 触发设备端 esp_ota_begin()(flash 擦除 + OTA 会话创建),
// 若用 writeValueWithoutResponse 在 BLE 链路层丢失,
// 设备永远不会启动 OTA 会话,后续所有包都被丢弃。
if (seq === 0) {
try {
await state.dataInChar.writeValueWithResponse(packet);
bleLog('OTA', 'seq=0 已用 writeValueWithResponse 确认送达', 'I');
} catch (e) {
bleLog('OTA', `seq=0 写入失败: ${e.message}`, 'E');
throw new Error('seq=0 写入失败,OTA 会话无法启动: ' + e.message);
}
// seq=0 写入后等待设备完成 esp_ota_begin()(flash 擦除较耗时)
await sleep(200);
} else {
await state.dataInChar.writeValueWithoutResponse(packet);
}
}
state.ackGeneration++;
try {
await waitForAck(cfg.packetCmd, endSeq, 5000, { 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
if (seq === 0) {
try {
await state.dataInChar.writeValueWithResponse(packet);
bleLog('OTA', '重试 seq=0 已用 writeValueWithResponse 确认', 'I');
} catch (e) {
bleLog('OTA', `重试 seq=0 写入失败: ${e.message}`, 'E');
}
await sleep(200);
} else {
await state.dataInChar.writeValueWithoutResponse(packet);
}
}
state.ackGeneration++;
try {
await waitForAck(cfg.packetCmd, endSeq, 3000, { expectedTotalSize: totalSize, payloadSize });
} catch (e) {
bleLog('OTA', `重试 ACK 等待失败: ${e.message}`, 'W');
}
expectedBytes = Math.min((endSeq + 1) * payloadSize, totalSize);
}
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 = new Uint8Array(32);
for (let i = 0; i < 32; i++) {
frontShaBytes[i] = parseInt(frontSha.slice(i * 2, i * 2 + 2), 16);
}
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 已发送,等待设备端校验');
} 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 = new Uint8Array(32);
for (let i = 0; i < 32; i++) {
frontShaBytes[i] = parseInt(frontSha.slice(i * 2, i * 2 + 2), 16);
}
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 已发送,等待设备端缓存校验与转发`);
} 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') {
clearOtaResultWaiter();
}
state.uploading = false;
state.uploadTargetKey = null;
setConnectionState(!!state.server);
}
}
/** @description 自动刷写流程:上传 ESP32 固件 → SHA验证 → 触发 GD32 中继。 */
export async function autoFlash() {
// 每次刷写前尝试重新从本地读取文件(支持热重载:用户重新编译后无需重新选择)
if (state.selectedFileHandle) {
try {
const freshFile = await state.selectedFileHandle.getFile();
// 仅在文件变化时才刷新元数据并重算 SHA-256
// (name/size/lastModified 任一不同即视为变化)
// 避免对同一文件重复触发 updateFileInfo → 产生重复的 [FILE] SHA-256 日志
const cached = state.selectedFile;
// HEX 文件转换后 cached.name 是 .bin 而 freshFile.name 是 .hex,
// 因此 HEX 文件用 lastModified 判断是否变化,避免每次都重新转换
const isHex = freshFile.name.toLowerCase().endsWith('.hex');
const changed = !cached ||
(isHex ? false : (cached.name !== freshFile.name || cached.size !== freshFile.size)) ||
cached.lastModified !== freshFile.lastModified;
if (changed) {
await updateFileInfo(freshFile);
var actualFile = state.selectedFile || freshFile;
bleLog('FILE', '已重新读取本地文件(已变更): ' + actualFile.name + ' (' + formatBytes(actualFile.size) + ')');
}
} catch (e) {
bleLog('FILE', '重新读取文件失败: ' + e.message + ',将使用缓存版本', 'W');
}
} else {
await syncSelectedFileFromInput();
}
if (!state.selectedFile || !state.server) return;
const cfg = currentTarget();
state.autoFlashing = true;
try {
bleLog('AUTO', `开始一键刷写 -> ${cfg.label}`);
const ok = await uploadFirmware();
if (!ok || state.abortRequested) {
bleLog('AUTO', '一键刷写中止', 'W');
// ★ 中止/失败后不再断开 BLE:0xFB 已让设备端清理 OTA 会话,保留连接方便重试。
// 若连接已半死,onDisconnected 会自动走重连路径。
return;
}
if (cfg.label === 'ESP32') {
setProgress(100, '[ESP32] OTA 完成,等待设备重启');
bleLog('AUTO', 'ESP32 OTA 完成,等待设备重启');
} else {
setProgress(100, '[GD32] OTA 转发完成,APP 已启动');
bleLog('AUTO', 'GD32 OTA 完成');
}
} catch (err) {
bleLog('AUTO', `一键刷写异常: ${err && err.message ? err.message : err}`, 'E');
setProgress(0, `一键刷写异常: ${err && err.message ? err.message : err}`);
} finally {
state.autoFlashing = false;
setConnectionState(!!state.server);
}
}
+26
View File
@@ -0,0 +1,26 @@
// ==================== BLE OTA SHA-256 校验展示 ====================
export function showBleVerifyInfo(frontSha, devSha, result) {
var panel = document.getElementById('stageVerifyInfo');
if (panel) { panel.className = 'stage-verify-info'; panel.style.display = 'block'; }
if (frontSha !== undefined) {
var el = document.getElementById('sviFrontSha');
if (el) el.textContent = frontSha || '-';
}
if (devSha !== undefined) {
var el = document.getElementById('sviDeviceSha');
if (el) el.textContent = devSha || '-';
}
if (result !== undefined) {
var el = document.getElementById('sviResult');
if (el) {
el.textContent = result.text || '-';
el.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'; }
}
+58
View File
@@ -0,0 +1,58 @@
/**
* 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})`);
},
disconnect: disconnectBle,
showToast: showToast,
notifyFileChange: async function(file, handle) {
// ★ 上传进行中拒绝外部文件变更通知:防止 index.html 的 syncBleFileFromSelectedTarget
// 在上传期间调用本函数 → updateFileInfo → setProgress(0, ...) 覆盖上传进度。
if (state.uploading || state.autoFlashing) {
bleLog('FILE', '上传进行中,忽略外部 notifyFileChange', 'D');
return;
}
// ★ WiFi 通道上传期间同样拒绝:setConsoleMode('ble') 等外部触发会覆盖 WiFi 进度
if (typeof window.isUploading !== 'undefined' && window.isUploading) {
bleLog('FILE', 'WiFi 上传进行中,忽略外部 notifyFileChange', 'D');
return;
}
if (file) {
if (handle) state.selectedFileHandle = handle;
await notifyFileChange(file, handle);
}
},
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();
+265
View File
@@ -0,0 +1,265 @@
/**
* can.js - CAN 工具页面入口(ESM 组装层)
*
* 本文件对应原 tools/can.js 中未拆分到子模块的编排逻辑:
* - cacheElements:缓存 DOM 元素到 el(dbc.js 导出的共享 el 对象)
* - setConnectionState:CAN 页面专用连接状态更新(canConnPill 等元素)
* - testConnection:CAN 页面专用连接检测(GET /system/status,操作 el.connectionNote 等)
* - 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 版本以保证行为一致。
*/
import { state, el, getBaseUrl, parseDbc, readDbcText, buildSummary } from './can/dbc.js';
import {
updateDbcFileInfo,
filterMessages,
bindNodeFilterEvents,
renderMessageList,
selectMessage
} from './can/messages.js';
import {
ensureMessageDefaults,
renderSelectedMessage,
updateEncodedPreview
} from './can/signals.js';
import {
renderHistory,
sendSelectedMessage,
sendCheckedMessages,
refreshManualHint,
sendManualFrame,
resetManualFields,
loadPreviewToManual
} from './can/send.js';
import { escapeHtml as sanitizeHtml, formatUptime } from './common/utils.js';
import { showToast } from './common/toast.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',
'testConnBtn', 'connIndicator', 'connIndicatorTxt', 'connectionNote',
'dbcDropZone', 'dbcFileInput', 'dbcFileInfo', 'dbcFileName', 'dbcMessageCount',
'dbcSignalCount', 'dbcNodeCount', 'dbcFileSize', 'dbcSearch', 'dbcHint', 'clearDbcBtn',
'messageList', 'sendSelectedBtn', 'generatedHex', 'generatedBytes', 'encodeHint',
'dbcResp', 'dbcRespText', 'loadPreviewToRawBtn', 'manualHint', 'sendManualBtn',
'resetManualBtn', 'manualResp', 'manualRespText', 'historyList', 'toast', 'toastIco',
'toastMsg', 'canConnPill', 'canConnTxt', '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);
});
}
// ==================== 连接状态 ====================
function setConnectionState(connected, text, buildVersion, buildTime) {
if (el.canConnPill) {
el.canConnPill.className = 'pill ' + (connected ? 'online' : 'offline');
}
if (el.canConnTxt) el.canConnTxt.textContent = text;
if (el.connIndicator) {
el.connIndicator.className = 'conn-indicator ' + (connected ? 'ok' : 'err');
}
if (el.connIndicatorTxt) el.connIndicatorTxt.textContent = text;
if (el.canVersionPill) {
el.canVersionPill.textContent = buildVersion ? ('v' + buildVersion) : 'v--';
el.canVersionPill.dataset.tooltip = buildTime ? ('编译: ' + buildTime) : '';
}
}
async function testConnection() {
var baseUrl = getBaseUrl();
if (el.connectionNote) {
el.connectionNote.className = 'helper-note';
el.connectionNote.innerHTML = '正在请求 <code>/system/status</code> 检查设备状态...';
}
try {
var resp = await fetch(baseUrl + '/system/status', { signal: AbortSignal.timeout(3000) });
if (!resp.ok) {
setConnectionState(false, 'HTTP ' + resp.status, '', '');
if (el.connectionNote) {
el.connectionNote.className = 'helper-note err';
el.connectionNote.innerHTML = '状态接口返回 HTTP <b>' + resp.status + '</b>,请确认设备地址和 HTTP 服务。';
}
showToast('设备响应异常: HTTP ' + resp.status, 'error');
return;
}
var data = await resp.json();
state.latestStatus = data;
var wifi = data.wifi || {};
var connected = !!wifi.connected;
var mode = wifi.mode || 'STA';
var text = connected ? (mode + ' 已连接') : (mode + ' 未连接');
setConnectionState(connected, text, data.version, data.build_time);
if (el.connectionNote) {
el.connectionNote.className = 'helper-note ' + (connected ? 'ok' : 'warn');
el.connectionNote.innerHTML =
'IP: <code>' + sanitizeHtml(wifi.ip || '--') + '</code>,RSSI: <b>' +
sanitizeHtml(wifi.rssi !== undefined && wifi.rssi !== -127 ? (wifi.rssi + ' dBm') : '--') +
'</b>,运行: <b>' + sanitizeHtml(formatUptime(data.uptime_sec)) + '</b>';
}
showToast(connected ? 'ESP32 当前在线' : '设备已响应,但 WiFi 未连接', connected ? 'success' : 'warning');
} catch (err) {
setConnectionState(false, '连接失败', '', '');
if (el.connectionNote) {
el.connectionNote.className = 'helper-note err';
el.connectionNote.innerHTML = '无法访问 <code>/system/status</code>:' + sanitizeHtml(err.message);
}
showToast('连接失败: ' + err.message, 'error');
}
}
// ==================== DBC 文件流程 ====================
async function handleDbcFile(file) {
if (!file) return;
var text = await readDbcText(file);
var dbc = parseDbc(text);
state.dbcFile = file;
state.dbcText = text;
state.dbc = dbc;
state.selectedMessageKey = '';
state.signalValues = {};
state.checkedKeys = {};
state.expandedKeys = {};
state.transmitterFilter = Object.create(null);
state.transmitterFilterEmpty = true;
updateDbcFileInfo(file, dbc);
buildSummary();
filterMessages();
if (dbc.messages.length) selectMessage(dbc.messages[0].key);
else renderSelectedMessage();
if (el.dbcHint) {
el.dbcHint.className = 'helper-note ok';
el.dbcHint.innerHTML = 'DBC 已解析:<b>' + dbc.messages.length + '</b> 条报文,<b>' +
dbc.messages.reduce(function (sum, msg) { return sum + msg.signals.length; }, 0) +
'</b> 个信号。';
}
showToast('DBC 导入成功', 'success');
}
function clearDbc() {
state.dbcFile = null;
state.dbcText = '';
state.dbc = null;
state.filteredMessages = [];
state.selectedMessageKey = '';
state.signalValues = {};
state.checkedKeys = {};
state.expandedKeys = {};
state.transmitterFilter = Object.create(null);
state.transmitterFilterEmpty = true;
state.generatedData = new Uint8Array(0);
if (el.dbcFileInput) el.dbcFileInput.value = '';
if (el.dbcSearch) el.dbcSearch.value = '';
updateDbcFileInfo(null, null);
buildSummary();
renderMessageList();
renderSelectedMessage();
if (el.generatedHex) el.generatedHex.textContent = '--';
if (el.generatedBytes) el.generatedBytes.innerHTML = '';
if (el.dbcHint) {
el.dbcHint.className = 'helper-note';
el.dbcHint.innerHTML = '当前解析器会自动识别常见 DBC 的 <code>BO_</code>、<code>SG_</code>、<code>VAL_</code>、<code>CM_</code> 和 <code>SIG_VALTYPE_</code>。';
}
}
function handleDbcError(err) {
if (el.dbcHint) {
el.dbcHint.className = 'helper-note err';
el.dbcHint.innerHTML = 'DBC 解析失败:' + sanitizeHtml(err.message);
}
showToast('DBC 解析失败: ' + err.message, 'error');
}
// ==================== 事件绑定 ====================
function bindEvents() {
el.testConnBtn.addEventListener('click', testConnection);
el.defaultChannel.addEventListener('change', function () {
el.manualChannel.value = el.defaultChannel.value;
el.canChannelPill.textContent = 'CH' + el.defaultChannel.value;
renderMessageList();
renderSelectedMessage();
updateEncodedPreview();
});
el.dbcDropZone.addEventListener('click', function () { el.dbcFileInput.click(); });
el.dbcDropZone.addEventListener('dragover', function (event) {
event.preventDefault();
el.dbcDropZone.classList.add('dragover');
});
el.dbcDropZone.addEventListener('dragleave', function () {
el.dbcDropZone.classList.remove('dragover');
});
el.dbcDropZone.addEventListener('drop', function (event) {
event.preventDefault();
el.dbcDropZone.classList.remove('dragover');
var file = event.dataTransfer && event.dataTransfer.files ? event.dataTransfer.files[0] : null;
if (file) handleDbcFile(file).catch(handleDbcError);
});
el.dbcFileInput.addEventListener('change', function (event) {
var file = event.target && event.target.files ? event.target.files[0] : null;
if (file) handleDbcFile(file).catch(handleDbcError);
});
el.clearDbcBtn.addEventListener('click', clearDbc);
el.dbcSearch.addEventListener('input', filterMessages);
el.sendSelectedBtn.addEventListener('click', sendSelectedMessage);
el.loadPreviewToRawBtn.addEventListener('click', loadPreviewToManual);
el.sendManualBtn.addEventListener('click', sendManualFrame);
el.resetManualBtn.addEventListener('click', resetManualFields);
el.manualData.addEventListener('input', refreshManualHint);
el.manualDlc.addEventListener('input', refreshManualHint);
el.manualId.addEventListener('input', refreshManualHint);
el.toolIp.addEventListener('change', testConnection);
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();
});
if (el.expandAllBtn) el.expandAllBtn.addEventListener('click', function () {
var visible = state.filteredMessages || [];
visible.forEach(function (m) { state.expandedKeys[m.key] = true; ensureMessageDefaults(m); });
renderMessageList();
});
if (el.collapseAllBtn) el.collapseAllBtn.addEventListener('click', function () {
var visible = state.filteredMessages || [];
visible.forEach(function (m) { delete state.expandedKeys[m.key]; });
renderMessageList();
});
bindNodeFilterEvents();
}
// ==================== 初始化 ====================
function init() {
cacheElements();
bindEvents();
setConnectionState(false, '未连接', '', '');
if (el.canChannelPill) el.canChannelPill.textContent = 'CH' + el.defaultChannel.value;
if (el.manualChannel) el.manualChannel.value = el.defaultChannel.value;
buildSummary();
renderMessageList();
renderSelectedMessage();
refreshManualHint();
renderHistory();
setTimeout(testConnection, 150);
}
init();
+256
View File
@@ -0,0 +1,256 @@
/**
* dbc.js - DBC 解析器 + 共享状态/元素缓存/基础工具
*
* 从 tools/can.js 拆分。对应原 can.js 约 4~21 行(state/el)、78~104 行(基础工具)、
* 178~335 行(DBC 解析)、830~833 行(cssEscape)、1335~1352 行(readDbcText)。
*
* 本模块是 can 子系统的基础层,不依赖其他 can 子模块;state/el 在此导出,
* 供 messages/signals/send/feedback 共享(ES Module 的 live binding 保证引用一致)。
*/
// ==================== 共享状态 ====================
export var state = {
dbcFile: null,
dbcText: '',
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 = {};
// ==================== 基础工具 ====================
/** @description 拼接设备基础 URL(http://<ip>),ip 取自 el.toolIp,默认 192.168.31.127。 */
export function getBaseUrl() {
var ip = (el.toolIp && el.toolIp.value ? el.toolIp.value.trim() : '') || '192.168.31.127';
return 'http://' + ip;
}
/** @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);
return String(value).replace(/[^a-zA-Z0-9_-]/g, function (ch) { return '\\\\' + ch; });
}
/** @description 去除回车并 trim,用于 DBC 搜索等输入归一化。 */
export function normalizeWhitespace(text) {
return String(text || '').replace(/\r/g, '').trim();
}
// ==================== DBC 文件读取 ====================
/** @description 读取 DBC 文件文本,优先 UTF-8,失败回退 GB18030,自动去 BOM。 */
export async function readDbcText(file) {
var buf = await file.arrayBuffer();
var bytes = new Uint8Array(buf);
var stripBom = function (s) {
return (s && s.charCodeAt(0) === 0xFEFF) ? s.slice(1) : s;
};
try {
var strictUtf8 = new TextDecoder('utf-8', { fatal: true });
return stripBom(strictUtf8.decode(bytes));
} catch (e) {
try {
var gb = new TextDecoder('gb18030');
return stripBom(gb.decode(bytes));
} catch (e2) {
return stripBom(new TextDecoder('utf-8').decode(bytes));
}
}
}
// ==================== DBC 解析 ====================
/** @description 解析 DBC 文本,返回 { messages, nodeNames, messageMap }。 */
export function parseDbc(text) {
var lines = String(text || '').replace(/\r/g, '').split('\n');
var messages = [];
var messageMap = {};
var comments = { messages: {}, signals: {} };
var valueTables = {};
var sigDefaults = {};
var nodeNames = [];
var current = null;
for (var i = 0; i < lines.length; i++) {
var line = lines[i].trim();
if (!line) continue;
var nodeMatch = line.match(/^BU_\s*:\s*(.*)$/);
if (nodeMatch) {
nodeNames = nodeMatch[1].trim() ? nodeMatch[1].trim().split(/\s+/) : [];
continue;
}
var messageMatch = line.match(/^BO_\s+(\d+)\s+([A-Za-z0-9_]+)\s*:\s*(\d+)\s+(.+)$/);
if (messageMatch) {
current = {
idRaw: parseInt(messageMatch[1], 10),
name: messageMatch[2],
dlc: parseInt(messageMatch[3], 10),
transmitter: messageMatch[4].trim(),
signals: [],
comment: ''
};
current.id = current.idRaw & 0x1FFFFFFF;
current.isExtended = current.idRaw > 0x7FF;
current.key = String(current.idRaw) + ':' + current.name;
messages.push(current);
messageMap[current.idRaw] = current;
continue;
}
var signalMatch = line.match(/^SG_\s+([A-Za-z0-9_]+)(?:\s+((?:M|m-?\d+)))?\s*:\s*(\d+)\|(\d+)@([01])([+-])\s+\(([^\),]+),([^\)]+)\)\s+\[([^\|]*)\|([^\]]*)\]\s+"([^"]*)"\s+(.+)$/);
if (signalMatch && current) {
var muxInfo = parseMultiplexToken(signalMatch[2]);
current.signals.push({
name: signalMatch[1],
startBit: parseInt(signalMatch[3], 10),
length: parseInt(signalMatch[4], 10),
byteOrder: signalMatch[5] === '1' ? 'little' : 'big',
isSigned: signalMatch[6] === '-',
factor: parseFloat(signalMatch[7]),
offset: parseFloat(signalMatch[8]),
min: signalMatch[9].trim() === '' ? null : parseFloat(signalMatch[9]),
max: signalMatch[10].trim() === '' ? null : parseFloat(signalMatch[10]),
unit: signalMatch[11],
receivers: signalMatch[12].split(/\s*,\s*/),
comment: '',
valueType: 'int',
enumMap: {},
defaultValue: null,
multiplexRole: muxInfo.role,
multiplexValue: muxInfo.value
});
continue;
}
var messageCommentMatch = line.match(/^CM_\s+BO_\s+(\d+)\s+"([\s\S]*)";$/);
if (messageCommentMatch) {
comments.messages[messageCommentMatch[1]] = messageCommentMatch[2];
continue;
}
var signalCommentMatch = line.match(/^CM_\s+SG_\s+(\d+)\s+([A-Za-z0-9_]+)\s+"([\s\S]*)";$/);
if (signalCommentMatch) {
comments.signals[signalCommentMatch[1] + ':' + signalCommentMatch[2]] = signalCommentMatch[3];
continue;
}
var valueMatch = line.match(/^VAL_\s+(\d+)\s+([A-Za-z0-9_]+)\s+(.+);$/);
if (valueMatch) {
var table = {};
var pairRe = /(-?\d+)\s+"([^"]*)"/g;
var m;
while ((m = pairRe.exec(valueMatch[3])) !== null) {
table[m[1]] = m[2];
}
valueTables[valueMatch[1] + ':' + valueMatch[2]] = table;
continue;
}
var valTypeMatch = line.match(/^SIG_VALTYPE_\s+(\d+)\s+([A-Za-z0-9_]+)\s*:\s*(\d+)\s*;$/);
if (valTypeMatch) {
var msg = messageMap[valTypeMatch[1]];
if (!msg) continue;
var signal = findSignal(msg, valTypeMatch[2]);
if (signal) {
signal.valueType = valTypeMatch[3] === '1' ? 'float' : valTypeMatch[3] === '2' ? 'double' : 'int';
}
continue;
}
/* BA_ "GenSigDefaultValue" SG_ <msgId> <signalName> <value>;
* 注意该值是物理值(已应用 factor/offset),与 GenSigStartValue(raw)不同 */
var sigDefaultMatch = line.match(/^BA_\s+"GenSigDefaultValue"\s+SG_\s+(\d+)\s+([A-Za-z0-9_]+)\s+(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)\s*;$/);
if (sigDefaultMatch) {
sigDefaults[sigDefaultMatch[1] + ':' + sigDefaultMatch[2]] = sigDefaultMatch[3];
continue;
}
}
messages.forEach(function (msg) {
msg.comment = comments.messages[String(msg.idRaw)] || '';
msg.signals.forEach(function (signal) {
signal.comment = comments.signals[String(msg.idRaw) + ':' + signal.name] || '';
signal.enumMap = valueTables[String(msg.idRaw) + ':' + signal.name] || {};
var dv = sigDefaults[String(msg.idRaw) + ':' + signal.name];
signal.defaultValue = (dv != null && Number.isFinite(Number(dv))) ? 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 解析 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 };
}
/** @description 更新顶部 DBC / 报文数量摘要 pill。 */
export function buildSummary() {
if (!state.dbc) {
if (el.dbcSummaryPill) el.dbcSummaryPill.textContent = '未导入 DBC';
if (el.messageSummaryPill) el.messageSummaryPill.textContent = '0 报文';
return;
}
if (el.dbcSummaryPill) {
el.dbcSummaryPill.textContent = (state.dbc.nodeNames.length || 0) + ' 节点';
}
if (el.messageSummaryPill) {
el.messageSummaryPill.textContent = state.dbc.messages.length + ' 报文';
}
}
+19
View File
@@ -0,0 +1,19 @@
/**
* feedback.js - 1Hz 反馈轮询(预留模块)
*
* ⚠️ 说明:原 tools/can.js(1526 行)中不存在 1Hz 反馈轮询 / ID 快照表渲染相关代码。
* 这些功能实际位于 tools/can_monitor.js(CAN 监控页面),操作的是 can_monitor.html
* 的 DOM 元素(fb0State / fb0Rx / idsnapBody 等),与 can.html 页面的元素体系不同,
* 因此无法直接迁移。
*
* can.js 中与"连接状态反馈"相关的代码为 setConnectionState / testConnection
* (一次性 GET /system/status),属于入口文件(js/can.js)的 init/cacheElements/bindEvents
* 编排范畴,不在本子模块内。
*
* 本模块按任务要求 import common/connection.js,供入口文件在需要时引入连接管理能力。
* 若后续 can 页面需要增加 1Hz 轮询 /can/feedback 或 ID 快照功能,可在此模块实现。
*/
import { updateConnectionUi, updateHealthUi, updateHeapAndTaskStatusUi, testConnection, refreshSystemStatusNow } from '../common/connection.js';
// 重新导出 common/connection.js 的连接管理函数,供 can 入口文件按需使用。
export { updateConnectionUi, updateHealthUi, updateHeapAndTaskStatusUi, testConnection, refreshSystemStatusNow };
+356
View File
@@ -0,0 +1,356 @@
/**
* messages.js - 报文列表管理(渲染、过滤、节点过滤、批量 UI)
*
* 从 tools/can.js 拆分。对应原 can.js 约 337~686 行(DBC 文件信息、报文过滤、
* 节点过滤面板、报文列表渲染、报文选择/展开、批量 UI)。
*
* 依赖:dbc.js(state/el/padHex/normalizeWhitespace/cssEscape)、
* common/utils.js(escapeHtml/formatBytes)、
* signals.js(renderInlineSignalGrid/ensureMessageDefaults/renderSelectedMessage/updateEncodedPreview)。
*/
import { state, el, padHex, normalizeWhitespace, cssEscape } from './dbc.js';
import { escapeHtml as sanitizeHtml, formatBytes } from '../common/utils.js';
import { renderInlineSignalGrid, ensureMessageDefaults, renderSelectedMessage, updateEncodedPreview } from './signals.js';
// ==================== DBC 文件信息 ====================
/** @description 更新 DBC 文件信息卡片(文件名/报文数/信号数/节点数/大小)。 */
export function updateDbcFileInfo(file, dbc) {
if (!el.dbcFileInfo) return;
if (!file || !dbc) {
el.dbcFileInfo.classList.remove('show');
el.dbcFileName.textContent = '-';
el.dbcMessageCount.textContent = '0';
el.dbcSignalCount.textContent = '0';
el.dbcNodeCount.textContent = '0';
el.dbcFileSize.textContent = '0 B';
return;
}
var signalCount = dbc.messages.reduce(function (sum, msg) { return sum + msg.signals.length; }, 0);
el.dbcFileInfo.classList.add('show');
el.dbcFileName.textContent = file.name || '-';
el.dbcMessageCount.textContent = String(dbc.messages.length);
el.dbcSignalCount.textContent = String(signalCount);
el.dbcNodeCount.textContent = String(dbc.nodeNames.length);
el.dbcFileSize.textContent = formatBytes(file.size || 0);
}
// ==================== 报文过滤 ====================
/** @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 中所有发送节点(去重 + 排序)。 */
export 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 渲染发送节点过滤面板的复选框列表。 */
export function renderNodeFilterList() {
if (!el.nodeFilterList) return;
var txs = collectTransmitters();
var kw = (el.nodeFilterSearch && el.nodeFilterSearch.value || '').trim().toLowerCase();
if (kw) txs = txs.filter(function (n) { return n.toLowerCase().indexOf(kw) >= 0; });
if (!txs.length) {
el.nodeFilterList.innerHTML = '<div class="status-empty">无匹配节点</div>';
return;
}
var counts = Object.create(null);
if (state.dbc) {
state.dbc.messages.forEach(function (msg) {
var tx = msg.transmitter || 'Vector__XXX';
counts[tx] = (counts[tx] || 0) + 1;
});
}
el.nodeFilterList.innerHTML = txs.map(function (name) {
var checked = !!state.transmitterFilter[name] ? ' checked' : '';
return '<label class="can-node-filter-item">' +
'<input type="checkbox" data-node="' + sanitizeHtml(name) + '"' + checked + '>' +
'<span class="can-node-filter-name">' + sanitizeHtml(name) + '</span>' +
'<span class="can-node-filter-count">' + (counts[name] || 0) + '</span>' +
'</label>';
}).join('');
var inputs = el.nodeFilterList.querySelectorAll('input[type="checkbox"]');
for (var i = 0; i < inputs.length; i++) {
inputs[i].addEventListener('change', function () {
var name = this.getAttribute('data-node');
if (this.checked) state.transmitterFilter[name] = true;
else delete state.transmitterFilter[name];
recomputeTransmitterFilterEmpty();
filterMessages();
});
}
}
/** @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 打开/关闭发送节点过滤面板。 */
export function setNodeFilterPanelOpen(open) {
if (!el.nodeFilterPanel || !el.nodeFilterBtn) return;
if (open) {
renderNodeFilterList();
el.nodeFilterPanel.hidden = false;
el.nodeFilterBtn.setAttribute('aria-expanded', 'true');
} else {
el.nodeFilterPanel.hidden = true;
el.nodeFilterBtn.setAttribute('aria-expanded', 'false');
}
}
/** @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();
});
if (el.nodeFilterNoneBtn) el.nodeFilterNoneBtn.addEventListener('click', function () {
state.transmitterFilter = Object.create(null);
state.transmitterFilterEmpty = true;
renderNodeFilterList();
filterMessages();
});
}
// ==================== 报文列表渲染 ====================
/** @description 渲染报文列表表格(表头 + 行 + 展开区)。 */
export function renderMessageList() {
if (!el.messageList) return;
if (!state.dbc) {
el.messageList.innerHTML = '<div class="status-empty">等待导入 DBC...</div>';
updateBatchUi();
return;
}
if (!state.filteredMessages.length) {
el.messageList.innerHTML = '<div class="status-empty">没有匹配的报文。</div>';
updateBatchUi();
return;
}
var channelTxt = el.defaultChannel ? el.defaultChannel.value : '0';
var rows = state.filteredMessages.map(function (msg, index) {
var active = msg.key === state.selectedMessageKey ? ' active' : '';
var checked = state.checkedKeys[msg.key] ? ' checked' : '';
var expanded = !!state.expandedKeys[msg.key];
var caret = expanded ? '\u25BE' : '\u25B8';
var idHex = '0x' + padHex(msg.id, msg.isExtended ? 8 : 3);
var comment = msg.comment || '';
if (comment.length > 36) comment = comment.slice(0, 36) + '...';
var rowHtml = '' +
'<div class="message-row' + active + '" data-key="' + sanitizeHtml(msg.key) + '" role="button" tabindex="0">' +
'<span class="message-col message-col-check"><input type="checkbox" class="message-check" data-key="' + sanitizeHtml(msg.key) + '"' + checked + '></span>' +
'<span class="message-col message-col-toggle"><button type="button" class="message-toggle' + (expanded ? ' expanded' : '') + '" data-key="' + sanitizeHtml(msg.key) + '" aria-label="展开/收起信号">' + caret + '</button></span>' +
'<span class="message-col message-col-index">' + (index + 1) + '</span>' +
'<span class="message-col message-col-name" title="' + sanitizeHtml(msg.name) + '">' + sanitizeHtml(msg.name) + '</span>' +
'<span class="message-col message-col-id">' + sanitizeHtml(idHex) + '</span>' +
'<span class="message-col message-col-channel">CH' + sanitizeHtml(channelTxt) + '</span>' +
'<span class="message-col message-col-type">' + sanitizeHtml(msg.isExtended ? '扩展' : '标准') + '</span>' +
'<span class="message-col message-col-dlc">' + sanitizeHtml(msg.dlc) + '</span>' +
'<span class="message-col message-col-signal">' + sanitizeHtml(msg.signals.length) + '</span>' +
'<span class="message-col message-col-node" title="' + sanitizeHtml(msg.transmitter || 'Vector__XXX') + '">' + sanitizeHtml(msg.transmitter || 'Vector__XXX') + '</span>' +
'<span class="message-col message-col-comment" title="' + sanitizeHtml(msg.comment || '') + '">' + sanitizeHtml(comment || '-') + '</span>' +
'</div>';
var expandHtml = '<div class="message-row-expand" data-key="' + sanitizeHtml(msg.key) + '"' + (expanded ? '' : ' hidden') + '>' +
'<div class="signal-table-host"></div>' +
'</div>';
return rowHtml + expandHtml;
}).join('');
el.messageList.innerHTML =
'<div class="message-table">' +
'<div class="message-table-head">' +
'<span class="message-col message-col-check"></span>' +
'<span class="message-col message-col-toggle"></span>' +
'<span class="message-col message-col-index">#</span>' +
'<span class="message-col message-col-name">报文名称</span>' +
'<span class="message-col message-col-id">标识符</span>' +
'<span class="message-col message-col-channel">通道</span>' +
'<span class="message-col message-col-type">类型</span>' +
'<span class="message-col message-col-dlc">DLC</span>' +
'<span class="message-col message-col-signal">信号</span>' +
'<span class="message-col message-col-node">发送节点</span>' +
'<span class="message-col message-col-comment">注释</span>' +
'</div>' +
'<div class="message-table-body">' + rows + '</div>' +
'</div>';
var rowEls = el.messageList.querySelectorAll('.message-row');
for (var i = 0; i < rowEls.length; i++) {
rowEls[i].addEventListener('click', handleMessageRowClick);
rowEls[i].addEventListener('keydown', function (event) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
selectMessage(this.getAttribute('data-key'));
}
});
}
var checks = el.messageList.querySelectorAll('.message-check');
for (var j = 0; j < checks.length; j++) {
checks[j].addEventListener('click', function (event) { event.stopPropagation(); });
checks[j].addEventListener('change', function () {
var k = this.getAttribute('data-key');
if (this.checked) state.checkedKeys[k] = true;
else delete state.checkedKeys[k];
updateBatchUi();
});
}
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 = null;
for (var i = 0; i < state.dbc.messages.length; i++) {
if (state.dbc.messages[i].key === key) { msg = state.dbc.messages[i]; break; }
}
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';
}
}
// ==================== 批量 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 = null;
for (var i = 0; i < state.dbc.messages.length; i++) {
if (state.dbc.messages[i].key === key) {
msg = state.dbc.messages[i];
break;
}
}
if (!msg) return;
state.selectedMessageKey = key;
ensureMessageDefaults(msg);
renderMessageList();
renderSelectedMessage();
updateEncodedPreview();
}
+383
View File
@@ -0,0 +1,383 @@
/**
* send.js - CAN 帧构建与发送(DBC 报文 / 原始帧 / 批量发送 / 发送历史)
*
* 从 tools/can.js 拆分。对应原 can.js 约 1030~1333 行(sendCanRequest、响应框、
* 历史、批量/选中/手动发送、手动帧校验/重置/载入预览)及 1491~1523 行
* (CAN_SEND_ERR_DESC / GD32_ERR_DESC / buildSendResponseText)。
*
* 依赖: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 保留 can.js 本地实现(HH:MM:SS,不含毫秒),与 common/utils.js
* 的 formatTime(含毫秒)不同,以保证发送历史时间戳输出与原版完全一致。
*/
import { state, el, getBaseUrl, padHex, bytesToHex, parseNumericId } from './dbc.js';
import { escapeHtml as sanitizeHtml } from '../common/utils.js';
import { showToast } from '../common/toast.js';
import { updateBatchUi } from './messages.js';
import { ensureMessageDefaults, getSignalValueMap, getSelectedMessage, updateEncodedPreview, encodeMessageData } from './signals.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;
}
// ==================== 发送错误码描述 ====================
export var CAN_SEND_ERR_DESC = {
'-1': '参数错误(通道/ID/DLC 无效)',
'-2': 'SPI 通信失败(ESP32 与 GD32 通信异常)',
'-3': 'GD32 CAN 总线拒绝',
};
export 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)。 */
export 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 + ')';
}
// ==================== HTTP 请求 ====================
async function sendCanRequest(payload) {
var resp = await fetch(getBaseUrl() + '/can/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
signal: AbortSignal.timeout(5000)
});
var text = await resp.text();
var json = {};
try {
json = text ? JSON.parse(text) : {};
} catch (err) {
throw new Error('响应不是合法 JSON: ' + text);
}
if (!resp.ok) {
throw new Error('HTTP ' + resp.status + ': ' + (json.message || text || '请求失败'));
}
return json;
}
// ==================== 响应框 ====================
function updateResponseBox(box, textEl, result, mode) {
if (!box || !textEl) return;
box.style.display = 'block';
box.className = 'can-response' + (result.status === 'ok' ? ' ok' : ' err');
textEl.textContent = buildSendResponseText(result);
if (mode === 'manual') {
el.manualHint.className = 'helper-note ' + (result.status === 'ok' ? 'ok' : 'err');
el.manualHint.innerHTML = result.status === 'ok'
? '原始帧已发送到设备,若有响应则显示在下方。'
: '设备返回错误码 <b>' + sanitizeHtml(result.code) + '</b>。';
}
}
// ==================== 发送历史 ====================
function addHistoryEntry(entry) {
state.history.unshift(entry);
if (state.history.length > 20) state.history.length = 20;
renderHistory();
}
/** @description 渲染发送历史列表。 */
export function renderHistory() {
if (!el.historyList) return;
if (!state.history.length) {
el.historyList.innerHTML = '<div class="status-empty">尚未发送任何 CAN 帧。</div>';
return;
}
el.historyList.innerHTML = state.history.map(function (item) {
return '' +
'<div class="history-item">' +
'<div class="history-item-top">' +
'<span class="history-item-tag ' + (item.ok ? 'ok' : 'err') + '">' + sanitizeHtml(item.mode) + '</span>' +
'<span class="history-item-time">' + sanitizeHtml(item.time) + '</span>' +
'</div>' +
'<div class="history-item-main">' +
'<b>CH' + sanitizeHtml(item.channel) + '</b> · ' +
sanitizeHtml(item.idHex) + ' · DLC ' + sanitizeHtml(item.dlc) +
'</div>' +
'<div class="history-item-hex">' + sanitizeHtml(item.dataHex || '--') + '</div>' +
'<div class="history-item-foot">' + sanitizeHtml(item.resultText) + '</div>' +
'</div>';
}).join('');
}
// ==================== 批量发送 ====================
/** @description 批量发送所有勾选的报文(30ms 帧间间隔防 GD32 SPI RXORERR)。 */
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;
/* BATCH 间隔:GD32 SPI 从机用 RBNE 中断逐字节接收,无 RX DMA,
* 连续高频 CS 事务易触发 RXORERR -> 帧字节丢失 -> CRC 失败 -> 0xEE。
* 30ms 间隔给 GD32 足够时间完成 onCsRelease 清理 + EXTI 重新待命。 */
var BATCH_INTERVAL_MS = 30;
for (var i = 0; i < keys.length; i++) {
var key = keys[i];
var msg = null;
for (var m = 0; m < state.dbc.messages.length; m++) {
if (state.dbc.messages[m].key === key) { msg = state.dbc.messages[m]; break; }
}
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({
time: formatTime(new Date()),
mode: 'BATCH',
ok: false,
channel: channel,
idHex: idHex,
dlc: msg.dlc,
dataHex: '',
resultText: '编码失败: ' + err.message
});
continue;
}
var dataHex = bytesToHex(dataBytes);
try {
var result = await sendCanRequest({
ch: channel,
id: msg.id,
dlc: msg.dlc,
data: dataHex
});
if (result.status === 'ok') ok++; else fail++;
addHistoryEntry({
time: formatTime(new Date()),
mode: 'BATCH',
ok: result.status === 'ok',
channel: channel,
idHex: idHex,
dlc: msg.dlc,
dataHex: dataHex,
resultText: msg.name + ' · ' + buildSendResponseText(result)
});
} catch (err) {
fail++;
addHistoryEntry({
time: formatTime(new Date()),
mode: 'BATCH',
ok: false,
channel: channel,
idHex: idHex,
dlc: msg.dlc,
dataHex: dataHex,
resultText: msg.name + ' · ' + err.message
});
}
/* 帧间间隔,避免 GD32 SPI RXORERR(最后一帧不需要等) */
if (i < keys.length - 1) {
await new Promise(function (r) { setTimeout(r, BATCH_INTERVAL_MS); });
}
}
state.batchInFlight = false;
updateBatchUi();
var tone = fail === 0 ? 'success' : (ok === 0 ? 'error' : 'warning');
showToast('批量发送完成:成功 ' + ok + ',失败 ' + fail, tone);
}
// ==================== 选中报文发送 ====================
/** @description 发送当前选中报文(DBC 编码后 POST /can/send)。 */
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 payload = {
ch: channel,
id: msg.id,
dlc: msg.dlc,
data: bytesToHex(state.generatedData)
};
try {
var result = await sendCanRequest(payload);
updateResponseBox(el.dbcResp, el.dbcRespText, result, 'dbc');
addHistoryEntry({
time: formatTime(new Date()),
mode: 'DBC',
ok: result.status === 'ok',
channel: channel,
idHex: '0x' + padHex(msg.id, msg.isExtended ? 8 : 3),
dlc: msg.dlc,
dataHex: bytesToHex(state.generatedData),
resultText: buildSendResponseText(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({
time: formatTime(new Date()),
mode: 'DBC',
ok: false,
channel: channel,
idHex: '0x' + padHex(msg.id, msg.isExtended ? 8 : 3),
dlc: msg.dlc,
dataHex: bytesToHex(state.generatedData),
resultText: err.message
});
showToast('发送失败: ' + err.message, 'error');
}
}
// ==================== 原始帧发送 ====================
function validateManualData() {
var dataHex = (el.manualData.value || '').trim().replace(/\s+/g, '');
var dlc = parseInt(el.manualDlc.value, 10);
if (!Number.isFinite(dlc) || dlc < 0 || dlc > 64) {
throw new Error('DLC 需在 0~64 之间');
}
if (dataHex && !/^[0-9a-fA-F]+$/.test(dataHex)) {
throw new Error('HEX 数据包含非法字符');
}
if (dataHex.length % 2 !== 0) {
throw new Error('HEX 数据长度必须为偶数');
}
var byteLen = dataHex.length / 2;
if (byteLen !== dlc) {
throw new Error('当前 HEX 为 ' + byteLen + ' 字节,但 DLC=' + dlc);
}
return dataHex.toUpperCase();
}
/** @description 刷新手动帧输入的校验提示。 */
export function refreshManualHint() {
try {
var dataHex = validateManualData();
el.manualHint.className = 'helper-note ok';
el.manualHint.innerHTML = '原始 HEX 合法,当前长度 <b>' + (dataHex.length / 2) + '</b> 字节。';
} catch (err) {
el.manualHint.className = 'helper-note err';
el.manualHint.innerHTML = sanitizeHtml(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 sendCanRequest({
ch: channel,
id: id,
dlc: dlc,
data: dataHex
});
updateResponseBox(el.manualResp, el.manualRespText, result, 'manual');
addHistoryEntry({
time: formatTime(new Date()),
mode: 'RAW',
ok: result.status === 'ok',
channel: channel,
idHex: '0x' + padHex(id, id > 0x7FF ? 8 : 3),
dlc: dlc,
dataHex: dataHex,
resultText: buildSendResponseText(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({
time: formatTime(new Date()),
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');
}
+384
View File
@@ -0,0 +1,384 @@
/**
* signals.js - 信号编辑器渲染 + 信号编解码(raw ↔ physical)
*
* 从 tools/can.js 拆分。对应原 can.js 约 629~1028 行(信号行渲染、信号值存取、
* 编码写入、浮点/整数/Motorola/Intel 位操作)及 761 行的 renderSelectedMessage shim。
*
* 依赖:dbc.js(state/el/padHex/bytesToHex/cssEscape/findSignal)、common/utils.js(escapeHtml)。
*/
import { state, el, padHex, bytesToHex, cssEscape, findSignal } from './dbc.js';
import { escapeHtml as sanitizeHtml } from '../common/utils.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;
for (var i = 0; i < state.dbc.messages.length; i++) {
if (state.dbc.messages[i].key === state.selectedMessageKey) return state.dbc.messages[i];
}
return null;
}
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;
});
}
// ==================== 旧版 shim ====================
/**
* @description 旧版"顶部独立信号编辑面板"的兼容 shim。
* 该面板已移除,信号编辑现在通过报文行展开完成;
* 仍被 selectMessage、handleDbcFile、clearDbc、handleSignalInputEvent 调用,
* 保留空函数以避免修改所有调用方。
*/
export function renderSelectedMessage() {}
// ==================== 信号行渲染 ====================
/** @description 在报文展开行内渲染信号表格(inline grid)。 */
export function renderInlineSignalGrid(message, host) {
if (!host) return;
ensureMessageDefaults(message);
var activeSignals = getActiveSignals(message);
if (!activeSignals.length) {
host.innerHTML = '<div class="status-empty">当前报文没有信号定义。</div>';
return;
}
var rows = activeSignals.map(function (signal, index) {
return renderSignalRow(message, signal, index);
}).join('');
host.innerHTML =
'<div class="signal-table signal-table-inline">' +
'<div class="signal-table-head">' +
'<span class="signal-cell signal-cell-index">#</span>' +
'<span class="signal-cell signal-cell-name">信号名称</span>' +
'<span class="signal-cell signal-cell-value">输入值</span>' +
'<span class="signal-cell signal-cell-range">范围 / 枚举</span>' +
'<span class="signal-cell signal-cell-meta">位定义</span>' +
'<span class="signal-cell signal-cell-comment">注释</span>' +
'</div>' +
'<div class="signal-table-body">' + rows + '</div>' +
'</div>';
bindSignalInputs(host, message);
}
function renderSignalRow(message, signal, index) {
var bag = getSignalValueMap(message);
var enumKeys = Object.keys(signal.enumMap || {});
var 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 (enumKeys.length) {
inputHtml = '<select class="input signal-input signal-input-compact" data-msg="' + sanitizeHtml(message.key) + '" data-signal="' + sanitizeHtml(signal.name) + '">' +
enumKeys.map(function (key) {
var label = signal.enumMap[key];
var selected = String(bag[signal.name]) === String(key) ? ' selected' : '';
return '<option value="' + sanitizeHtml(key) + '"' + selected + '>' +
sanitizeHtml(key + ' - ' + label) + '</option>';
}).join('') + '</select>';
} else {
inputHtml = '<input class="input signal-input signal-input-compact" type="number" step="any" data-msg="' + sanitizeHtml(message.key) + '" data-signal="' + sanitizeHtml(signal.name) +
'" value="' + sanitizeHtml(bag[signal.name]) + '">';
}
return '' +
'<div class="signal-row">' +
'<span class="signal-cell signal-cell-index">' + (index + 1) + '</span>' +
'<span class="signal-cell signal-cell-name">' +
'<b>' + sanitizeHtml(signal.name) + '</b>' +
'<small>' + sanitizeHtml((signal.receivers || []).join(', ') || '-') + '</small>' +
'</span>' +
'<span class="signal-cell signal-cell-value">' + inputHtml + '</span>' +
'<span class="signal-cell signal-cell-range">' +
'<b>' + sanitizeHtml(rangeText) + '</b>' +
(enumKeys.length ? '<small>' + sanitizeHtml(enumKeys.slice(0, 3).map(function (key) { return key + ':' + signal.enumMap[key]; }).join(' / ')) + (enumKeys.length > 3 ? ' ...' : '') + '</small>' : '<small>' + sanitizeHtml(factorText) + '</small>') +
'</span>' +
'<span class="signal-cell signal-cell-meta">' +
'<b>' + sanitizeHtml(bitRange) + '</b>' +
'<small>' + sanitizeHtml(typeText) + '</small>' +
'</span>' +
'<span class="signal-cell signal-cell-comment" title="' + sanitizeHtml(signal.comment || '') + '">' + sanitizeHtml(comment) + '</span>' +
'</div>';
}
function handleSignalInputEvent(evt) {
var input = evt.currentTarget;
var key = input.getAttribute('data-msg');
var name = input.getAttribute('data-signal');
if (!key || !name || !state.dbc) return;
var message = null;
for (var i = 0; i < state.dbc.messages.length; i++) {
if (state.dbc.messages[i].key === key) { message = state.dbc.messages[i]; break; }
}
if (!message) return;
setSignalValue(message, name, input.value);
var signal = findSignal(message, name);
if (signal && signal.multiplexRole === 'mux') {
if (key === state.selectedMessageKey) renderSelectedMessage();
var inlineGrid = document.querySelector('.message-row-expand[data-key="' + cssEscape(key) + '"] .signal-table-host');
if (inlineGrid) renderInlineSignalGrid(message, inlineGrid);
}
if (key === state.selectedMessageKey) updateEncodedPreview();
}
function bindSignalInputs(container, message) {
if (!container) return;
var inputs = container.querySelectorAll('.signal-input');
for (var i = 0; i < inputs.length; i++) {
inputs[i].addEventListener('input', handleSignalInputEvent);
inputs[i].addEventListener('change', handleSignalInputEvent);
}
if (message && message.signals.length && message.key === state.selectedMessageKey) {
updateEncodedPreview();
}
}
// ==================== 编码预览 ====================
/** @description 根据当前选中报文 + 信号值更新编码预览(hex / byte strip / hint)。 */
export function updateEncodedPreview() {
var message = getSelectedMessage();
if (!message) {
state.generatedData = new Uint8Array(0);
if (el.generatedHex) el.generatedHex.textContent = '--';
if (el.generatedBytes) el.generatedBytes.innerHTML = '';
return;
}
try {
state.generatedData = encodeMessageData(message, getSignalValueMap(message));
var hex = bytesToHex(state.generatedData);
el.generatedHex.textContent = hex || '--';
el.generatedBytes.innerHTML = renderByteStrip(state.generatedData);
var hint = 'DLC <b>' + message.dlc + '</b>,当前编码 <b>' + state.generatedData.length +
'</b> 字节,发送通道 <b>CH' + (el.defaultChannel ? el.defaultChannel.value : '0') + '</b>。';
if (el.encodeHint) {
el.encodeHint.className = 'helper-note ok';
el.encodeHint.innerHTML = hint;
}
} catch (err) {
state.generatedData = new Uint8Array(0);
el.generatedHex.textContent = '--';
el.generatedBytes.innerHTML = '';
if (el.encodeHint) {
el.encodeHint.className = 'helper-note err';
el.encodeHint.innerHTML = '编码失败:' + sanitizeHtml(err.message);
}
}
}
function renderByteStrip(bytes) {
if (!bytes || !bytes.length) return '<div class="status-empty">无数据</div>';
var html = [];
for (var i = 0; i < bytes.length; i++) {
html.push('<span class="byte-pill"><b>B' + i + '</b><span>' + padHex(bytes[i], 2) + '</span></span>');
}
return html.join('');
}
// ==================== 信号编解码(raw ↔ physical) ====================
/** @description 将报文 + 信号值编码为 Uint8Array。 */
export function encodeMessageData(message, signalValues) {
var bytes = new Uint8Array(message.dlc);
var activeSignals = getActiveSignals(message);
for (var i = 0; i < activeSignals.length; i++) {
var signal = activeSignals[i];
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;
}
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 + ' 不是合法数字');
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);
}
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 buffer = new ArrayBuffer(signal.length / 8);
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[(signal.startBit / 8) + 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);
}
if (bitValue) bytes[byteIndex] |= (1 << bitInByte);
else bytes[byteIndex] &= ~(1 << bitInByte);
}
+232
View File
@@ -0,0 +1,232 @@
import { showToast } from './common/toast.js';
var state = { fbTimer: null };
var el = {};
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'
];
function cacheElements() {
ids.forEach(function (id) { el[id] = document.getElementById(id); });
}
function sanitize(text) {
return String(text == null ? '' : text)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function getBaseUrl() {
var ip = (el.monIp && el.monIp.value ? el.monIp.value.trim() : '') || '192.168.31.127';
return 'http://' + ip;
}
function setConnState(ok, text) {
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;
}
// ── 1Hz 轮询 /can/feedback ─────────────────────────────────
async function pollFeedback() {
// 取消上一次 in-flight 请求,防止 SPI 慢响应堆积导致 UI 瞬间刷新多次
if (state.fbAbortCtrl) { try { state.fbAbortCtrl.abort(); } catch(_e) {} }
state.fbAbortCtrl = new AbortController();
var fbPollTimer = setTimeout(function() { try { state.fbAbortCtrl.abort(); } catch(_e) {} }, 3000);
try {
var resp = await fetch(getBaseUrl() + '/can/feedback', { signal: state.fbAbortCtrl.signal });
if (!resp.ok) return;
var data = await resp.json();
var channels = data.channels || [];
var selectedCh = el.idsnapChannel ? parseInt(el.idsnapChannel.value) : 0;
for (var i = 0; i < 3; i++) {
var ch = channels[i];
if (!ch || !ch.valid) {
renderChannelEmpty(i);
continue;
}
renderChannel(i, ch);
}
// 渲染选中通道的 ID 快照表
var sel = channels[selectedCh];
if (sel && sel.valid && sel.entries) {
renderIdTable(sel.entries, sel.count || sel.entries.length);
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, '已连接');
clearTimeout(fbPollTimer);
} catch (e) {
clearTimeout(fbPollTimer);
// 自身 AbortController 触发的取消不算失败,静默返回
if (e && e.name === "AbortError") return;
if (el.fbPollStatus) {
el.fbPollStatus.textContent = '轮询: 离线';
el.fbPollStatus.className = 'pill offline';
}
setConnState(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'; }
}
function renderIdTable(entries, count) {
var tbody = el.idsnapBody;
if (!tbody) return;
if (!entries || !entries.length) {
tbody.innerHTML = '<tr><td colspan="7" class="status-empty">无数据(CAN 未收到帧)</td></tr>';
return;
}
var html = '';
for (var i = 0; i < entries.length; i++) {
var e = entries[i];
html += '<tr>';
html += '<td>' + (i + 1) + '</td>';
html += '<td class="mono">' + sanitize(e.id) + '</td>';
html += '<td>' + e.dlc + '</td>';
html += '<td class="' + (e.fd ? 'flag-on' : 'flag-off') + '">' + (e.fd ? 'Y' : '-') + '</td>';
html += '<td class="' + (e.brs ? 'flag-on' : 'flag-off') + '">' + (e.brs ? 'Y' : '-') + '</td>';
html += '<td class="' + (e.esi ? 'flag-on' : 'flag-off') + '">' + (e.esi ? 'Y' : '-') + '</td>';
html += '<td class="idsnap-data mono">' + sanitize(e.data || '--') + '</td>';
html += '</tr>';
}
tbody.innerHTML = html;
}
function renderIdTableEmpty() {
var tbody = el.idsnapBody;
if (tbody) tbody.innerHTML = '<tr><td colspan="7" class="status-empty">等待数据...</td></tr>';
if (el.idsnapStatus) { el.idsnapStatus.textContent = '--'; el.idsnapStatus.className = 'pill'; }
}
// ── 连接测试 ─────────────────────────────────────────────
async function testConnection() {
try {
var resp = await fetch(getBaseUrl() + '/can/feedback', { signal: AbortSignal.timeout(3000) });
if (!resp.ok) { setConnState(false, 'HTTP ' + resp.status); return; }
setConnState(true, '已连接');
showToast('ESP32 已连接', 'success');
} catch (e) {
setConnState(false, '连接失败');
showToast('连接失败: ' + e.message, 'error');
}
}
// ── 模式切换 ─────────────────────────────────────────────
async function setCanMode(mode) {
try {
var resp = await fetch(getBaseUrl() + '/can/mode', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode: mode }),
signal: AbortSignal.timeout(3000)
});
var data = await resp.json();
if (data.ok) {
showToast('CAN 模式已切换: ' + (data.actual === 2 ? '桥接(CAN0→1/2)' : (data.actual === 1 ? '过滤' : '全收')), 'success');
} else {
showToast('模式切换失败', 'error');
}
} catch (e) {
showToast('模式切换失败: ' + e.message, 'error');
}
}
// ── 初始化 ───────────────────────────────────────────────
function init() {
cacheElements();
setConnState(false, '未连接');
if (el.monTestBtn) el.monTestBtn.addEventListener('click', testConnection);
if (el.monIp) el.monIp.addEventListener('change', testConnection);
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);
setTimeout(testConnection, 150);
pollFeedback();
state.fbTimer = setInterval(pollFeedback, 1000);
}
init();
+270
View File
@@ -0,0 +1,270 @@
/**
* connection.js - 连接管理 + 系统状态显示
*
* 从 app.js 的「连接管理」段 + testConnection / refreshSystemStatusNow 提取。
* 操作 DOM 元素 #pillConn / #pillConnTxt / #connStatus / #connStatusTxt / #bConn 等。
*
* 共享状态(通过 window.* 引用,后续 app.js 拆分时会统一挂载):
* window.latestSystemStatus — 最近一次 /system/status 响应
* window.lastSysStateCode — 上次成功的 sys_state_code(polling.js 读取)
* window.lastRelayState — 上次成功的 relay_state(polling.js 读取)
* window.consecutivePollFailures — 连续轮询失败计数(polling.js 读取)
*
* 未拆分的 app.js 函数(通过 window.* 引用):
* window.getBaseUrl / window.updateSystemStatusUi / window.diagnoseNetwork / window.formatPercent
*/
import { formatBytes, formatTime, formatUptime, escapeHtml } from './utils.js';
import { addLog } from './log.js';
import { showToast } from './toast.js';
// ==================== 连接管理 ====================
export function updateConnectionUi(connected, text) {
// header pill
var pc = document.getElementById('pillConn');
var pct = document.getElementById('pillConnTxt');
pc.className = connected ? 'pill online' : 'pill offline';
pct.textContent = text || (connected ? '已连接' : '未连接');
// connection card indicator
var cs = document.getElementById('connStatus');
var cst = document.getElementById('connStatusTxt');
if (cs) cs.className = connected ? 'conn-indicator ok' : 'conn-indicator err';
if (cst) cst.textContent = connected ? (text || '已连接') : '未连接';
// primary badge
var bc = document.getElementById('bConn');
bc.textContent = connected ? '在线' : '离线';
bc.className = 'v ' + (connected ? 'ok' : 'err');
}
export function updateHealthUi(healthCode, freeHeap) {
var healthNames = ['正常', '警告', '危险', '关机'];
var health = healthCode !== undefined ? (healthNames[healthCode] || '未知') : '未知';
var connStatusTxt = document.getElementById('connStatusTxt');
if (connStatusTxt && window.latestSystemStatus && window.latestSystemStatus.wifi) {
connStatusTxt.textContent = (window.latestSystemStatus.wifi.connected ? '设备在线' : '设备离线') + ' · 系统' + health;
}
if (freeHeap !== undefined) {
var mHeapEl = document.getElementById('mHeap');
if (mHeapEl) mHeapEl.textContent = formatBytes(freeHeap);
}
var mh = document.getElementById('mHealth');
if (mh) {
mh.textContent = health;
mh.className = 'v ' + (healthCode === 0 ? 'ok' : healthCode === 1 ? 'warn' : 'err');
}
}
export function updateHeapAndTaskStatusUi(data) {
if (!data) return;
var ota = data.ota || {};
var wifi = data.wifi || {};
var targetHost = window.getBaseUrl().replace(/^http:\/\//, '');
var setText = function (id, value) {
var el = document.getElementById(id);
if (el) el.textContent = value;
};
// ---- 堆概况:已用 / 总计 + 占用条 ----
function setFill(id, pct) {
var el = document.getElementById(id);
if (!el) return;
el.style.width = Math.max(0, Math.min(100, pct)) + '%';
el.className = '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 = document.getElementById('cpuUsageFill');
if (!el) return;
var safePct = Math.max(0, Math.min(100, Number(pct) || 0));
el.style.width = safePct + '%';
el.className = 'heap-bar-fill cpu-fill' + (safePct >= 85 ? ' danger' : safePct >= 70 ? ' warn' : '');
}
function updateCpuUsageUi(cpu) {
cpu = cpu || {};
var taskList = document.getElementById('cpuTaskList');
if (!cpu.available) {
setText('cpuUsageText', '未启用 FreeRTOS 统计');
setText('cpuUsagePct', '--');
setText('cpuIdlePct', '--');
setText('cpuSampleMs', '--');
setText('cpuTaskCount', '--');
setCpuFill(0);
if (taskList) taskList.innerHTML = '<div class="status-empty">固件未启用 CPU runtime stats</div>';
return;
}
if (!cpu.valid) {
setText('cpuUsageText', '等待下一次采样...');
setText('cpuUsagePct', '--');
setText('cpuIdlePct', '--');
setText('cpuSampleMs', '--');
setText('cpuTaskCount', '--');
setCpuFill(0);
if (taskList) taskList.innerHTML = '<div class="status-empty">首次刷新只建立基线,下一次刷新后显示 CPU 占用</div>';
return;
}
var usagePct = Number(cpu.usage_pct) || 0;
var idlePct = Number(cpu.idle_pct) || 0;
var sampleMs = Number(cpu.sample_ms) || 0;
var tasks = Array.isArray(cpu.tasks) ? cpu.tasks.slice() : [];
tasks.sort(function (a, b) {
return (Number(b.usage_pct) || 0) - (Number(a.usage_pct) || 0);
});
setText('cpuUsageText', 'CPU 已用 ' + window.formatPercent(usagePct));
setText('cpuUsagePct', window.formatPercent(usagePct));
setText('cpuIdlePct', window.formatPercent(idlePct));
setText('cpuSampleMs', sampleMs > 0 ? sampleMs + ' ms' : '--');
setText('cpuTaskCount', String(tasks.length));
setCpuFill(usagePct);
if (taskList) {
var visibleTasks = tasks.slice(0, 8);
if (!visibleTasks.length) {
taskList.innerHTML = '<div class="status-empty">当前采样没有任务增量</div>';
} else {
taskList.innerHTML = visibleTasks.map(function (task) {
var pct = Number(task.usage_pct) || 0;
var delta = Number(task.runtime_delta) || 0;
var fillClass = pct >= 85 ? 'err' : pct >= 70 ? 'warn' : '';
return '' +
'<div class="status-task cpu-task">' +
'<div class="status-task-head">' +
'<div class="status-task-name">' + escapeHtml(task.name || 'task') + '</div>' +
'<div class="status-task-ratio">' + escapeHtml(window.formatPercent(pct)) + '</div>' +
'</div>' +
'<div class="status-task-bar"><div class="status-task-bar-fill ' + fillClass + '" style="width:' + Math.max(0, Math.min(100, pct)) + '%"></div></div>' +
'<div class="status-task-meta">' +
'<div><span>任务号</span><b>' + escapeHtml(task.task_number) + '</b></div>' +
'<div><span>增量</span><b>' + escapeHtml(delta) + '</b></div>' +
'<div><span>占比</span><b>' + escapeHtml(window.formatPercent(pct)) + '</b></div>' +
'</div>' +
'</div>';
}).join('');
}
}
}
updateCpuUsageUi(data.cpu);
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);
setText('mLargestBlock', data.largest_free_block !== undefined ? formatBytes(data.largest_free_block) : '--');
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) : '--');
// 缓存最近一次成功的 sys_state_code / relay_state,供 pollStatus 失败时判断设备是否在忙
if (Number.isFinite(ota.sys_state_code)) window.lastSysStateCode = ota.sys_state_code;
if (Number.isFinite(ota.relay_state)) window.lastRelayState = ota.relay_state;
var storedText = '--';
if (ota.total_size || ota.stored_size) {
storedText = formatBytes(ota.stored_size || 0) + ' / ' + formatBytes(ota.total_size || 0);
}
setText('sOtaStored', storedText);
setText('sStatusUpdated', formatTime(new Date()));
var taskList = document.getElementById('taskStackList');
if (taskList) {
var taskStack = data.task_stack || {};
var names = Object.keys(taskStack);
if (!names.length) {
taskList.innerHTML = '<div class="status-empty">当前状态未包含任务栈信息</div>';
} else {
taskList.innerHTML = 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;
var fillClass = usagePct >= 85 ? 'err' : usagePct >= 70 ? 'warn' : '';
return '' +
'<div class="status-task">' +
'<div class="status-task-head">' +
'<div class="status-task-name">' + escapeHtml(name) + '</div>' +
'<div class="status-task-ratio">' + escapeHtml(window.formatPercent(usagePct)) + '</div>' +
'</div>' +
'<div class="status-task-bar"><div class="status-task-bar-fill ' + fillClass + '" style="width:' + Math.max(0, Math.min(100, usagePct)) + '%"></div></div>' +
'<div class="status-task-meta">' +
'<div><span>栈大小</span><b>' + escapeHtml(formatBytes(stackSize)) + '</b></div>' +
'<div><span>剩余水位</span><b>' + escapeHtml(formatBytes(watermark)) + '</b></div>' +
'<div><span>峰值占用</span><b>' + escapeHtml(formatBytes(peakUsed)) + '</b></div>' +
'</div>' +
'</div>';
}).join('');
}
}
var statusJson = document.getElementById('statusJson');
if (statusJson) {
statusJson.textContent = JSON.stringify(data, null, 2);
}
}
/** @description 测试与 ESP32 的连接(GET /status)。 */
export async function testConnection() {
// 立即发一次请求检测
// 手动检测使用独立的 AbortController,避免打断递归调度的 pollStatus
var testAbort = new AbortController();
var testTimer = setTimeout(function() { try { testAbort.abort(); } catch(_e) {} }, 3000);
try {
var resp = await fetch(window.getBaseUrl() + '/system/status', {
signal: testAbort.signal
});
if (resp.ok) {
var data = await resp.json();
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');
} else {
addLog('E', '连接', 'HTTP ' + resp.status);
showToast('设备响应异常: HTTP ' + resp.status, 'error');
}
} catch (e) {
addLog('E', '连接', '连接失败: ' + e.message);
// 连接失败时自动诊断
var diag = await window.diagnoseNetwork();
addLog('W', '诊断', diag);
showToast('无法连接: ' + diag, 'error');
} finally {
clearTimeout(testTimer);
}
}
export async function refreshSystemStatusNow(event) {
if (event) {
event.preventDefault();
event.stopPropagation();
}
// 手动刷新使用独立的 AbortController,避免与递归调度的 pollStatus 互相干扰
var manualAbort = new AbortController();
var manualTimer = setTimeout(function() { try { manualAbort.abort(); } catch(_e) {} }, 3000);
try {
var resp = await fetch(window.getBaseUrl() + '/system/status', {
signal: manualAbort.signal
});
if (!resp.ok) {
addLog('E', '状态', '刷新失败: HTTP ' + resp.status);
showToast('状态刷新失败: HTTP ' + resp.status, 'error');
return;
}
var data = await resp.json();
window.latestSystemStatus = data;
window.updateSystemStatusUi(data);
window.consecutivePollFailures = 0;
addLog('I', '状态', '已手动刷新 /system/status');
} catch (e) {
addLog('E', '状态', '刷新失败: ' + e.message);
showToast('状态刷新失败', 'error');
} finally {
clearTimeout(manualTimer);
}
}
+70
View File
@@ -0,0 +1,70 @@
/**
* log.js - 日志系统(带去重 + 自动滚动)
*
* 从 app.js 提取的日志环形缓冲、去重、过滤、自动滚动逻辑。
* 操作 DOM 元素 #logScroll / #logCount / #autoScrollBtn / .log-filter。
*/
import { formatTime } from './utils.js';
// ==================== 日志系统状态 ====================
let logFilter = 'all';
let logEntries = [];
let lastLogHash = null;
let autoScroll = true;
// ==================== 日志系统 (带去重 + 自动滚动) ====================
function makeLogHash(level, tag, message) {
return level + '|' + tag + '|' + message;
}
/** @description 添加一条日志条目到环形缓冲区和日志面板。 */
export function addLog(level, tag, message) {
var scroll = document.getElementById('logScroll');
var time = formatTime(new Date());
var hash = makeLogHash(level, tag, message);
if (hash === lastLogHash) return; // 去重
lastLogHash = hash;
var entry = { time: time, level: level, tag: tag, message: message };
logEntries.push(entry);
if (logFilter !== 'all' && level !== logFilter) return;
var div = document.createElement('div');
div.className = 'log-line';
div.innerHTML =
'<span class="lt">[' + time + ']</span>' +
'<span class="lv ' + level + '">' + level + '</span>' +
'<span class="lg">[' + tag + ']</span>' +
'<span class="lm">' + message + '</span>';
scroll.appendChild(div);
if (autoScroll) scroll.scrollTop = scroll.scrollHeight;
document.getElementById('logCount').textContent = logEntries.length;
}
export function clearLogs() {
document.getElementById('logScroll').innerHTML = '';
logEntries = [];
lastLogHash = null;
document.getElementById('logCount').textContent = '0';
}
export function setLogFilter(level, btn) {
document.querySelectorAll('.log-filter').forEach(function (b) { b.classList.remove('active'); });
btn.classList.add('active');
logFilter = level;
var scroll = document.getElementById('logScroll');
scroll.innerHTML = '';
logEntries.forEach(function (e) {
if (level === 'all' || e.level === level) {
var div = document.createElement('div');
div.className = 'log-line';
div.innerHTML =
'<span class="lt">[' + e.time + ']</span>' +
'<span class="lv ' + e.level + '">' + e.level + '</span>' +
'<span class="lg">[' + e.tag + ']</span>' +
'<span class="lm">' + e.message + '</span>';
scroll.appendChild(div);
}
});
if (autoScroll) scroll.scrollTop = scroll.scrollHeight;
}
export function toggleAutoScroll() {
autoScroll = !autoScroll;
var btn = document.getElementById('autoScrollBtn');
btn.textContent = autoScroll ? '\uD83D\uDCCC 自动滚动' : '\u23F8\uFE0F 暂停滚动';
}
+363
View File
@@ -0,0 +1,363 @@
/**
* polling.js - 轮询状态 + Page Visibility 补偿 + ESP32 实时日志拉取
*
* 从 app.js 的「轮询状态」「Page Visibility 补偿」「ESP32 实时日志拉取 (增量)」段提取。
* 递归 setTimeout 调度 /system/status + setInterval 调度 /logs,避免 setInterval 慢响应被 abort。
*
* 模块级状态(仅本模块使用,不导出):
* statusTimerId / logsTimerId / statusAbortCtrl / statusPollInFlight / statusPollIntervalMs
* lastDiagTime / diagResult / uploadPollChip / lastLogTimestamp / pageVisible
*
* 共享状态(通过 window.* 引用,后续 app.js 拆分时会统一挂载):
* 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 目标
*
* 未拆分的 app.js 函数(通过 window.* 引用):
* window.getEspIp / window.getBaseUrl
* window.updateSystemStatusUi / window.updateEsp32StagesFromPhase
* window.applyRebootingRuntimeState / window.applyDisconnectedRuntimeState / window.applyUploadingRuntimeState
* window.otaSessionActive / window.otaSessionTransition / window.otaSessionReset
* window.updateUploadButtonLabel / window.updateSpiHint
*/
import { addLog } from './log.js';
import { showToast } from './toast.js';
import { updateConnectionUi } from './connection.js';
// ==================== 轮询状态 ====================
// 共享状态(初始化到 window,供 connection.js 等其他模块读写)
window.consecutivePollFailures = 0;
window.lastSysStateCode = -1; // 上次成功的 sys_state_code,用于失败时判断设备是否在忙
window.lastRelayState = 0; // 上次成功的 relay_state
// 模块级状态(仅本模块使用)
var statusTimerId = null;
var logsTimerId = null;
var lastDiagTime = 0; // 上次诊断时间
var diagResult = ''; // 最近一次诊断结果
var statusAbortCtrl = null; // 当前 /system/status 请求的 AbortController(3s 超时用)
var statusPollIntervalMs = 1000; // 状态轮询目标间隔
var statusPollInFlight = false; // 是否正在请求 /system/status,防止重叠
var pageVisible = !document.hidden; // 页面可见性:切到后台/最小化/切标签会变 false
var uploadPollChip = null; // 当前暂停轮询的上传目标,用于区分 WiFi/日志策略
// 从设备 IP 推导网关 IP(取前三段 + .1)
function getGatewayIp() {
var parts = window.getEspIp().split('.');
if (parts.length === 4) return parts[0] + '.' + parts[1] + '.' + parts[2] + '.1';
return '192.168.31.1';
}
// 网络诊断:区分浏览器离线 / 网关不通 / 仅设备不通
async function diagnoseNetwork() {
lastDiagTime = Date.now();
var result = [];
// 1) 浏览器是否在线
if (navigator.onLine === false) {
result.push('浏览器无网络连接');
diagResult = result.join(';');
return diagResult;
}
// 2) 尝试触达网关(超时 2s)
var gwIp = getGatewayIp();
var gwOk = false;
try {
await fetch('http://' + gwIp + '/', { mode: 'no-cors', signal: AbortSignal.timeout(2000) });
gwOk = true; // no-cors 模式下不抛异常即视为可达
} catch (_) {}
if (!gwOk) {
// 再试一次 favicon(有些路由器不响应 /)
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 + ' 可达');
// 3) 网关通但设备不通 → 设备自身问题
result.push('设备 ' + window.getEspIp() + ' 不响应(可能断电、重启中或 WiFi 断开)');
}
diagResult = result.join(';');
return diagResult;
}
/** @description 调度下一次 /system/status 轮询(递归 setTimeout)。
* 上一次请求完成(或 3s 超时)后才调度下一次,避免 setInterval 把慢响应
* 直接 abort 而导致堆/任务栈/CPU 等卡片刷新率不稳。
*/
export function scheduleStatusPoll(delayMs) {
if (statusTimerId) clearTimeout(statusTimerId);
// 中继期加快:进度条依赖 status.relay_seq,1s 间隔体感像卡死
var interval = statusPollIntervalMs;
// 防御性守卫:window.OtaSessionState 可能尚未挂载(模块加载早期),此时不加速
if (window.OtaSessionState && window.otaSessionState === window.OtaSessionState.RELAYING) {
interval = Math.min(interval, 400);
}
statusTimerId = setTimeout(function() {
statusTimerId = null;
pollStatus();
}, delayMs !== undefined ? delayMs : interval);
}
/** @description 启动定时轮询(状态 + 日志)。 */
export function startPolling() {
window.consecutivePollFailures = 0;
statusPollIntervalMs = 1000;
if (statusTimerId) clearTimeout(statusTimerId);
if (logsTimerId) clearInterval(logsTimerId);
scheduleStatusPoll(0); // 立即开始第一次,完成后按 interval 递归调度
logsTimerId = setInterval(pollLogs, 1000);
}
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) {
clearInterval(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) clearInterval(logsTimerId);
logsTimerId = setInterval(pollLogs, statusPollIntervalMs);
// 上传结束立即刷新一次状态;若当前已在请求中,则让它完成后按新间隔继续
if (statusPollInFlight) {
if (statusTimerId) clearTimeout(statusTimerId);
} else {
scheduleStatusPoll(0);
}
pollLogs();
}
/** @description 轮询 ESP32 系统状态(GET /status)并更新 UI。 */
export async function pollStatus() {
// 递归 setTimeout 调度,正常情况下不会重叠;做个保险避免并发请求
if (statusPollInFlight) return;
statusPollInFlight = true;
statusAbortCtrl = new AbortController();
var pollTimer = setTimeout(function() { try { statusAbortCtrl.abort(); } catch(_e) {} }, 3000);
try {
var resp = await fetch(window.getBaseUrl() + '/system/status', {
signal: statusAbortCtrl.signal
});
if (resp.ok) {
var data = await resp.json();
// 恢复:之前有失败计数,说明设备曾离线,现在恢复在线
if (window.consecutivePollFailures > 0) {
window.consecutivePollFailures = 0;
addLog('I', '系统', '设备已恢复在线,轮询恢复正常');
showToast('设备已恢复在线', 'success');
// 设备离线后恢复在线,清理残留的 OTA 会话状态
if (window.otaSessionState !== window.OtaSessionState.IDLE) {
window.otaSessionReset();
addLog('I', '系统', '已清理 OTA 会话状态');
}
}
if (window.isEsp32Rebooting) {
var rbElapsed = Math.round((Date.now() - window.rebootStartTime) / 1000);
if (!window.rebootObservedDown && rbElapsed < 10) {
// pre-reboot 窗口:后端发响应后约 1s 才 esp_restart,期间设备仍响应轮询
// 保持"重启中"状态,不清除标志,不推进阶段
// ★ 真正的"重启完成"由 pollLogs 收到启动 banner(MAIN: ESP32-C3 OTA Relay v1.0 启动)触发
} else {
// 设备已重启并恢复在线(rebootObservedDown=true)
// 或超 10s 安全阈值仍未观察到下线(重启过快未捕获或未重启)——接受恢复
window.isEsp32Rebooting = false;
window.rebootObservedDown = false;
window.updateEsp32StagesFromPhase(6); // 启动完成
addLog('I', '系统', 'ESP32 已恢复在线');
showToast('ESP32 已恢复在线', 'success');
// ★ 状态机:ESP32 重启完成 → DONE
if (window.otaSessionState === window.OtaSessionState.REBOOTING) window.otaSessionTransition(window.OtaSessionState.DONE);
}
}
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;
// 后台期间 setInterval 会被 Chrome/Edge 节流到最低 1 分钟/次,
// 因此后台累积的失败计数不应递增,避免切回前台瞬间误触发 5-fail 诊断。
if (pageVisible) window.consecutivePollFailures++;
// 判断设备是否处于"已知忙/重启"状态(依据最近一次成功轮询的 sys_state_code):
// 1=GD32_STORING, 3=GD32_RELAYING, 5=ESP32_RECEIVING,
// 6=ESP32_FINALIZING, 7=ESP32_REBOOTING
// 也包含 relay_state 非 IDLE(1-4 表示握手/协商/传输/收尾)
var lastDeviceBusy =
window.lastSysStateCode === 1 || window.lastSysStateCode === 3 ||
window.lastSysStateCode === 5 || window.lastSysStateCode === 6 ||
window.lastSysStateCode === 7 ||
(window.lastRelayState >= 1 && window.lastRelayState <= 4);
// OTA/传输/重启中屏蔽网关无响应的检测功能(设备忙或正在重启,无法响应轮询是正常的)
// 设备已知忙时:保持 1s 轮询,不触发降速诊断,避免错过设备恢复的即时反馈
// ★ 状态机:用统一的会话活跃/失败判断替代散布的标志。
// FAILED 态不触发诊断降速(让用户尽快看到设备恢复),但也不覆盖失败 UI。
var sessionActive = window.otaSessionActive();
var sessionFailed = window.otaSessionState === window.OtaSessionState.FAILED;
if (pageVisible && window.consecutivePollFailures >= 5 && !sessionActive && !window.isEsp32Rebooting && !window.currentOtaTarget && !lastDeviceBusy && !sessionFailed) {
addLog('W', '诊断', '设备连续 5 次无响应,正在诊断网络...');
var diag = await diagnoseNetwork();
addLog('W', '诊断', diag);
updateConnectionUi(false, '未连接');
window.applyDisconnectedRuntimeState();
var cst = document.getElementById('connStatusTxt');
if (cst) cst.textContent = '诊断中: ' + diag;
}
// ★ 状态机优先:会话已 FAILED → 保持失败 UI,仅标连接胶囊为"设备忙/未连接"
if (sessionFailed) {
if (lastDeviceBusy) {
// 设备端仍在跑(中继没停),标"设备忙"但不覆盖进度/阶段
var pcF = document.getElementById('pillConn');
var pctF = document.getElementById('pillConnTxt');
if (pcF) pcF.className = 'pill processing';
if (pctF) pctF.textContent = '设备忙';
var csF = document.getElementById('connStatus');
var cstF = document.getElementById('connStatusTxt');
if (csF) csF.className = 'conn-indicator warn';
if (cstF) cstF.textContent = '设备忙(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) {
window.otaSessionTransition(window.OtaSessionState.FAILED, 'ESP32 重启超时');
window.isEsp32Rebooting = false;
window.rebootObservedDown = false;
addLog('E', '系统', 'ESP32 重启超时,设备未恢复');
showToast('ESP32 重启超时,请检查设备', 'error');
updateConnectionUi(false, '未连接');
window.applyDisconnectedRuntimeState();
} else {
window.applyRebootingRuntimeState();
}
} else if (sessionActive || lastDeviceBusy) {
// 设备在 OTA/中继/重启期间不响应是正常的:保持 1s 轮询,UI 标记为"设备忙"
// 不调用 applyRebootingRuntimeState(避免显示"重启中 Xs"误导)
var pc = document.getElementById('pillConn');
var pct = document.getElementById('pillConnTxt');
if (pc) pc.className = 'pill processing';
if (pct) pct.textContent = '设备忙';
var cs = document.getElementById('connStatus');
var cst = document.getElementById('connStatusTxt');
if (cs) cs.className = 'conn-indicator warn';
if (cst) cst.textContent = '设备忙(OTA/重启中),保持 1s 轮询';
updateConnectionUi(true, '设备忙');
} else {
updateConnectionUi(false, '未连接');
window.applyDisconnectedRuntimeState();
}
} finally {
statusPollInFlight = false;
scheduleStatusPoll();
}
}
// 注意: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 实时日志拉取 (增量) ====================
var lastLogTimestamp = 0;
/** @description 轮询 ESP32 日志(GET /logs)并追加到日志面板。 */
export async function pollLogs() {
// ★ ESP32 上传期间也允许日志轮询
try {
var resp = await fetch(window.getBaseUrl() + '/logs', {
signal: AbortSignal.timeout(2000)
});
if (!resp.ok) return;
var logs = await resp.json();
if (!Array.isArray(logs) || logs.length === 0) return;
var levelMap = { E: 'E', W: 'W', I: 'I', D: 'D' };
logs.forEach(function (line) {
var m = line.match(/^\[([EWID])\]\[([^\]]+)\]\s*(.*)$/);
if (m) {
addLog(levelMap[m[1]] || 'I', m[2], m[3].trimEnd());
// ★ 复用设备启动 banner 作为 ESP32 重启完成的权威信号。
// esp_restart() 是硬件复位,不保证 TCP FIN/RST;靠轮询超时/网络错误判断
// "已下线"不可靠。设备启动后会推 [I][MAIN] ESP32-C3 OTA Relay v1.0 启动,
// 收到这行即说明设备已重启回来,直接推进到阶段 6(启动完成)。
if (window.isEsp32Rebooting && m[2] === 'MAIN' && /OTA Relay v1\.0 启动/.test(m[3])) {
window.isEsp32Rebooting = false;
window.rebootObservedDown = false;
window.updateEsp32StagesFromPhase(6); // 启动完成
addLog('I', '系统', 'ESP32 已恢复在线(启动 banner 收到)');
showToast('ESP32 已恢复在线', 'success');
if (window.otaSessionState === window.OtaSessionState.REBOOTING) window.otaSessionTransition(window.OtaSessionState.DONE);
}
} else {
addLog('I', 'ESP32', line.trimEnd());
}
});
} catch (e) { /* 静默 */ }
}
setTimeout(function () {
window.updateUploadButtonLabel();
addLog('I', '系统', '控制台初始化完成,等待固件文件...');
window.updateSpiHint();
// 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);
+33
View File
@@ -0,0 +1,33 @@
/**
* toast.js - Toast 提示组件
*
* 从 app.js 提取的 showToast 函数,操作 DOM 元素 #toast / #toastIco / #toastMsg。
* 支持 info / success / warning / error 类型,内置 5s 去重防抖。
*/
/** @description 显示 Toast 提示消息,支持 info/success/warning/error 类型。 */
var _toastHideTimer = null;
var _toastLastKey = '';
var _toastLastAt = 0;
export function showToast(message, type) {
type = type || 'success';
// 相同文案 5s 内不重复弹,避免轮询把 ✓ 刷成「疯狂跳」
var key = type + '|' + message;
var now = Date.now();
if (key === _toastLastKey && (now - _toastLastAt) < 5000) {
return;
}
_toastLastKey = key;
_toastLastAt = now;
const toast = document.getElementById('toast');
const ico = document.getElementById('toastIco');
const msg = document.getElementById('toastMsg');
toast.className = 'toast ' + (type === 'success' ? 'ok' : type === 'error' ? 'err' : 'warn') + ' show';
ico.textContent = type === 'success' ? '\u2713' : type === 'error' ? '\u2715' : '\u26A0';
msg.textContent = message;
if (_toastHideTimer) clearTimeout(_toastHideTimer);
_toastHideTimer = setTimeout(function () {
toast.classList.remove('show');
_toastHideTimer = null;
}, 3000);
}
+41
View File
@@ -0,0 +1,41 @@
/**
* utils.js - 通用工具函数
*
* 从 app.js 提取的格式化 / 转义工具函数,供各子模块和 can.js / can_monitor.js 共享。
*/
// ==================== 工具函数 ====================
/** @description 格式化字节大小为人类可读字符串(B/KB/MB)。 */
export function formatBytes(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(2) + ' MB';
}
/** @description 格式化 Date 对象为 HH:MM:SS.mmm 时间字符串。 */
export function formatTime(date) {
var h = String(date.getHours()).padStart(2, '0');
var m = String(date.getMinutes()).padStart(2, '0');
var s = String(date.getSeconds()).padStart(2, '0');
var ms = String(date.getMilliseconds()).padStart(3, '0');
return h + ':' + m + ':' + s + '.' + ms;
}
/** @description 格式化秒数为人类可读运行时间(d/h/m/s)。 */
export function formatUptime(sec) {
if (!Number.isFinite(sec) || sec < 0) return '--';
const d = Math.floor(sec / 86400);
const h = Math.floor((sec % 86400) / 3600);
const m = Math.floor((sec % 3600) / 60);
const s = Math.floor(sec % 60);
if (d > 0) return d + 'd ' + h + 'h ' + m + 'm';
if (h > 0) return h + 'h ' + m + 'm ' + s + 's';
if (m > 0) return m + 'm ' + s + 's';
return s + 's';
}
/** @description 转义 HTML 特殊字符,防止 XSS 注入。 */
export function escapeHtml(text) {
return String(text == null ? '' : text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
+273
View File
@@ -0,0 +1,273 @@
/**
* console-mode.js - 共享模式/目标切换胶水代码(从 index.html 内联脚本抽取)
*
* 职责:
* - 覆盖 app.js 中的 switchStageFlow,支持 BLE 阶段流程
* - 提供 setConsoleMode / selectFlashTarget 等供 HTML onclick 调用
* - 维护共享固件文件状态 (sharedFirmwareFiles) 并在 WiFi/BLE 间同步
*
* 依赖:app.js (通过 window.* 或 bare 引用) 提供 switchStageFlow、
* handleFileSelect、clearFile、pickFileViaFSAPI、addLog、showToast、setActivePhase 等。
*/
window.connectionMode = window.connectionMode || 'wifi';
window.flashTarget = window.flashTarget || 'esp32';
window.sharedFirmwareFiles = window.sharedFirmwareFiles || {
esp32: { file: null, handle: null },
gd32: { file: null, handle: null }
};
(function () {
var originalSwitchStageFlow = window.switchStageFlow;
window.switchStageFlow = function (target) {
if (target === 'ble') {
window.currentOtaTarget = 'ble';
var esp32El = document.getElementById('esp32Stages');
var gd32El = document.getElementById('gd32Stages');
var bleEl = document.getElementById('bleStages');
if (esp32El) esp32El.style.display = 'none';
if (gd32El) gd32El.style.display = 'none';
if (bleEl) bleEl.style.display = '';
return;
}
if (typeof originalSwitchStageFlow === 'function') {
originalSwitchStageFlow(target);
}
var bleEl = document.getElementById('bleStages');
if (bleEl) bleEl.style.display = 'none';
};
window.setConsoleMode = function (mode) {
// ★ 刷写进行中禁止切换连接模式:setConsoleMode → syncBleFileFromSelectedTarget →
// notifyFileChange → updateFileInfo → finalizeFileInfo → setProgress(0) 会覆盖
// 进行中的 WiFi/BLE 上传进度(与 selectFlashTarget 同样的守卫策略)。
var bleBusy = window.bleOta && window.bleOta.isUploading && window.bleOta.isUploading();
if (window.isUploading || bleBusy) {
var active = bleBusy ? 'BLE' : ((window.activeUploadChip || 'OTA').toUpperCase());
if (typeof addLog === 'function') addLog('W', 'OTA', active + ' 刷写进行中,无法切换连接模式');
if (typeof showToast === 'function') showToast('当前有 ' + active + ' 任务在运行,请等待结束后再切换', 'error');
return;
}
var isBle = mode === 'ble';
window.connectionMode = isBle ? 'ble' : 'wifi';
var wifiCards = document.getElementById('wifiModeCards');
var bleCards = document.getElementById('bleModeCards');
var wifiBtn = document.getElementById('modeWifiBtn');
var bleBtn = document.getElementById('modeBleBtn');
if (wifiCards) wifiCards.style.display = isBle ? 'none' : '';
if (bleCards) bleCards.style.display = isBle ? '' : 'none';
var headBtns = document.getElementById('bleHeadActions');
if (headBtns) headBtns.style.display = isBle ? '' : 'none';
if (wifiBtn) wifiBtn.className = isBle ? 'btn btn-outline' : 'btn btn-primary';
if (bleBtn) bleBtn.className = isBle ? 'btn btn-primary' : 'btn btn-outline';
applySharedModeUi();
syncBleFileFromSelectedTarget();
if (isBle) {
window.switchStageFlow('ble');
if (typeof setActivePhase === 'function') {
setActivePhase(window.flashTarget === 'gd32' ? 'gd32Ota' : 'esp32Ota');
}
if (window.bleOta && typeof window.bleOta.refreshButtons === 'function') {
window.bleOta.refreshButtons();
}
} else {
window.switchStageFlow(window.flashTarget || (window.activePhase === 'esp32Ota' ? 'esp32' : 'gd32'));
}
};
window.selectFlashTarget = function (target) {
// OTA 上传进行中禁止切换目标:避免前端状态与后端 relay/upload 不同步导致卡死
// ★ 兼容 BLE 模式:BLE 上传用 bleOta.state.uploading,window.isUploading 仅 WiFi 通道使用。
var bleBusy = window.bleOta && window.bleOta.isUploading && window.bleOta.isUploading();
if (window.isUploading || bleBusy) {
var active = bleBusy ? 'BLE' : ((window.activeUploadChip || 'OTA').toUpperCase());
if (typeof addLog === 'function') addLog('W', 'OTA', active + ' 刷写进行中,无法切换目标');
if (typeof showToast === 'function') showToast('当前有 ' + active + ' 任务在运行,请等待结束后再切换', 'error');
return;
}
window.flashTarget = target === 'gd32' ? 'gd32' : 'esp32';
applySharedModeUi();
syncBleFileFromSelectedTarget();
if (window.connectionMode === 'ble') {
window.switchStageFlow('ble');
if (typeof setActivePhase === 'function') {
setActivePhase(target === 'gd32' ? 'gd32Ota' : 'esp32Ota');
}
} else {
window.switchStageFlow(window.flashTarget);
}
if (window.bleOta && typeof window.bleOta.refreshTargetUi === 'function') {
window.bleOta.refreshTargetUi();
}
};
window.handleSharedFileSelect = function (event, chip) {
var file = event && event.target && event.target.files && event.target.files[0] ? event.target.files[0] : null;
if (file) {
window.sharedFirmwareFiles[chip] = { file: file, handle: null };
}
if (typeof window.handleFileSelect === 'function') {
window.handleFileSelect(event, chip);
}
window.flashTarget = chip === 'gd32' ? 'gd32' : 'esp32';
applySharedModeUi();
setTimeout(syncBleFileFromSelectedTarget, 0);
};
window.pickSharedFirmware = function (chip) {
window.flashTarget = chip === 'gd32' ? 'gd32' : 'esp32';
applySharedModeUi();
if (window.showOpenFilePicker && typeof window.pickFileViaFSAPI === 'function') {
var nativePicker = window.showOpenFilePicker;
var capturedHandle = null;
window.showOpenFilePicker = function () {
return nativePicker.apply(this, arguments).then(function (handles) {
capturedHandle = handles && handles.length ? handles[0] : null;
return handles;
});
};
Promise.resolve(window.pickFileViaFSAPI(chip)).then(function () {
window.showOpenFilePicker = nativePicker;
if (!capturedHandle || typeof capturedHandle.getFile !== 'function') {
syncBleFileFromSelectedTarget();
return;
}
capturedHandle.getFile().then(function (file) {
window.sharedFirmwareFiles[chip] = { file: file, handle: capturedHandle };
syncBleFileFromSelectedTarget();
}).catch(function () {
syncBleFileFromSelectedTarget();
});
}).catch(function (err) {
window.showOpenFilePicker = nativePicker;
if (err && err.name !== 'AbortError' && typeof window.addLog === 'function') {
window.addLog('W', '文件', 'FSAPI 选择失败: ' + err.message);
}
});
} else {
var input = document.getElementById(chip === 'gd32' ? 'fileInputGd32' : 'fileInputEsp32');
if (input) input.click();
}
};
window.clearSharedFirmware = function (chip) {
window.sharedFirmwareFiles[chip] = { file: null, handle: null };
if (typeof window.clearFile === 'function') {
window.clearFile(chip);
}
if (window.connectionMode === 'ble' && window.flashTarget === chip && window.bleOta && typeof window.bleOta.clearFile === 'function') {
window.bleOta.clearFile();
}
};
window.setBleFirmwareInputFile = function (file) {
var input = document.getElementById('firmwareFile');
if (!input || !file || typeof window.DataTransfer !== 'function') {
return;
}
try {
var dt = new DataTransfer();
dt.items.add(file);
input.files = dt.files;
} catch (err) {
// Some browsers restrict assignment to file inputs; BLE state is still synced via notifyFileChange.
}
};
window.syncBleFileFromSelectedTarget = function () {
if (window.connectionMode !== 'ble' || !window.bleOta || typeof window.bleOta.notifyFileChange !== 'function') {
return;
}
var isGd32 = window.flashTarget === 'gd32';
var chip = isGd32 ? 'gd32' : 'esp32';
var suffix = isGd32 ? 'Gd32' : 'Esp32';
var stored = window.sharedFirmwareFiles[chip];
if (stored && stored.handle && typeof stored.handle.getFile === 'function') {
stored.handle.getFile().then(function (file) {
stored.file = file;
setBleFirmwareInputFile(file);
window.bleOta.notifyFileChange(file, stored.handle);
}).catch(function () {
if (stored.file) {
setBleFirmwareInputFile(stored.file);
window.bleOta.notifyFileChange(stored.file, stored.handle);
}
});
return;
}
if (stored && stored.file) {
setBleFirmwareInputFile(stored.file);
window.bleOta.notifyFileChange(stored.file, stored.handle || null);
return;
}
var input = document.getElementById('fileInput' + suffix);
var info = document.getElementById('fileInfo' + suffix);
var hasInfo = info && info.classList.contains('show');
var file = input && input.files && input.files[0] ? input.files[0] : null;
if (file) {
window.sharedFirmwareFiles[chip] = { file: file, handle: null };
setBleFirmwareInputFile(file);
window.bleOta.notifyFileChange(file, null);
return;
}
if (!hasInfo) {
return;
}
var nameEl = document.getElementById('fileName' + suffix);
var typeEl = document.getElementById('fileType' + suffix);
var sizeEl = document.getElementById('fileSize' + suffix);
var bleInfo = document.getElementById('fileInfo');
var bleName = document.getElementById('fileName');
var bleType = document.getElementById('fileType');
var bleSize = document.getElementById('fileSize');
if (bleInfo) bleInfo.classList.add('show');
if (bleName && nameEl) bleName.textContent = nameEl.textContent;
if (bleType && typeEl) bleType.textContent = typeEl.textContent;
if (bleSize && sizeEl) bleSize.textContent = sizeEl.textContent;
var packetCount = document.getElementById('packetCount');
var sentCount = document.getElementById('sentCount');
if (packetCount) packetCount.textContent = '-';
if (sentCount) sentCount.textContent = '0';
};
window.applySharedModeUi = function () {
var isBle = window.connectionMode === 'ble';
var isGd32 = window.flashTarget === 'gd32';
var targetEsp32 = document.getElementById('targetEsp32Tab');
var targetGd32 = document.getElementById('targetGd32Tab');
if (targetEsp32) targetEsp32.className = isGd32 ? 'btn btn-outline' : 'btn btn-primary';
if (targetGd32) targetGd32.className = isGd32 ? 'btn btn-primary' : 'btn btn-outline';
var ids = {
wifiEsp32UploadPane: !isGd32,
wifiGd32UploadPane: isGd32,
bleUploadPane: false,
wifiEsp32ActionPane: !isBle && !isGd32,
wifiGd32ActionPane: !isBle && isGd32,
bleActionPane: isBle
};
Object.keys(ids).forEach(function (id) {
var el = document.getElementById(id);
if (el) el.style.display = ids[id] ? '' : 'none';
});
var hint = document.getElementById('readyHint');
if (hint) {
if (isBle) {
hint.innerHTML = 'BLE 模式:连接设备 → 选择固件 → 一键刷写。';
} else if (isGd32) {
hint.innerHTML = 'WiFi 模式:上传 GD32 固件后自动执行 SPI OTA。';
} else {
hint.innerHTML = 'WiFi 模式:上传 ESP32 固件后自动执行 OTA 并重启。';
}
}
};
applySharedModeUi();
})();
// 暴露给 HTML onclick 调用的函数(ES Module 迁移:模块作用域不再自动成为全局变量)
window.setConsoleMode = setConsoleMode;
window.selectFlashTarget = selectFlashTarget;
window.handleSharedFileSelect = handleSharedFileSelect;
window.pickSharedFirmware = pickSharedFirmware;
window.clearSharedFirmware = clearSharedFirmware;
window.setBleFirmwareInputFile = setBleFirmwareInputFile;
window.syncBleFileFromSelectedTarget = syncBleFileFromSelectedTarget;
window.applySharedModeUi = applySharedModeUi;
+263
View File
@@ -0,0 +1,263 @@
/**
* file-handle.js - 文件处理 + Intel HEX → BIN 转换
*
* 从 app.js 的「文件处理」「Intel HEX → BIN 转换」「文件处理」段提取。
* 包含:HEX 解析、文件选择、FSAPI、SHA-256 计算、文件信息 UI、清除。
*
* 共享状态(通过 window.* 引用):
* window.esp32File / window.gd32File / window.esp32FileHandle / window.gd32FileHandle
* window._esp32FileSha256 / window._gd32FileSha256
*
* 未拆分的函数(通过 window.* 引用):
* window.updateUploadButtonLabel / window.showToast / window.addLog
*/
import { formatBytes } from '../common/utils.js';
import { showToast } from '../common/toast.js';
import { addLog } from '../common/log.js';
// ==================== 文件处理 ====================
// 文件状态(模块级,但被 upload-esp32/upload-gd32 通过 window.* 读写)
window.esp32File = null;
window.gd32File = null;
window.esp32FileHandle = null; // FileSystemFileHandle for hot reload
window.gd32FileHandle = null; // FileSystemFileHandle for hot reload
export function handleFileSelect(event, chip) {
var file = event.target.files[0];
if (file) processFile(file, chip);
}
// 通过 File System Access API 选择文件(支持热重载)
export async function pickFileViaFSAPI(chip) {
if (!window.showOpenFilePicker) {
addLog('W', '文件', '当前浏览器不支持 File System Access API,请手动重新选择文件');
return false;
}
try {
var acceptMap = chip === 'esp32'
? { 'application/octet-stream': ['.bin'] }
: { 'application/octet-stream': ['.bin', '.hex'] };
var handles = await window.showOpenFilePicker({
types: [{ description: '固件文件', accept: acceptMap }],
multiple: false
});
if (handles && handles.length > 0) {
var handle = handles[0];
var file = await handle.getFile();
if (chip === 'esp32') window.esp32FileHandle = handle;
else window.gd32FileHandle = handle;
processFile(file, chip);
addLog('I', '文件', '已通过 FSAPI 选择文件(支持热重载): ' + file.name);
return true;
}
} catch (e) {
if (e.name !== 'AbortError') {
addLog('W', '文件', 'FSAPI 选择失败: ' + e.message);
}
}
return false;
}
export function setupDropZone(zoneId, chip) {
var zone = document.getElementById(zoneId);
if (!zone) return;
zone.addEventListener('dragover', function (e) {
e.preventDefault(); e.currentTarget.classList.add('dragover');
});
zone.addEventListener('dragleave', function (e) {
e.currentTarget.classList.remove('dragover');
});
zone.addEventListener('drop', function (e) {
e.preventDefault(); e.currentTarget.classList.remove('dragover');
var file = e.dataTransfer.files[0];
if (file) processFile(file, chip);
});
}
// ==================== Intel HEX → BIN 转换 ====================
/**
* 将 Intel HEX 字符串解析为原始 BIN 字节数组。
*
* 支持记录类型: 00(数据), 01(EOF), 02(扩展段地址), 04(扩展线性地址)。
* 地址间隙填充 0xFF(Flash 擦除态)。
*
* @param {string} hexText Intel HEX 文件的完整文本内容。
* @returns {{ data: Uint8Array, baseAddr: number, endAddr: number }}
*/
export function parseIntelHex(hexText) {
var baseAddr = 0; // 当前扩展地址
var segments = []; // { offset, data } 列表
var minAddr = 0xFFFFFFFF;
var maxAddr = 0;
var lines = hexText.split(/\r?\n/);
for (var i = 0; i < lines.length; i++) {
var line = lines[i].trim();
if (!line || line[0] !== ':') continue;
// 解析行: :LLAAAATT[DD...]CC
var byteCount = parseInt(line.substr(1, 2), 16);
var addr16 = parseInt(line.substr(3, 4), 16);
var recType = parseInt(line.substr(7, 2), 16);
// 校验和验证
var sum = 0;
for (var j = 1; j < line.length; j += 2) {
sum += parseInt(line.substr(j, 2), 16);
}
if ((sum & 0xFF) !== 0) {
throw new Error('HEX 行 ' + (i + 1) + ' 校验和错误: ' + line);
}
if (recType === 0x01) {
// EOF
break;
} else if (recType === 0x04) {
// 扩展线性地址 (upper 16 bits)
baseAddr = parseInt(line.substr(9, 4), 16) << 16;
} else if (recType === 0x02) {
// 扩展段地址
baseAddr = (parseInt(line.substr(9, 4), 16) << 4) & 0xFFFFF;
} else if (recType === 0x00) {
// 数据记录
var absAddr = baseAddr + addr16;
var data = new Uint8Array(byteCount);
for (var k = 0; k < byteCount; k++) {
data[k] = parseInt(line.substr(9 + k * 2, 2), 16);
}
segments.push({ offset: absAddr, data: data });
if (absAddr < minAddr) minAddr = absAddr;
var endAddr = absAddr + byteCount;
if (endAddr > maxAddr) maxAddr = endAddr;
}
// 其他记录类型 (03, 05 等) 忽略
}
if (segments.length === 0) {
throw new Error('HEX 文件中没有有效数据记录');
}
// 合并为连续 bin,间隙填 0xFF
var totalSize = maxAddr - minAddr;
var bin = new Uint8Array(totalSize);
// 先填充 0xFF
for (var m = 0; m < totalSize; m++) bin[m] = 0xFF;
for (var s = 0; s < segments.length; s++) {
var seg = segments[s];
var off = seg.offset - minAddr;
bin.set(seg.data, off);
}
return { data: bin, baseAddr: minAddr, endAddr: maxAddr };
}
/**
* 检测文件内容是否为 Intel HEX 格式。
* 看前几个非空行是否以 ':' 开头。
*/
export function isIntelHexText(text) {
var lines = text.split(/\r?\n/);
for (var i = 0; i < Math.min(lines.length, 5); i++) {
var line = lines[i].trim();
if (!line) continue;
return line[0] === ':';
}
return false;
}
/**
* 公共 HEX→BIN 转换:如果文件是 .hex 格式,自动解析为 bin。
* BLE / WiFi 共用此函数,避免转换逻辑重复。
* @param {File} file 原始文件
* @returns {Promise<{file: File, converted: boolean, baseAddr: number|null}>}
*/
export async function convertHexFileToBin(file) {
if (!file || !file.name.toLowerCase().endsWith('.hex')) {
return { file: file, converted: false, baseAddr: null };
}
var text = await file.text();
if (!isIntelHexText(text)) {
return { file: file, converted: false, baseAddr: null };
}
var result = parseIntelHex(text);
var binName = file.name.replace(/\.hex$/i, '.bin');
var binBlob = new Blob([result.data], { type: 'application/octet-stream' });
var binFile = new File([binBlob], binName, { type: 'application/octet-stream', lastModified: file.lastModified });
return { file: binFile, converted: true, baseAddr: result.baseAddr, endAddr: result.endAddr };
}
// ==================== 文件处理 ====================
/** @description 处理用户选择的固件文件(HEX→BIN 转换、SHA-256 计算、文件校验)。 */
export function processFile(file, chip) {
var suffix = chip === 'esp32' ? 'Esp32' : 'Gd32';
var isHexByName = file.name.toLowerCase().endsWith('.hex');
if (chip === 'gd32' && isHexByName) {
// GD32 固件: .hex 文件自动转换为 .bin,然后替换文件对象
convertHexFileToBin(file).then(function(result) {
if (!result.converted) {
// 文件名 .hex 但内容不像,直接用原始文件
addLog('W', '文件', file.name + ' 扩展名为 .hex 但内容不像 HEX 格式,按原始文件处理');
window.gd32File = file;
document.getElementById('fileInfo' + suffix).classList.add('show');
document.getElementById('fileName' + suffix).textContent = file.name;
document.getElementById('fileType' + suffix).textContent = 'Binary(?)';
document.getElementById('fileSize' + suffix).textContent = formatBytes(file.size);
window.updateUploadButtonLabel();
return;
}
window.gd32File = result.file;
var baseHex = '0x' + result.baseAddr.toString(16).toUpperCase().padStart(8, '0');
addLog('I', '文件', 'HEX → BIN 转换完成: ' + file.name + ' → ' + result.file.name);
addLog('I', '文件', '基地址: ' + baseHex + ', 数据大小: ' + formatBytes(result.file.size));
showToast('HEX 已自动转换为 BIN', 'success');
document.getElementById('fileInfo' + suffix).classList.add('show');
document.getElementById('fileName' + suffix).textContent = result.file.name + ' (由 ' + file.name + ' 转换)';
document.getElementById('fileType' + suffix).textContent = 'Binary (HEX转换)';
document.getElementById('fileSize' + suffix).textContent = formatBytes(result.file.size);
window.updateUploadButtonLabel();
}).catch(function(err) {
addLog('E', '文件', 'HEX 解析失败: ' + err.message);
showToast('HEX 文件解析失败: ' + err.message, 'error');
window.gd32File = null;
document.getElementById('fileInfo' + suffix).classList.remove('show');
window.updateUploadButtonLabel();
});
return; // 异步,回调里设置 gd32File
}
// .bin 文件或 ESP32 文件: 直接使用
if (chip === 'esp32') window.esp32File = file;
else window.gd32File = file;
document.getElementById('fileInfo' + suffix).classList.add('show');
document.getElementById('fileName' + suffix).textContent = file.name;
document.getElementById('fileType' + suffix).textContent =
isHexByName ? 'Intel HEX' : 'Binary';
document.getElementById('fileSize' + suffix).textContent = formatBytes(file.size);
window.updateUploadButtonLabel();
addLog('I', '文件', chip.toUpperCase() + ' 已选择: ' + file.name + ' (' + formatBytes(file.size) + ')');
showToast(chip.toUpperCase() + ' 文件已选择', 'success');
// ESP32 文件: 计算并显示 SHA-256
if (chip === 'esp32') {
var shaElem = document.getElementById('fileSha256' + suffix);
if (shaElem) {
shaElem.textContent = '计算中...';
file.arrayBuffer().then(function(buf) {
return crypto.subtle.digest('SHA-256', buf);
}).then(function(hashBuffer) {
var hashArray = Array.from(new Uint8Array(hashBuffer));
var hashHex = hashArray.map(function(b) { return b.toString(16).padStart(2, '0'); }).join('');
shaElem.textContent = hashHex.toUpperCase();
// 存储到全局变量供上传时使用
window._esp32FileSha256 = hashHex.toUpperCase();
addLog('I', '文件', 'ESP32 固件 SHA-256: ' + hashHex.toUpperCase());
}).catch(function(err) {
shaElem.textContent = '计算失败: ' + err.message;
});
}
}
}
/** @description 清除已选固件文件并重置对应 UI 状态。 */
export function clearFile(chip) {
var suffix = chip === 'esp32' ? 'Esp32' : 'Gd32';
if (chip === 'esp32') { window.esp32File = null; window.esp32FileHandle = null; window._esp32FileSha256 = null; }
else { window.gd32File = null; window.gd32FileHandle = null; }
document.getElementById('fileInput' + suffix).value = '';
document.getElementById('fileInfo' + suffix).classList.remove('show');
var shaElem = document.getElementById('fileSha256' + suffix);
if (shaElem) shaElem.textContent = '-';
window.updateUploadButtonLabel();
addLog('I', '文件', chip.toUpperCase() + ' 文件已清除');
}
+210
View File
@@ -0,0 +1,210 @@
/**
* progress.js - 折叠面板 + 刷写进度 + 阶段节点 UI
*
* 从 app.js 的「折叠面板」「刷写进度」段提取。
* 操作 DOM: #esp32Stages / #gd32Stages / #stageStatusBar / #progressFill / #progressTxt / #progressPct
*
* 共享状态(通过 window.* 引用):
* window.activePhase / window.currentOtaTarget / window.esp32OtaPhase
*
* 未拆分的函数(通过 window.* 引用):
* window.updateStagesFromRelayState / window.cleanupAfterOtaFailure
* window.resumePollingAfterUpload / window.addLog
*/
import { addLog } from '../common/log.js';
// ==================== 折叠面板 ====================
export function toggleCollapse(headEl) {
headEl.closest('.card').classList.toggle('collapsed');
}
// ==================== 刷写进度 ====================
window.activePhase = null; // 'esp32Ota' | 'gd32Ota' | null
window.currentOtaTarget = null; // 'esp32' | 'gd32' | null — 当前 OTA 目标,决定显示哪套阶段
// 切换 OTA 阶段面板显示(原始版本,会被 console-mode.js 覆盖以支持 BLE)
export function switchStageFlow(target) {
window.currentOtaTarget = target;
var esp32El = document.getElementById('esp32Stages');
var gd32El = document.getElementById('gd32Stages');
if (target === 'esp32') {
if (esp32El) esp32El.style.display = '';
if (gd32El) gd32El.style.display = 'none';
} else {
if (esp32El) esp32El.style.display = 'none';
if (gd32El) gd32El.style.display = '';
}
// 重置 ESP32 阶段节点
['es1','es2','es3','es4','es5','es6'].forEach(function(id) {
var el = document.getElementById(id);
if (el) el.className = 'stage-node';
});
['esd1','esd2','esd3','esd4','esd5','esd6'].forEach(function(id) {
var el = document.getElementById(id);
if (el) el.textContent = '';
});
// 重置 GD32 阶段节点
['s1','s2','s3','s4','s5','s6'].forEach(function(id) {
var el = document.getElementById(id);
if (el) el.className = 'stage-node';
});
['sd1','sd2','sd3','sd4','sd5','sd6'].forEach(function(id) {
var el = document.getElementById(id);
if (el) el.textContent = '';
});
}
// ESP32 OTA 阶段状态: 0=待机, 1=上传中, 2=OTA写入, 3=SHA-256校验, 4=应用固件, 5=重启中, 6=启动完成
window.esp32OtaPhase = 0;
export const esp32PhaseLabels = ['', '上传中', '写入中', '校验中', '应用中', '重启中', '已完成'];
export function updateEsp32StagesFromPhase(phase) {
window.esp32OtaPhase = phase;
var ids = ['es1', 'es2', 'es3', 'es4', 'es5', 'es6'];
var detailIds = ['esd1', 'esd2', 'esd3', 'esd4', 'esd5', 'esd6'];
var all = ids.map(function(id) { return document.getElementById(id); });
var details = detailIds.map(function(id) { return document.getElementById(id); });
all.forEach(function (s) { if (s) s.className = 'stage-node'; });
details.forEach(function (d) { if (d) d.textContent = ''; });
var statusBar = document.getElementById('stageStatusBar');
var statusText = document.getElementById('stageStatusText');
if (phase <= 0) {
if (statusBar) statusBar.className = 'stage-status-bar';
if (statusText) statusText.textContent = '待机';
return;
}
for (var i = 0; i < phase - 1 && i < 6; i++) {
if (all[i]) all[i].className = 'stage-node done';
}
if (phase >= 1 && phase <= 6) {
if (all[phase - 1]) all[phase - 1].className = 'stage-node live';
if (details[phase - 1]) details[phase - 1].textContent = esp32PhaseLabels[phase] || '';
}
if (statusBar && statusText) {
if (phase >= 6) {
statusBar.className = 'stage-status-bar ok';
statusText.textContent = 'ESP32 刷写完成';
} else {
statusBar.className = 'stage-status-bar active';
statusText.textContent = (esp32PhaseLabels[phase] || '进行中') + ' (' + phase + '/6)';
}
}
}
export function updateEsp32StagesError() {
var ids = ['es1', 'es2', 'es3', 'es4', 'es5', 'es6'];
var all = ids.map(function(id) { return document.getElementById(id); });
all.forEach(function (s) { if (s) s.className = 'stage-node'; });
var statusBar = document.getElementById('stageStatusBar');
var statusText = document.getElementById('stageStatusText');
if (statusBar) statusBar.className = 'stage-status-bar err';
if (statusText) statusText.textContent = '校验/应用失败';
}
/**
* @description OTA 失败后的统一清理函数。
* 所有上传失败、校验失败、中继触发失败等异常路径都应调用此函数,
* 确保 UI 状态完整重置,不会残留"上传中"或中间态。
*
* ★ 状态机改造:转入 FAILED 态,置位 otaFailReason。
* 之后轮询(pollStatus/updateSystemStatusUi)不得覆盖此失败态,
* 直到用户发起新上传(otaSessionReset)或检测到设备已回 IDLE。
* @param {string} reason 失败原因描述
*/
export function cleanupAfterOtaFailure(reason) {
var chip = window.activeUploadChip || window.otaSessionTarget || window.currentOtaTarget || window.flashTarget || 'esp32';
// 0. 状态机:转入 FAILED(先于标志重置)
window.otaSessionTransition(window.OtaSessionState.FAILED, reason);
// 1. 清掉本会话全部残留标志 / 定时器 / 校验面板 / 旧 progress 语义
window.clearOtaSessionResiduals({
keepErrorStages: true,
statusText: reason || '失败',
errorStyle: true,
clearCacheDisplay: true
});
// 2. 阶段指示器标错
if (chip === 'esp32') {
updateEsp32StagesError();
} else {
window.updateStagesFromRelayState(255);
}
// 3. 进度条失败文案(clear 已把 fill 的 done 去掉)
setProgress(0, reason || '刷写失败');
setActivePhase(null);
window.currentOtaTarget = null;
// 4. 恢复上传按钮
var btnId = (chip === 'gd32') ? 'uploadGd32Btn' : 'uploadEsp32Btn';
var btn = document.getElementById(btnId);
if (btn) {
btn.disabled = false;
if (window.uploadBtnOrigText) btn.textContent = window.uploadBtnOrigText;
}
window.activeUploadChip = null;
window.otaSessionTarget = null;
window.uploadBtnOrigText = '';
window.updateUploadButtonLabel();
// 5. 恢复轮询(之后 status 不得覆盖 FAILED UI,直至用户新上传或设备 idle 复位)
window.resumePollingAfterUpload();
addLog('E', 'OTA', '刷写流程已中止,已清理残留状态: ' + (reason || '未知原因'));
}
/** @description 设置当前活跃 OTA 阶段并更新 UI 步骤指示器。 */
export function setActivePhase(phase) {
if (phase === 'gd32Upload' || phase === 'gd32Relay') phase = 'gd32Ota';
window.activePhase = phase;
var stepMap = { esp32Ota: 'ppEsp32', gd32Ota: 'ppGd32Ota' };
Object.keys(stepMap).forEach(function(p) {
var el = document.getElementById(stepMap[p]);
if (!el) return;
el.classList.remove('active', 'done');
if (p === phase) el.classList.add('active');
});
// Reset progress fill color
var fill = document.getElementById('progressFill');
if (fill) fill.classList.remove('done');
}
/** @description 标记指定阶段为完成,更新进度条颜色。 */
export function markPhaseDone(phase) {
if (phase === 'gd32Upload' || phase === 'gd32Relay') phase = 'gd32Ota';
var stepMap = { esp32Ota: 'ppEsp32', gd32Ota: 'ppGd32Ota' };
var el = document.getElementById(stepMap[phase]);
if (el) { el.classList.remove('active'); el.classList.add('done'); }
// All done? turn progress bar green
if (phase === 'gd32Ota' || phase === 'esp32Ota') {
var fill = document.getElementById('progressFill');
if (fill) fill.classList.add('done');
}
}
/** @description 更新进度条百分比和文本显示。 */
export function setProgress(pct, text) {
var fillEl = document.getElementById('progressFill');
var txtEl = document.getElementById('progressTxt');
var pctEl = document.getElementById('progressPct');
if (fillEl) fillEl.style.width = pct + '%';
if (txtEl) txtEl.textContent = text;
if (pctEl) pctEl.textContent = pct + '%';
}
/** @description 重置进度条和阶段状态到初始值。 */
export function resetProgress() {
setProgress(0, '等待操作...');
window.activePhase = null;
window.esp32OtaPhase = 0;
switchStageFlow(null);
['ppEsp32', 'ppGd32Ota'].forEach(function(id) {
var el = document.getElementById(id);
if (el) el.classList.remove('active', 'done');
});
var fill = document.getElementById('progressFill');
if (fill) fill.classList.remove('done');
}
+244
View File
@@ -0,0 +1,244 @@
/**
* session.js - 前端 OTA 会话状态机
*
* 从 app.js 的「全局状态」+「前端 OTA 会话状态机」段提取。
* 统一管理一次 OTA 会话的完整生命周期,消除散布的布尔标志
* (isUploading / currentOtaTarget / activeUploadChip / isEsp32Rebooting)
* 在网络错误后被轮询错误覆盖、状态混乱的问题。
*
* 状态流转:
* IDLE → UPLOADING → (ESP32: VERIFYING → REBOOTING) → DONE
* IDLE → UPLOADING → (GD32: RELAYING) → DONE
* 任意活跃态 ──(failure/abort)──→ FAILED
* FAILED/DONE ──(startUpload)──→ UPLOADING
* FAILED/DONE ──(device idle detected by poll)──→ IDLE
*
* 共享状态(通过 window.* 引用,入口模块统一挂载):
* window.isUploading / window.isEsp32Rebooting / window.rebootStartTime
* window.rebootObservedDown / window.activeUploadChip / window.currentOtaTarget
* window.esp32OtaPhase / window.activePhase
* window.gd32CompleteAnnounced / window.relayWatchLastSeq / window.gd32RelaySessionLive
* window.lastSysStateCode / window.lastRelayState
*
* 未拆分的函数(通过 window.* 引用):
* window.stopRelayProgressWatch / window.setProgress / window.addLog
*/
import { addLog } from '../common/log.js';
// ==================== 全局状态 ====================
// 文件句柄与上传进度等状态保留为模块级,被 file-handle / upload-esp32 等模块通过 window.* 读写
window.isUploading = false;
window.uploadProgress = 0;
window.isEsp32Rebooting = false;
window.rebootStartTime = 0;
window.rebootObservedDown = false;
window.pollingPausedForUpload = false;
window.uploadPollChip = null;
window.activeXhr = null;
window.uploadBtnOrigText = '';
window.activeUploadChip = null;
// 心跳定时器(模块级,仅本模块使用)
let uploadWaitHeartbeatId = null;
let uploadWaitStartMs = 0;
/** @description 停止「等待设备 HTTP 响应」心跳,避免残留定时器改进度条。 */
export function stopUploadWaitHeartbeat() {
if (uploadWaitHeartbeatId) {
clearInterval(uploadWaitHeartbeatId);
uploadWaitHeartbeatId = null;
}
uploadWaitStartMs = 0;
}
/**
* @description 浏览器 body 已发完但尚未收到 HTTP 响应时启动心跳。
* 上传期轮询已暂停,若无此反馈会像“卡在 100%”。
*/
export function startUploadWaitHeartbeat() {
stopUploadWaitHeartbeat();
uploadWaitStartMs = Date.now();
uploadWaitHeartbeatId = setInterval(function () {
if (!window.activeXhr) {
stopUploadWaitHeartbeat();
return;
}
var sec = Math.round((Date.now() - uploadWaitStartMs) / 1000);
window.setProgress(100, '数据已发完,设备处理中… 已等待 ' + sec + 's');
// 每 10s 打一条日志,便于区分“仍在处理”与“真死锁”
if (sec > 0 && sec % 10 === 0) {
addLog('I', '上传', '仍在等待设备 HTTP 响应(已 ' + sec +
's;设备可能仍在写 Flash / 算 SHA,或连接异常)');
}
}, 5000);
}
// ==================== 前端 OTA 会话状态机 ====================
export const OtaSessionState = {
IDLE: 'idle', // 无会话
UPLOADING: 'uploading', // 固件上传中(XHR 上传阶段)
VERIFYING: 'verifying', // ESP32 SHA-256 校验 + 应用中
RELAYING: 'relaying', // GD32 中继转发中(triggerGd32Relay 发起后)
REBOOTING: 'rebooting', // ESP32 重启等待中
DONE: 'done', // 会话成功完成
FAILED: 'failed' // 会话失败/中止
};
window.OtaSessionState = OtaSessionState;
let otaSessionState = OtaSessionState.IDLE;
let otaSessionTarget = null; // 'esp32' | 'gd32' | null — 本会话的目标芯片
let otaFailReason = ''; // FAILED 时的失败原因(用于 UI 显示)
// 同步到 window 供 polling.js / stages.js / upload-gd32.js 等通过 window.* 读取
window.otaSessionState = otaSessionState;
window.otaSessionTarget = otaSessionTarget;
export { otaSessionState, otaSessionTarget, otaFailReason };
/** @description 查询当前 OTA 会话是否处于活跃态(非 IDLE/DONE/FAILED)。 */
export function otaSessionActive() {
return otaSessionState === OtaSessionState.UPLOADING ||
otaSessionState === OtaSessionState.VERIFYING ||
otaSessionState === OtaSessionState.RELAYING ||
otaSessionState === OtaSessionState.REBOOTING;
}
/** @description 查询当前 OTA 会话是否已终结(DONE 或 FAILED)。 */
export function otaSessionTerminated() {
return otaSessionState === OtaSessionState.DONE ||
otaSessionState === OtaSessionState.FAILED;
}
/**
* @description 转换 OTA 会话状态。
* 统一在此处维护状态机一致性,同时同步旧标志位
* (isUploading / currentOtaTarget),保证渐进式迁移期间两套标志不冲突。
* @param {string} newState 目标状态。
* @param {string} [reason] 失败原因(仅转入 FAILED 时使用)。
*/
export function otaSessionTransition(newState, reason) {
var prev = otaSessionState;
otaSessionState = newState;
window.otaSessionState = newState;
if (newState === OtaSessionState.FAILED) {
otaFailReason = reason || '未知原因';
} else if (newState === OtaSessionState.UPLOADING) {
otaFailReason = '';
}
// 同步旧标志(渐进式迁移):UPLOADING 对应 isUploading=true
if (newState === OtaSessionState.UPLOADING) window.isUploading = true;
else if (newState === OtaSessionState.IDLE ||
newState === OtaSessionState.DONE ||
newState === OtaSessionState.FAILED) window.isUploading = false;
// REBOOTING 复用 isEsp32Rebooting(pollStatus 重启检测依赖)
if (newState === OtaSessionState.REBOOTING) window.isEsp32Rebooting = true;
else if (newState !== OtaSessionState.VERIFYING) window.isEsp32Rebooting = false;
}
/**
* @description 清掉本会话残留 UI/标志(失败、超时、中止、新会话开始前都要调)。
* 不改变 otaSessionState(由调用方负责 FAILED/IDLE 转换)。
* @param {object} [opts]
* @param {boolean} [opts.keepErrorStages] true=保留错误阶段样式;false=节点复位到待机
* @param {string} [opts.statusText] 状态栏文案;null 表示「待机」
* @param {boolean} [opts.errorStyle] 状态栏是否红色
*/
export function clearOtaSessionResiduals(opts) {
opts = opts || {};
window.stopRelayProgressWatch();
stopUploadWaitHeartbeat();
window.gd32CompleteAnnounced = false;
window.relayWatchLastSeq = -1;
window.gd32RelaySessionLive = false;
window.lastSysStateCode = -1;
window.lastRelayState = 0;
window.isEsp32Rebooting = false;
window.rebootObservedDown = false;
window.rebootStartTime = 0;
window.isUploading = false;
/* 先摘掉引用再 abort,避免 abort 事件回调再次进入 cleanup 递归 */
var xh = window.activeXhr;
window.activeXhr = null;
if (xh) {
try { xh.abort(); } catch (_) {}
}
// 校验面板
var verifyInfo = document.getElementById('stageVerifyInfo');
if (verifyInfo) verifyInfo.style.display = 'none';
var sviFront = document.getElementById('sviFrontSha');
var sviDevice = document.getElementById('sviDeviceSha');
var sviResult = document.getElementById('sviResult');
if (sviFront) sviFront.textContent = '-';
if (sviDevice) sviDevice.textContent = '-';
if (sviResult) {
sviResult.textContent = '-';
sviResult.className = 'svi-val';
}
// 阶段节点
if (!opts.keepErrorStages) {
['es1','es2','es3','es4','es5','es6','s1','s2','s3','s4','s5','s6'].forEach(function(id) {
var el = document.getElementById(id);
if (el) el.className = 'stage-node';
});
['esd1','esd2','esd3','esd4','esd5','esd6','sd1','sd2','sd3','sd4','sd5','sd6'].forEach(function(id) {
var el = document.getElementById(id);
if (el) el.textContent = '';
});
window.esp32OtaPhase = 0;
}
// 顶部步骤 pill
['ppEsp32', 'ppGd32Ota'].forEach(function(id) {
var el = document.getElementById(id);
if (el) el.classList.remove('active', 'done');
});
var fill = document.getElementById('progressFill');
if (fill) fill.classList.remove('done');
window.activePhase = null;
// 状态栏
var sb = document.getElementById('stageStatusBar');
var st = document.getElementById('stageStatusText');
if (sb) {
sb.className = opts.errorStyle ? 'stage-status-bar err' : 'stage-status-bar';
}
if (st) {
st.textContent = (opts.statusText !== undefined && opts.statusText !== null)
? opts.statusText
: '待机';
}
// 设备侧缓存展示:失败后不要继续显示“已满缓存像成功”
var mOtaCache = document.getElementById('mOtaCache');
if (mOtaCache && opts.clearCacheDisplay) {
mOtaCache.textContent = '0 / 216 KB';
mOtaCache.className = 'v';
}
var sOtaStored = document.getElementById('sOtaStored');
if (sOtaStored && opts.clearCacheDisplay) {
sOtaStored.textContent = '--';
}
}
/** @description 将会话从 FAILED/DONE 复位到 IDLE(用户发起新上传或设备空闲时调用)。 */
export function otaSessionReset() {
clearOtaSessionResiduals({
keepErrorStages: false,
statusText: '待机',
errorStyle: false,
clearCacheDisplay: false
});
if (otaSessionState !== OtaSessionState.IDLE) {
otaSessionState = OtaSessionState.IDLE;
otaSessionTarget = null;
otaFailReason = '';
window.otaSessionState = OtaSessionState.IDLE;
window.otaSessionTarget = null;
window.currentOtaTarget = null;
window.isUploading = false;
}
window.activeUploadChip = null;
}
+105
View File
@@ -0,0 +1,105 @@
/**
* spi.js - SPI 帧构建与发送
*
* 从 app.js 的「SPI 发送」段提取。
* 操作 DOM: #spiType / #spiData / #spiTimeout / #spiHint / #spiResp / #spiRespText
*
* 未拆分的函数(通过 window.* 引用):
* window.getBaseUrl / window.showToast / window.addLog
*/
import { showToast } from '../common/toast.js';
import { addLog } from '../common/log.js';
function updateSpiHint() {
var typeHex = document.getElementById('spiType').value.trim();
var dataHex = document.getElementById('spiData').value.trim().replace(/\s/g, '');
var hint = document.getElementById('spiHint');
if (!typeHex || !/^[0-9a-fA-F]{1,2}$/.test(typeHex)) {
hint.className = 'helper-note err';
hint.innerHTML = 'type 需填 1 字节 hex(如 01, B0)。';
return;
}
if (dataHex && !/^[0-9a-fA-F]*$/.test(dataHex)) {
hint.className = 'helper-note err';
hint.innerHTML = '数据包含非 HEX 字符。';
return;
}
if (dataHex && dataHex.length % 2 !== 0) {
hint.className = 'helper-note err';
hint.innerHTML = 'HEX 长度必须为偶数。';
return;
}
var bytes = dataHex ? dataHex.length / 2 : 0;
var totalOnWire = 5 + bytes;
hint.className = 'helper-note ok';
hint.innerHTML = 'type=<code>0x' + typeHex.toUpperCase() + '</code>' +
(bytes > 0 ? ',payload: <b>' + bytes + '</b>B' : ',无 payload') +
',线上 <b>' + totalOnWire + '</b>B(SOF+TYPE+LEN+PAYLOAD+CRC)';
}
export async function sendSpiFrame() {
var typeHex = document.getElementById('spiType').value.trim();
var dataStr = document.getElementById('spiData').value.trim();
var timeout = parseInt(document.getElementById('spiTimeout').value) || 1000;
if (!typeHex || !/^[0-9a-fA-F]{1,2}$/.test(typeHex)) {
showToast('type 需填 1 字节 hex', 'error');
return;
}
var dataHex = dataStr.replace(/\s/g, '');
if (dataHex && !/^[0-9a-fA-F]*$/.test(dataHex)) {
showToast('数据包含非 HEX 字符', 'error');
return;
}
if (dataHex && dataHex.length % 2 !== 0) {
showToast('HEX 数据长度必须为偶数', 'error');
return;
}
var txBytes = dataHex ? dataHex.length / 2 : 0;
addLog('I', 'SPI', 'type=0x' + typeHex.toUpperCase() +
(txBytes > 0 ? ', payload=' + dataHex.toUpperCase() : ', 无负载'));
var body = { type: typeHex, data: dataHex.toUpperCase(), timeout_ms: timeout };
try {
var resp = await fetch(window.getBaseUrl() + '/spi/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: AbortSignal.timeout(timeout + 2000)
});
var result = await resp.json();
if (result.status === 'ok') {
var recvHex = result.recv || '';
var respTypeName = {'80': 'ACK', '81': 'WAIT', '82': 'ERROR'};
var respTypeLabel = respTypeName[result.resp_type] || ('0x' + result.resp_type);
addLog('I', 'SPI', '→ ' + respTypeLabel +
(recvHex ? ' 0x' + recvHex : '') + ' (' + (result.resp_len || 0) + 'B)');
var rd = document.getElementById('spiResp');
rd.style.display = 'block';
document.getElementById('spiRespText').textContent =
'type=0x' + result.type + ' payload=' + result.payload_len + 'B → ' + respTypeLabel +
(recvHex ? ' 0x' + recvHex : '') +
' (' + (result.resp_len || 0) + 'B)';
showToast('SPI 发送成功', 'success');
} else {
addLog('E', 'SPI', '发送失败: code=' + result.code + ' ' + (result.message || ''));
var rd = document.getElementById('spiResp');
rd.style.display = 'block';
document.getElementById('spiRespText').textContent =
'错误: code=' + result.code + ' ' + (result.message || '');
showToast('SPI 发送失败', 'error');
}
} catch (e) {
addLog('E', 'SPI', '发送失败: ' + e.message);
showToast('SPI 发送失败: ' + e.message, 'error');
}
}
export function clearSpiFields() {
document.getElementById('spiType').value = '01';
document.getElementById('spiData').value = '';
document.getElementById('spiTimeout').value = '1000';
document.getElementById('spiResp').style.display = 'none';
updateSpiHint();
}
// 导出 updateSpiHint 供入口挂载 window.updateSpiHint
export { updateSpiHint };
+279
View File
@@ -0,0 +1,279 @@
/**
* stages.js - OTA 阶段驱动 (真实状态)
*
* 从 app.js 的「OTA 阶段驱动 (真实状态)」段提取。
* 将设备端 relay_state / sys_state 映射到 GD32 阶段节点 UI,
* 并驱动 updateSystemStatusUi 综合 UI 更新。
*
* 共享状态(通过 window.* 引用):
* window.latestSystemStatus / window.currentOtaTarget / window.esp32OtaPhase
* window.otaSessionState / window.otaSessionTarget / window.isUploading
* window.gd32CompleteAnnounced / window.gd32RelaySessionLive
* window.lastSysStateCode / window.lastRelayState
*
* 未拆分的函数(通过 window.* 引用):
* window.updateHeapAndTaskStatusUi / window.updateConnectionUi / window.updateHealthUi
* window.updateStagesFromRelayState / window.setActivePhase / window.setProgress
* window.markGd32OtaUiComplete / window.cleanupAfterOtaFailure / window.otaSessionReset
* window.stopRelayProgressWatch / window.pollLogs / window.formatBytes / window.formatUptime
*/
import { formatBytes, formatUptime } from '../common/utils.js';
import { updateConnectionUi, updateHealthUi, updateHeapAndTaskStatusUi } from '../common/connection.js';
import { setActivePhase, setProgress, markPhaseDone } from './progress.js';
import {
otaSessionActive, otaSessionReset, otaSessionTransition, OtaSessionState
} from './session.js';
// Relay state comes from relay_state_t in main/relay/ota_relay.h:
// 0=IDLE, 1=HANDSHAKING, 2=SIZE_NEG, 3=TRANSFERRING,
// 4=FINISHING, 5=DONE, 255=ERROR.
const relayStateDetailMap = {
0: '待机',
1: '认证握手',
2: '传输协商',
3: '数据转发',
4: '收尾验证',
5: 'OTA完成',
255: '错误'
};
export function updateStagesFromRelayState(rs) {
var ids = ['s1', 's2', 's3', 's4', 's5', 's6'];
var detailIds = ['sd1', 'sd2', 'sd3', 'sd4', 'sd5', 'sd6'];
var all = ids.map(function(id) { return document.getElementById(id); });
var details = detailIds.map(function(id) { return document.getElementById(id); });
// reset all
all.forEach(function (s) { if (s) s.className = 'stage-node'; });
details.forEach(function (d) { if (d) d.textContent = ''; });
// status bar
var statusBar = document.getElementById('stageStatusBar');
var statusText = document.getElementById('stageStatusText');
if (rs === undefined || rs === null) {
if (statusBar) statusBar.className = 'stage-status-bar';
if (statusText) statusText.textContent = '待机';
return;
}
if (rs === 255) {
all.forEach(function (s) { if (s) s.className = 'stage-node err'; });
if (statusBar) statusBar.className = 'stage-status-bar err';
if (statusText) statusText.textContent = '错误';
return;
}
var activeStage = 0;
if (rs === 1) activeStage = 1;
else if (rs === 2) activeStage = 2;
else if (rs === 3) activeStage = 3;
else if (rs === 4) activeStage = 5;
else if (rs === 5) activeStage = 6;
for (var i = 0; i < activeStage - 1; i++) {
if (all[i]) all[i].className = 'stage-node done';
}
if (rs === 5) {
if (all[5]) all[5].className = 'stage-node done';
} else if (activeStage >= 1 && activeStage <= 6) {
if (all[activeStage - 1]) all[activeStage - 1].className = 'stage-node live';
}
// Show detail text for the active stage
var detailText = relayStateDetailMap[rs] || '';
if (activeStage >= 1 && activeStage <= 6 && details[activeStage - 1]) {
details[activeStage - 1].textContent = detailText;
}
// Update status bar
if (statusBar && statusText) {
if (rs === 0) {
statusBar.className = 'stage-status-bar';
statusText.textContent = '待机';
} else {
statusBar.className = 'stage-status-bar active';
statusText.textContent = (relayStateDetailMap[rs] || '未知') + ' (' + rs + ')';
}
}
}
export function updateSystemStatusUi(data) {
if (!data) return;
window.latestSystemStatus = data;
updateHeapAndTaskStatusUi(data);
var wifi = data.wifi || {};
var ota = data.ota || {};
var healthCode = data.health;
// Use global relayStateDetailMap defined in updateStagesFromRelayState section
var connected = !!wifi.connected;
var connText = connected ? (wifi.mode || 'STA') + ' 已连接' : (wifi.mode || 'STA') + ' 未连接';
updateConnectionUi(connected, connText);
updateHealthUi(healthCode, data.free_heap);
if (data.version) {
var pillVerEl = document.getElementById('pillVer');
if (pillVerEl) {
pillVerEl.textContent = 'v' + data.version;
pillVerEl.dataset.tooltip = data.build_time ? ("编译: " + data.build_time) : '';
}
// GD32 版本
var mGd32App = document.getElementById('mGd32App');
var mGd32Boot = document.getElementById('mGd32Boot');
if (mGd32App) {
if (ota.gd32_app && ota.gd32_app !== 'v0.0.0.0') {
mGd32App.textContent = ota.gd32_app;
mGd32App.className = 'v ok';
} else {
mGd32App.textContent = ota.gd32_app_alive ? '未知' : '离线';
mGd32App.className = 'v';
}
}
if (mGd32Boot) {
if (ota.gd32_boot && ota.gd32_boot !== 'v0.0.0.0') {
mGd32Boot.textContent = ota.gd32_boot;
mGd32Boot.className = 'v ok';
} else {
mGd32Boot.textContent = ota.gd32_app_alive ? '未知' : '离线';
mGd32Boot.className = 'v';
}
}
}
// temperature
var mTempEl = document.getElementById('mTemp');
if (mTempEl) mTempEl.textContent =
Number.isFinite(data.temperature) ? data.temperature.toFixed(1) + '\u00B0C' : '--';
// uptime
var uptimeEl = document.getElementById('pillUptime');
if (uptimeEl) uptimeEl.textContent = '\u23F1 ' + formatUptime(data.uptime_sec);
// wifi / signal
var pillIPEl = document.getElementById('pillIP');
if (pillIPEl) pillIPEl.textContent = wifi.ip || '--';
var rssi = (wifi.rssi !== undefined && wifi.rssi !== -127) ? wifi.rssi + ' dBm' : '--';
var pillRSSIEl = document.getElementById('pillRSSI');
if (pillRSSIEl) pillRSSIEl.textContent = rssi;
var mOtaCache = document.getElementById('mOtaCache');
var otaStored = ota.stored_size || 0;
if (mOtaCache) {
if (otaStored > 0) {
mOtaCache.textContent = formatBytes(otaStored) + ' / 216 KB';
mOtaCache.className = 'v ok';
} else {
mOtaCache.textContent = '0 / 216 KB';
mOtaCache.className = 'v';
}
}
// 共享进度条按当前连接模式归属:BLE 模式下由 ble_ota.js 的设备 ACK
// 驱动包数、阶段和完成状态,HTTP 状态轮询不能覆盖这套实时进度。
if (window.connectionMode === 'ble') {
return;
}
// relay state - skip GD32 relay UI when ESP32 OTA is active
if (window.currentOtaTarget === 'esp32' && window.esp32OtaPhase > 0) {
return;
}
// ★ 状态机守卫:本会话已 FAILED 时,禁止后端状态覆盖失败 UI。
// 后端可能仍在跑 GD32 中继(网络断开不会停止设备端流程),
// 若不拦截,会把进度条/阶段从"网络错误"错误地刷回"数据转发"。
// 只有设备真正回 IDLE 才允许自动复位状态机(见下方 else 分支)。
var rs = ota.relay_state;
var sysState = ota.sys_state || '';
var sysStateCode = Number.isFinite(ota.sys_state_code) ? ota.sys_state_code : -1;
var isGd32Storing = sysState === 'GD32_STORING' || sysStateCode === 1;
var isGd32Relaying = sysState === 'GD32_RELAYING' || sysStateCode === 3;
var isGd32Complete = sysState === 'GD32_COMPLETE' || sysStateCode === 4;
var isGd32Error = sysState === 'ERROR' || sysStateCode === 8 || rs === 255;
// OTA cache progress (GD32 upload to ESP32 flash)
var total = ota.total_size || 0;
var stored = ota.stored_size || 0;
var storageProgress = total > 0 ? Math.min(100, Math.round(stored * 100 / total)) : 0;
// GD32 relay forwarding progress — 以 /system/status 的 relay_seq 为准,不依赖日志
var relaySeq = ota.relay_seq || 0;
var relayTotal = ota.relay_total_packets || 0;
var relayProgress = relayTotal > 0 ? Math.min(100, Math.round(relaySeq * 100 / relayTotal)) : 0;
var gd32AppAlive = ota.gd32_app_alive;
if (window.otaSessionState === OtaSessionState.FAILED) {
var _deviceIdle = (sysStateCode === 0 || sysStateCode === 2 || sysStateCode === 4) &&
(rs === 0 || rs === 5);
if (!_deviceIdle) {
// 会话已失败,但设备可能仍在中继:继续用 relay_seq 刷进度,避免“假卡死”
if ((rs === 3 || isGd32Relaying) && relayTotal > 0) {
setActivePhase('gd32Ota');
var failRelayOverall = 60 + Math.round(relayProgress * 0.30);
setProgress(failRelayOverall,
'已转发 ' + relaySeq + ' / ' + relayTotal + ' 包(设备仍在跑)');
updateStagesFromRelayState(3);
} else {
updateStagesFromRelayState(255);
}
return;
}
// 设备已回 IDLE —— 自动复位状态机,恢复正常 UI
otaSessionReset();
window.stopRelayProgressWatch();
// 落到下方正常分支刷新 UI
}
var displayRelayState = isGd32Complete ? 5 : (isGd32Relaying && rs === 0 ? 1 : rs);
if (window.currentOtaTarget !== 'esp32') { updateStagesFromRelayState(displayRelayState); }
if (isGd32Error && (window.currentOtaTarget === 'gd32' || window.otaSessionTarget === 'gd32')) {
var failDetail = 'GD32 OTA 失败';
if (relayTotal > 0) {
failDetail += '(已转发 ' + relaySeq + '/' + relayTotal +
',relay_state=' + rs + ',sys=' + (sysState || sysStateCode) + ')';
} else {
failDetail += '(relay_state=' + rs + ',sys=' + (sysState || sysStateCode) + ')';
}
window.cleanupAfterOtaFailure(failDetail);
window.pollLogs();
} else if (isGd32Error) {
// 非当前 OTA 会话的错误:仅标记阶段指示器,不触发完整清理
updateStagesFromRelayState(255);
var sbErr = document.getElementById('stageStatusBar');
var stErr = document.getElementById('stageStatusText');
if (sbErr) sbErr.className = 'stage-status-bar err';
if (stErr) stErr.textContent = 'GD32 错误(待机)';
} else if (window.otaSessionState === OtaSessionState.UPLOADING) {
/* 上传进行中:不要用旧缓存/旧完成态覆盖进度条 */
} else if (window.otaSessionState === OtaSessionState.RELAYING) {
/* 必须先看到本会话真实中继(HANDSHAKE~FINISHING),才允许 DONE;
* 禁止一进 RELAYING 就吃掉上一次的 rs=5 / app_alive。 */
if (isGd32Relaying || (rs >= 1 && rs <= 4)) {
window.gd32RelaySessionLive = true;
}
if (window.gd32RelaySessionLive &&
(isGd32Complete || rs === 5 ||
(gd32AppAlive && relayTotal > 0 && relaySeq >= relayTotal && rs >= 3))) {
if (!window.gd32CompleteAnnounced) {
window.markGd32OtaUiComplete('GD32 刷写完成');
}
} else if (rs === 1) {
// HANDSHAKING
setActivePhase('gd32Ota');
setProgress(50, '握手与认证中...');
} else if (rs === 3 || (relayTotal > 0 && relaySeq > 0 && rs !== 5)) {
// TRANSFERRING
setActivePhase('gd32Ota');
var relayOverall = 60 + Math.round(relayProgress * 0.30);
var relayLabel = relayTotal > 0
? ('已转发 ' + relaySeq + ' / ' + relayTotal + ' 包 (' + relayProgress + '%)')
: '数据包传输中...';
setProgress(relayOverall, relayLabel);
} else if (rs === 2 || (isGd32Relaying && relaySeq === 0)) {
// SIZE_NEG
setActivePhase('gd32Ota');
setProgress(55, relayTotal > 0
? ('大小协商中(共 ' + relayTotal + ' 包)')
: '固件大小协商中');
} else if (rs === 4) {
setActivePhase('gd32Ota');
if (!window.gd32CompleteAnnounced) {
setProgress(95, gd32AppAlive ? '收尾中…' : '收尾中,等待 APP 启动...');
}
} else if (!window.gd32RelaySessionLive) {
setActivePhase('gd32Ota');
setProgress(48, '已触发中继,等待设备进入握手…');
}
} else if (isGd32Storing || (total > 0 && stored < total && !window.isUploading &&
window.otaSessionState !== OtaSessionState.DONE &&
window.otaSessionState !== OtaSessionState.IDLE)) {
setActivePhase('gd32Ota');
var storeOverall = Math.min(45, Math.round(storageProgress * 0.45));
if (!window.isUploading) {
setProgress(storeOverall, '已缓存 ' + formatBytes(stored) + ' / ' + formatBytes(total) + '');
}
}
}
+443
View File
@@ -0,0 +1,443 @@
/**
* upload-esp32.js - ESP32 本机 OTA 上传流程 + 完成升级
*
* 从 app.js 的「上传固件」「ESP32 OTA 完成升级」段提取。
* 包含:startUpload 入口(处理 esp32 + gd32 共用互斥/重读逻辑)、
* XHR 上传、SHA 校验、verifyEsp32Firmware、restartEsp32。
*
* 共享状态(通过 window.* 引用):
* window.esp32File / window.gd32File / window.esp32FileHandle / window.gd32FileHandle
* window._esp32FileSha256 / window._gd32FileSha256 / window.activeXhr
* window.isUploading / window.uploadBtnOrigText / window.activeUploadChip
*
* 未拆分的函数(通过 window.* 引用):
* window.otaSessionActive / window.otaSessionTerminated / window.otaSessionReset
* window.otaSessionTransition / window.OtaSessionState / window.otaSessionTarget
* window.cleanupAfterOtaFailure / window.pausePollingForUpload
* window.resumePollingAfterUpload / window.updateEsp32StagesFromPhase
* window.switchStageFlow / window.setActivePhase / window.setProgress
* window.markPhaseDone / window.startUploadWaitHeartbeat / window.stopUploadWaitHeartbeat
* window.updateUploadButtonLabel / window.processFile / window.clearFile
* window.triggerGd32Relay / window.rebootObservedDown / window.rebootStartTime
* window.isEsp32Rebooting / window.getBaseUrl / window.formatBytes
* window.showToast / window.addLog
*/
import { formatBytes } from '../common/utils.js';
import { showToast } from '../common/toast.js';
import { addLog } from '../common/log.js';
import {
otaSessionActive, otaSessionTerminated, otaSessionReset,
otaSessionTransition, OtaSessionState, stopUploadWaitHeartbeat,
startUploadWaitHeartbeat
} from './session.js';
import { updateEsp32StagesFromPhase, switchStageFlow, setActivePhase, setProgress, markPhaseDone, cleanupAfterOtaFailure } from './progress.js';
function isOtaBusyError(status, detail) {
var text = (detail || '').toLowerCase();
return status === 403 ||
status === 409 ||
status === 423 ||
text.indexOf('busy') >= 0 ||
text.indexOf('occupied') >= 0 ||
text.indexOf('another ota') >= 0 ||
text.indexOf('invalid state') >= 0 ||
text.indexOf('占用') >= 0 ||
text.indexOf('互斥') >= 0 ||
text.indexOf('系统忙') >= 0;
}
function formatHttpErrorDetail(text) {
var detail = (text || '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
if (detail.length > 180) detail = detail.slice(0, 177) + '...';
return detail;
}
/** @description 启动 ESP32 本机 OTA 上传流程(互斥获取→分块上传→SHA验证→重启)。 */
export async function startUpload(chip) {
var file = chip === 'esp32' ? window.esp32File : window.gd32File;
if (!file) return;
// ★ 状态机守卫:用统一的会话活跃判断替代裸 isUploading。
// FAILED/DONE 会话需要先复位才能发起新上传(下方 otaSessionReset)。
if (otaSessionActive()) {
var busyChip = (window.otaSessionTarget || window.activeUploadChip || window.flashTarget || chip || 'ota').toUpperCase();
addLog('W', '上传', '已有 ' + busyChip + ' OTA 会话进行中 (' + window.otaSessionState + '),拒绝新的 ' + chip.toUpperCase() + ' 上传');
showToast('OTA 通道正在占用,请等待当前刷写结束', 'error');
return;
}
// 上一次会话已终结(DONE/FAILED):发起新会话前先复位状态机
if (otaSessionTerminated()) otaSessionReset();
// 记录本会话目标
window.otaSessionTarget = chip;
window.pausePollingForUpload();
// ★ 必须先抢锁再 await:守卫同步设置,await 期间重复点击才能被拦下。
// 旧实现把 isUploading 设在 await handle.getFile() 之后 → 用户在 BLE 期间
// 狂点 WiFi 上传按钮时,多个 startUpload 都能通过守卫,最终并发 xhr.send,
// 设备端互斥被绕过、403 路径走漏。
// ★ 状态机:进入 UPLOADING 态(内部同步 isUploading = true)
otaSessionTransition(OtaSessionState.UPLOADING);
window.activeUploadChip = chip;
/* 新上传:清掉上次完成标志,避免立刻被 status 残留判成“又完成了” */
window.gd32CompleteAnnounced = false;
window.relayWatchLastSeq = -1;
window.gd32RelaySessionLive = false;
if (chip === 'esp32') {
updateEsp32StagesFromPhase(1);
}
// 跨通道互斥前置检查(含 BLE 取消后的瞬态重试)
var _probeReady = false;
for (var _pi = 0; _pi < 4; _pi++) {
if (_pi > 0) {
var _pd = [1500, 3000, 3000][_pi - 1];
addLog('I', '上传', '等待设备清理 OTA 状态 (' + _pd + 'ms)...');
await new Promise(function(r) { setTimeout(r, _pd); });
}
try {
var probe = await fetch(window.getBaseUrl() + '/system/status', {
signal: AbortSignal.timeout(2000)
});
if (probe.ok) {
var st = await probe.json();
var ota = st.ota || {};
var ssc = Number.isFinite(ota.sys_state_code) ? ota.sys_state_code : -1;
var otaChannel = Number.isFinite(ota.channel) ? ota.channel : -1;
var otaChannelName = ota.channel_name || (otaChannel === 1 ? 'BLE' : 'WIFI');
// ESP32 RECEIVING(5) 可由同一 WiFi 通道的新上传接管;后台/休眠可能让 /ota/update 没发出,
// 若这里提前拦截,后端 beginEsp32OtaWithRetry 的同通道清理分支永远没有机会执行。
// ERROR(8) 也放行给后端清理底层 OTA 句柄;FINALIZING(6)/REBOOTING(7) 是不可逆忙态。
if (ssc === 5 && otaChannel === 1 && _pi < 3) continue;
var esp32Busy = (ssc === 6 || ssc === 7 || (ssc === 5 && otaChannel !== 0));
// GD32 STORING(1) 可由同一 WiFi 通道的新上传接管;RELAYING(3) 不可中断。
var gd32Busy = (ssc === 3 || (ssc === 1 && otaChannel !== 0));
if ((esp32Busy || gd32Busy)) {
addLog('E', '上传', '设备已有 OTA 进行中 (sys_state=' + (ota.sys_state || ssc) + ', channel=' + otaChannelName + '),拒绝本次 ' + chip.toUpperCase() + ' 上传');
showToast('设备正在 OTA 中,请等待结束后再试', 'error');
// ★ 状态机:预检失败也需完整清理(转入 FAILED)
cleanupAfterOtaFailure('设备 OTA 占用中');
return;
}
}
_probeReady = true;
break;
} catch (e) {
// 探测失败:可能是设备瞬时忙或网络问题。保守继续走,由后端 403 兜底。
addLog('W', '上传', '跨通道互斥状态探测失败: ' + e.message + '(继续尝试,由设备端兜底)');
_probeReady = true;
break;
}
}
// 每次上传前尝试重新从本地读取文件
var handle = chip === 'esp32' ? window.esp32FileHandle : window.gd32FileHandle;
if (handle) {
// 有 FileSystemFileHandle,调用 getFile() 获取最新内容
try {
var freshFile = await handle.getFile();
if (chip === 'esp32') {
window.esp32File = freshFile;
file = freshFile;
} else {
if (freshFile.name.toLowerCase().endsWith('.hex')) {
window.processFile(freshFile, 'gd32');
await new Promise(function(resolve) { setTimeout(resolve, 200); });
file = window.gd32File;
} else {
window.gd32File = freshFile;
file = freshFile;
}
}
addLog('I', '文件', '已重新读取本地文件: ' + file.name + ' (' + formatBytes(file.size) + ')');
} catch (e) {
addLog('W', '文件', '重新读取文件失败: ' + e.message + ',将使用缓存版本');
}
} else {
// 没有 handle,尝试直接读取当前 File 对象
try {
await file.arrayBuffer();
} catch (e) {
// File 引用已失效,提示用户重新选择
addLog('E', '文件', '本地文件已变更或失效,请重新选择文件');
showToast('文件已变更,请重新选择', 'error');
window.clearFile(chip);
// ★ 状态机:文件失效属于会话失败
cleanupAfterOtaFailure('本地文件失效');
return;
}
}
var uploadBtn = document.getElementById(chip === 'esp32' ? 'uploadEsp32Btn' : 'uploadGd32Btn');
window.uploadBtnOrigText = uploadBtn.textContent;
uploadBtn.disabled = true;
uploadBtn.textContent = '上传中...';
switchStageFlow(chip);
setActivePhase(chip === 'esp32' ? 'esp32Ota' : 'gd32Ota');
if (chip === 'esp32') updateEsp32StagesFromPhase(1);
// Reset verification panel
var verifyInfo = document.getElementById('stageVerifyInfo');
if (verifyInfo) verifyInfo.style.display = 'none';
var sviFront = document.getElementById('sviFrontSha');
var sviDevice = document.getElementById('sviDeviceSha');
var sviResult = document.getElementById('sviResult');
if (sviFront) sviFront.textContent = '-';
if (sviDevice) sviDevice.textContent = '-';
if (sviResult) { sviResult.textContent = '-'; sviResult.className = 'svi-val'; }
addLog('I', '上传', '开始上传: ' + file.name + ' \u2192 ' + chip.toUpperCase());
showToast('开始上传固件到 ' + chip.toUpperCase(), 'success');
try {
var arrayBuffer = await file.arrayBuffer();
// 刷写时重新计算 SHA-256(ESP32 和 GD32 都算)
if (chip === 'esp32') {
try {
var hashBuffer = await crypto.subtle.digest('SHA-256', arrayBuffer);
var hashArray = Array.from(new Uint8Array(hashBuffer));
var hashHex = hashArray.map(function(b) { return b.toString(16).padStart(2, '0'); }).join('');
window._esp32FileSha256 = hashHex.toUpperCase();
var shaElem = document.getElementById('fileSha256Esp32');
if (shaElem) shaElem.textContent = window._esp32FileSha256;
addLog('I', '文件', '刷写前重新计算 SHA-256: ' + window._esp32FileSha256);
} catch (e) {
addLog('W', '文件', 'SHA-256 重新计算失败: ' + e.message);
}
} else if (chip === 'gd32') {
try {
var hashBuffer = await crypto.subtle.digest('SHA-256', arrayBuffer);
var hashArray = Array.from(new Uint8Array(hashBuffer));
var hashHex = hashArray.map(function(b) { return b.toString(16).padStart(2, '0'); }).join('');
window._gd32FileSha256 = hashHex.toUpperCase();
addLog('I', '文件', 'GD32 SHA-256: ' + window._gd32FileSha256);
} catch (e) {
addLog('W', '文件', 'GD32 SHA-256 重新计算失败: ' + e.message);
window._gd32FileSha256 = null;
}
}
var fileSize = file.size;
var body, url = window.getBaseUrl() + '/ota/upload';
if (chip === 'esp32') {
var headerObj = { type: 'esp32', total_size: fileSize };
var payload = arrayBuffer;
addLog('I', '上传', '直传: ' + formatBytes(fileSize) +
'(设备边收边写 Flash;进度 100% 后仍需等 HTTP 响应)');
setProgress(1, '连接设备并开始写入…');
var headerJson = JSON.stringify(headerObj);
var encoder = new TextEncoder();
var headerBytes = encoder.encode(headerJson);
var combined = new Uint8Array(headerBytes.length + payload.byteLength);
combined.set(headerBytes, 0);
combined.set(new Uint8Array(payload), headerBytes.length);
body = combined.buffer;
} else {
var headerObj = { total_size: fileSize };
var payload = arrayBuffer;
addLog('I', '上传', '直传: ' + formatBytes(fileSize));
var gd32Header = JSON.stringify(headerObj);
var encoder = new TextEncoder();
var headerBytes = encoder.encode(gd32Header);
var combined = new Uint8Array(headerBytes.length + payload.byteLength);
combined.set(headerBytes, 0);
combined.set(new Uint8Array(payload), headerBytes.length);
body = combined.buffer;
}
window.activeXhr = new XMLHttpRequest();
var xhr = window.activeXhr;
xhr.upload.addEventListener('progress', function (e) {
if (e.lengthComputable) {
var pct = Math.round((e.loaded / e.total) * 100);
window.uploadProgress = pct;
if (chip === 'gd32') {
var overallPct = Math.min(45, Math.round(pct * 0.45));
setProgress(overallPct, '已上传 ' + formatBytes(e.loaded) + ' / ' + formatBytes(e.total));
} else if (pct >= 100) {
setProgress(100, '数据已发完,等待设备写 Flash 并响应…');
} else {
setProgress(pct, '已上传 ' + formatBytes(e.loaded) + ' / ' + formatBytes(e.total));
}
}
});
xhr.upload.addEventListener('load', function () {
// 请求体发送完毕 ≠ 服务端处理完毕
setProgress(100, '数据已发完,等待设备写 Flash 并响应…');
addLog('I', '上传', '浏览器已发完全部数据,等待 ESP32 HTTP 响应…');
startUploadWaitHeartbeat();
});
// 1MB+ 固件:服务端写 Flash 可能较久;禁止无限挂起无反馈
xhr.timeout = 300000;
xhr.addEventListener('timeout', function () {
stopUploadWaitHeartbeat();
if (window.activeXhr) {
cleanupAfterOtaFailure('上传超时(设备未在 5 分钟内响应)');
showToast('上传超时:设备可能忙或连接被踢,请重试', 'error');
}
});
xhr.addEventListener('load', function () {
stopUploadWaitHeartbeat();
window.isUploading = false;
window.activeXhr = null;
window.resumePollingAfterUpload();
uploadBtn.disabled = false;
uploadBtn.textContent = window.uploadBtnOrigText;
window.updateUploadButtonLabel();
if (xhr.status === 200) {
/* 校验 body:避免空响应/网关误 200 被当成上传成功 */
var bodyTxt = (xhr.responseText || '').trim();
var bodyOk = false;
try {
var bj = bodyTxt ? JSON.parse(bodyTxt) : null;
bodyOk = !!(bj && (bj.status === 'ok' || bj.target === chip));
} catch (_pe) {
bodyOk = bodyTxt.indexOf('"status"') >= 0 && bodyTxt.indexOf('ok') >= 0;
}
if (!bodyOk) {
addLog('E', '上传', 'HTTP 200 但响应异常: ' + (bodyTxt.slice(0, 120) || '(空)'));
showToast('上传响应异常,请重试', 'error');
cleanupAfterOtaFailure('响应异常');
return;
}
// 成功路径只打一条「上传完成」日志,避免 [上传]+[OTA] 语义重复
if (chip === 'esp32') {
addLog('I', 'OTA', '上传完成,开始 SHA-256 校验…');
showToast('固件上传成功', 'success');
setProgress(100, '上传完成');
markPhaseDone('esp32Ota');
updateEsp32StagesFromPhase(2); // OTA 写入阶段
// ★ 状态机:ESP32 上传完成 → 进入 VERIFYING(verifyEsp32Firmware 内部继续推进)
otaSessionTransition(OtaSessionState.VERIFYING);
verifyEsp32Firmware();
} else {
addLog('I', 'OTA', '上传完成,校验 SHA 并触发 GD32 中继…');
showToast('上传完成,GD32 OTA 已自动开始', 'success');
switchStageFlow('gd32');
setActivePhase('gd32Ota');
setProgress(45, '上传完成,校验并触发 GD32 OTA…');
// ★ 状态机:GD32 上传完成 → 进入 RELAYING(triggerGd32Relay 内部驱动中继)
otaSessionTransition(OtaSessionState.RELAYING);
window.triggerGd32Relay();
}
} else if (isOtaBusyError(xhr.status, formatHttpErrorDetail(xhr.responseText))) {
var busyDetail = formatHttpErrorDetail(xhr.responseText);
addLog('E', '上传', chip.toUpperCase() + ' OTA 被其他通道占用(BLE/WiFi 互斥)' + (busyDetail ? ': ' + busyDetail : ''));
showToast('OTA 通道被占用,请稍后重试', 'error');
cleanupAfterOtaFailure('通道被占用');
} else {
var detail = formatHttpErrorDetail(xhr.responseText);
var message = '上传失败: HTTP ' + xhr.status + (detail ? ' - ' + detail : '');
addLog('E', '上传', message);
showToast(detail ? ('上传失败: ' + detail) : '上传失败', 'error');
cleanupAfterOtaFailure('HTTP ' + xhr.status);
}
});
xhr.addEventListener('error', function () {
stopUploadWaitHeartbeat();
if (window.activeXhr) {
cleanupAfterOtaFailure('网络错误');
showToast('上传过程发生网络错误', 'error');
}
});
xhr.addEventListener('abort', function () {
stopUploadWaitHeartbeat();
if (window.activeXhr) {
cleanupAfterOtaFailure('已中止');
showToast('上传已中止', 'error');
}
});
xhr.open('POST', url);
xhr.send(body);
} catch (e) {
addLog('E', '上传', '上传失败: ' + e.message);
showToast('上传失败: ' + e.message, 'error');
cleanupAfterOtaFailure('上传异常');
}
}
// ==================== ESP32 OTA 完成升级 ====================
// 一次调用完成:校验 SHA-256 → 设置启动分区 → 自动重启(无需确认按钮)
/** @description 验证 ESP32 固件 SHA-256 是否与设备端计算值一致。 */
export async function verifyEsp32Firmware() {
updateEsp32StagesFromPhase(3); // 校验中
var sha256 = window._esp32FileSha256;
if (sha256) {
addLog('I', 'OTA', '前端文件 SHA-256: ' + sha256);
var verifyInfo = document.getElementById('stageVerifyInfo');
if (verifyInfo) verifyInfo.style.display = 'block';
var frontSha = document.getElementById('sviFrontSha');
if (frontSha) frontSha.textContent = sha256;
}
showToast('ESP32 正在校验固件并应用...', 'success');
try {
// 带 sha256 时设备端校验、set_boot 并自动重启;无需二次“应用”请求
var requestBody = {};
if (sha256) requestBody.sha256 = sha256;
var resp = await fetch(window.getBaseUrl() + '/ota/update', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody),
signal: AbortSignal.timeout(120000)
});
var data = await resp.json();
if (data.status === 'ok') {
addLog('I', 'OTA', '设备端 SHA-256: ' + (data.sha256 || '-'));
if (data.sha256) {
var deviceSha = document.getElementById('sviDeviceSha');
if (deviceSha) deviceSha.textContent = data.sha256;
}
var resultEl = document.getElementById('sviResult');
if (resultEl) {
if (sha256 && data.sha256 && sha256.toLowerCase() === data.sha256.toLowerCase()) {
resultEl.textContent = '验证通过';
resultEl.className = 'svi-val ok';
} else {
resultEl.textContent = '已应用(未比对 SHA)';
resultEl.className = 'svi-val ok';
}
}
// 服务器已设置启动分区并自动重启,前端直接进入重启等待状态
addLog('I', 'OTA', 'SHA-256 校验通过,设备自动重启中...');
showToast('校验通过,设备重启中...', 'success');
updateEsp32StagesFromPhase(4); // 应用固件
window.rebootObservedDown = false; // 重置:尚未观察到设备实际下线
updateEsp32StagesFromPhase(5); // 重启中
window.rebootStartTime = Date.now();
// ★ 状态机:校验通过 → 进入 REBOOTING(内部同步 isEsp32Rebooting = true)
otaSessionTransition(OtaSessionState.REBOOTING);
} else {
var detail = data.message || (data.device ? '不匹配' : '未知错误');
addLog('E', 'OTA', '校验失败: ' + detail);
showToast('校验失败: ' + detail, 'error');
var resultEl = document.getElementById('sviResult');
if (resultEl) { resultEl.textContent = '✗ ' + detail; resultEl.className = 'svi-val err'; }
cleanupAfterOtaFailure('校验失败');
}
} catch (e) {
// fetch 异常仅代表请求未完成,不能推断设备已重启:
// 设备可能仍在 finalize(SHA 计算耗时)、可能已崩溃、可能网络抖动。
// 如实报告失败,由 pollStatus 探测设备真实状态。
addLog('E', 'OTA', '应用请求失败: ' + e.message + '(设备状态未知,等待轮询探测)');
showToast('应用请求失败: ' + e.message, 'error');
var resultEl = document.getElementById('sviResult');
if (resultEl) { resultEl.textContent = '✗ 请求失败: ' + e.message; resultEl.className = 'svi-val err'; }
cleanupAfterOtaFailure('应用请求失败');
}
}
export async function restartEsp32() {
if (!confirm('确定要重启 ESP32 吗?')) return;
addLog('I', '系统', '=== 请求重启 ESP32 ===');
showToast('ESP32 正在重启...', 'success');
try {
var resp = await fetch(window.getBaseUrl() + '/restart', {
method: 'POST', signal: AbortSignal.timeout(5000)
});
if (resp.ok) {
var data = await resp.json();
addLog('I', '系统', '重启已触发: ' + data.message);
showToast('ESP32 重启中,页面将自动恢复...', 'success');
window.isEsp32Rebooting = true;
window.rebootObservedDown = false; // 重置:尚未观察到设备实际下线
window.rebootStartTime = Date.now();
}
} catch (e) {
addLog('E', '系统', '重启请求失败: ' + e.message);
showToast('重启失败', 'error');
}
}
+212
View File
@@ -0,0 +1,212 @@
/**
* upload-gd32.js - GD32 中继 OTA 触发 + relay 态跟踪
*
* 从 app.js 的「GD32 OTA 触发(带 SHA-256 校验,对齐 BLE 0xF7)」段提取。
* 包含:relay 进度观察定时器、markGd32OtaUiComplete、triggerGd32Relay。
*
* 共享状态(通过 window.* 引用):
* window.gd32CompleteAnnounced / window.relayWatchLastSeq / window.gd32RelaySessionLive
* window.pollingPausedForUpload / window.statusPollIntervalMs
* window.otaSessionState / window.lastSysStateCode / window.lastRelayState
*
* 未拆分的函数(通过 window.* 引用):
* window.otaSessionTransition / window.OtaSessionState / window.otaSessionActive
* window.markPhaseDone / window.setProgress / window.updateSystemStatusUi
* window.cleanupAfterOtaFailure / window.scheduleStatusPoll / window.pollLogs
* window.resumePollingAfterUpload / window.getBaseUrl / window.showToast / window.addLog
*/
import { showToast } from '../common/toast.js';
import { addLog } from '../common/log.js';
import {
otaSessionTransition, otaSessionActive, OtaSessionState
} from './session.js';
import { markPhaseDone, setProgress } from './progress.js';
/**
* GD32 中继进度观察:以 /system/status 为唯一真相源。
* 日志环只有 64 条,中继/擦除期间 INFO 会被挤掉,时间戳还是浏览器本地时间——
* 绝不能用「最后一条日志停在 SEND_SIZE」判断卡死。
*/
let relayWatchTimerId = null;
let relayWatchInFlight = false;
window.gd32CompleteAnnounced = false;
window.relayWatchLastSeq = -1;
/** 本会话是否已观察到真实中继态(1~4),避免旧 relay_state=5 残留导致一点击就“完成” */
window.gd32RelaySessionLive = false;
export function markGd32OtaUiComplete(msg) {
// 已公告过:禁止再改 className / setProgress / toast,否则每 400ms 重触发动效会「疯狂跳 ✓」
if (window.gd32CompleteAnnounced) {
return;
}
/* 仅本会话 RELAYING/FAILED 可宣布完成;IDLE/UPLOADING 时 status 里的旧 relay 残留一律忽略 */
if (window.otaSessionState !== OtaSessionState.RELAYING &&
window.otaSessionState !== OtaSessionState.FAILED) {
return;
}
window.gd32CompleteAnnounced = true;
stopRelayProgressWatch();
markPhaseDone('gd32Ota');
window.currentOtaTarget = null;
setProgress(100, msg || 'GD32 刷写完成');
var sbDone = document.getElementById('stageStatusBar');
var stDone = document.getElementById('stageStatusText');
if (sbDone) sbDone.className = 'stage-status-bar ok';
if (stDone) stDone.textContent = msg || 'GD32 刷写完成';
if (window.otaSessionState === OtaSessionState.RELAYING ||
window.otaSessionState === OtaSessionState.FAILED) {
otaSessionTransition(OtaSessionState.DONE);
}
addLog('I', 'OTA', msg || 'GD32 刷写完成');
showToast(msg || 'GD32 刷写完成', 'success');
}
export function stopRelayProgressWatch() {
if (relayWatchTimerId) {
clearInterval(relayWatchTimerId);
relayWatchTimerId = null;
}
relayWatchInFlight = false;
if (window.statusPollIntervalMs < 1000) {
window.statusPollIntervalMs = 1000;
}
}
export function startRelayProgressWatch() {
stopRelayProgressWatch();
if (window.pollingPausedForUpload) {
window.resumePollingAfterUpload();
}
window.statusPollIntervalMs = 400;
window.scheduleStatusPoll(0);
window.pollLogs();
addLog('I', 'OTA', '跟踪中继进度:看进度条与 relay_seq(/system/status),勿只看日志');
relayWatchTimerId = setInterval(function () {
var active = window.otaSessionState === OtaSessionState.RELAYING ||
(window.otaSessionState === OtaSessionState.FAILED &&
(window.lastSysStateCode === 3 || window.lastRelayState >= 1 && window.lastRelayState <= 4));
if (!active) {
if (window.otaSessionState === OtaSessionState.DONE) {
stopRelayProgressWatch();
}
return;
}
if (relayWatchInFlight) return;
relayWatchInFlight = true;
fetch(window.getBaseUrl() + '/system/status', { signal: AbortSignal.timeout(3000) })
.then(function (resp) { return resp.ok ? resp.json() : null; })
.then(function (data) {
if (!data) return;
try { window.updateSystemStatusUi(data); } catch (e) { /* ignore */ }
var o = data.ota || {};
var code = Number(o.sys_state_code);
var rstate = Number(o.relay_state);
var alive = !!o.gd32_app_alive;
var rseq = Number(o.relay_seq) || 0;
var rtot = Number(o.relay_total_packets) || 0;
if (rstate >= 1 && rstate <= 4) {
window.gd32RelaySessionLive = true;
}
if (window.gd32RelaySessionLive &&
(code === 4 || rstate === 5 ||
(alive && rtot > 0 && rseq >= rtot && rstate >= 3))) {
markGd32OtaUiComplete('GD32 刷写完成');
window.pollLogs();
return;
}
if (code === 8 || rstate === 255) {
stopRelayProgressWatch();
window.pollLogs();
return;
}
// 前端进度同步:包数变化时更新进度条(updateSystemStatusUi 已做)并写一条前端日志
// 后端不再打「转发进度」文字,只靠这里 + 进度条
if (rtot > 0 && rseq > 0 && rseq !== window.relayWatchLastSeq) {
window.relayWatchLastSeq = rseq;
var pct = Math.round(rseq * 100 / rtot);
setProgress(60 + Math.round(pct * 0.30),
'已转发 ' + rseq + ' / ' + rtot + ' 包 (' + pct + '%)');
addLog('I', 'OTA', '转发 ' + rseq + '/' + rtot + ' (' + pct + '%)');
}
})
.catch(function () { /* 下轮继续 */ })
.then(function () { relayWatchInFlight = false; });
}, 400);
}
/** @description 触发 GD32 中继 OTA 流程(通过 HTTP 接口)。 */
export async function triggerGd32Relay() {
var sha256 = window._gd32FileSha256;
if (!sha256) {
addLog('E', 'OTA', '缺少 GD32 文件 SHA-256,请先上传文件');
showToast('SHA-256 缺失', 'error');
window.cleanupAfterOtaFailure('SHA-256 缺失');
return;
}
addLog('I', 'OTA', 'GD32 SHA-256: ' + sha256 + ' → 触发中继...');
showToast('正在触发 GD32 OTA', 'success');
setProgress(48, '校验 SHA 并触发 GD32 中继…');
try {
// 仅触发中继(设备端异步跑),不阻塞到刷写结束;SHA 读分区可能数秒
var resp = await fetch(window.getBaseUrl() + '/ota/relay', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sha256: sha256 }),
signal: AbortSignal.timeout(120000)
});
var text = await resp.text();
if (resp.ok) {
try {
var data = JSON.parse(text);
if (data.sha256) addLog('I', 'OTA', '设备端 SHA-256: ' + data.sha256);
} catch (_) {}
addLog('I', 'OTA', 'GD32 OTA 已触发;进度看状态栏包数(勿只看日志时间戳)');
showToast('GD32 OTA 已触发', 'success');
setProgress(50, '中继已启动,同步进度…');
window.gd32CompleteAnnounced = false;
window.relayWatchLastSeq = -1;
startRelayProgressWatch();
} else {
// 尝试解析错误响应中的 device SHA
try {
var err = JSON.parse(text);
if (err.device) {
addLog('E', 'OTA', 'GD32 SHA-256 不匹配: 设备=' + err.device + ' 前端=' + sha256);
showToast('SHA-256 不匹配', 'error');
window.cleanupAfterOtaFailure('SHA-256 不匹配');
return;
}
} catch (_) {}
addLog('E', 'OTA', 'GD32 触发失败: HTTP ' + resp.status + ' ' + text);
showToast('GD32 触发失败', 'error');
window.cleanupAfterOtaFailure('触发失败 HTTP ' + resp.status);
}
} catch (e) {
// 请求超时/断连 ≠ 设备未启动中继:先探测 status 再决定是否标失败
try {
var probe = await fetch(window.getBaseUrl() + '/system/status', {
signal: AbortSignal.timeout(3000)
});
if (probe.ok) {
var st = await probe.json();
var o = st.ota || {};
var code = Number(o.sys_state_code);
var rstate = Number(o.relay_state);
if (code === 3 || (rstate >= 1 && rstate <= 4)) {
addLog('W', 'OTA', '触发请求中断(' + e.message + '),但设备已在中继,继续跟踪进度');
showToast('设备已在刷写,同步进度中', 'success');
if (window.otaSessionState !== OtaSessionState.RELAYING) {
otaSessionTransition(OtaSessionState.RELAYING);
}
try { window.updateSystemStatusUi(st); } catch (_) {}
startRelayProgressWatch();
return;
}
}
} catch (_) { /* probe 失败再走失败清理 */ }
addLog('E', 'OTA', 'GD32 触发失败: ' + e.message);
showToast('GD32 触发失败', 'error');
window.cleanupAfterOtaFailure('触发异常');
}
}
-2146
View File
File diff suppressed because it is too large Load Diff