From c0ca4e2a9f43d16fcd263ace9acc13ce175d47b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=B0=8F=E9=BE=99=E8=99=BE?= Date: Sat, 11 Jul 2026 17:51:42 +0800 Subject: [PATCH] =?UTF-8?q?feat(can):=20=E4=BC=98=E5=8C=96CAN=E5=8F=8D?= =?UTF-8?q?=E9=A6=88=E4=BB=BB=E5=8A=A1=E9=94=99=E8=AF=AF=E6=97=A5=E5=BF=97?= =?UTF-8?q?=E8=AE=B0=E5=BD=95=E6=9C=BA=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加s_lastLoggedError数组跟踪各通道最后记录的错误计数 - 实现错误计数变化时才触发告警,避免重复打印 - 扩展错误日志信息,包含TEC、REC和总线状态详细信息 - 修复P0/P1页面携带相同统计数据时的重复打印问题 perf(ota): 移除冗余的OTA协调器状态检查方法 - 删除isGd32BusyState、isEsp32BusyState等未使用的状态判断方法 - 移除esp32FinalizeErrCategory错误分类函数 - 清理相关的头文件声明,减少代码冗余 refactor(relay): 移除未使用的中继状态获取方法 - 删除getRelaySeq、getRelayTotalPackets状态查询接口 - 移除getGd32Version版本获取方法 - 清理StateLockGuard中的isLocked方法 --- .trae/documents/fix-esp32-reboot-ui-race.md | 124 +++++++++++ ...ss_2a2d2d21-42f5-42bc-a2db-8dcc73396c9f.md | 116 ++++++++++ ...ss_9aded586-08ab-42db-88f4-d4953189bd11.md | 117 ++++++++++ main/include/version.h | 8 +- main/monitor/can_feedback_task.cpp | 32 +-- main/monitor/can_feedback_task.h | 15 -- main/ota/ota_coordinator.cpp | 58 +---- main/ota/ota_coordinator.h | 50 ----- main/relay/ota_relay.cpp | 19 -- main/relay/ota_relay.h | 16 -- main/server/http_handlers.cpp | 103 --------- main/server/wifi_ota.h | 2 - main/transport/canfd_bridge.cpp | 86 -------- main/transport/canfd_bridge.h | 24 --- tools/app.js | 203 ++---------------- tools/ble_ota.js | 26 +-- tools/can.js | 19 +- tools/index.html | 16 -- 18 files changed, 404 insertions(+), 630 deletions(-) create mode 100644 .trae/documents/fix-esp32-reboot-ui-race.md create mode 100644 .zcode/plans/plan-sess_2a2d2d21-42f5-42bc-a2db-8dcc73396c9f.md create mode 100644 .zcode/plans/plan-sess_9aded586-08ab-42db-88f4-d4953189bd11.md diff --git a/.trae/documents/fix-esp32-reboot-ui-race.md b/.trae/documents/fix-esp32-reboot-ui-race.md new file mode 100644 index 0000000..95fce35 --- /dev/null +++ b/.trae/documents/fix-esp32-reboot-ui-race.md @@ -0,0 +1,124 @@ +# 修复 ESP32 OTA 重启 UI 竞态问题 + +## 问题描述 + +前端显示 `SHA-256 校验通过,设备自动重启中...` 消息后,"重启中"阶段 UI 不会稳定触发(仅偶发触发一次)。 + +## 根因分析 + +**后端时序**([http_handlers.cpp](file:///g:/Robot_v2/DCU/ESP32_COMM/main/server/http_handlers.cpp#L148-L152) + [ota_coordinator.cpp](file:///g:/Robot_v2/DCU/ESP32_COMM/main/ota/ota_coordinator.cpp#L550-L556)): +1. `httpd_resp_send()` 发送 HTTP 响应(L148) +2. `vTaskDelay(500ms)` 等待 TCP 刷新(L151) +3. `restartEsp32()` → `notifyEsp32Rebooting()` + `vTaskDelay(500ms)` + `esp_restart()`(L152) + +即:**HTTP 响应发出后,设备仍存活约 1000ms 才真正重启**。 + +**前端竞态**([app.js](file:///g:/Robot_v2/DCU/ESP32_COMM/tools/app.js#L1496-L1498)): +1. `verifyEsp32Firmware()` 收到 `/ota/update` 成功响应后,在 L1496 设置 `isEsp32Rebooting = true`,L1497 推进阶段到 5"重启中"。 +2. 此时轮询以 1s 间隔运行(上传结束后 `resumePollingAfterUpload()` 已恢复 1s 间隔)。 +3. **在 ~1000ms 的 pre-reboot 窗口内,设备仍能响应 `/system/status` 轮询**。 +4. `pollStatus()` 成功路径([app.js:1740-1745](file:///g:/Robot_v2/DCU/ESP32_COMM/tools/app.js#L1740-L1745))发现 `isEsp32Rebooting === true`,**立即清除标志并推进到阶段 6"启动完成"+ 弹出"ESP32 已恢复在线"** —— 全部错误。 +5. 当设备 ~1s 后真正重启时,轮询失败,但 `isEsp32Rebooting` 已被清为 false,失败分支走"设备忙"或"未连接",**永远不会再显示"重启中"**。 + +**偶发触发的原因**:仅当轮询时序恰好使得 pre-reboot 窗口内没有成功的轮询(例如轮询正在 in-flight 且设备重启打断它)时,"重启中"UI 才能正确显示。 + +## 修复方案 + +引入 `rebootObservedDown` 标志,要求"先观察到设备下线,再接受其恢复在线",避免在 pre-reboot 窗口内被成功轮询误清除重启状态。 + +### 改动文件:[tools/app.js](file:///g:/Robot_v2/DCU/ESP32_COMM/tools/app.js) + +#### 1. 新增全局标志(L28 附近) + +在 `let rebootStartTime = 0;` 下方新增: +```js +let rebootObservedDown = false; // 是否已观察到设备实际下线(重启) +``` + +#### 2. arming 点 1:`verifyEsp32Firmware()` 成功路径(L1496-L1498) + +```js +isEsp32Rebooting = true; +rebootObservedDown = false; // ← 新增 +updateEsp32StagesFromPhase(5); // 重启中 +rebootStartTime = Date.now(); +``` + +#### 3. arming 点 2:`restartEsp32()` 手动重启(L1531-L1532) + +```js +isEsp32Rebooting = true; +rebootObservedDown = false; // ← 新增 +rebootStartTime = Date.now(); +``` + +#### 4. `pollStatus()` 成功路径(L1740-L1746)—— 核心修复 + +将无条件清除改为"先确认设备下线过再清除": + +```js +if (isEsp32Rebooting) { + var rbElapsed = Math.round((Date.now() - rebootStartTime) / 1000); + if (!rebootObservedDown && rbElapsed < 10) { + // pre-reboot 窗口:设备尚未实际重启(后端有 ~1s 延迟才 esp_restart) + // 保持"重启中"状态,不清除标志,不推进阶段 + addLog('I', '系统', '设备尚未实际重启,等待 esp_restart...'); + } else { + // 设备已重启并恢复在线(rebootObservedDown=true) + // 或超 10s 安全阈值仍未观察到下线(重启过快未捕获或未重启)——接受恢复 + isEsp32Rebooting = false; + rebootObservedDown = false; + updateEsp32StagesFromPhase(6); // 启动完成 + addLog('I', '系统', 'ESP32 已恢复在线'); + showToast('ESP32 已恢复在线', 'success'); + } +} +try { updateSystemStatusUi(data); } catch (e2) { console.warn('updateSystemStatusUi 异常:', e2); } +// pre-reboot 窗口内 updateSystemStatusUi 会把连接胶囊刷成"已连接",需覆盖回"重启中" +if (isEsp32Rebooting) { + applyRebootingRuntimeState(); +} +``` + +#### 5. `pollStatus()` 失败路径 isEsp32Rebooting 分支(L1781-L1791)—— 标记已下线 + +```js +} else if (isEsp32Rebooting) { + rebootObservedDown = true; // ← 新增:确认设备已下线(正在重启) + var elapsed = Math.round((Date.now() - rebootStartTime) / 1000); + if (elapsed > 60) { + isEsp32Rebooting = false; + rebootObservedDown = false; // ← 新增 + addLog('E', '系统', 'ESP32 重启超时,设备未恢复'); + showToast('ESP32 重启超时,请检查设备', 'error'); + updateConnectionUi(false, '未连接'); + applyDisconnectedRuntimeState(); + } else { + applyRebootingRuntimeState(); + } +} +``` + +## 时序验证(修复后) + +| 时间 | 事件 | isEsp32Rebooting | rebootObservedDown | UI | +|------|------|------------------|--------------------|-----| +| t=0 | 收到 /ota/update 响应,arming | true | false | 阶段5"重启中" | +| t=0~1s | pre-reboot 窗口,轮询成功 | true | false | 保持"重启中"(不清除)✓ | +| t=1s | esp_restart(),设备下线 | true | false | — | +| t=1~2s | 轮询失败 | true | **true** | "重启中 Ns" ✓ | +| t=3~4s | 设备重启完成,轮询成功 | → false | → false | 阶段6"启动完成" ✓ | + +## 假设与决策 + +- **10s 安全阈值**:ESP32 重启(boot ROM + bootloader + app init + WiFi init)通常 1-3s,1s 间隔轮询至少 1-2 次失败,`rebootObservedDown` 必然被设置。10s 阈值仅用于兜底(设备未实际重启或重启极快未捕获到下线)。 +- **不修改后端时序**:后端 1000ms 延迟(500ms TCP 刷新 + 500ms reboot 通知窗口)是必要的(确保响应送达 + 状态上报),问题在前端竞态处理。 +- **仅修改 WiFi 路径**:BLE 路径使用事件驱动(0x07 REBOOT_NOW + 断连重连),不受此竞态影响。 + +## 验证步骤 + +1. 通过 WiFi 上传 ESP32 固件并完成 SHA-256 校验 +2. 确认显示 `SHA-256 校验通过,设备自动重启中...` 后,阶段稳定停留在 5"重启中",连接胶囊显示"重启中 Ns"计数 +3. 不应出现阶段跳到 6"启动完成"再回退的情况 +4. 等待设备实际重启完成,确认阶段推进到 6"启动完成"并弹出"ESP32 已恢复在线" +5. 多次重复验证(此前为偶发问题,修复后应 100% 稳定) diff --git a/.zcode/plans/plan-sess_2a2d2d21-42f5-42bc-a2db-8dcc73396c9f.md b/.zcode/plans/plan-sess_2a2d2d21-42f5-42bc-a2db-8dcc73396c9f.md new file mode 100644 index 0000000..c8c1328 --- /dev/null +++ b/.zcode/plans/plan-sess_2a2d2d21-42f5-42bc-a2db-8dcc73396c9f.md @@ -0,0 +1,116 @@ +# 死函数清理与兼容标记计划 + +## 目标 +逐函数扫描 `main/`(C++)、`tools/`(JS)及根目录 Python 脚本,识别: +1. **死函数**:已定义但无调用、未注册、未绑定,直接清理。 +2. **旧版本兼容函数**:仍被调用但无实际作用,或注释/命名表明为兼容保留,进行明确标记(保留并加注释,或连同调用点一起移除)。 + +扫描已完成,共覆盖约 349 个 C++ 定义、~130 个 JS 函数、2 个 Python 脚本。 + +--- + +## 一、C++ 死函数(建议删除) + +| # | 函数/方法 | 文件 | 行 | 说明 | +|---|-----------|------|----|------| +| 1 | `esp_err_t ota_prepare_handler(httpd_req_t *req)` | `main/server/http_handlers.cpp` | 202 | 完整实现,但 `wifi_ota.cpp` 的 `kHttpRoutes` 未注册 `/ota/prepare`,无任何调用。 | +| 2 | `bool WifiOta::getGd32StorageDone() const` | `main/server/wifi_ota.h` | 73 | 内联 getter;`setGd32StorageDone()` 在用,getter 无调用。 | +| 3 | `bool OtaRelay::StateLockGuard::isLocked() const` | `main/relay/ota_relay.h` | 241 | 公共嵌套类方法,无调用。 | +| 4 | `int canFdSendNoReply(...)` | `main/transport/canfd_bridge.cpp/h` | 93/61 | 无调用者。 | +| 5 | `int canFdPullIdSnapshot(...)` | `main/transport/canfd_bridge.cpp/h` | 183/158 | 无调用者。 | +| 6 | `bool getLatestIdSnapshot(...)` | `main/monitor/can_feedback_task.cpp/h` | 156/44 | 无调用者;`getLatestCanFeedback()` 已覆盖同一需求。 | +| 7 | `const char* OtaCoordinator::getSystemStateString() const` | `main/ota/ota_coordinator.cpp/h` | 711/438 | 注释已写明“保持向后兼容”,但无任何调用。 | +| 8 | `int OtaCoordinator::esp32FinalizeErrCategory(esp_err_t err)` | `main/ota/ota_coordinator.cpp/h` | 563/466 | 声明、定义齐全,无调用。 | +| 9 | `bool OtaCoordinator::isGd32Busy() const` | `main/ota/ota_coordinator.cpp/h` | 680/424 | 无调用;`isGd32BusyState()` 仍保留供内部使用。 | +| 10 | `bool OtaCoordinator::isEsp32Busy() const` | `main/ota/ota_coordinator.cpp/h` | 686/431 | 无调用;`isEsp32BusyState()` 仍保留。 | +| 11 | `bool OtaCoordinator::isGd32StorageDone() const` | `main/ota/ota_coordinator.cpp/h` | 723/? | 无调用;`getSnapshot()` 直接读取 `m_gd32StorageDone`。 | +| 12 | `static void OtaRelay::getGd32Version(gd32_version_t *out)` | `main/relay/ota_relay.cpp/h` | 144/156 | 无调用;`getSnapshot()` 已包含 version。 | +| 13 | `static uint32_t OtaRelay::getRelaySeq()` | `main/relay/ota_relay.cpp/h` | 126/141 | 无调用;`getSnapshot()` 已包含 seq。 | +| 14 | `static uint32_t OtaRelay::getRelayTotalPackets()` | `main/relay/ota_relay.cpp/h` | 132/146 | 无调用;`getSnapshot()` 已包含 total_packets。 | + +### C++ 清理动作 +- 删除上述函数的实现与对应头文件声明。 +- 删除 `ota_prepare_handler` 后,检查并清理其专用的 `clear_esp32_prepare` / `clear_gd32_prepare` 静态状态变量是否仍被其他 handler 使用;若仅由 `ota_prepare_handler` 使用则一并移除。 +- 删除 `getLatestIdSnapshot` 后,若 `id_snapshot_cache_t` 类型不再被使用,保留在头文件中(作为公共类型)或同步删除。 + +--- + +## 二、C++ 旧版本兼容标记(建议保留并强化注释) + +| 函数/成员 | 文件 | 说明 | +|-----------|------|------| +| `ble_ota_start_advertising(void)` | `main/server/ble_ota.cpp` | 注释已写“kept as free function for BLE callback compatibility”,确实被注册使用,保留并维持注释。 | +| `volatile bool m_wifiConnected` | `main/monitor/health_monitor.h` | 注释已写“保留为历史兼容”,保留。 | +| CANFD/SPI/Flash 迁移注释 | `protocol.cpp`、`spi_master.h/.cpp`、`ota_relay.h` | 已是历史迁移说明,无需改动。 | +| NVS 兼容 fallback | `gd32_flash_store.cpp` | 注释“视为空元数据,保持兼容”,保留。 | + +--- + +## 三、JS 死函数(建议删除) + +### `tools/app.js` +| 函数 | 行 | 说明 | +|------|----|------| +| `sendCanFrame()` | 925 | 引用已移除的 `#canChannel/#canId/#canDlc/#canData`。 | +| `clearCanFields()` | 968 | 同上。 | +| `updateCanHint()` | 376 | 同上;事件绑定也指向已移除 DOM。 | +| `setHelper(id, text, type)` | 370 | 仅被 `updateCanHint()` 调用。 | +| `retryEsp32Upload()` | 1629 | 无按钮/调用者。 | +| `cancelEsp32Ota()` | 1643 | 无按钮/调用者。 | +| `applyEsp32Firmware()` | 1655 | 当前流程由 `verifyEsp32Firmware()` 直接 `/ota/update` 应用。 | +| `triggerRelay()` | 1759 | 仅转发 `triggerGd32Relay()`,注释写“旧页面兜底”。 | + +### `tools/app.js` 兼容/空壳处理 +| 函数 | 行 | 建议 | +|------|----|------| +| `showVerifyActions(type)` | 179 | 空壳,被 `verifyEsp32Firmware()` 等调用。删除函数体及所有调用点(因为 UI 按钮已移除)。 | +| `setOtaProgressBadge()` | 782 | 空壳局部函数,被多处调用。删除函数及所有调用点。 | + +### `tools/ble_ota.js` +| 函数 | 行 | 说明 | +|------|----|------| +| `restartEsp32()` | 837 | IIFE 内部定义,无绑定/调用;全局 `restartEsp32()` 来自 `app.js` 已绑定按钮。 | + +### `tools/can.js` +| 函数 | 行 | 说明 | +|------|----|------| +| `metaCard(label, value)` | 747 | 从未调用。 | +| `renderSignalGrid(/* message */)` | 751 | 空壳,无调用者。 | +| `renderSelectedMessage()` | 742 | 空壳但**仍被 `selectMessage` 等多个调用点调用**,是兼容 shim。保留并强化注释“旧版信号编辑面板兼容 shim”。 | + +### `tools/index.html` +| 函数 | 行 | 说明 | +|------|----|------| +| `window.attachSharedFirmwareDropBridge` | 639 | 定义但从未调用;`app.js` 的 `setupDropZone()` 已处理 drop。 | + +--- + +## 四、Python 脚本 + +- `ota_upload_test.py`、`serial_monitor.py` 均为纯顺序脚本,无函数定义,无需改动。 + +--- + +## 五、验证步骤 + +1. 修改后执行 `idf.py build`(或等价的 `idf.py fullclean` + `build`)验证 C++ 无编译/链接错误。 +2. 浏览器打开 `tools/index.html`、`tools/can.html`,验证控制台无 `xxx is not defined` 报错。 +3. 检查 C++ 中是否产生新的 `-Wunused-function` / `-Wunused-variable` 警告。 +4. 对存疑项做二次 grep 确认无调用。 + +--- + +## 六、风险与保留项 + +- `canFdSendNoReply` / `canFdPullIdSnapshot` 是公共 API,当前无调用。若你计划后续通过 HTTP/SPI 暴露它们,可以改为加 `[[maybe_unused]]` 或保留注释;否则按死函数删除。 +- `ota_prepare_handler` 实现完整,若你打算未来启用 `/ota/prepare` 端点,应改为注册而非删除。本次计划按“当前死代码”删除处理。 +- `renderSelectedMessage()` 仍被调用,保留作为明确的旧版兼容 shim。 + +--- + +## 七、交付物 + +1. 修改后的 C++ 源文件与头文件(死函数已删除,兼容项已标记/保留)。 +2. 修改后的 JS 文件(死函数已删除,空壳 shim 已清理)。 +3. 一份简短变更摘要,列出删除的函数及保留的兼容 shim。 +4. `idf.py build` 成功输出。 \ No newline at end of file diff --git a/.zcode/plans/plan-sess_9aded586-08ab-42db-88f4-d4953189bd11.md b/.zcode/plans/plan-sess_9aded586-08ab-42db-88f4-d4953189bd11.md new file mode 100644 index 0000000..47e178e --- /dev/null +++ b/.zcode/plans/plan-sess_9aded586-08ab-42db-88f4-d4953189bd11.md @@ -0,0 +1,117 @@ +## 实施计划:DCU 初始化时通过 PC9 复位 ESP32 + +### 背景确认 +- 目标仓库:`G:/Robot_v2/DCU/DCU_APP/GD32E508_DCU_APP`(GD32 侧 DCU 固件)。 +- PC9 当前在 DCU_APP 中未被使用,不会与 USART0/CAN/SPI/EXMC 等冲突。 +- ESP32 EN/RESET 为低电平复位、高电平释放;你给出的 50ms 低电平保持后释放逻辑正确。 +- 项目风格:配置集中在 `sys/config.h`,外设封装为 `device/*.h` 中的 C++ class,延迟统一用 `SysTickTimer::delayMs()`,GPIO 清 0 用 `gpio_bit_reset()`。 + +### 修改点 + +#### 1. `sys/config.h`:增加 ESP32 复位引脚配置 +在 SPI Ready 配置附近新增: +```cpp +/** @brief ESP32 硬件复位控制端口(PC9 接 ESP32 EN/RESET)。 */ +constexpr uint32_t CONFIG_ESP32_RESET_PORT = GPIOC; +/** @brief ESP32 硬件复位控制引脚。 */ +constexpr uint32_t CONFIG_ESP32_RESET_PIN = GPIO_PIN_9; +/** @brief ESP32 复位低电平保持时间,单位 ms。 */ +constexpr uint32_t CONFIG_ESP32_RESET_HOLD_MS = 50U; +``` + +#### 2. 新增 `device/esp32_reset.h` +按现有 `device/watchdog.h` 风格编写: +```cpp +#ifndef DEVICE_ESP32_RESET_H +#define DEVICE_ESP32_RESET_H + +#include + +class Esp32Reset { +public: + /** @brief 初始化 PC9 为推挽输出,先拉低 50ms 再释放 ESP32。 */ + static void init(); + + /** @brief 主动拉低 ESP32 RESET(进入复位)。 */ + static void assertReset(); + + /** @brief 释放 ESP32 RESET(高电平,ESP32 启动)。 */ + static void releaseReset(); +}; + +#endif /* DEVICE_ESP32_RESET_H */ +``` + +#### 3. 新增 `device/esp32_reset.cpp` +按现有 `device/watchdog.cpp` 风格编写: +```cpp +#include "device/esp32_reset.h" +#include "sys/config.h" +#include "sys/systick.h" +#include "main.h" + +void Esp32Reset::init() +{ + /* 1. 使能 GPIOC 时钟 */ + rcu_periph_clock_enable(RCU_GPIOC); + + /* 2. 先把输出数据寄存器清零,确保配置成输出时就是低电平 */ + gpio_bit_reset(CONFIG_ESP32_RESET_PORT, CONFIG_ESP32_RESET_PIN); + + /* 3. 配置 PC9 为推挽输出 */ + gpio_init(CONFIG_ESP32_RESET_PORT, GPIO_MODE_OUT_PP, GPIO_OSPEED_50MHZ, CONFIG_ESP32_RESET_PIN); + + /* 4. 保持复位 50ms,等电源和其他 IO 稳定 */ + SysTickTimer::delayMs(CONFIG_ESP32_RESET_HOLD_MS); + + /* 5. 释放 ESP32 */ + gpio_bit_set(CONFIG_ESP32_RESET_PORT, CONFIG_ESP32_RESET_PIN); +} + +void Esp32Reset::assertReset() +{ + gpio_bit_reset(CONFIG_ESP32_RESET_PORT, CONFIG_ESP32_RESET_PIN); +} + +void Esp32Reset::releaseReset() +{ + gpio_bit_set(CONFIG_ESP32_RESET_PORT, CONFIG_ESP32_RESET_PIN); +} +``` + +#### 4. `sys/initial.cpp`:在初始化序列中加入 ESP32 复位 +- 新增 `#include "device/esp32_reset.h"`。 +- 在 `SysTickTimer::initMs()` 之后、`Usart0::configurePins()` 之前调用 `Esp32Reset::init()`,原因: + - 需要 SysTick 才能做 50ms 延迟; + - 尽早释放 ESP32,让它在 DCU 继续初始化 USART/CAN/SPI 期间完成自身启动,等 SPI 命令总线初始化时 ESP32 已经可用。 + +修改后顺序: +```cpp +nvic_priority_group_set(...); +SysTickTimer::initMs(); +Esp32Reset::init(); // 新增 +Usart0::configurePins(); +Usart0::init(); +... +``` + +#### 5. `GD32E508_DCU_APP.cproject.yml`:把新文件加入构建 +在 `groups → device` 中增加: +```yaml + - file: ./device/esp32_reset.h + - file: ./device/esp32_reset.cpp +``` + +### 代码适配说明 +- 你原代码中的 `GPIO_BC(GPIOA) = GPIO_PIN_9` 等价于 `gpio_bit_reset(...)`,项目里统一用后一个 API。 +- `delay_ms(50)` 替换为 `SysTickTimer::delayMs(CONFIG_ESP32_RESET_HOLD_MS)`,与项目中其他复位/ADC 稳定延迟一致。 +- 端口从 `GPIOA/PA9` 改为 `GPIOC/PC9` 的 `CONFIG_*` 常量,便于后续硬件变更。 + +### 验证 +- 重新生成并编译 CMSIS csolution 工程(`cbuild DCU_APP.csolution.yml` 或 Keil Studio),确认新文件被编译、无重复定义、无未定义符号。 +- 若编译失败,根据错误输出修正头文件包含或 cproject.yml 路径。 + +### 风险/注意事项 +- `Esp32Reset::init()` 调用时 SysTick 已就绪,因此 50ms 阻塞等待是安全的(此时中断已使能但尚未初始化其他外设)。 +- 若后续需要在运行期再次复位 ESP32,可直接调用 `Esp32Reset::assertReset()` + `SysTickTimer::delayMs(...)` + `Esp32Reset::releaseReset()`。 +- 本次修改不改变 PA9 的 USART0_TX 功能,调试串口保持正常。 \ No newline at end of file diff --git a/main/include/version.h b/main/include/version.h index cab974c..7f0d6b3 100644 --- a/main/include/version.h +++ b/main/include/version.h @@ -4,11 +4,11 @@ // 固件版本 #define FW_VERSION_MAJOR 0 #define FW_VERSION_MINOR 3 -#define FW_VERSION_PATCH 39 -#define FW_VERSION_STRING "0.3.39" +#define FW_VERSION_PATCH 40 +#define FW_VERSION_STRING "0.3.40" // 编译时间 -#define BUILD_TIME "2026-07-09 02:43:44" +#define BUILD_TIME "2026-07-11 17:47:55" // Git 信息 -#define GIT_HASH "661ae54" +#define GIT_HASH "fedb08e" diff --git a/main/monitor/can_feedback_task.cpp b/main/monitor/can_feedback_task.cpp index a776aa0..6b719f4 100644 --- a/main/monitor/can_feedback_task.cpp +++ b/main/monitor/can_feedback_task.cpp @@ -39,6 +39,7 @@ static volatile bool s_paused = false; /* 累积工作缓冲:跨 page 0/1 累积每通道的完整反馈 */ static can_feedback_result_t s_work [CAN_CHANNEL_MAX]; static uint8_t s_workIdx[CAN_CHANNEL_MAX]; +static uint32_t s_lastLoggedError[CAN_CHANNEL_MAX]; static void publish(uint8_t ch, const can_feedback_result_t &fb) { @@ -86,19 +87,22 @@ static void taskLoop(void *) } /* 解析本页并累积到工作缓冲,page=0 会自动重置 */ - const uint8_t page = ring_payload[1]; if (canFdParseFeedbackPage(ring_payload, ring_len, &s_work[ch], &s_workIdx[ch]) == 0) { publish(ch, s_work[ch]); - /* 仅在出现错误时打印告警,正常工作静默 */ - if (s_work[ch].stats.error_count != 0U) { + /* 累计错误发生变化时才告警一次;P0/P1 携带相同统计,不能重复打印。 */ + if (s_work[ch].stats.error_count != 0U + && s_work[ch].stats.error_count != s_lastLoggedError[ch]) { DEBUG_WARN(kTag, - "CH%u P%u seq=%lu err=%lu", + "CH%u seq=%lu err=%lu TEC=%u REC=%u state=0x%02X", static_cast(ch), - static_cast(page), static_cast(s_work[ch].stats.snapshot_seq), - static_cast(s_work[ch].stats.error_count)); + static_cast(s_work[ch].stats.error_count), + static_cast(s_work[ch].stats.tec), + static_cast(s_work[ch].stats.rec), + static_cast(s_work[ch].stats.bus_state)); + s_lastLoggedError[ch] = s_work[ch].stats.error_count; } } } @@ -123,6 +127,7 @@ bool startCanFeedbackTask() } } memset(s_latest, 0, sizeof(s_latest)); + memset(s_lastLoggedError, 0, sizeof(s_lastLoggedError)); memset(s_valid, 0, sizeof(s_valid)); memset(s_work, 0, sizeof(s_work)); memset(s_workIdx, 0, sizeof(s_workIdx)); @@ -153,21 +158,6 @@ void pauseCanFeedbackTask(bool pause) } } -bool getLatestIdSnapshot(uint8_t channel, id_snapshot_cache_t *out) -{ - if (channel >= CAN_CHANNEL_MAX || out == nullptr || s_mutex == nullptr) return false; - bool ok = false; - if (xSemaphoreTake(s_mutex, pdMS_TO_TICKS(100)) == pdTRUE) { - if (s_latest[channel].valid) { - out->count = s_latest[channel].valid; - memcpy(out->entries, s_latest[channel].entries, sizeof(out->entries)); - ok = true; - } - xSemaphoreGive(s_mutex); - } - return ok; -} - /** * @brief 拷贝某通道的最新快照(线程安全)。 * @return true 表示拿到一份有效数据。 diff --git a/main/monitor/can_feedback_task.h b/main/monitor/can_feedback_task.h index 7808913..356deaa 100644 --- a/main/monitor/can_feedback_task.h +++ b/main/monitor/can_feedback_task.h @@ -7,13 +7,6 @@ #include #include "transport/canfd_bridge.h" -/** @brief ID 快照缓存结构。 */ -typedef struct { - id_snapshot_entry_t entries[CANFD_ID_SNAPSHOT_MAX]; - uint8_t count; - bool valid; -} id_snapshot_cache_t; - /** @brief 暂停/恢复 CAN 反馈轮询(OTA 期间调用 pause=true)。 */ void pauseCanFeedbackTask(bool pause); @@ -35,14 +28,6 @@ bool startCanFeedbackTask(); */ bool getLatestCanFeedback(uint8_t channel, can_feedback_result_t *out); -/** - * @brief 获取指定通道的最新 ID 快照(线程安全)。 - * @param channel 0/1/2。 - * @param out 输出缓冲。 - * @return true 有有效数据。 - */ -bool getLatestIdSnapshot(uint8_t channel, id_snapshot_cache_t *out); - #ifdef __cplusplus } #endif diff --git a/main/ota/ota_coordinator.cpp b/main/ota/ota_coordinator.cpp index a7c143f..2a006d3 100644 --- a/main/ota/ota_coordinator.cpp +++ b/main/ota/ota_coordinator.cpp @@ -555,18 +555,6 @@ void OtaCoordinator::restartEsp32() // 不会返回 } -/** - * @brief 将 ESP32 OTA finalize 错误码分类。 - * @param err finalize 返回的 esp_err_t。 - * @return 0=成功,1=SHA不匹配,2=其他失败。 - */ -int OtaCoordinator::esp32FinalizeErrCategory(esp_err_t err) -{ - if (err == ESP_OK) return 0; // 成功 - if (err == ESP_ERR_INVALID_VERSION) return 1; // SHA 不匹配 - return 2; // 其他失败 -} - // ============================================================ // 中止 // ============================================================ @@ -655,39 +643,12 @@ bool OtaCoordinator::isIdleState(ota_system_state_t s) const s == OTA_SYS_GD32_COMPLETE); } -/** @brief 判断给定状态值是否属于 GD32 忙态(不加锁,纯判断)。 */ -bool OtaCoordinator::isGd32BusyState(ota_system_state_t s) const -{ - return (s == OTA_SYS_GD32_STORING || - s == OTA_SYS_GD32_RELAYING); -} - -/** @brief 判断给定状态值是否属于 ESP32 忙态(不加锁,纯判断)。 */ -bool OtaCoordinator::isEsp32BusyState(ota_system_state_t s) const -{ - return (s == OTA_SYS_ESP32_RECEIVING || - s == OTA_SYS_ESP32_FINALIZING || - s == OTA_SYS_ESP32_REBOOTING); -} - /** @brief 判断系统是否处于空闲状态(IDLE/STORED/COMPLETE)。 */ bool OtaCoordinator::isIdle() const { return isIdleState(getSystemState()); } -/** @brief 判断 GD32 通道是否正忙(STORING 或 RELAYING)。 */ -bool OtaCoordinator::isGd32Busy() const -{ - return isGd32BusyState(getSystemState()); -} - -/** @brief 判断 ESP32 通道是否正忙(RECEIVING/FINALIZING/REBOOTING)。 */ -bool OtaCoordinator::isEsp32Busy() const -{ - return isEsp32BusyState(getSystemState()); -} - /** @brief 把状态枚举转成可读字符串(不加锁,纯函数)。 * 供持锁代码路径和已取一次状态值的调用方复用,避免二次加锁。 */ const char* OtaCoordinator::stateToString(ota_system_state_t s) @@ -706,29 +667,12 @@ const char* OtaCoordinator::stateToString(ota_system_state_t s) } } -/** @brief 获取系统状态的字符串名称(用于日志和前端显示)。 - * 委托 stateToString(getSystemState()),保持向后兼容。 */ -const char* OtaCoordinator::getSystemStateString() const -{ - return stateToString(getSystemState()); -} - /** @brief 获取 GD32 APP 存活状态(委托 OtaRelay)。 */ bool OtaCoordinator::getGd32AppAlive() const { return OtaRelay::getGd32AppAlive(); } -/** @brief 获取 GD32 固件是否已存储完成(线程安全)。 */ -bool OtaCoordinator::isGd32StorageDone() const -{ - // ★ M1/L3: m_mutex 为 mutable + lock()/unlock() 为 const,无需 const_cast - bool h = lock(); - bool done = m_gd32StorageDone; - unlock(h); - return done; -} - // ============================================================ // OTA 状态快照(PR1: 收口状态读取) // ============================================================ @@ -738,7 +682,7 @@ OtaSnapshot OtaCoordinator::getSnapshot() const // ── coordinator 自身状态(一次 mutex,避免二次加锁导致快照不一致)── // 持锁时一次性取 m_sysState,并调不加锁的 stateToString() - // 就地转字符串。旧实现释放锁后再调 getSystemStateString() 二次加锁, + // 就地转字符串。旧实现释放锁后再调 stateToString(getSystemState()) 二次加锁, // 两次取值之间状态可能变化,导致 snap.sys_state 与 snap.sys_state_str // 描述不同时刻状态。 bool h = lock(); diff --git a/main/ota/ota_coordinator.h b/main/ota/ota_coordinator.h index fe0a3a1..1c80905 100644 --- a/main/ota/ota_coordinator.h +++ b/main/ota/ota_coordinator.h @@ -395,20 +395,6 @@ public: */ bool isIdleState(ota_system_state_t s) const; - /** - * @brief 检查给定状态值是否属于 GD32 忙态(不加锁,纯判断)。 - * @param s 状态枚举值。 - * @return true 表示 STORING 或 RELAYING。 - */ - bool isGd32BusyState(ota_system_state_t s) const; - - /** - * @brief 检查给定状态值是否属于 ESP32 忙态(不加锁,纯判断)。 - * @param s 状态枚举值。 - * @return true 表示 RECEIVING/FINALIZING/REBOOTING。 - */ - bool isEsp32BusyState(ota_system_state_t s) const; - /** * @brief 检查系统是否空闲(可接受新的 OTA 请求)。 * 内部仍加锁取一次状态后调 isIdleState()。 @@ -416,27 +402,6 @@ public: */ bool isIdle() const; - /** - * @brief 检查是否有 GD32 相关操作正在进行。 - * 内部仍加锁取一次状态后调 isGd32BusyState()。 - * @return true 表示 STORING 或 RELAYING。 - */ - bool isGd32Busy() const; - - /** - * @brief 检查是否有 ESP32 OTA 正在进行。 - * 内部仍加锁取一次状态后调 isEsp32BusyState()。 - * @return true 表示 RECEIVING/FINALIZING/REBOOTING。 - */ - bool isEsp32Busy() const; - - /** - * @brief 获取系统状态的可读字符串描述。 - * 内部委托 stateToString(getSystemState()),保持向后兼容。 - * @return 状态名称字符串(如 "IDLE"、"GD32_STORING" 等)。 - */ - const char* getSystemStateString() const; - // ── GD32 状态(供 status API 使用,委托给 OtaRelay 查询)── /** * @brief 获取 GD32 APP 存活状态。 @@ -444,27 +409,12 @@ public: */ bool getGd32AppAlive() const; - /** - * @brief GD32 固件存储完成标志(供状态查询)。 - * @return true 表示固件已完整存储。 - */ - bool isGd32StorageDone() const; - // ── 完全复位 ── /** * @brief 完全复位协调器和所有子系统状态。 */ void reset(); - // ── ESP32 OTA finalize 错误分类(BLE/WiFi 共用)── - /** - * @brief 把 finalize 返回的 esp_err_t 映射为前端可读的事件码。 - * 统一 BLE/WiFi 两端语义,便于日志/状态一致。 - * @param err finalize 返回的 esp_err_t。 - * @return 0=成功;1=SHA 不匹配;2=其他失败。 - */ - static int esp32FinalizeErrCategory(esp_err_t err); - // ── IRelayListener 接口实现(H4: OtaRelay 通过接口回调通知协调器)── /** * @brief IRelayListener 回调:中继已开始。 diff --git a/main/relay/ota_relay.cpp b/main/relay/ota_relay.cpp index 4649396..9cfde49 100644 --- a/main/relay/ota_relay.cpp +++ b/main/relay/ota_relay.cpp @@ -123,31 +123,12 @@ relay_state_t OtaRelay::getState() return relayState; } -uint32_t OtaRelay::getRelaySeq() -{ - StateLockGuard lock; - return relaySeq; -} - -uint32_t OtaRelay::getRelayTotalPackets() -{ - StateLockGuard lock; - return relayTotalPackets; -} - bool OtaRelay::getGd32AppAlive() { StateLockGuard lock; return gd32AppAlive; } -void OtaRelay::getGd32Version(gd32_version_t *out) -{ - if (!out) return; - StateLockGuard lock; - *out = gd32Version; -} - RelaySnapshot OtaRelay::getSnapshot() { StateLockGuard lock; diff --git a/main/relay/ota_relay.h b/main/relay/ota_relay.h index 9a32efc..6621537 100644 --- a/main/relay/ota_relay.h +++ b/main/relay/ota_relay.h @@ -134,26 +134,11 @@ public: * @return 当前 relay_state_t 状态值。 */ static relay_state_t getState(); - /** - * @brief 获取中继当前序号. - * @return 当前中继序号。 - */ - static uint32_t getRelaySeq(); - /** - * @brief 获取中继总包数. - * @return 本次中继的总包数。 - */ - static uint32_t getRelayTotalPackets(); /** * @brief 获取GD32 APP存活标志位. * @return true 表示 GD32 APP 存活。 */ static bool getGd32AppAlive(); - /** - * @brief 获取GD32固件版本. - * @param out 输出版本信息结构体指针。 - */ - static void getGd32Version(gd32_version_t *out); /** * @brief 一次性读取所有中继状态字段(单次锁保护)。 * 消除逐 getter 调用之间 relay task 更新状态导致的不一致。 @@ -238,7 +223,6 @@ public: ~StateLockGuard() { if (m_locked && s_stateMutex) { xSemaphoreGive(s_stateMutex); } } - bool isLocked() const { return m_locked; } StateLockGuard(const StateLockGuard&) = delete; StateLockGuard& operator=(const StateLockGuard&) = delete; private: diff --git a/main/server/http_handlers.cpp b/main/server/http_handlers.cpp index ef8e776..1efeeaa 100644 --- a/main/server/http_handlers.cpp +++ b/main/server/http_handlers.cpp @@ -199,109 +199,6 @@ static int recv_json_header(httpd_req_t *req, char *buf, int buf_size) return total; } -esp_err_t ota_prepare_handler(httpd_req_t *req) -{ - http_set_cors_headers(req); - DEBUG_INFO("WIFI", "=== HTTP: POST /ota/prepare ==="); - - char buf[256] = {0}; - int ret = httpd_req_recv(req, buf, sizeof(buf) - 1); - if (ret <= 0) { - httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "recv failed"); - return ESP_FAIL; - } - buf[ret] = '\0'; - - cJSON *root = cJSON_Parse(buf); - if (!root) { - httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "JSON parse failed"); - return ESP_FAIL; - } - - cJSON *type_item = cJSON_GetObjectItem(root, "type"); - cJSON *size_item = cJSON_GetObjectItem(root, "total_size"); - if (!type_item || !cJSON_IsString(type_item) || - !size_item || !cJSON_IsNumber(size_item)) { - cJSON_Delete(root); - httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "missing type or total_size"); - return ESP_FAIL; - } - - const char *type = cJSON_GetStringValue(type_item); - uint32_t total_size = (uint32_t)cJSON_GetNumberValue(size_item); - cJSON_Delete(root); - - if (type && strcmp(type, "esp32") == 0) { - clear_esp32_prepare(); - - esp_err_t err = OtaCoordinator::instance().beginEsp32OtaWithRetry(total_size, OTA_CHANNEL_WIFI); - if (err != ESP_OK) { - if (err == ESP_ERR_INVALID_STATE) { - httpd_resp_send_err(req, HTTPD_403_FORBIDDEN, - "ESP32 OTA occupied by another channel"); - } else { - httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "OTA begin failed"); - } - return ESP_FAIL; - } - - s_esp32_prepared = true; - s_esp32_prepare_size = total_size; - s_esp32_prepare_epoch = OtaCoordinator::instance().getOtaEpoch(); - const char resp[] = "{\"status\": \"ok\", \"target\": \"esp32\"}"; - httpd_resp_set_type(req, "application/json"); - httpd_resp_send(req, resp, strlen(resp)); - DEBUG_INFO("WIFI", "ESP32 OTA 准备完成: %u 字节", total_size); - return ESP_OK; - } - - if (type && strcmp(type, "gd32") == 0) { - clear_gd32_prepare(); - - if (OtaRelay::getState() != RELAY_IDLE) { - DEBUG_WARN("WIFI", "GD32 prepare 前复位中继状态: %d", OtaRelay::getState()); - OtaRelay::instance().reset(); - } - - // ★ 统一调用 prepareGd32Storage 完成:validate + begin + erase + clearMeta + saveMeta - // 与 BLE 路径 / ota_upload_handler 共享同一序列,消除手写重复 - esp_err_t prep_err = OtaCoordinator::instance().prepareGd32Storage(total_size, OTA_CHANNEL_WIFI); - if (prep_err == ESP_ERR_INVALID_SIZE) { - DEBUG_ERR("WIFI", "prepare: firmware size invalid: %u", total_size); - httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "firmware size invalid"); - return ESP_FAIL; - } else if (prep_err == ESP_ERR_INVALID_STATE) { - DEBUG_ERR("WIFI", "GD32 prepare 协调器拒绝(系统忙)"); - httpd_resp_send_err(req, HTTPD_403_FORBIDDEN, - "system busy with another OTA operation"); - return ESP_FAIL; - } else if (prep_err != ESP_OK) { - DEBUG_ERR("WIFI", "GD32 prepare 失败: %s", esp_err_to_name(prep_err)); - char err_msg[96]; - snprintf(err_msg, sizeof(err_msg), "prepare gd32_ota failed: %s", - esp_err_to_name(prep_err)); - httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, err_msg); - return ESP_FAIL; - } - - WifiOta::instance().stateLock(); - WifiOta::instance().setGd32StorageDone(false); - WifiOta::instance().stateUnlock(); - - s_gd32_prepared = true; - s_gd32_prepare_size = total_size; - s_gd32_prepare_epoch = OtaCoordinator::instance().getOtaEpoch(); - const char resp[] = "{\"status\": \"ok\", \"target\": \"gd32\"}"; - httpd_resp_set_type(req, "application/json"); - httpd_resp_send(req, resp, strlen(resp)); - DEBUG_INFO("WIFI", "GD32 OTA 准备完成: %u 字节", total_size); - return ESP_OK; - } - - httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "unknown type"); - return ESP_FAIL; -} - esp_err_t ota_upload_handler(httpd_req_t *req) { http_set_cors_headers(req); diff --git a/main/server/wifi_ota.h b/main/server/wifi_ota.h index 865e8b4..01d514f 100644 --- a/main/server/wifi_ota.h +++ b/main/server/wifi_ota.h @@ -69,8 +69,6 @@ public: bool getStaRetryExhausted() const { return m_staRetryExhausted.load(); } /** @brief 设置 STA 重试耗尽标志。 */ void setStaRetryExhausted(bool v) { m_staRetryExhausted.store(v); } - /** @brief 获取 GD32 固件存储完成标志。 */ - bool getGd32StorageDone() const { return m_gd32StorageDone.load(); } /** @brief 设置 GD32 固件存储完成标志。 */ void setGd32StorageDone(bool v) { m_gd32StorageDone.store(v); } // ── 状态互斥锁(保护复合读-改-写操作)── diff --git a/main/transport/canfd_bridge.cpp b/main/transport/canfd_bridge.cpp index c4b5cfd..3902cbd 100644 --- a/main/transport/canfd_bridge.cpp +++ b/main/transport/canfd_bridge.cpp @@ -58,7 +58,6 @@ static int sendCanFrameOverSpi(can_channel_t channel, uint32_t ext_id, constexpr uint8_t kSpiCanFeedbackType = 0xC1U; constexpr uint8_t kSpiCanSetModeType = 0xC2U; -constexpr uint8_t kSpiIdSnapshotType = 0xC3U; constexpr size_t kFeedbackPayloadLen = 123U; // 与 GD32 协议表对齐 (kTxBufSize=128 - overhead=5) static inline uint32_t readU32Le(const uint8_t *p) { @@ -86,21 +85,6 @@ int canFdSend(can_channel_t channel, uint32_t ext_id, return sendCanFrameOverSpi(channel, ext_id, data, dlc, recv_buf, recv_max, timeout_ms); } -/** - * @brief 发送一帧无需上层消费响应的 CANFD 数据。 - * GD32 APP 仍会返回 ACK/ERROR,这里主动读走响应,避免残留到下一笔 SPI 事务。 - */ -int canFdSendNoReply(can_channel_t channel, uint32_t ext_id, - const uint8_t *data, uint8_t dlc) -{ - if (channel >= CAN_CHANNEL_MAX || dlc > CAN_MAX_DLC || !data) { - return -1; - } - - return (sendCanFrameOverSpi(channel, ext_id, data, dlc, nullptr, 0U, 1000U) >= 0) ? 0 : -1; -} - - /** * @brief 解析 GD32 主动推送的一页 CAN 反馈 payload,累积到结果结构中。 * @@ -176,76 +160,6 @@ int canFdParseFeedbackPage(const uint8_t *payload, size_t len, return 0; } -/** - * @brief 拉取指定通道的不重复 ID 快照表(GD32 SPI 帧类型 0xC3)。 - * 分页拉取,每页 8 条,最多拉 2 页(16 条)。 - */ -int canFdPullIdSnapshot(can_channel_t channel, - id_snapshot_entry_t *out, - uint8_t *out_count, - uint32_t timeout_ms) -{ - if (channel >= CAN_CHANNEL_MAX || out == nullptr || out_count == nullptr) { - return -1; - } - - *out_count = 0U; - uint8_t total = 0U; - - for (uint8_t page = 0U; page < 2U; ++page) { - uint8_t req[2] = { static_cast(channel), page }; - uint8_t resp_buf[kFeedbackPayloadLen] = {}; - uint8_t resp_type = 0U; - size_t resp_len = 0U; - - esp_err_t ret = SpiMaster::instance().otaSendAndPoll( - kSpiIdSnapshotType, - req, 2U, - &resp_type, resp_buf, sizeof(resp_buf), &resp_len, timeout_ms); - if (ret != ESP_OK || resp_type != SPI_OTA_RSP_ACK || resp_len < 4U) { - return -1; - } - - /* resp: [ch][page][total_count][entry_count][entries...] */ - if (page == 0U) { - total = resp_buf[2]; - } - const uint8_t entryCount = resp_buf[3]; - if (entryCount == 0U) break; - - uint16_t offset = 4U; - for (uint8_t i = 0U; i < entryCount && *out_count < CANFD_ID_SNAPSHOT_MAX; ++i) { - if (offset + 16U > resp_len) break; - - id_snapshot_entry_t &e = out[*out_count]; - e.id = readU32Le(&resp_buf[offset]); - e.seq = readU32Le(&resp_buf[offset + 4U]); - e.rx_count = readU32Le(&resp_buf[offset + 8U]); - e.dlc = resp_buf[offset + 12U]; - const uint8_t flags = resp_buf[offset + 13U]; - e.fd_flag = (flags & 0x01U) ? 1U : 0U; - e.brs = (flags & 0x02U) ? 1U : 0U; - e.esi = (flags & 0x04U) ? 1U : 0U; - - const uint8_t dataLen = (e.dlc < CANFD_ID_SNAPSHOT_DATA_MAX) - ? e.dlc : static_cast(CANFD_ID_SNAPSHOT_DATA_MAX); - const uint16_t entrySize = 16U + dataLen; - if (offset + entrySize > resp_len) break; - - memcpy(e.data, &resp_buf[offset + 16U], dataLen); - /* 清零剩余字节 */ - for (uint8_t d = dataLen; d < CANFD_ID_SNAPSHOT_DATA_MAX; ++d) { - e.data[d] = 0U; - } - - offset += entrySize; - (*out_count)++; - } - - if (*out_count >= total) break; /* 已拉完 */ - } - return 0; -} int canFdSetRxMode(can_rx_mode_t mode, can_rx_mode_t *actual, uint32_t timeout_ms) diff --git a/main/transport/canfd_bridge.h b/main/transport/canfd_bridge.h index b8fd858..bbda318 100644 --- a/main/transport/canfd_bridge.h +++ b/main/transport/canfd_bridge.h @@ -50,18 +50,6 @@ int canFdSend(can_channel_t channel, uint32_t ext_id, uint8_t *recv_buf, uint8_t recv_max, uint32_t timeout_ms); -/** - * @brief 发送一帧无需响应的 CANFD 数据。 - * @param channel CAN 通道编号(0~2)。 - * @param ext_id 扩展帧 ID(29 位)。 - * @param data CAN 数据负载。 - * @param dlc 数据长度。 - * @return 成功返回 0,失败返回 -1。 - */ -int canFdSendNoReply(can_channel_t channel, uint32_t ext_id, - const uint8_t *data, uint8_t dlc); - - // ============================================================ // 不重复 ID 快照表(SPI 0xC3 协议配套) // ============================================================ @@ -147,16 +135,4 @@ int canFdSetRxMode(can_rx_mode_t mode, can_rx_mode_t *actual, uint32_t timeout_ms); -/** - * @brief 拉取指定通道的不重复 ID 快照表(SPI 帧 0xC3,分页)。 - * @param channel 目标通道 0/1/2。 - * @param out 输出条目数组(至少 CANFD_ID_SNAPSHOT_MAX 个元素)。 - * @param out_count 输出实际条目数。 - * @param timeout_ms SPI 超时。 - * @return 0 成功,<0 失败。 - */ -int canFdPullIdSnapshot(can_channel_t channel, - id_snapshot_entry_t *out, - uint8_t *out_count, - uint32_t timeout_ms); #endif // CANFD_BRIDGE_H diff --git a/tools/app.js b/tools/app.js index 9c8697f..e62f999 100644 --- a/tools/app.js +++ b/tools/app.js @@ -26,6 +26,7 @@ let pageVisible = !document.hidden; // 页面可见性:切到后台/最小 let latestSystemStatus = null; let isEsp32Rebooting = false; let rebootStartTime = 0; +let rebootObservedDown = false; // 是否已观察到设备实际下线(重启),用于避免 pre-reboot 窗口内误清除重启状态 let pollingPausedForUpload = false; let uploadPollChip = null; // 当前暂停轮询的上传目标,用于区分 WiFi/日志策略 let activeXhr = null; // 当前上传 XHR,用于 abort @@ -176,11 +177,6 @@ function cleanupAfterOtaFailure(reason) { addLog('E', 'OTA', '刷写流程已中止: ' + (reason || '未知原因')); } -function showVerifyActions(type) { - // 已移除确认/重试/取消按钮:SHA-256 校验通过后自动重启,校验失败仅记录日志 - void type; -} - /** @description 设置当前活跃 OTA 阶段并更新 UI 步骤指示器。 */ function setActivePhase(phase) { if (phase === 'gd32Upload' || phase === 'gd32Relay') phase = 'gd32Ota'; @@ -367,38 +363,6 @@ function updateUploadButtonLabel() { if (esp32Btn) esp32Btn.disabled = !esp32File || isUploading; if (gd32Btn) gd32Btn.disabled = !gd32File || isUploading; } -function setHelper(id, text, type) { - var el = document.getElementById(id); - if (!el) return; - el.className = 'helper-note ' + (type || ''); - el.innerHTML = text; -} -function updateCanHint() { - var dlcEl = document.getElementById('canDlc'); - var dataEl = document.getElementById('canData'); - var hintEl = document.getElementById('canHint'); - if (!dlcEl || !dataEl || !hintEl) return; - var dlc = parseInt(dlcEl.value) || 0; - var dataHex = dataEl.value.trim().replace(/\s/g, ''); - if (!dataHex) { - setHelper('canHint', '输入十六进制数据后会自动检查字节数是否与 DLC 一致。', ''); - return; - } - if (!/^[0-9a-fA-F]*$/.test(dataHex)) { - setHelper('canHint', '数据包含非 HEX 字符,请只输入 0-9 A-F。', 'err'); - return; - } - if (dataHex.length % 2 !== 0) { - setHelper('canHint', 'HEX 长度必须为偶数,当前还差半个字节。', 'err'); - return; - } - var bytes = dataHex.length / 2; - if (dlc && bytes !== dlc) { - setHelper('canHint', '当前数据为 ' + bytes + ' 字节,但 DLC = ' + dlc + '。发送前建议对齐。', 'warn'); - return; - } - setHelper('canHint', 'HEX 数据合法,当前长度 ' + bytes + ' 字节,与 DLC 一致。', 'ok'); -} function resetStageFlow() { ['s1', 's2', 's3', 's4', 's5', 's6'].forEach(function (id) { var el = document.getElementById(id); @@ -779,7 +743,6 @@ function updateSystemStatusUi(data) { 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; - function setOtaProgressBadge() {} // OTA 进度卡片已移除,保留空壳避免调用报错 // GD32 relay forwarding progress var relaySeq = ota.relay_seq || 0; var relayTotal = ota.relay_total_packets || 0; @@ -800,7 +763,6 @@ function updateSystemStatusUi(data) { } else if (isGd32Complete || rs === 5 || (gd32AppAlive && sysState === 'GD32_COMPLETE')) { markPhaseDone('gd32Ota'); currentOtaTarget = null; // GD32 OTA 完成,允许切换目标 - setOtaProgressBadge(100); setProgress(100, 'GD32 刷写完成'); var sbDone = document.getElementById('stageStatusBar'); var stDone = document.getElementById('stageStatusText'); @@ -809,30 +771,25 @@ function updateSystemStatusUi(data) { } else if (isGd32Storing || (total > 0 && stored < total && !isUploading)) { setActivePhase('gd32Ota'); var storeOverall = Math.min(45, Math.round(storageProgress * 0.45)); - setOtaProgressBadge(storeOverall); if (!isUploading) { setProgress(storeOverall, '已缓存 ' + formatBytes(stored) + ' / ' + formatBytes(total)); } } else if (rs === 1) { // HANDSHAKING: 握手 + 挑战认证 setActivePhase('gd32Ota'); - setOtaProgressBadge(50); setProgress(50, '握手与认证中...'); } else if (rs === 2) { // SIZE_NEG: 固件大小协商 setActivePhase('gd32Ota'); - setOtaProgressBadge(55); setProgress(55, '协商固件大小...'); } else if (rs === 3) { // TRANSFERRING: 数据包传输 setActivePhase('gd32Ota'); var relayOverall = 60 + Math.round(relayProgress * 0.30); - setOtaProgressBadge(relayOverall); setProgress(relayOverall, '已转发 ' + relaySeq + ' / ' + relayTotal + ' 包'); } else if (rs === 4) { // FINISHING: 结束OTA + APP存活验证 setActivePhase('gd32Ota'); - setOtaProgressBadge(95); if (gd32AppAlive) { markPhaseDone('gd32Ota'); currentOtaTarget = null; // GD32 OTA 完成,允许切换目标 @@ -846,10 +803,7 @@ function updateSystemStatusUi(data) { } } else if (total > 0 && stored >= total) { setActivePhase('gd32Ota'); - setOtaProgressBadge(45); setProgress(45, '上传完成,等待自动启动 GD32 OTA...'); - } else { - setOtaProgressBadge(storageProgress); } } /** @description 测试与 ESP32 的连接(GET /status)。 */ @@ -921,62 +875,6 @@ async function refreshSystemStatusNow(event) { clearTimeout(manualTimer); } } -// ==================== CAN 帧发送 ==================== -async function sendCanFrame() { - var chEl = document.getElementById('canChannel'); - var idEl = document.getElementById('canId'); - var dlcEl = document.getElementById('canDlc'); - var dataEl = document.getElementById('canData'); - if (!chEl || !idEl || !dlcEl || !dataEl) { - showToast('请在独立 CAN 页面中发送帧', 'warning'); - return; - } - var ch = parseInt(chEl.value); - var idStr = idEl.value.trim(); - var dlc = parseInt(dlcEl.value) || 8; - var dataStr = dataEl.value.trim(); - var extId = 0; - if (idStr.toLowerCase().indexOf('0x') === 0) extId = parseInt(idStr, 16); - else { extId = parseInt(idStr, 10); if (isNaN(extId)) extId = parseInt(idStr, 16); } - if (isNaN(extId)) { showToast('ID 格式错误', 'error'); return; } - var dataHex = dataStr.replace(/\s/g, ''); - if (dataHex.length % 2 !== 0) { showToast('数据长度必须为偶数', 'error'); return; } - addLog('I', 'CAN', '发送 CH' + ch + ' ID=0x' + extId.toString(16).toUpperCase() + ' DLC=' + dlc); - try { - var resp = await fetch(getBaseUrl() + '/can/send', { - method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ ch: ch, id: extId, dlc: dlc, data: dataHex }), - signal: AbortSignal.timeout(5000) - }); - var result = await resp.json(); - if (result.status === 'ok') { - addLog('I', 'CAN', '响应: ' + (result.recv || '(无数据)') + ' (' + (result.recv_len || 0) + 'B)'); - var rd = document.getElementById('canResp'); - rd.style.display = 'block'; - document.getElementById('canRespText').textContent = - result.recv ? '0x' + result.recv + ' (' + result.recv_len + ' 字节)' : '无响应数据'; - showToast('CH' + ch + ' 发送成功', 'success'); - } else { - addLog('E', 'CAN', '发送失败: code=' + result.code); - showToast('发送失败: code=' + result.code, 'error'); - } - } catch (e) { - addLog('E', 'CAN', '发送失败: ' + e.message); - showToast('发送失败', 'error'); - } -} -function clearCanFields() { - var idEl = document.getElementById('canId'); - var dlcEl = document.getElementById('canDlc'); - var dataEl = document.getElementById('canData'); - var respEl = document.getElementById('canResp'); - if (!idEl || !dlcEl || !dataEl) return; - idEl.value = '0x18DA00F1'; - dlcEl.value = '8'; - dataEl.value = '0102030405060708'; - if (respEl) respEl.style.display = 'none'; - updateCanHint(); -} // ==================== SPI 发送 ==================== function updateSpiHint() { var typeHex = document.getElementById('spiType').value.trim(); @@ -1118,10 +1016,6 @@ function setupDropZone(zoneId, chip) { } setupDropZone('uploadZoneEsp32', 'esp32'); setupDropZone('uploadZoneGd32', 'gd32'); -var canDataEl = document.getElementById('canData'); -var canDlcEl = document.getElementById('canDlc'); -if (canDataEl) canDataEl.addEventListener('input', updateCanHint); -if (canDlcEl) canDlcEl.addEventListener('input', updateCanHint); var spiTypeEl = document.getElementById('spiType'); var spiDataEl = document.getElementById('spiData'); if (spiTypeEl) spiTypeEl.addEventListener('input', updateSpiHint); @@ -1599,9 +1493,9 @@ async function verifyEsp32Firmware() { // 服务器已设置启动分区并自动重启,前端直接进入重启等待状态 addLog('I', 'OTA', 'SHA-256 校验通过,设备自动重启中...'); showToast('校验通过,设备重启中...', 'success'); - showVerifyActions('none'); updateEsp32StagesFromPhase(4); // 应用固件 isEsp32Rebooting = true; + rebootObservedDown = false; // 重置:尚未观察到设备实际下线 updateEsp32StagesFromPhase(5); // 重启中 rebootStartTime = Date.now(); } else { @@ -1610,7 +1504,6 @@ async function verifyEsp32Firmware() { showToast('校验失败: ' + detail, 'error'); var resultEl = document.getElementById('sviResult'); if (resultEl) { resultEl.textContent = '✗ ' + detail; resultEl.className = 'svi-val err'; } - showVerifyActions('retry'); cleanupAfterOtaFailure('校验失败'); } } catch (e) { @@ -1621,70 +1514,10 @@ async function verifyEsp32Firmware() { showToast('应用请求失败: ' + e.message, 'error'); var resultEl = document.getElementById('sviResult'); if (resultEl) { resultEl.textContent = '✗ 请求失败: ' + e.message; resultEl.className = 'svi-val err'; } - showVerifyActions('retry'); cleanupAfterOtaFailure('应用请求失败'); } } -async function retryEsp32Upload() { - addLog('I', 'OTA', '重新上传固件...'); - showVerifyActions('none'); - 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'; } - startUpload('esp32'); -} - -async function cancelEsp32Ota() { - addLog('I', 'OTA', '用户取消 OTA 刷写'); - showToast('已取消 OTA', 'error'); - showVerifyActions('none'); - var xhr = activeXhr; - activeXhr = null; - if (xhr) { - try { xhr.abort(); } catch (_) {} - } - cleanupAfterOtaFailure('用户取消'); -} - -async function applyEsp32Firmware() { - addLog('I', 'OTA', '确认应用固件并重启 ESP32...'); - showToast('正在应用固件并重启...', 'success'); - try { - var resp = await fetch(getBaseUrl() + '/ota/update', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({}), - signal: AbortSignal.timeout(120000) - }); - - if (resp.ok) { - var data = await resp.json(); - addLog('I', 'OTA', '升级完成: ' + (data.message || '')); - updateEsp32StagesFromPhase(4); // 应用固件 - isEsp32Rebooting = true; - updateEsp32StagesFromPhase(5); // 重启中 - rebootStartTime = Date.now(); - } else { - var detail = '应用失败: HTTP ' + resp.status; - try { var d = await resp.json(); detail += ' - ' + (d.message || ''); } catch (_) {} - addLog('E', 'OTA', detail); - showToast(detail, 'error'); - } - } catch (e) { - // fetch 异常仅代表请求未完成,不能推断设备已重启。 - addLog('E', 'OTA', '应用请求失败: ' + e.message + '(设备状态未知,等待轮询探测)'); - showToast('应用请求失败: ' + e.message, 'error'); - cleanupAfterOtaFailure('应用请求失败'); - } -} - - async function restartEsp32() { if (!confirm('确定要重启 ESP32 吗?')) return; addLog('I', '系统', '=== 请求重启 ESP32 ==='); @@ -1698,6 +1531,7 @@ async function restartEsp32() { addLog('I', '系统', '重启已触发: ' + data.message); showToast('ESP32 重启中,页面将自动恢复...', 'success'); isEsp32Rebooting = true; + rebootObservedDown = false; // 重置:尚未观察到设备实际下线 rebootStartTime = Date.now(); } } catch (e) { @@ -1754,12 +1588,6 @@ async function triggerGd32Relay() { } } -// ==================== 手动触发 GD32 OTA (调试/旧页面兜底) ==================== -/** @description 触发 GD32 OTA 中继(封装函数,调用实际 API)。 */ -async function triggerRelay() { - // 转发到统一入口:自动使用最近一次上传的 GD32 文件 SHA - await triggerGd32Relay(); -} // ==================== 轮询状态 ==================== var consecutivePollFailures = 0; var statusTimerId = null; @@ -1913,12 +1741,26 @@ async function pollStatus() { } } if (isEsp32Rebooting) { - isEsp32Rebooting = false; - updateEsp32StagesFromPhase(6); // 启动完成 - addLog('I', '系统', 'ESP32 已恢复在线'); - showToast('ESP32 已恢复在线', 'success'); + var rbElapsed = Math.round((Date.now() - rebootStartTime) / 1000); + if (!rebootObservedDown && rbElapsed < 10) { + // pre-reboot 窗口:后端发响应后约 1s 才 esp_restart,期间设备仍响应轮询 + // 保持"重启中"状态,不清除标志,不推进阶段 + addLog('I', '系统', '设备尚未实际重启,等待 esp_restart...'); + } else { + // 设备已重启并恢复在线(rebootObservedDown=true) + // 或超 10s 安全阈值仍未观察到下线(重启过快未捕获或未重启)——接受恢复 + isEsp32Rebooting = false; + rebootObservedDown = false; + updateEsp32StagesFromPhase(6); // 启动完成 + addLog('I', '系统', 'ESP32 已恢复在线'); + showToast('ESP32 已恢复在线', 'success'); + } } try { updateSystemStatusUi(data); } catch (e2) { console.warn('updateSystemStatusUi 异常:', e2); } + // pre-reboot 窗口内 updateSystemStatusUi 会把连接胶囊刷成"已连接",需覆盖回"重启中" + if (isEsp32Rebooting) { + applyRebootingRuntimeState(); + } } clearTimeout(pollTimer); } catch (e) { @@ -1954,9 +1796,11 @@ async function pollStatus() { if (isUploading) { applyUploadingRuntimeState(); } else if (isEsp32Rebooting) { + rebootObservedDown = true; // 确认设备已下线(正在重启) var elapsed = Math.round((Date.now() - rebootStartTime) / 1000); if (elapsed > 60) { isEsp32Rebooting = false; + rebootObservedDown = false; addLog('E', '系统', 'ESP32 重启超时,设备未恢复'); showToast('ESP32 重启超时,请检查设备', 'error'); updateConnectionUi(false, '未连接'); @@ -2030,7 +1874,6 @@ async function pollLogs() { } setTimeout(function () { updateUploadButtonLabel(); - updateCanHint(); addLog('I', '系统', '控制台初始化完成,等待固件文件...'); updateSpiHint(); // IP 输入变化时自动恢复轮询 diff --git a/tools/ble_ota.js b/tools/ble_ota.js index 6a5360f..8262336 100644 --- a/tools/ble_ota.js +++ b/tools/ble_ota.js @@ -833,31 +833,7 @@ } - // ===== 重启设备 + 自动重连 ===== - async function restartEsp32() { - if (!state.ctrlChar || !state.device) return; - if (!confirm('确定要重启 ESP32 吗?\n\n重启后页面会自动重连。')) return; - if (state.expectedReboot) return; - state.expectedReboot = true; - state.reconnectAttempts = 0; - if (typeof connectionMode !== 'undefined' && connectionMode === 'ble') { - if (els.connState) els.connState.textContent = '重启中'; - if (els.connState && els.connState.parentElement) els.connState.parentElement.className = 'pill processing'; - } - setConnectionState(!!state.server); - bleLog('SYS', '=== 触发 ESP32 重启 (0xFA) ==='); - showToast('ESP32 重启中...', 'success'); - try { - await state.ctrlChar.writeValue(new Uint8Array([0xFA])); - bleLog('SYS', '0xFA 已发,等待设备端推送 FINISH_REBOOT_NOW 后断开'); - } catch (e) { - bleLog('SYS', '0xFA 发送失败: ' + e.message, 'E'); - showToast('0xFA 发送失败', 'error'); - state.expectedReboot = false; - setConnectionState(!!state.server); - } - } - + // ===== 自动重连 ===== async function tryReconnect() { if (!state.device || !state.expectedReboot) { state.reconnecting = false; diff --git a/tools/can.js b/tools/can.js index 76778f1..9c4e547 100644 --- a/tools/can.js +++ b/tools/can.js @@ -739,18 +739,13 @@ }); } - function renderSelectedMessage() { - // 顶部独立的“信号编辑”面板已移除,所有信号编辑现在通过报文行展开完成。 - // 这里保留函数以兼容旧调用方(selectMessage 等),不再触碰已删除的 DOM。 - } - - function metaCard(label, value) { - return '
' + sanitizeHtml(label) + '' + sanitizeHtml(value) + '
'; - } - - function renderSignalGrid(/* message */) { - // 旧的独立信号编辑表已移除,信号编辑全部走报文行内展开 renderInlineSignalGrid。 - } + /** + * @description 旧版“顶部独立信号编辑面板”的兼容 shim。 + * 该面板已移除,信号编辑现在通过报文行展开完成; + * 仍被 selectMessage、handleDbcFile、clearDbc、handleSignalInputEvent 调用, + * 保留空函数以避免修改所有调用方。 + */ + function renderSelectedMessage() {} function renderSignalRow(message, signal, index) { var bag = getSignalValueMap(message); diff --git a/tools/index.html b/tools/index.html index 10cb134..f13fff4 100644 --- a/tools/index.html +++ b/tools/index.html @@ -636,22 +636,6 @@ window.sharedFirmwareFiles = window.sharedFirmwareFiles || { if (sentCount) sentCount.textContent = '0'; }; - window.attachSharedFirmwareDropBridge = function () { - ['esp32', 'gd32'].forEach(function (chip) { - var zone = document.getElementById(chip === 'gd32' ? 'uploadZoneGd32' : 'uploadZoneEsp32'); - if (!zone) return; - zone.addEventListener('drop', function (event) { - var file = event && event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files[0] ? event.dataTransfer.files[0] : null; - if (file) { - window.sharedFirmwareFiles[chip] = { file: file, handle: null }; - } - window.flashTarget = chip; - applySharedModeUi(); - setTimeout(syncBleFileFromSelectedTarget, 120); - }, true); - }); - }; - window.applySharedModeUi = function () { var isBle = window.connectionMode === 'ble'; var isGd32 = window.flashTarget === 'gd32';