diff --git a/docs/can_protocol.md b/docs/can_protocol.md new file mode 100644 index 0000000..45d1d0b --- /dev/null +++ b/docs/can_protocol.md @@ -0,0 +1,296 @@ +# CAN 通信协议文档 + +> 更新日期:2026-06-29 +> 涉及固件:GD32 DCU_APP + ESP32 ESP32_COMM +> SPI 时钟:4 MHz | 应答帧最大 payload:123 字节(kTxBufSize=128 - overhead=5) + +--- + +## 1. 概述 + +ESP32 通过 SPI 协议与 GD32 通信,实现三路 CAN/CANFD 的收发控制。CAN 相关 SPI 命令占用 `0xC0` ~ `0xC2` 帧类型。 + +| 帧类型 | 名称 | 方向 | 用途 | +|--------|------|------|------| +| `0xC0` | kTypeCanSend | ESP32 → GD32 | 发送一帧 CAN 数据 | +| `0xC1` | kTypeCanFeedback | ESP32 → GD32 | 拉取不重复 ID 快照表(分页) | +| `0xC2` | kTypeCanSetMode | ESP32 → GD32 | 切换三路 CAN 接收过滤模式 | + +--- + +## 2. SPI 协议帧格式 + +所有 SPI 帧遵循统一格式: + +``` +[SOF:1B][TYPE:1B][LEN_LO:1B][LEN_HI:1B][PAYLOAD:NB][CRC8:1B] +``` + +- SOF = `0xA5` +- LEN = payload 长度(小端 16 位) +- CRC8 = 对 SOF+TYPE+LEN+PAYLOAD 的校验 + +应答帧 TYPE: +- `0x80` = ACK(成功) +- `0x81` = WAIT(OTA 中间态) +- `0x82` = ERROR + +--- + +## 3. 0xC0 — CAN 发送 + +### 请求 payload + +``` +[0] channel 0/1/2 +[1..4] ext_id 扩展帧 ID(Big-Endian,29 位) +[5..N] data CAN 数据负载(1~64 字节) +``` + +### 应答 payload + +``` +[0] 0x00(ACK)或错误码 +``` + +--- + +## 4. 0xC1 — 不重复 ID 快照表(核心协议) + +每秒 ESP32 对每通道发 2 次请求(page=0 和 page=1),拉取该通道所有**不重复 CAN ID** 的最新帧快照。 + +### 请求 payload + +``` +[0] channel 0/1/2 +[1] page 页号:0 = 条目 0~7,1 = 条目 8~15 +``` + +### 应答 payload(变长,最大 123 字节) + +``` +[0] channel 回显通道号 +[1] page 回显页号 +[2] total_count 该通道实际 ID 条目总数(0~16) +[3] entry_count 本页实际条目数 +[4] mode 当前 CAN 接收模式(0=全收, 1=过滤) +[5..8] snapshot_seq 快照序号(LE,每秒 +1) +[9..12] rx_count 通道累计接收帧数(LE) +[13..16] tx_count 通道累计发送帧数(LE) +[17..20] rx_drop_count 通道累计丢帧(LE) +[21..24] error_count 通道累计错误(LE) +[25..] 条目逐条拼接(见下) +``` + +### 单条目格式(变长) + +``` +[+0..+3] id CAN 报文 ID(LE) +[+4] dlc 数据长度(1~64) +[+5] flags bit0=FD, bit1=BRS, bit2=ESI +[+6..+7] reserved 保留(0x00) +[+8..+N] data 报文数据(dlc 字节) +``` + +条目总大小 = 8 + dlc 字节。 +- 标准 CAN(dlc=8):14 字节/条 → 每页最多 8 条 +- CANFD(dlc=64):72 字节/条 → 每页最多 1 条 + +### 拉取时序 + +``` +ESP32 GD32 + |--- 0xC1 [ch=0, page=0] --->| + |<-- ACK [统计+条目0~7] -----| + |--- 0xC1 [ch=0, page=1] --->| + |<-- ACK [条目8~15] ---------| + |--- 0xC1 [ch=1, page=0] --->| + |<-- ACK [统计+条目0~7] -----| + |--- 0xC1 [ch=1, page=1] --->| + |<-- ACK [条目8~15] ---------| + |--- 0xC1 [ch=2, page=0] --->| + |<-- ACK [统计+条目0~7] -----| + |--- 0xC1 [ch=2, page=1] --->| + |<-- ACK [条目8~15] ---------| + | (1 秒后重复) +``` + +每轮 6 次 SPI 事务,SPI 总线占用 ~5ms(0.5%)。 + +### total_count 与 entry_count 的关系 + +- `total_count` = 该通道当前所有不重复 ID 的总数(0~16) +- `entry_count` = 本页实际返回的条目数(≤8) +- 当 `total_count ≤ 8` 时,page=1 的 `entry_count=0`,ESP32 不再拉第二页 +- 当 `total_count = 0` 时,表示该通道未收到任何 CAN 帧 + +--- + +## 5. 0xC2 — 切换接收过滤模式 + +### 请求 payload + +``` +[0] mode 0 = 全部接收(kModeAllPass) + 1 = 按 ID 过滤(kModeFilterId) +``` + +### 应答 payload + +``` +[0] actual_mode GD32 实际生效的模式 +``` + +模式切换影响三路 CAN 通道(全局生效,不是单通道)。 + +--- + +## 6. GD32 端实现要点 + +### 不重复 ID 快照表(canfd.h) + +```cpp +struct IdSnapshotEntry { + uint32_t id; + uint32_t seq; // 更新序号 + uint32_t rx_count; // 该 ID 累计接收 + uint8_t dlc, fd_flag, brs, esi; + uint8_t data[64]; + bool occupied; +}; + +struct IdSnapshotTable { + IdSnapshotEntry entries[16]; // CANFD_ID_SNAPSHOT_MAX + uint8_t count; + uint32_t seq; +}; +``` + +### 中断更新逻辑(canfd.cpp::handleRxInterrupt) + +每收到一帧 CAN: +1. 遍历 `s_idSnapshot[channel].entries[0..count-1]`,查找 ID 匹配的条目 +2. 找到 → 更新 data/dlc/flags/rx_count++ +3. 未找到且 count < 16 → 新建条目 +4. 未找到且 count = 16 → FIFO 淘汰最旧条目(index 0),整体左移,新条目插入末尾 + +### SPI 处理器(app_spi_cmd_can_feedback.cpp) + +- `kFeedbackPayloadLen = 123`(kTxBufSize 128 - overhead 5) +- 固定头 25 字节 + 变长条目逐条拼接 +- 条目不足时 `entry_count < kPageEntries`,ESP32 侧据此判断是否需要拉下一页 + +--- + +## 7. ESP32 端实现要点 + +### 数据流 + +``` +GD32 CAN 中断 + → s_idSnapshot[ch].entries[匹配ID] + ↓ (1Hz 快照刷新) + refreshFeedbackSnapshot → seq++ + ↓ (ESP32 1Hz 轮询) + 0xC1 × 3通道 × 2页 = 6次 SPI/s + ↓ + can_feedback_task 缓存到 s_latest[ch] + ↓ + /can/feedback HTTP → JSON + ↓ + can_monitor.js 表格渲染 +``` + +### SPI 轮询(can_feedback_task.cpp) + +- 固定 1Hz 循环,每通道发 2 次 0xC1(page=0 和 page=1) +- OTA 进行中时暂停(`s_paused` 标志,由 OtaCoordinator 和 OtaRelay 设置) +- 结果缓存在 `s_latest[3]`(`can_feedback_result_t`),线程安全(互斥锁) + +### HTTP 接口 + +| 端点 | 方法 | 用途 | +|------|------|------| +| `/can/feedback` | GET | 三路 CAN 通道统计 + ID 快照表 | +| `/can/mode` | GET | 查询当前接收模式 | +| `/can/mode` | POST | 切换模式 `{"mode":0}` 或 `{"mode":1}` | +| `/can/send` | POST | 发送一帧 CAN 数据 | + +### /can/feedback 响应格式 + +```json +{ + "channels": [ + { + "ch": 0, + "valid": true, + "mode": 0, + "seq": 1234, + "rx": 5678, + "tx": 0, + "drop": 0, + "err": 0, + "count": 5, + "entries": [ + { + "id": "0x18DA00F1", + "dlc": 8, + "fd": 0, + "brs": 0, + "esi": 0, + "data": "0102030405060708" + } + ] + }, + { "ch": 1, "valid": false }, + { "ch": 2, "valid": false } + ] +} +``` + +--- + +## 8. 前端(tools/can_monitor.html) + +### 页面结构 + +1. **顶部状态栏**:连接状态、当前模式、轮询状态 +2. **三路通道面板**(CH0/CH1/CH2): + - 状态(活跃/无帧) + - rx/tx/drop/err 统计 + - seq(活性指示) + - 当前 ID 数量 +3. **每 ID 快照表**: + - 通道下拉选择 + - 每行一个不重复 ID + - 列:CAN ID | DLC | FD | BRS | ESI | 数据 (HEX) + - 1Hz 自动刷新 + +### 导航 + +- OTA 控制台 (`index.html`) → "CAN 接收监控" 按钮 +- CAN 发送控制台 (`can.html`) → header "CAN 接收监控" 链接 +- CAN 接收监控 (`can_monitor.html`) → header 可跳回 CAN 发送或 OTA 控制台 + +--- + +## 9. 资源消耗 + +| 资源 | 消耗 | +|------|------| +| GD32 SRAM(快照表) | 3.8KB / 128KB = 3% | +| GD32 中断开销(16 条遍历) | < 1μs | +| SPI 事务/秒 | 6 次(3 通道 × 2 页) | +| SPI 总线占用 | ~5ms/s = 0.5% | +| ESP32 RAM(缓存) | ~4KB | +| OTA 期间 CAN 轮询 | 自动暂停 | + +--- + +## 10. 已废弃 + +| 项目 | 状态 | 说明 | +|------|------|------| +| `0xC3 kTypeIdSnapshot` | 保留定义但不使用 | 功能已合并到 0xC1 | +| `/can/idsnap` | 已移除 | 数据由 `/can/feedback` 统一提供 | +| `canFdPullIdSnapshot()` | 已移除 | 改用 `canFdPollFeedback()` 分页拉取 | diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index 0470a6b..b58b3cd 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -10,6 +10,7 @@ idf_component_register( "server/http_handlers.cpp" # HTTP 路由处理(H2 从 wifi_ota 提取) "server/ble_ota.cpp" # BLE GATT OTA 服务器 "monitor/health_monitor.cpp" # 系统健康监控(温度/RSSI/堆内存) + "monitor/can_feedback_task.cpp" # CAN 1Hz 反馈轮询任务(0xC1) "monitor/debug_log.cpp" # 三路调试日志(UART/UDP/环形缓冲) INCLUDE_DIRS "." diff --git a/main/core/main.cpp b/main/core/main.cpp index 2ac4112..b488bfd 100644 --- a/main/core/main.cpp +++ b/main/core/main.cpp @@ -1,4 +1,4 @@ -/* +/* * ESP32-C3FH4 OTA 中继 - 程序入口 * 四个 FreeRTOS 任务:健康监控 / OTA Relay / WiFi Server / BLE GATT * @@ -23,6 +23,7 @@ #include "monitor/debug_log.h" #include "app_config.h" #include "ota/esp32_ota_core.h" +#include "monitor/can_feedback_task.h" /** * @brief 程序主入口,初始化系统组件并创建各 FreeRTOS 任务。 @@ -150,6 +151,13 @@ extern "C" void app_main(void) DEBUG_INFO("MAIN", "BLE OTA 任务已创建"); } + // CAN 1Hz 反馈轮询任务(依赖 SPI 已初始化) + if (startCanFeedbackTask()) { + DEBUG_INFO("MAIN", "CAN 反馈轮询任务已创建"); + } else { + DEBUG_WARN("MAIN", "CAN 反馈轮询任务创建失败(非致命,OTA 不受影响)"); + } + // ★ OTA 回滚保护:标记当前固件有效 // 启用 CONFIG_APP_ROLLBACK_ENABLE 后,新固件首次启动必须调用此函数 // 若未调用就崩溃/重启,bootloader 会自动回退到上一个有效分区 diff --git a/main/include/version.h b/main/include/version.h index 8d1b367..edc48db 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 34 -#define FW_VERSION_STRING "0.3.34" +#define FW_VERSION_PATCH 35 +#define FW_VERSION_STRING "0.3.35" // 编译时间 -#define BUILD_TIME "2026-06-28 04:26:17" +#define BUILD_TIME "2026-06-29 01:50:43" // Git 信息 -#define GIT_HASH "6ef9730" +#define GIT_HASH "a93dba6" diff --git a/main/monitor/can_feedback_task.cpp b/main/monitor/can_feedback_task.cpp new file mode 100644 index 0000000..73c5079 --- /dev/null +++ b/main/monitor/can_feedback_task.cpp @@ -0,0 +1,181 @@ +/* + * can_feedback_task.cpp - 周期性轮询 GD32 三路 CAN 1Hz 反馈快照 + * + * 通过 SPI 协议帧 0xC1 拉取 GD32 端的通道统计与最近一帧信息, + * 用调试日志输出;其它消费者(Web/WebSocket)可直接读取 + * CanFeedbackTask::snapshot(channel) 拷贝最新一份数据。 + */ +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "freertos/semphr.h" + +#include "transport/canfd_bridge.h" +#include "monitor/debug_log.h" +#include "monitor/can_feedback_task.h" + +#include + +namespace { + +constexpr uint32_t kPollIntervalMs = 1000U; ///< 1Hz 拉取节拍 +constexpr uint32_t kPollTimeoutMs = 200U; ///< 单次 SPI 事务超时 +constexpr const char *kTag = "CAN_FB"; + +static can_feedback_result_t s_latest[CAN_CHANNEL_MAX]; +static bool s_valid [CAN_CHANNEL_MAX]; +static SemaphoreHandle_t s_mutex = nullptr; +static volatile bool s_paused = false; +static can_channel_feedback_t s_prev_stats[CAN_CHANNEL_MAX]; +static bool s_prev_valid[CAN_CHANNEL_MAX]; +static bool s_prev_poll_ok[CAN_CHANNEL_MAX]; + +static void publish(uint8_t ch, const can_feedback_result_t &fb) +{ + if (s_mutex == nullptr) return; + if (xSemaphoreTake(s_mutex, pdMS_TO_TICKS(50)) == pdTRUE) { + s_latest[ch] = fb; + s_valid[ch] = true; + xSemaphoreGive(s_mutex); + } +} + +static void taskLoop(void *) +{ + DEBUG_INFO(kTag, "CAN 反馈轮询任务启动 (1Hz, 0xC1)"); + + can_feedback_result_t fb{}; + for (;;) { + /* OTA 进行中时暂停 CAN 轮询,避免抢占 SPI 导致 OTA 握手失败 */ + if (s_paused) { + vTaskDelay(pdMS_TO_TICKS(kPollIntervalMs)); + continue; + } + + for (uint8_t ch = 0U; ch < CAN_CHANNEL_MAX; ++ch) { + const int rc = canFdPollFeedback(static_cast(ch), + &fb, kPollTimeoutMs); + if (rc == 0) { + publish(ch, fb); + + const bool prevOk = s_prev_poll_ok[ch]; + const bool changed = (!s_prev_valid[ch]) || + (fb.stats.rx_count != s_prev_stats[ch].rx_count) || + (fb.stats.tx_count != s_prev_stats[ch].tx_count) || + (fb.stats.rx_drop_count != s_prev_stats[ch].rx_drop_count) || + (fb.stats.error_count != s_prev_stats[ch].error_count) || + (fb.stats.mode != s_prev_stats[ch].mode) || + (fb.stats.snapshot_seq != s_prev_stats[ch].snapshot_seq); + const bool abnormal = (fb.stats.rx_drop_count != 0U) || + (fb.stats.error_count != 0U) || + (fb.stats.mode != 0U); + + if (!prevOk || changed || abnormal) { + DEBUG_INFO(kTag, + "CH%u seq=%lu mode=%u rx=%lu tx=%lu drop=%lu err=%lu ids=%u", + static_cast(ch), + static_cast(fb.stats.snapshot_seq), + static_cast(fb.stats.mode), + static_cast(fb.stats.rx_count), + static_cast(fb.stats.tx_count), + static_cast(fb.stats.rx_drop_count), + static_cast(fb.stats.error_count), + static_cast(fb.valid)); + } + + s_prev_stats[ch] = fb.stats; + s_prev_valid[ch] = true; + s_prev_poll_ok[ch] = true; + } else { + if (s_prev_poll_ok[ch]) { + DEBUG_WARN(kTag, "CH%u 轮询失败", static_cast(ch)); + } + s_prev_stats[ch] = {}; + s_prev_valid[ch] = false; + s_prev_poll_ok[ch] = false; + } + } + vTaskDelay(pdMS_TO_TICKS(kPollIntervalMs)); + } +} + +} // namespace + +/** + * @brief 启动 1Hz CAN 反馈轮询任务(幂等)。 + * @return true 表示任务已存在或创建成功。 + */ +bool startCanFeedbackTask() +{ + static bool s_started = false; + if (s_started) return true; + + if (s_mutex == nullptr) { + s_mutex = xSemaphoreCreateMutex(); + if (s_mutex == nullptr) { + DEBUG_ERR(kTag, "互斥量创建失败"); + return false; + } + } + memset(s_latest, 0, sizeof(s_latest)); + memset(s_valid, 0, sizeof(s_valid)); + + BaseType_t xRet = xTaskCreatePinnedToCore( + taskLoop, + "can_fb", + 4096, + nullptr, + 4, + nullptr, + 0); + if (xRet != pdPASS) { + DEBUG_ERR(kTag, "任务创建失败"); + return false; + } + s_started = true; + return true; +} + +void pauseCanFeedbackTask(bool pause) +{ + s_paused = pause; + if (pause) { + DEBUG_INFO(kTag, "CAN 轮询已暂停(OTA 进行中)"); + } else { + DEBUG_INFO(kTag, "CAN 轮询已恢复"); + } +} + +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 表示拿到一份有效数据。 + */ +bool getLatestCanFeedback(uint8_t channel, can_feedback_result_t *out) +{ + if (channel >= CAN_CHANNEL_MAX || out == nullptr || s_mutex == nullptr) { + return false; + } + bool ok = false; + if (xSemaphoreTake(s_mutex, pdMS_TO_TICKS(50)) == pdTRUE) { + if (s_valid[channel]) { + *out = s_latest[channel]; + ok = true; + } + xSemaphoreGive(s_mutex); + } + return ok; +} diff --git a/main/monitor/can_feedback_task.h b/main/monitor/can_feedback_task.h new file mode 100644 index 0000000..7808913 --- /dev/null +++ b/main/monitor/can_feedback_task.h @@ -0,0 +1,50 @@ +/* + * can_feedback_task.h - 周期性轮询 GD32 CAN 反馈快照的任务接口 + */ +#ifndef CAN_FEEDBACK_TASK_H +#define CAN_FEEDBACK_TASK_H + +#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); + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief 启动 1Hz CAN 反馈轮询任务(幂等)。 + * @return true 表示任务已存在或创建成功。 + */ +bool startCanFeedbackTask(); + +/** + * @brief 拷贝某通道的最新反馈快照(线程安全)。 + * @param channel 0/1/2。 + * @param out 输出缓冲。 + * @return true 表示拿到一份有效数据,false 表示通道无效或尚未拿到第一份。 + */ +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 + +#endif // CAN_FEEDBACK_TASK_H diff --git a/main/ota/ota_coordinator.cpp b/main/ota/ota_coordinator.cpp index 7ef4ec1..f4082b9 100644 --- a/main/ota/ota_coordinator.cpp +++ b/main/ota/ota_coordinator.cpp @@ -5,6 +5,7 @@ * 传输层(WiFi/BLE)通过本协调器发起所有 OTA 操作。 */ #include "ota_coordinator.h" +#include "monitor/can_feedback_task.h" #include "esp32_ota_core.h" #include "relay/ota_relay.h" #include "storage/gd32_flash_store.h" @@ -313,6 +314,7 @@ esp_err_t OtaCoordinator::triggerGd32Relay() m_sysState = OTA_SYS_GD32_RELAYING; m_busySinceTick = xTaskGetTickCount(); // ★ 记录忙状态起始时刻 + pauseCanFeedbackTask(true); DEBUG_INFO(TAG, "触发 GD32 中继, 系统状态 → GD32_RELAYING"); uint32_t cmd = 1; @@ -336,6 +338,7 @@ void OtaCoordinator::notifyRelayStarted() if (h && m_sysState != OTA_SYS_GD32_RELAYING) { m_sysState = OTA_SYS_GD32_RELAYING; m_busySinceTick = xTaskGetTickCount(); // ★ 记录起始时刻 + pauseCanFeedbackTask(true); DEBUG_INFO(TAG, "中继已开始, 系统状态 → GD32_RELAYING"); } unlock(h); @@ -350,10 +353,12 @@ void OtaCoordinator::notifyRelayComplete(bool success) m_sysState = OTA_SYS_GD32_COMPLETE; m_gd32StorageDone = false; m_busySinceTick = 0; // ★ COMPLETE 非忙 + pauseCanFeedbackTask(false); DEBUG_INFO(TAG, "GD32 中继完成, 系统状态 → GD32_COMPLETE"); } else { m_sysState = OTA_SYS_ERROR; m_busySinceTick = 0; // ★ ERROR 非忙(可被后续请求恢复) + pauseCanFeedbackTask(false); DEBUG_ERR(TAG, "GD32 中继失败, 系统状态 → ERROR"); } } diff --git a/main/relay/ota_relay.cpp b/main/relay/ota_relay.cpp index ae4512a..ea40b43 100644 --- a/main/relay/ota_relay.cpp +++ b/main/relay/ota_relay.cpp @@ -10,6 +10,7 @@ * 协议子步骤与加密/Flash 工具方法保持不变。 */ #include +#include "monitor/can_feedback_task.h" #include #include #include "freertos/FreeRTOS.h" @@ -1335,6 +1336,7 @@ void OtaRelay::taskEntry(void *pvParameters) DEBUG_INFO("OTA", "收到 OTA 调度信号: %" PRIu32 ",开始转发...", msg); // ★ H4: 通过 IRelayListener 接口回调通知协调器(不再直接依赖 OtaCoordinator) IRelayListener *listener = getRelayListener(); + pauseCanFeedbackTask(true); if (listener) listener->onRelayStarted(); ota_result_t result = instance().forwardFirmware(); @@ -1353,5 +1355,6 @@ void OtaRelay::taskEntry(void *pvParameters) // ★ H4: 通过 IRelayListener 接口回调通知协调器中继结束 listener = getRelayListener(); if (listener) listener->onRelayComplete(result == OTA_RESULT_OK); + pauseCanFeedbackTask(false); } } diff --git a/main/server/http_handlers.cpp b/main/server/http_handlers.cpp index eda1d2e..bc12c7d 100644 --- a/main/server/http_handlers.cpp +++ b/main/server/http_handlers.cpp @@ -1,4 +1,4 @@ -/* +/* * http_handlers.cpp - HTTP 请求处理器实现 * 从 wifi_ota.cpp 提取的 HTTP handler 函数 */ @@ -28,6 +28,7 @@ #include "monitor/debug_log.h" #include "spi_master.h" #include "canfd_bridge.h" +#include "monitor/can_feedback_task.h" #include "ble_ota.h" // OTA 准备状态:/ota/prepare 完成后设置,/ota/upload 消费后清除 @@ -714,6 +715,140 @@ esp_err_t logs_handler(httpd_req_t *req) return ESP_OK; } + +// ============================================================ +// GET /can/feedback — 三路 CAN 1Hz 反馈快照 +// ============================================================ +esp_err_t can_feedback_handler(httpd_req_t *req) +{ + http_set_cors_headers(req); + + can_feedback_result_t results[CAN_CHANNEL_MAX] = {}; + bool valid[CAN_CHANNEL_MAX] = {}; + for (uint8_t i = 0; i < CAN_CHANNEL_MAX; ++i) { + valid[i] = getLatestCanFeedback(i, &results[i]); + } + + /* 估算缓冲大小:每通道 ~2KB (统计+ID条目) */ + char *resp = (char *)calloc(1, 8192); + if (!resp) { + httpd_resp_send_err(req, HTTPD_500_INTERNAL_SERVER_ERROR, "no memory"); + return ESP_FAIL; + } + + char *p = resp; + p += sprintf(p, "{\"channels\":["); + for (uint8_t i = 0; i < CAN_CHANNEL_MAX; ++i) { + const can_feedback_result_t &r = results[i]; + if (i > 0) p += sprintf(p, ","); + p += sprintf(p, "{\"ch\":%u,\"valid\":%s", (unsigned)i, + valid[i] ? "true" : "false"); + if (valid[i]) { + p += sprintf(p, + ",\"mode\":%u,\"seq\":%lu,\"rx\":%lu,\"tx\":%lu," + "\"drop\":%lu,\"err\":%lu,\"count\":%u,\"entries\":[", + (unsigned)r.stats.mode, + (unsigned long)r.stats.snapshot_seq, + (unsigned long)r.stats.rx_count, + (unsigned long)r.stats.tx_count, + (unsigned long)r.stats.rx_drop_count, + (unsigned long)r.stats.error_count, + (unsigned)r.total_count); + for (uint8_t j = 0; j < r.total_count && j < CANFD_ID_SNAPSHOT_MAX; ++j) { + const id_snapshot_entry_t &e = r.entries[j]; + if (j > 0) p += sprintf(p, ","); + p += sprintf(p, + "{\"id\":\"0x%08lX\",\"dlc\":%u,\"fd\":%u," + "\"brs\":%u,\"esi\":%u,\"data\":\"", + (unsigned long)e.id, + (unsigned)e.dlc, + (unsigned)e.fd_flag, + (unsigned)e.brs, + (unsigned)e.esi); + const uint8_t dataLen = (e.dlc < CANFD_ID_SNAPSHOT_DATA_MAX) + ? e.dlc : CANFD_ID_SNAPSHOT_DATA_MAX; + for (uint8_t d = 0; d < dataLen; ++d) { + p += sprintf(p, "%02X", e.data[d]); + } + p += sprintf(p, "\"}"); + } + p += sprintf(p, "]"); + } + p += sprintf(p, "}"); + } + p += sprintf(p, "]}"); + + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, resp, (int)(p - resp)); + free(resp); + return ESP_OK; +} + +// ============================================================ +// GET/POST /can/mode — 查询或切换三路 CAN 接收模式 +// ============================================================ +esp_err_t can_mode_handler(httpd_req_t *req) +{ + http_set_cors_headers(req); + + if (req->method == HTTP_GET) { + can_feedback_result_t fb{}; + if (getLatestCanFeedback(0, &fb)) { + char resp[64]; + int len = snprintf(resp, sizeof(resp), + "{\"mode\":%u}", (unsigned)fb.stats.mode); + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, resp, len); + } else { + can_rx_mode_t actual = CAN_RX_MODE_ALL_PASS; + int rc = canFdSetRxMode(CAN_RX_MODE_ALL_PASS, &actual, 500); + char resp[64]; + int len = snprintf(resp, sizeof(resp), + "{\"mode\":%u,\"source\":\"spi\",\"ok\":%s}", + (unsigned)actual, rc == 0 ? "true" : "false"); + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, resp, len); + } + return ESP_OK; + } + + char buf[128] = {0}; + int ret = httpd_req_recv(req, buf, sizeof(buf) - 1); + if (ret <= 0) { + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "接收数据失败"); + return ESP_FAIL; + } + buf[ret] = '\0'; + + cJSON *root = cJSON_Parse(buf); + if (!root) { + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "JSON 解析失败"); + return ESP_FAIL; + } + + cJSON *mode_item = cJSON_GetObjectItem(root, "mode"); + if (!mode_item || !cJSON_IsNumber(mode_item)) { + cJSON_Delete(root); + httpd_resp_send_err(req, HTTPD_400_BAD_REQUEST, "缺少 mode 字段"); + return ESP_FAIL; + } + + int requested = (int)cJSON_GetNumberValue(mode_item); + cJSON_Delete(root); + + can_rx_mode_t target = (requested == 1) ? CAN_RX_MODE_FILTER_ID + : CAN_RX_MODE_ALL_PASS; + can_rx_mode_t actual = CAN_RX_MODE_ALL_PASS; + int rc = canFdSetRxMode(target, &actual, 500); + + char resp[96]; + int len = snprintf(resp, sizeof(resp), + "{\"ok\":%s,\"requested\":%d,\"actual\":%u}", + rc == 0 ? "true" : "false", requested, (unsigned)actual); + httpd_resp_set_type(req, "application/json"); + httpd_resp_send(req, resp, len); + return ESP_OK; +} esp_err_t can_send_handler(httpd_req_t *req) { http_set_cors_headers(req); diff --git a/main/server/http_handlers.h b/main/server/http_handlers.h index 6271d25..7075796 100644 --- a/main/server/http_handlers.h +++ b/main/server/http_handlers.h @@ -20,4 +20,8 @@ esp_err_t spi_send_handler(httpd_req_t *req); esp_err_t system_status_handler(httpd_req_t *req); esp_err_t system_probe_handler(httpd_req_t *req); +// CAN 1Hz 反馈快照与接收模式 +esp_err_t can_feedback_handler(httpd_req_t *req); +esp_err_t can_mode_handler(httpd_req_t *req); + #endif // HTTP_HANDLERS_H diff --git a/main/server/wifi_ota.cpp b/main/server/wifi_ota.cpp index 68bf04a..92b8b26 100644 --- a/main/server/wifi_ota.cpp +++ b/main/server/wifi_ota.cpp @@ -456,6 +456,9 @@ esp_err_t WifiOta::httpServerStart() {"/system/status", HTTP_GET, system_status_handler}, {"/system/probe", HTTP_GET, system_probe_handler}, {"/can/send", HTTP_POST, can_send_handler}, + {"/can/feedback", HTTP_GET, can_feedback_handler}, + {"/can/mode", HTTP_GET, can_mode_handler}, + {"/can/mode", HTTP_POST, can_mode_handler}, {"/spi/send", HTTP_POST, spi_send_handler}, }; @@ -475,7 +478,7 @@ esp_err_t WifiOta::httpServerStart() httpd_register_uri_handler(WifiOta::instance().m_httpServer, &opts_uri); } - DEBUG_INFO("WIFI", "注册端点: /ota/upload, /ota/relay, /ota/update, /restart, /logs, /system/status, /system/probe, /can/send, /spi/send"); + DEBUG_INFO("WIFI", "注册端点: /ota/upload, /ota/relay, /ota/update, /restart, /logs, /system/status, /system/probe, /can/send, /spi/send, /can/feedback, /can/mode"); return ESP_OK; } diff --git a/main/transport/canfd_bridge.cpp b/main/transport/canfd_bridge.cpp index 7353817..f3e952d 100644 --- a/main/transport/canfd_bridge.cpp +++ b/main/transport/canfd_bridge.cpp @@ -1,4 +1,4 @@ -/* +/* * canfd_bridge.cpp - CANFD 3通道通信桥接实现(M4: 从 SpiMaster 提取) */ #include @@ -50,6 +50,18 @@ static int sendCanFrameOverSpi(can_channel_t channel, uint32_t ext_id, return static_cast(copied_len); } +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) { + return static_cast(p[0]) + | (static_cast(p[1]) << 8) + | (static_cast(p[2]) << 16) + | (static_cast(p[3]) << 24); +} + } // namespace /** @@ -80,3 +92,182 @@ int canFdSendNoReply(can_channel_t channel, uint32_t ext_id, return (sendCanFrameOverSpi(channel, ext_id, data, dlc, nullptr, 0U, 1000U) >= 0) ? 0 : -1; } + + +/** + * @brief 拉取一路 CAN 通道的 1Hz 反馈 + ID 快照表(GD32 SPI 帧类型 0xC1,分页)。 + * 连续拉取 page=0 和 page=1,合并为完整结果。 + */ +int canFdPollFeedback(can_channel_t channel, + can_feedback_result_t *out, + uint32_t timeout_ms) +{ + if (channel >= CAN_CHANNEL_MAX || out == nullptr) { + return -1; + } + + out->valid = 0; + uint8_t entryIdx = 0U; + + constexpr uint8_t kFixedHeader = 25U; /* ch+page+total+cnt+mode+seq+rx+tx+drop+err */ + + for (uint8_t page = 0U; page < 2U; ++page) { + uint8_t req[2] = { static_cast(channel & CAN_CHANNEL_MASK), page }; + uint8_t resp_buf[kFeedbackPayloadLen] = {}; + uint8_t resp_type = 0U; + size_t resp_len = 0U; + + esp_err_t ret = SpiMaster::instance().otaTransfer( + kSpiCanFeedbackType, + 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 < kFixedHeader) { + return -1; + } + + /* 第一页时解析固定头 */ + if (page == 0U) { + out->stats.channel = resp_buf[0]; + out->stats.mode = resp_buf[4]; + out->stats.snapshot_seq = readU32Le(&resp_buf[5]); + out->stats.rx_count = readU32Le(&resp_buf[9]); + out->stats.tx_count = readU32Le(&resp_buf[13]); + out->stats.rx_drop_count = readU32Le(&resp_buf[17]); + out->stats.error_count = readU32Le(&resp_buf[21]); + out->total_count = resp_buf[2]; + } + + const uint8_t entryCount = resp_buf[3]; + if (entryCount == 0U) break; + + uint16_t offset = kFixedHeader; + for (uint8_t i = 0U; i < entryCount && entryIdx < CANFD_ID_SNAPSHOT_MAX; ++i) { + if (offset + 8U > resp_len) break; + + id_snapshot_entry_t &e = out->entries[entryIdx]; + e.id = readU32Le(&resp_buf[offset]); + e.dlc = resp_buf[offset + 4U]; + const uint8_t flags = resp_buf[offset + 5U]; + 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 = static_cast(8U + dataLen); + if (offset + entrySize > resp_len) break; + + memcpy(e.data, &resp_buf[offset + 8U], dataLen); + for (uint8_t d = dataLen; d < CANFD_ID_SNAPSHOT_DATA_MAX; ++d) { + e.data[d] = 0U; + } + + offset += entrySize; + entryIdx++; + } + + if (entryIdx >= out->total_count) break; + } + out->valid = 1; + return 0; +} + +/** + * @brief 切换 GD32 三路 CAN 接收过滤模式(GD32 SPI 帧类型 0xC2)。 + */ + +/** + * @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().otaTransfer( + 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) +{ + if (mode != CAN_RX_MODE_ALL_PASS && mode != CAN_RX_MODE_FILTER_ID) { + return -1; + } + + uint8_t req = static_cast(mode); + uint8_t resp_buf[1] = {0}; + uint8_t resp_type = 0U; + size_t resp_len = 0U; + + esp_err_t ret = SpiMaster::instance().otaTransfer( + kSpiCanSetModeType, + &req, 1U, + &resp_type, resp_buf, sizeof(resp_buf), &resp_len, timeout_ms); + if (ret != ESP_OK || resp_type != SPI_OTA_RSP_ACK || resp_len < 1U) { + return -1; + } + if (actual != nullptr) { + *actual = static_cast(resp_buf[0]); + } + return 0; +} diff --git a/main/transport/canfd_bridge.h b/main/transport/canfd_bridge.h index 441ee72..351df37 100644 --- a/main/transport/canfd_bridge.h +++ b/main/transport/canfd_bridge.h @@ -1,4 +1,4 @@ -/* +/* * canfd_bridge.h - CANFD 3通道通信桥接(M4: 从 SpiMaster 提取) * * 封装 CANFD 帧格式构建 + SPI 传输,与 SpiMaster 的 OTA 协议职责解耦。 @@ -61,4 +61,93 @@ int canFdSend(can_channel_t channel, uint32_t ext_id, int canFdSendNoReply(can_channel_t channel, uint32_t ext_id, const uint8_t *data, uint8_t dlc); + +// ============================================================ +// 不重复 ID 快照表(SPI 0xC3 协议配套) +// ============================================================ +#define CANFD_ID_SNAPSHOT_MAX 16 ///< 每通道最大不重复 ID 条目数 +#define CANFD_ID_SNAPSHOT_DATA_MAX 64 ///< 快照条目最大数据字节数 + +/** + * @brief 单个不重复 ID 的最新帧快照条目。 + */ +typedef struct { + uint32_t id; + uint32_t seq; + uint32_t rx_count; + uint8_t dlc; + uint8_t fd_flag; + uint8_t brs; + uint8_t esi; + uint8_t data[CANFD_ID_SNAPSHOT_DATA_MAX]; +} id_snapshot_entry_t; + +// ============================================================ +// CAN 接收过滤模式 & 1Hz 反馈快照 +// 与 GD32 DCU_APP SPI 协议帧 0xC1/0xC2 配套 +// ============================================================ +#define SPI_CAN_FEEDBACK_LAST_DATA_BYTES 93 ///< 反馈快照中最近一帧数据字节数(受 SPI 128B 应答帧约束(kTxBufSize=128 - overhead=5 - 前缀30 = 93)) + +typedef enum { + CAN_RX_MODE_ALL_PASS = 0, ///< 默认:禁用 ID 过滤,三路全部接收 + CAN_RX_MODE_FILTER_ID = 1, ///< 按 CONFIG_CAN_RX_FILTER_ID/MASK 做标准帧过滤 +} can_rx_mode_t; + +/** + * @brief 单通道 1Hz 反馈统计。 + */ +typedef struct { + uint8_t channel; + uint8_t mode; + uint32_t snapshot_seq; + uint32_t rx_count; + uint32_t tx_count; + uint32_t rx_drop_count; + uint32_t error_count; +} can_channel_feedback_t; + +/** + * @brief 单通道 0xC1 完整响应:统计 + ID 快照表。 + */ +typedef struct { + can_channel_feedback_t stats; + id_snapshot_entry_t entries[CANFD_ID_SNAPSHOT_MAX]; + uint8_t total_count; /**< 该通道实际 ID 条目总数 */ + uint8_t valid; /**< 数据是否有效 */ +} can_feedback_result_t; + +/** + * @brief 拉取一路 CAN 通道的 1Hz 反馈快照。 + * @param channel 目标通道 0/1/2。 + * @param out 输出快照(不可为 nullptr)。 + * @param timeout_ms SPI 事务超时。 + * @return 0 成功,<0 失败(ERROR 应答、payload 长度不足或 SPI 错误)。 + */ +int canFdPollFeedback(can_channel_t channel, + can_feedback_result_t *out, + uint32_t timeout_ms); + +/** + * @brief 切换 GD32 三路 CAN 的接收过滤模式。 + * @param mode 目标模式(0=全收,1=按 ID 过滤)。 + * @param actual 可选输出,GD32 实际生效模式。 + * @param timeout_ms SPI 事务超时。 + * @return 0 成功,<0 失败。 + */ +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/main/transport/spi_master.cpp b/main/transport/spi_master.cpp index 44dbc88..90de889 100644 --- a/main/transport/spi_master.cpp +++ b/main/transport/spi_master.cpp @@ -452,7 +452,7 @@ esp_err_t SpiMaster::otaPollResponse(uint8_t *resp_type, uint8_t *resp_payload, esp_err_t SpiMaster::otaPollResponseUnlocked(uint8_t *resp_type, uint8_t *resp_payload, size_t resp_max, size_t *resp_len, uint32_t timeout_ms) { - constexpr size_t kPollBytes = 96U; + constexpr size_t kPollBytes = 160U; TickType_t deadline = xTaskGetTickCount() + pdMS_TO_TICKS(timeout_ms); while (xTaskGetTickCount() <= deadline) { diff --git a/main/transport/spi_master.h b/main/transport/spi_master.h index e7e6bec..843278d 100644 --- a/main/transport/spi_master.h +++ b/main/transport/spi_master.h @@ -17,7 +17,7 @@ #define SPI_CLOCK_HZ (4 * 1000 * 1000) // 4MHz #define SPI_MODE (0) // CPOL=0, CPHA=0 #define SPI_QUEUE_SIZE (1) ///< SPI 事务队列深度 -#define SPI_OTA_FRAME_MAX (96) ///< SPI OTA 单帧最大字节数 +#define SPI_OTA_FRAME_MAX (133) ///< SPI OTA 单帧最大字节数 // GD32 SPI OTA 帧类型 #define SPI_OTA_SOF (0xA5) diff --git a/tools/can.html b/tools/can.html index 48c5962..a796cd0 100644 --- a/tools/can.html +++ b/tools/can.html @@ -4,7 +4,7 @@ ESP32 CAN 通信控制台 - +
@@ -23,6 +23,7 @@
@@ -101,6 +102,7 @@ +
🎚️ 信号编辑
@@ -204,6 +206,6 @@ 操作成功
- + diff --git a/tools/can.js b/tools/can.js index 777a96a..1552f38 100644 --- a/tools/can.js +++ b/tools/can.js @@ -1093,6 +1093,8 @@ showToast('DBC 解析失败: ' + err.message, 'error'); } + + function init() { cacheElements(); bindEvents(); diff --git a/tools/can_monitor.html b/tools/can_monitor.html new file mode 100644 index 0000000..59db554 --- /dev/null +++ b/tools/can_monitor.html @@ -0,0 +1,154 @@ + + + + + +ESP32 CAN 接收监控 + + + +
+ +
+
+ + ESP32 CAN 接收监控 +
+
+ 未连接 + 模式: -- + 轮询: 待连接 +
+ +
+ + +
+
🧰 连接设置
+
+
+
+ +
+ + +
+
+
+ +
+ + +
+
+
+
+
+ + 等待连接测试 +
+
+ 页面每 1 秒通过 /can/feedback 拉取三路 CAN 通道快照。无 RX 的通道显示 空(无 RX)。 +
+
+
+
+ + +
+
📊 三路 CAN 通道反馈(1Hz)
+
+ +
+
+ +
+ +
+ ✓ + 操作成功 + + +
+
🔬 每 ID 快照表(1Hz 刷新) + +
+
+
+ + + + + + + + + + +
#CAN IDDLCFDBRSESI数据 (HEX)
等待数据...
+
+
+
+ +
+ + + + \ No newline at end of file diff --git a/tools/can_monitor.js b/tools/can_monitor.js new file mode 100644 index 0000000..b204ac9 --- /dev/null +++ b/tools/can_monitor.js @@ -0,0 +1,195 @@ +(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', 'fb0Seq', 'fb0Delta', + 'fb1State', 'fb1Rx', 'fb1Tx', 'fb1Drop', 'fb1Err', 'fb1Seq', 'fb1Delta', + 'fb2State', 'fb2Rx', 'fb2Tx', 'fb2Drop', 'fb2Err', 'fb2Seq', 'fb2Delta', + 'btnModeAllPass', 'btnModeFilter', + '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, '&').replace(//g, '>'); + } + + 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() { + try { + var resp = await fetch(getBaseUrl() + '/can/feedback', { signal: AbortSignal.timeout(3000) }); + 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, '已连接'); + } catch (e) { + 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 + 'Seq']) el[p + 'Seq'].textContent = ch.seq; + if (el[p + 'Delta']) { el[p + 'Delta'].textContent = ch.count || 0; el[p + 'Delta'].className = 'fb-val'; } + if (i === 0 && el.fbModePill) el.fbModePill.textContent = '模式: ' + (ch.mode === 1 ? '过滤' : '全收'); + } + + 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','Seq','Delta'].forEach(function (f) { + if (el[p + f]) { el[p + f].textContent = '--'; el[p + f].className = 'fb-val'; } + }); + } + function renderIdTable(entries, count) { + var tbody = el.idsnapBody; + if (!tbody) return; + if (!entries || !entries.length) { + tbody.innerHTML = '无数据(CAN 未收到帧)'; + return; + } + var html = ''; + for (var i = 0; i < entries.length; i++) { + var e = entries[i]; + html += ''; + html += '' + (i + 1) + ''; + html += '' + sanitize(e.id) + ''; + html += '' + e.dlc + ''; + html += '' + (e.fd ? 'Y' : '-') + ''; + html += '' + (e.brs ? 'Y' : '-') + ''; + html += '' + (e.esi ? 'Y' : '-') + ''; + html += '' + sanitize(e.data || '--') + ''; + html += ''; + } + tbody.innerHTML = html; + } + + function renderIdTableEmpty() { + var tbody = el.idsnapBody; + if (tbody) tbody.innerHTML = '等待数据...'; + if (el.idsnapStatus) { el.idsnapStatus.textContent = '--'; el.idsnapStatus.className = 'pill'; } + } + + // ── 连接测试 ───────────────────────────────────────────── + async function testConnection() { + try { + var resp = await fetch(getBaseUrl() + '/can/feedback', { signal: AbortSignal.timeout(3000) }); + if (!resp.ok) { setConnState(false, 'HTTP ' + resp.status); return; } + setConnState(true, '已连接'); + showToast('ESP32 已连接', 'success'); + } catch (e) { + setConnState(false, '连接失败'); + showToast('连接失败: ' + e.message, 'error'); + } + } + + // ── 模式切换 ───────────────────────────────────────────── + async function setCanMode(mode) { + try { + var resp = await fetch(getBaseUrl() + '/can/mode', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mode: mode }), + signal: AbortSignal.timeout(3000) + }); + var data = await resp.json(); + if (data.ok) { + showToast('CAN 模式已切换: ' + (data.actual === 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.idsnapChannel) el.idsnapChannel.addEventListener('change', pollFeedback); + setTimeout(testConnection, 150); + pollFeedback(); + state.fbTimer = setInterval(pollFeedback, 1000); + } + + init(); +})(); diff --git a/tools/index.html b/tools/index.html index 1744bd0..72d1ca5 100644 --- a/tools/index.html +++ b/tools/index.html @@ -359,6 +359,7 @@
说明 diff --git a/tools/style.css b/tools/style.css index b80dceb..fd61325 100644 --- a/tools/style.css +++ b/tools/style.css @@ -1708,3 +1708,68 @@ code { color: #d3f9ff; font-family: var(--font-mono); } transition-duration: .001ms !important; } } + +/* === CAN 1Hz FEEDBACK === */ +.can-feedback-card { margin-top: 18px; } +.can-feedback-card .card-head { + display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 10px; +} +.feedback-controls { display: flex; align-items: center; gap: 8px; } +.feedback-grid { + display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; +} +.feedback-channel { + background: rgba(255,255,255,.03); border: 1px solid rgba(255,255,255,.07); + border-radius: 10px; padding: 14px 16px; +} +.feedback-ch-head { + font-weight: 700; font-size: 15px; margin-bottom: 10px; + display: flex; align-items: center; gap: 8px; +} +.ch-dot { + width: 10px; height: 10px; border-radius: 50%; display: inline-block; +} +.ch-dot.ch0 { background: #22d3ee; } +.ch-dot.ch1 { background: #a78bfa; } +.ch-dot.ch2 { background: #f59e0b; } +.feedback-row { + display: flex; justify-content: space-between; padding: 3px 0; + font-size: 13px; border-bottom: 1px solid rgba(255,255,255,.04); +} +.feedback-row:last-child { border-bottom: none; } +.fb-label { color: var(--muted, #8892a4); } +.fb-val { font-weight: 600; font-variant-numeric: tabular-nums; } +.fb-val.mono { font-family: monospace; } +.fb-val.ok { color: #34d399; } +.fb-val.warn { color: #f59e0b; } +.fb-val.err { color: #f87171; } +.fb-val.zero { color: #64748b; } +@media (max-width: 900px) { + .feedback-grid { grid-template-columns: 1fr; } +} + +/* === ID SNAPSHOT TABLE === */ +.idsnap-table-wrap { overflow-x: auto; } +.idsnap-table { + width: 100%; border-collapse: collapse; font-size: 13px; + font-variant-numeric: tabular-nums; +} +.idsnap-table th { + text-align: left; padding: 6px 10px; font-weight: 600; + border-bottom: 2px solid rgba(255,255,255,.12); color: var(--muted, #8892a4); + white-space: nowrap; +} +.idsnap-table td { + padding: 5px 10px; border-bottom: 1px solid rgba(255,255,255,.05); + white-space: nowrap; +} +.idsnap-table td.mono { font-family: monospace; } +.idsnap-table tr:hover td { background: rgba(255,255,255,.03); } +.idsnap-table .flag-on { color: #34d399; font-weight: 600; } +.idsnap-table .flag-off { color: #475569; } +.idsnap-data { font-family: monospace; max-width: 320px; overflow: hidden; text-overflow: ellipsis; } + +.fb-data-cell { + max-width: 200px; overflow: hidden; text-overflow: ellipsis; + font-size: 11px; word-break: break-all; line-height: 1.4; +}