feat(main): 添加CAN 1Hz反馈轮询任务和HTTP接口

- 新增monitor/can_feedback_task.cpp组件,实现CAN 1Hz反馈轮询功能
- 在main.cpp中注册CAN反馈轮询任务,依赖SPI初始化完成后启动
- 在OTA协调器和中继模块中集成CAN反馈任务暂停/恢复机制
- 扩展CANFD桥接层,添加0xC1反馈查询、0xC2模式切换、0xC3ID快照协议支持
- 新增HTTP接口/can/feedback提供三路CAN反馈快照数据
- 新增HTTP接口/can/mode支持查询和切换CAN接收过滤模式
- 更新版本号从0.3.34到0.35
- 扩大SPI缓冲区以支持更大帧传输需求

BREAKING CHANGE: 新增CAN反馈监控功能需要GD32固件支持0xC1/0xC2/0xC3协议扩展
This commit is contained in:
小龙虾
2026-06-29 01:51:44 +08:00
parent a93dba60d2
commit 6335d05198
21 changed files with 1398 additions and 13 deletions
+296
View File
@@ -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()` 分页拉取 |
+1
View File
@@ -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
"."
+9 -1
View File
@@ -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 会自动回退到上一个有效分区
+4 -4
View File
@@ -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"
+181
View File
@@ -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 <string.h>
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<can_channel_t>(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<unsigned>(ch),
static_cast<unsigned long>(fb.stats.snapshot_seq),
static_cast<unsigned>(fb.stats.mode),
static_cast<unsigned long>(fb.stats.rx_count),
static_cast<unsigned long>(fb.stats.tx_count),
static_cast<unsigned long>(fb.stats.rx_drop_count),
static_cast<unsigned long>(fb.stats.error_count),
static_cast<unsigned>(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<unsigned>(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;
}
+50
View File
@@ -0,0 +1,50 @@
/*
* can_feedback_task.h - 周期性轮询 GD32 CAN 反馈快照的任务接口
*/
#ifndef CAN_FEEDBACK_TASK_H
#define CAN_FEEDBACK_TASK_H
#include <stdint.h>
#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
+5
View File
@@ -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");
}
}
+3
View File
@@ -10,6 +10,7 @@
* 协议子步骤与加密/Flash 工具方法保持不变。
*/
#include <string.h>
#include "monitor/can_feedback_task.h"
#include <stdlib.h>
#include <inttypes.h>
#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);
}
}
+136 -1
View File
@@ -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);
+4
View File
@@ -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
+4 -1
View File
@@ -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;
}
+192 -1
View File
@@ -1,4 +1,4 @@
/*
/*
* canfd_bridge.cpp - CANFD 3通道通信桥接实现(M4: 从 SpiMaster 提取)
*/
#include <string.h>
@@ -50,6 +50,18 @@ static int sendCanFrameOverSpi(can_channel_t channel, uint32_t ext_id,
return static_cast<int>(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<uint32_t>(p[0])
| (static_cast<uint32_t>(p[1]) << 8)
| (static_cast<uint32_t>(p[2]) << 16)
| (static_cast<uint32_t>(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<uint8_t>(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<uint8_t>(CANFD_ID_SNAPSHOT_DATA_MAX);
const uint16_t entrySize = static_cast<uint16_t>(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<uint8_t>(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<uint8_t>(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<uint8_t>(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<can_rx_mode_t>(resp_buf[0]);
}
return 0;
}
+90 -1
View File
@@ -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
+1 -1
View File
@@ -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) {
+1 -1
View File
@@ -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)
+4 -2
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ESP32 CAN 通信控制台</title>
<link rel="stylesheet" href="style.css?v=20260624b">
<link rel="stylesheet" href="style.css?v=20260628a">
</head>
<body class="can-page">
<div class="container can-container">
@@ -23,6 +23,7 @@
</div>
<div class="header-sub can-header-actions">
<a class="pill can-link-pill" href="index.html">返回 OTA 控制台</a>
<a class="pill can-link-pill" href="can_monitor.html">CAN 接收监控</a>
</div>
</header>
@@ -101,6 +102,7 @@
</div>
</section>
<div class="can-editor-layout">
<section class="card can-signal-card">
<div class="card-head"><span class="ico">🎚️</span> 信号编辑</div>
@@ -204,6 +206,6 @@
<span id="toastMsg">操作成功</span>
</div>
<script src="can.js?v=20260624b"></script>
<script src="can.js?v=20260628a"></script>
</body>
</html>
+2
View File
@@ -1093,6 +1093,8 @@
showToast('DBC 解析失败: ' + err.message, 'error');
}
function init() {
cacheElements();
bindEvents();
+154
View File
@@ -0,0 +1,154 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ESP32 CAN 接收监控</title>
<link rel="stylesheet" href="style.css?v=20260628a">
</head>
<body class="can-page">
<div class="container can-container">
<header class="header-bar can-header">
<div class="header-brand">
<span class="dot-mark"></span>
<strong>ESP32 CAN 接收监控</strong>
</div>
<div class="header-status">
<span class="pill offline" id="monConnPill"><span class="indicator"></span><span id="monConnTxt">未连接</span></span>
<span class="pill" id="fbModePill">模式: --</span>
<span class="pill" id="fbPollStatus">轮询: 待连接</span>
</div>
<div class="header-sub can-header-actions">
<a class="pill can-link-pill" href="can.html">CAN 发送控制台</a>
<a class="pill can-link-pill" href="index.html">返回 OTA 控制台</a>
</div>
</header>
<!-- ═══ 工作台 ═══ -->
<section class="card can-toolbar-card">
<div class="card-head"><span class="ico">🧰</span> 连接设置</div>
<div class="card-body">
<div class="can-toolbar-grid" style="grid-template-columns: minmax(260px, 1.2fr) 1fr;">
<div class="can-toolbar-block">
<label class="form-label" for="monIp">ESP32 IP</label>
<div class="input-group">
<input class="input" type="text" id="monIp" value="192.168.31.127" placeholder="输入 IP 地址">
<button class="btn btn-outline" id="monTestBtn" type="button">测试</button>
</div>
</div>
<div class="can-toolbar-block">
<label class="form-label">接收模式</label>
<div class="input-group">
<button class="btn btn-primary btn-sm" id="btnModeAllPass" type="button">全收模式</button>
<button class="btn btn-outline btn-sm" id="btnModeFilter" type="button">过滤模式</button>
</div>
</div>
</div>
<div class="can-toolbar-foot">
<div class="conn-indicator err" id="monConnIndicator">
<span class="light"></span>
<span id="monConnIndicatorTxt">等待连接测试</span>
</div>
<div class="helper-note" id="monHelper">
页面每 1 秒通过 <code>/can/feedback</code> 拉取三路 CAN 通道快照。无 RX 的通道显示 <b>空(无 RX)</b>。
</div>
</div>
</div>
</section>
<!-- ═══ 三路反馈 ═══ -->
<section class="card can-feedback-card">
<div class="card-head"><span class="ico">📊</span> 三路 CAN 通道反馈(1Hz)</div>
<div class="card-body">
<div class="feedback-grid">
<div class="feedback-channel" id="fbCh0">
<div class="feedback-ch-head"><span class="ch-dot ch0"></span> CAN0</div>
<div class="feedback-row"><span class="fb-label">状态</span><span class="fb-val" id="fb0State">--</span></div>
<div class="feedback-row"><span class="fb-label">接收数</span><span class="fb-val" id="fb0Rx">--</span></div>
<div class="feedback-row"><span class="fb-label">发送数</span><span class="fb-val" id="fb0Tx">--</span></div>
<div class="feedback-row"><span class="fb-label">丢帧</span><span class="fb-val" id="fb0Drop">--</span></div>
<div class="feedback-row"><span class="fb-label">错误</span><span class="fb-val" id="fb0Err">--</span></div>
<div class="feedback-row"><span class="fb-label">最新ID</span><span class="fb-val mono" id="fb0LastId">--</span></div>
<div class="feedback-row"><span class="fb-label">DLC</span><span class="fb-val" id="fb0Dlc">--</span></div>
<div class="feedback-row"><span class="fb-label">FD/BRS/ESI</span><span class="fb-val" id="fb0Flags">--</span></div>
<div class="feedback-row"><span class="fb-label">Seq</span><span class="fb-val" id="fb0Seq">--</span></div>
<div class="feedback-row"><span class="fb-label">Δrx/s</span><span class="fb-val" id="fb0Delta">--</span></div>
<div class="feedback-row"><span class="fb-label">最新HEX</span><span class="fb-val mono fb-data-cell" id="fb0LastData">--</span></div>
</div>
<div class="feedback-channel" id="fbCh1">
<div class="feedback-ch-head"><span class="ch-dot ch1"></span> CAN1</div>
<div class="feedback-row"><span class="fb-label">状态</span><span class="fb-val" id="fb1State">--</span></div>
<div class="feedback-row"><span class="fb-label">接收数</span><span class="fb-val" id="fb1Rx">--</span></div>
<div class="feedback-row"><span class="fb-label">发送数</span><span class="fb-val" id="fb1Tx">--</span></div>
<div class="feedback-row"><span class="fb-label">丢帧</span><span class="fb-val" id="fb1Drop">--</span></div>
<div class="feedback-row"><span class="fb-label">错误</span><span class="fb-val" id="fb1Err">--</span></div>
<div class="feedback-row"><span class="fb-label">最新ID</span><span class="fb-val mono" id="fb1LastId">--</span></div>
<div class="feedback-row"><span class="fb-label">DLC</span><span class="fb-val" id="fb1Dlc">--</span></div>
<div class="feedback-row"><span class="fb-label">FD/BRS/ESI</span><span class="fb-val" id="fb1Flags">--</span></div>
<div class="feedback-row"><span class="fb-label">Seq</span><span class="fb-val" id="fb1Seq">--</span></div>
<div class="feedback-row"><span class="fb-label">Δrx/s</span><span class="fb-val" id="fb1Delta">--</span></div>
<div class="feedback-row"><span class="fb-label">最新HEX</span><span class="fb-val mono fb-data-cell" id="fb1LastData">--</span></div>
</div>
<div class="feedback-channel" id="fbCh2">
<div class="feedback-ch-head"><span class="ch-dot ch2"></span> CAN2</div>
<div class="feedback-row"><span class="fb-label">状态</span><span class="fb-val" id="fb2State">--</span></div>
<div class="feedback-row"><span class="fb-label">接收数</span><span class="fb-val" id="fb2Rx">--</span></div>
<div class="feedback-row"><span class="fb-label">发送数</span><span class="fb-val" id="fb2Tx">--</span></div>
<div class="feedback-row"><span class="fb-label">丢帧</span><span class="fb-val" id="fb2Drop">--</span></div>
<div class="feedback-row"><span class="fb-label">错误</span><span class="fb-val" id="fb2Err">--</span></div>
<div class="feedback-row"><span class="fb-label">最新ID</span><span class="fb-val mono" id="fb2LastId">--</span></div>
<div class="feedback-row"><span class="fb-label">DLC</span><span class="fb-val" id="fb2Dlc">--</span></div>
<div class="feedback-row"><span class="fb-label">FD/BRS/ESI</span><span class="fb-val" id="fb2Flags">--</span></div>
<div class="feedback-row"><span class="fb-label">Seq</span><span class="fb-val" id="fb2Seq">--</span></div>
<div class="feedback-row"><span class="fb-label">Δrx/s</span><span class="fb-val" id="fb2Delta">--</span></div>
<div class="feedback-row"><span class="fb-label">最新HEX</span><span class="fb-val mono fb-data-cell" id="fb2LastData">--</span></div>
</div>
</div>
</div>
</section>
</div>
<div class="toast" id="toast">
<span class="toast-ico" id="toastIco">✓</span>
<span id="toastMsg">操作成功</span>
<!-- ═══ 每 ID 快照表 ═══ -->
<section class="card" id="idSnapshotSection" style="margin-top:18px;">
<div class="card-head"><span class="ico">🔬</span> 每 ID 快照表(1Hz 刷新)
<div class="feedback-controls">
<select class="input" id="idsnapChannel" style="width:100px;">
<option value="0">CAN0</option>
<option value="1">CAN1</option>
<option value="2">CAN2</option>
</select>
<span class="pill" id="idsnapStatus">--</span>
</div>
</div>
<div class="card-body">
<div class="idsnap-table-wrap">
<table class="idsnap-table" id="idsnapTable">
<thead>
<tr>
<th>#</th><th>CAN ID</th><th>DLC</th>
<th>FD</th><th>BRS</th><th>ESI</th><th>数据 (HEX)</th>
</tr>
</thead>
<tbody id="idsnapBody">
<tr><td colspan="7" class="status-empty">等待数据...</td></tr>
</tbody>
</table>
</div>
</div>
</section>
</div>
<script src="can_monitor.js?v=20260629b"></script>
</body>
</html>
+195
View File
@@ -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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function getBaseUrl() {
var ip = (el.monIp && el.monIp.value ? el.monIp.value.trim() : '') || '192.168.31.127';
return 'http://' + ip;
}
function setConnState(ok, text) {
if (el.monConnPill) el.monConnPill.className = 'pill ' + (ok ? 'online' : 'offline');
if (el.monConnTxt) el.monConnTxt.textContent = text;
if (el.monConnIndicator) el.monConnIndicator.className = 'conn-indicator ' + (ok ? 'ok' : 'err');
if (el.monConnIndicatorTxt) el.monConnIndicatorTxt.textContent = text;
}
// ── 1Hz 轮询 /can/feedback ─────────────────────────────────
async function pollFeedback() {
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 = '<tr><td colspan="7" class="status-empty">无数据(CAN 未收到帧)</td></tr>';
return;
}
var html = '';
for (var i = 0; i < entries.length; i++) {
var e = entries[i];
html += '<tr>';
html += '<td>' + (i + 1) + '</td>';
html += '<td class="mono">' + sanitize(e.id) + '</td>';
html += '<td>' + e.dlc + '</td>';
html += '<td class="' + (e.fd ? 'flag-on' : 'flag-off') + '">' + (e.fd ? 'Y' : '-') + '</td>';
html += '<td class="' + (e.brs ? 'flag-on' : 'flag-off') + '">' + (e.brs ? 'Y' : '-') + '</td>';
html += '<td class="' + (e.esi ? 'flag-on' : 'flag-off') + '">' + (e.esi ? 'Y' : '-') + '</td>';
html += '<td class="idsnap-data mono">' + sanitize(e.data || '--') + '</td>';
html += '</tr>';
}
tbody.innerHTML = html;
}
function renderIdTableEmpty() {
var tbody = el.idsnapBody;
if (tbody) tbody.innerHTML = '<tr><td colspan="7" class="status-empty">等待数据...</td></tr>';
if (el.idsnapStatus) { el.idsnapStatus.textContent = '--'; el.idsnapStatus.className = 'pill'; }
}
// ── 连接测试 ─────────────────────────────────────────────
async function testConnection() {
try {
var resp = await fetch(getBaseUrl() + '/can/feedback', { signal: AbortSignal.timeout(3000) });
if (!resp.ok) { setConnState(false, 'HTTP ' + resp.status); return; }
setConnState(true, '已连接');
showToast('ESP32 已连接', 'success');
} catch (e) {
setConnState(false, '连接失败');
showToast('连接失败: ' + e.message, 'error');
}
}
// ── 模式切换 ─────────────────────────────────────────────
async function setCanMode(mode) {
try {
var resp = await fetch(getBaseUrl() + '/can/mode', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ mode: mode }),
signal: AbortSignal.timeout(3000)
});
var data = await resp.json();
if (data.ok) {
showToast('CAN 模式已切换: ' + (data.actual === 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();
})();
+1
View File
@@ -359,6 +359,7 @@
<div class="can-launch-actions">
<a class="btn btn-primary can-launch-primary" href="can.html">进入 CAN 页面</a>
<button class="btn btn-outline can-launch-secondary" type="button" onclick="window.open('can.html','_blank','noopener')">新窗口打开</button>
<a class="btn btn-outline can-launch-secondary" href="can_monitor.html">CAN 接收监控</a>
</div>
<div class="can-launch-foot">
<span class="can-launch-foot-label">说明</span>
+65
View File
@@ -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;
}