Compare commits
2
Commits
a93dba60d2
...
2fe5393769
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2fe5393769 | ||
|
|
6335d05198 |
@@ -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()` 分页拉取 |
|
||||
@@ -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
@@ -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,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 36
|
||||
#define FW_VERSION_STRING "0.3.36"
|
||||
|
||||
// 编译时间
|
||||
#define BUILD_TIME "2026-06-28 04:26:17"
|
||||
#define BUILD_TIME "2026-07-02 01:59:27"
|
||||
|
||||
// Git 信息
|
||||
#define GIT_HASH "6ef9730"
|
||||
#define GIT_HASH "6335d05"
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
/*
|
||||
* 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);
|
||||
|
||||
if (fb.stats.error_count != 0U) {
|
||||
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;
|
||||
}
|
||||
@@ -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,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");
|
||||
}
|
||||
}
|
||||
@@ -472,13 +477,17 @@ esp_err_t OtaCoordinator::beginEsp32OtaWithRetry(uint32_t total_size, ota_channe
|
||||
// 两通道并发写同一 OTA 分区(之前 BLE 与 WiFi 可同时 OTA 的根因)。
|
||||
// 避免连续 isIdle() + getSystemState() 各加锁一次(共 2 次锁)。
|
||||
ota_system_state_t cur = getSystemState();
|
||||
if (!isIdleState(cur)) {
|
||||
if (!isIdleState(cur) && cur != OTA_SYS_ERROR) {
|
||||
DEBUG_WARN(TAG, "ESP32 OTA 被拒绝且系统非空闲 (state=%d),不强制清理,直接失败",
|
||||
(int)cur);
|
||||
return err;
|
||||
}
|
||||
// 协调器空闲但 Esp32OtaCore.begin 仍被拒 → 锁残留,强制清理后重试
|
||||
DEBUG_WARN(TAG, "ESP32 OTA 被拒绝但系统空闲,判定为锁残留,强制清理状态机后重试");
|
||||
|
||||
if (cur == OTA_SYS_ERROR) {
|
||||
DEBUG_WARN(TAG, "ESP32 OTA 被拒绝但协调器处于 ERROR,清理底层残留句柄后重试");
|
||||
} else {
|
||||
DEBUG_WARN(TAG, "ESP32 OTA 被拒绝但系统空闲,判定为锁残留,强制清理状态机后重试");
|
||||
}
|
||||
Esp32OtaCore::instance().forceCleanup();
|
||||
err = beginEsp32Ota(total_size, channel);
|
||||
}
|
||||
@@ -733,6 +742,7 @@ OtaSnapshot OtaCoordinator::getSnapshot() const
|
||||
bool h = lock();
|
||||
if (h) {
|
||||
snap.sys_state = m_sysState;
|
||||
snap.channel = m_otaChannel;
|
||||
snap.storage_done = m_gd32StorageDone;
|
||||
// 就地把状态枚举转字符串,与 sys_state 来自同一时刻
|
||||
snap.sys_state_str = stateToString(snap.sys_state);
|
||||
|
||||
@@ -57,6 +57,7 @@ typedef enum {
|
||||
struct OtaSnapshot {
|
||||
ota_system_state_t sys_state = OTA_SYS_IDLE; ///< 系统级 OTA 状态
|
||||
const char *sys_state_str = "IDLE"; ///< 状态字符串
|
||||
ota_channel_t channel = OTA_CHANNEL_WIFI; ///< 当前/最近一次 OTA 通道
|
||||
bool storage_done = false; ///< GD32 固件存储完成标志
|
||||
ota_meta_t meta = {}; ///< GD32 NVS 元数据
|
||||
RelaySnapshot relay; ///< 中继进度快照
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,148 @@ 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,\"tec\":%u,\"rec\":%u,"
|
||||
"\"bus_state\":%u,\"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.stats.tec,
|
||||
(unsigned)r.stats.rec,
|
||||
(unsigned)r.stats.bus_state,
|
||||
(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 = CAN_RX_MODE_ALL_PASS;
|
||||
if (requested == 1) {
|
||||
target = CAN_RX_MODE_FILTER_ID;
|
||||
} else if (requested == 2) {
|
||||
target = CAN_RX_MODE_BRIDGE_0_TO_12;
|
||||
}
|
||||
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);
|
||||
@@ -1123,7 +1266,7 @@ esp_err_t system_status_handler(httpd_req_t *req)
|
||||
"\"internal_heap_total\": %lu, \"default_heap_total\": %lu, "
|
||||
"\"temperature\": %.1f, \"uptime_sec\": %lu, "
|
||||
"\"wifi\": {\"mode\": \"%s\", \"ip\": \"%s\", \"rssi\": %d, \"connected\": %s}, "
|
||||
"\"ota\": {\"sys_state\": \"%s\", \"sys_state_code\": %d, \"total_size\": %lu, \"stored_size\": %lu, \"storage_done\": %s, "
|
||||
"\"ota\": {\"sys_state\": \"%s\", \"sys_state_code\": %d, \"channel\": %d, \"channel_name\": \"%s\", \"total_size\": %lu, \"stored_size\": %lu, \"storage_done\": %s, "
|
||||
"\"relay_state\": %d, \"relay_seq\": %lu, \"relay_total_packets\": %lu, \"gd32_app_alive\": %s, "
|
||||
"\"gd32_app\": \"v%d.%d.%d.%d\", \"gd32_boot\": \"v%d.%d.%d.%d\"}, ",
|
||||
FW_VERSION, FW_BUILD_TIME, FW_GIT_HASH, HealthMonitor::instance().getCurrent(),
|
||||
@@ -1143,6 +1286,8 @@ esp_err_t system_status_handler(httpd_req_t *req)
|
||||
connected ? "true" : "false",
|
||||
snap.sys_state_str,
|
||||
(int)snap.sys_state,
|
||||
(int)snap.channel,
|
||||
snap.channel == OTA_CHANNEL_BLE ? "BLE" : "WIFI",
|
||||
(unsigned long)snap.meta.total_size,
|
||||
(unsigned long)snap.meta.stored_size,
|
||||
snap.storage_done ? "true" : "false",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,185 @@ 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 = 28U; /* ch+page+total+cnt+mode+seq+rx+tx+drop+err+tec+rec+bus_state */
|
||||
|
||||
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->stats.tec = resp_buf[25];
|
||||
out->stats.rec = resp_buf[26];
|
||||
out->stats.bus_state = resp_buf[27];
|
||||
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 && mode != CAN_RX_MODE_BRIDGE_0_TO_12) {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -61,4 +61,97 @@ 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_BRIDGE_0_TO_12 = 2, ///< CAN0 桥接:CAN0 收到的每一帧按原帧格式自动转发到 CAN1 与 CAN2
|
||||
} 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;
|
||||
uint8_t tec; /**< 硬件发送错误计数器 (0~255) */
|
||||
uint8_t rec; /**< 硬件接收错误计数器 (0~255) */
|
||||
uint8_t bus_state; /**< 总线状态位图:bit0=init,bit1=sleep,bit2=error_passive,bit3=bus_off,bit4=error_warning */
|
||||
} 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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
+10
-8
@@ -1332,15 +1332,17 @@ async function startUpload(chip) {
|
||||
var st = await probe.json();
|
||||
var ota = st.ota || {};
|
||||
var ssc = Number.isFinite(ota.sys_state_code) ? ota.sys_state_code : -1;
|
||||
// ★ RECEIVING (5) 可能是刚取消的 BLE OTA 残留态(设备端 GAP disconnect
|
||||
// 处理需 3-4 秒),等它清理完;FINALIZING(6)/REBOOTING(7) 是真实忙,直接拒绝。
|
||||
if (ssc === 5 && _pi < 3) continue;
|
||||
// ESP32 活跃态(5=RECEIVING, 6=FINALIZING, 7=REBOOTING)
|
||||
var esp32Busy = (ssc === 5 || ssc === 6 || ssc === 7);
|
||||
// GD32 存储/中继(1=STORING, 3=RELAYING)→ 阻止新上传,避免 NVS 与 Flash 互踩
|
||||
var gd32Busy = (ssc === 1 || ssc === 3);
|
||||
var otaChannel = Number.isFinite(ota.channel) ? ota.channel : -1;
|
||||
var otaChannelName = ota.channel_name || (otaChannel === 1 ? 'BLE' : 'WIFI');
|
||||
// ESP32 RECEIVING(5) 可由同一 WiFi 通道的新上传接管;后台/休眠可能让 /ota/update 没发出,
|
||||
// 若这里提前拦截,后端 beginEsp32OtaWithRetry 的同通道清理分支永远没有机会执行。
|
||||
// ERROR(8) 也放行给后端清理底层 OTA 句柄;FINALIZING(6)/REBOOTING(7) 是不可逆忙态。
|
||||
if (ssc === 5 && otaChannel === 1 && _pi < 3) continue;
|
||||
var esp32Busy = (ssc === 6 || ssc === 7 || (ssc === 5 && otaChannel !== 0));
|
||||
// GD32 STORING(1) 可由同一 WiFi 通道的新上传接管;RELAYING(3) 不可中断。
|
||||
var gd32Busy = (ssc === 3 || (ssc === 1 && otaChannel !== 0));
|
||||
if ((esp32Busy || gd32Busy)) {
|
||||
addLog('E', '上传', '设备已有 OTA 进行中 (sys_state=' + (ota.sys_state || ssc) + '),拒绝本次 ' + chip.toUpperCase() + ' 上传');
|
||||
addLog('E', '上传', '设备已有 OTA 进行中 (sys_state=' + (ota.sys_state || ssc) + ', channel=' + otaChannelName + '),拒绝本次 ' + chip.toUpperCase() + ' 上传');
|
||||
showToast('设备正在 OTA 中,请等待结束后再试', 'error');
|
||||
isUploading = false;
|
||||
resumePollingAfterUpload();
|
||||
|
||||
+103
-94
@@ -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=20260701a">
|
||||
</head>
|
||||
<body class="can-page">
|
||||
<div class="container can-container">
|
||||
@@ -23,112 +23,125 @@
|
||||
</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>
|
||||
|
||||
<section class="card can-toolbar-card">
|
||||
<div class="card-head"><span class="ico">🧰</span> 工作台设置</div>
|
||||
<div class="card-body">
|
||||
<div class="can-toolbar-grid">
|
||||
<div class="can-toolbar-block">
|
||||
<label class="form-label" for="toolIp">ESP32 IP</label>
|
||||
<div class="input-group">
|
||||
<input class="input" type="text" id="toolIp" value="192.168.31.127" placeholder="输入 IP 地址">
|
||||
<button class="btn btn-outline" id="testConnBtn" type="button">测试</button>
|
||||
<div class="can-top-layout">
|
||||
<section class="card can-toolbar-card">
|
||||
<div class="card-head"><span class="ico">🧰</span> 工作台设置</div>
|
||||
<div class="card-body">
|
||||
<div class="can-toolbar-grid">
|
||||
<div class="can-toolbar-block">
|
||||
<label class="form-label" for="toolIp">ESP32 IP</label>
|
||||
<div class="input-group">
|
||||
<input class="input" type="text" id="toolIp" value="192.168.31.127" placeholder="输入 IP 地址">
|
||||
<button class="btn btn-outline" id="testConnBtn" type="button">测试</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="can-toolbar-block can-toolbar-channel">
|
||||
<label class="form-label" for="defaultChannel">默认通道</label>
|
||||
<select class="input" id="defaultChannel">
|
||||
<option value="0">CH0</option>
|
||||
<option value="1">CH1</option>
|
||||
<option value="2">CH2</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="can-toolbar-block can-toolbar-upload">
|
||||
<label class="form-label">DBC 文件</label>
|
||||
<div class="upload-zone upload-zone-sm dbc-dropzone" id="dbcDropZone">
|
||||
<div class="upload-ico">📥</div>
|
||||
<h4>导入 DBC</h4>
|
||||
<p>点击或拖拽 .dbc 文件</p>
|
||||
</div>
|
||||
<input type="file" id="dbcFileInput" accept=".dbc,text/plain" style="display:none;">
|
||||
</div>
|
||||
|
||||
<div class="can-toolbar-block can-toolbar-search">
|
||||
<label class="form-label" for="dbcSearch">报文搜索</label>
|
||||
<input class="input" type="text" id="dbcSearch" placeholder="按报文名、ID、节点或信号名过滤">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="can-toolbar-block can-toolbar-channel">
|
||||
<label class="form-label" for="defaultChannel">默认通道</label>
|
||||
<select class="input" id="defaultChannel">
|
||||
<option value="0">CH0</option>
|
||||
<option value="1">CH1</option>
|
||||
<option value="2">CH2</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="can-toolbar-block can-toolbar-upload">
|
||||
<label class="form-label">DBC 文件</label>
|
||||
<div class="upload-zone upload-zone-sm dbc-dropzone" id="dbcDropZone">
|
||||
<div class="upload-ico">📥</div>
|
||||
<h4>导入 DBC</h4>
|
||||
<p>点击或拖拽 .dbc 文件</p>
|
||||
<div class="file-info can-file-info-inline" id="dbcFileInfo">
|
||||
<div class="file-info-row">
|
||||
<span class="can-file-name">📄 <span id="dbcFileName">-</span></span>
|
||||
<button class="btn btn-ghost btn-sm" id="clearDbcBtn" type="button">清除</button>
|
||||
</div>
|
||||
<div class="file-info-grid">
|
||||
<div class="file-meta-item"><span>报文</span><b id="dbcMessageCount">0</b></div>
|
||||
<div class="file-meta-item"><span>信号</span><b id="dbcSignalCount">0</b></div>
|
||||
<div class="file-meta-item"><span>节点</span><b id="dbcNodeCount">0</b></div>
|
||||
<div class="file-meta-item"><span>大小</span><b id="dbcFileSize">0 B</b></div>
|
||||
</div>
|
||||
<input type="file" id="dbcFileInput" accept=".dbc,text/plain" style="display:none;">
|
||||
</div>
|
||||
|
||||
<div class="can-toolbar-block can-toolbar-search">
|
||||
<label class="form-label" for="dbcSearch">报文搜索</label>
|
||||
<input class="input" type="text" id="dbcSearch" placeholder="按报文名、ID、节点或信号名过滤">
|
||||
<div class="can-toolbar-foot">
|
||||
<div class="conn-indicator err" id="connIndicator">
|
||||
<span class="light"></span>
|
||||
<span id="connIndicatorTxt">等待连接测试</span>
|
||||
</div>
|
||||
<div class="helper-note" id="connectionNote">
|
||||
当前页面会直接调用 <code>/system/status</code> 和 <code>/can/send</code>。
|
||||
</div>
|
||||
<div class="helper-note" id="dbcHint">
|
||||
当前解析器会自动识别常见 DBC 的 <code>BO_</code>、<code>SG_</code>、<code>VAL_</code>、
|
||||
<code>CM_</code> 和 <code>SIG_VALTYPE_</code>。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="file-info can-file-info-inline" id="dbcFileInfo">
|
||||
<div class="file-info-row">
|
||||
<span class="can-file-name">📄 <span id="dbcFileName">-</span></span>
|
||||
<button class="btn btn-ghost btn-sm" id="clearDbcBtn" type="button">清除</button>
|
||||
</div>
|
||||
<div class="file-info-grid">
|
||||
<div class="file-meta-item"><span>报文</span><b id="dbcMessageCount">0</b></div>
|
||||
<div class="file-meta-item"><span>信号</span><b id="dbcSignalCount">0</b></div>
|
||||
<div class="file-meta-item"><span>节点</span><b id="dbcNodeCount">0</b></div>
|
||||
<div class="file-meta-item"><span>大小</span><b id="dbcFileSize">0 B</b></div>
|
||||
</section>
|
||||
<section class="card can-history-card">
|
||||
<div class="card-head"><span class="ico">🕘</span> 最近发送</div>
|
||||
<div class="card-body">
|
||||
<div class="history-list history-list-compact" id="historyList">
|
||||
<div class="status-empty">尚未发送任何 CAN 帧。</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="can-toolbar-foot">
|
||||
<div class="conn-indicator err" id="connIndicator">
|
||||
<span class="light"></span>
|
||||
<span id="connIndicatorTxt">等待连接测试</span>
|
||||
</div>
|
||||
<div class="helper-note" id="connectionNote">
|
||||
当前页面会直接调用 <code>/system/status</code> 和 <code>/can/send</code>。
|
||||
</div>
|
||||
<div class="helper-note" id="dbcHint">
|
||||
当前解析器会自动识别常见 DBC 的 <code>BO_</code>、<code>SG_</code>、<code>VAL_</code>、
|
||||
<code>CM_</code> 和 <code>SIG_VALTYPE_</code>。
|
||||
<div class="can-editor-layout">
|
||||
<section class="card can-table-card">
|
||||
<div class="card-head">
|
||||
<span class="ico">📚</span>
|
||||
<span>报文列表</span>
|
||||
<div class="card-head-actions can-batch-actions">
|
||||
<span class="pill" id="selectedCountPill">已勾选 0</span>
|
||||
<div class="can-node-filter" id="nodeFilterWrap">
|
||||
<button class="btn btn-outline btn-sm can-node-filter-btn" id="nodeFilterBtn" type="button" aria-haspopup="true" aria-expanded="false">
|
||||
<span class="can-node-filter-label" id="nodeFilterLabel">发送节点:全部</span>
|
||||
<span class="can-node-filter-caret">▾</span>
|
||||
</button>
|
||||
<div class="can-node-filter-panel" id="nodeFilterPanel" hidden>
|
||||
<div class="can-node-filter-head">
|
||||
<input class="input can-node-filter-search" id="nodeFilterSearch" type="text" placeholder="筛选节点名…">
|
||||
<div class="can-node-filter-actions">
|
||||
<button class="btn btn-ghost btn-sm" id="nodeFilterAllBtn" type="button">全选</button>
|
||||
<button class="btn btn-ghost btn-sm" id="nodeFilterNoneBtn" type="button">清空</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="can-node-filter-list" id="nodeFilterList">
|
||||
<div class="status-empty">先导入 DBC。</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<label class="checkbox-inline can-batch-all-label" title="全选/取消当前过滤的所有报文">
|
||||
<input type="checkbox" id="selectAllBox">
|
||||
<span>全选当前过滤</span>
|
||||
</label>
|
||||
<button class="btn btn-outline btn-sm" id="expandAllBtn" type="button">展开全部</button>
|
||||
<button class="btn btn-outline btn-sm" id="collapseAllBtn" type="button">收起全部</button>
|
||||
<button class="btn btn-primary btn-sm" id="sendCheckedBtn" type="button">批量发送勾选</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card can-table-card">
|
||||
<div class="card-head"><span class="ico">📚</span> 报文列表</div>
|
||||
<div class="card-body can-table-card-body">
|
||||
<div class="message-list" id="messageList">
|
||||
<div class="status-empty">等待导入 DBC...</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="can-editor-layout">
|
||||
<section class="card can-signal-card">
|
||||
<div class="card-head"><span class="ico">🎚️</span> 信号编辑</div>
|
||||
<div class="card-body">
|
||||
<div class="can-empty-state" id="emptyMessageState">
|
||||
<div class="can-empty-icon">📡</div>
|
||||
<h3>先导入 DBC,再选择一条 CAN 报文</h3>
|
||||
<p>上方按表格选报文,下方按信号名直接编辑,右侧查看编码结果并发送。</p>
|
||||
</div>
|
||||
|
||||
<div id="messageWorkspace" style="display:none;">
|
||||
<div class="can-selection-bar">
|
||||
<div class="can-selection-copy">
|
||||
<div class="can-title" id="selectedMessageTitle">-</div>
|
||||
<div class="can-subtitle" id="selectedMessageSubtitle">-</div>
|
||||
</div>
|
||||
<div class="can-selection-actions">
|
||||
<button class="btn btn-outline" id="loadPreviewToRawBtn" type="button">载入原始帧</button>
|
||||
<button class="btn btn-primary" id="sendSelectedBtn" type="button">发送当前报文</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="can-meta-grid" id="selectedMessageMeta"></div>
|
||||
<div class="helper-note can-comment-note" id="selectedMessageComment">暂无报文注释。</div>
|
||||
|
||||
<div class="signal-grid signal-table-host" id="signalGrid"></div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside class="can-sidepanel">
|
||||
@@ -140,6 +153,10 @@
|
||||
<div class="can-frame-hex" id="generatedHex">--</div>
|
||||
<div class="can-byte-strip" id="generatedBytes"></div>
|
||||
<div class="helper-note" id="encodeHint">选择报文后,信号值会实时编码为 CAN 数据。</div>
|
||||
<div class="btn-row can-frame-actions">
|
||||
<button class="btn btn-outline btn-sm" id="loadPreviewToRawBtn" type="button">载入到原始发送区</button>
|
||||
<button class="btn btn-primary btn-sm" id="sendSelectedBtn" type="button">发送当前预览帧</button>
|
||||
</div>
|
||||
<div class="can-response" id="dbcResp" style="display:none;">
|
||||
<div class="can-response-label">设备响应</div>
|
||||
<div id="dbcRespText"></div>
|
||||
@@ -187,14 +204,6 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<div class="card-head"><span class="ico">🕘</span> 最近发送</div>
|
||||
<div class="card-body">
|
||||
<div class="history-list history-list-compact" id="historyList">
|
||||
<div class="status-empty">尚未发送任何 CAN 帧。</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
@@ -204,6 +213,6 @@
|
||||
<span id="toastMsg">操作成功</span>
|
||||
</div>
|
||||
|
||||
<script src="can.js?v=20260624b"></script>
|
||||
<script src="can.js?v=20260701a"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+466
-95
@@ -8,9 +8,14 @@
|
||||
filteredMessages: [],
|
||||
selectedMessageKey: '',
|
||||
signalValues: {},
|
||||
checkedKeys: {},
|
||||
expandedKeys: {},
|
||||
transmitterFilter: Object.create(null),
|
||||
transmitterFilterEmpty: true,
|
||||
generatedData: new Uint8Array(0),
|
||||
history: [],
|
||||
latestStatus: null
|
||||
latestStatus: null,
|
||||
batchInFlight: false
|
||||
};
|
||||
|
||||
var el = {};
|
||||
@@ -21,13 +26,14 @@
|
||||
'testConnBtn', 'connIndicator', 'connIndicatorTxt', 'connectionNote',
|
||||
'dbcDropZone', 'dbcFileInput', 'dbcFileInfo', 'dbcFileName', 'dbcMessageCount',
|
||||
'dbcSignalCount', 'dbcNodeCount', 'dbcFileSize', 'dbcSearch', 'dbcHint', 'clearDbcBtn',
|
||||
'messageList', 'emptyMessageState', 'messageWorkspace', 'selectedMessageTitle',
|
||||
'selectedMessageSubtitle', 'selectedMessageMeta', 'selectedMessageComment',
|
||||
'sendSelectedBtn', 'signalGrid', 'generatedHex', 'generatedBytes', 'encodeHint',
|
||||
'messageList', 'sendSelectedBtn', 'generatedHex', 'generatedBytes', 'encodeHint',
|
||||
'dbcResp', 'dbcRespText', 'loadPreviewToRawBtn', 'manualHint', 'sendManualBtn',
|
||||
'resetManualBtn', 'manualResp', 'manualRespText', 'historyList', 'toast', 'toastIco',
|
||||
'toastMsg', 'canConnPill', 'canConnTxt', 'canVersionPill', 'canChannelPill',
|
||||
'dbcSummaryPill', 'messageSummaryPill'
|
||||
'dbcSummaryPill', 'messageSummaryPill',
|
||||
'selectedCountPill', 'selectAllBox', 'expandAllBtn', 'collapseAllBtn', 'sendCheckedBtn',
|
||||
'nodeFilterWrap', 'nodeFilterBtn', 'nodeFilterLabel', 'nodeFilterPanel', 'nodeFilterSearch',
|
||||
'nodeFilterAllBtn', 'nodeFilterNoneBtn', 'nodeFilterList'
|
||||
].forEach(function (id) {
|
||||
el[id] = document.getElementById(id);
|
||||
});
|
||||
@@ -338,6 +344,12 @@
|
||||
function filterMessages() {
|
||||
var keyword = normalizeWhitespace(el.dbcSearch ? el.dbcSearch.value : '').toLowerCase();
|
||||
var messages = state.dbc ? state.dbc.messages.slice() : [];
|
||||
if (!state.transmitterFilterEmpty) {
|
||||
messages = messages.filter(function (msg) {
|
||||
var tx = msg.transmitter || 'Vector__XXX';
|
||||
return !!state.transmitterFilter[tx];
|
||||
});
|
||||
}
|
||||
if (keyword) {
|
||||
messages = messages.filter(function (msg) {
|
||||
var idHex = '0x' + padHex(msg.id, msg.isExtended ? 8 : 3);
|
||||
@@ -353,39 +365,167 @@
|
||||
}
|
||||
state.filteredMessages = messages;
|
||||
renderMessageList();
|
||||
updateNodeFilterLabel();
|
||||
}
|
||||
|
||||
function collectTransmitters() {
|
||||
var set = Object.create(null);
|
||||
var list = [];
|
||||
if (!state.dbc) return list;
|
||||
state.dbc.messages.forEach(function (msg) {
|
||||
var tx = msg.transmitter || 'Vector__XXX';
|
||||
if (!set[tx]) { set[tx] = 1; list.push(tx); }
|
||||
});
|
||||
list.sort(function (a, b) { return a.localeCompare(b); });
|
||||
return list;
|
||||
}
|
||||
|
||||
function renderNodeFilterList() {
|
||||
if (!el.nodeFilterList) return;
|
||||
var txs = collectTransmitters();
|
||||
var kw = (el.nodeFilterSearch && el.nodeFilterSearch.value || '').trim().toLowerCase();
|
||||
if (kw) txs = txs.filter(function (n) { return n.toLowerCase().indexOf(kw) >= 0; });
|
||||
if (!txs.length) {
|
||||
el.nodeFilterList.innerHTML = '<div class="status-empty">无匹配节点</div>';
|
||||
return;
|
||||
}
|
||||
var counts = Object.create(null);
|
||||
if (state.dbc) {
|
||||
state.dbc.messages.forEach(function (msg) {
|
||||
var tx = msg.transmitter || 'Vector__XXX';
|
||||
counts[tx] = (counts[tx] || 0) + 1;
|
||||
});
|
||||
}
|
||||
el.nodeFilterList.innerHTML = txs.map(function (name) {
|
||||
var checked = !!state.transmitterFilter[name] ? ' checked' : '';
|
||||
return '<label class="can-node-filter-item">' +
|
||||
'<input type="checkbox" data-node="' + sanitizeHtml(name) + '"' + checked + '>' +
|
||||
'<span class="can-node-filter-name">' + sanitizeHtml(name) + '</span>' +
|
||||
'<span class="can-node-filter-count">' + (counts[name] || 0) + '</span>' +
|
||||
'</label>';
|
||||
}).join('');
|
||||
var inputs = el.nodeFilterList.querySelectorAll('input[type="checkbox"]');
|
||||
for (var i = 0; i < inputs.length; i++) {
|
||||
inputs[i].addEventListener('change', function () {
|
||||
var name = this.getAttribute('data-node');
|
||||
if (this.checked) state.transmitterFilter[name] = true;
|
||||
else delete state.transmitterFilter[name];
|
||||
recomputeTransmitterFilterEmpty();
|
||||
filterMessages();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function recomputeTransmitterFilterEmpty() {
|
||||
var any = false;
|
||||
for (var k in state.transmitterFilter) {
|
||||
if (state.transmitterFilter[k]) { any = true; break; }
|
||||
}
|
||||
state.transmitterFilterEmpty = !any;
|
||||
}
|
||||
|
||||
function updateNodeFilterLabel() {
|
||||
if (!el.nodeFilterLabel) return;
|
||||
if (state.transmitterFilterEmpty) {
|
||||
el.nodeFilterLabel.textContent = '发送节点:全部';
|
||||
if (el.nodeFilterBtn) el.nodeFilterBtn.classList.remove('active');
|
||||
} else {
|
||||
var picked = Object.keys(state.transmitterFilter).filter(function (k) { return state.transmitterFilter[k]; });
|
||||
var preview = picked.length === 1 ? picked[0] : ('已选 ' + picked.length + ' 个');
|
||||
el.nodeFilterLabel.textContent = '发送节点:' + preview;
|
||||
if (el.nodeFilterBtn) el.nodeFilterBtn.classList.add('active');
|
||||
}
|
||||
}
|
||||
|
||||
function setNodeFilterPanelOpen(open) {
|
||||
if (!el.nodeFilterPanel || !el.nodeFilterBtn) return;
|
||||
if (open) {
|
||||
renderNodeFilterList();
|
||||
el.nodeFilterPanel.hidden = false;
|
||||
el.nodeFilterBtn.setAttribute('aria-expanded', 'true');
|
||||
} else {
|
||||
el.nodeFilterPanel.hidden = true;
|
||||
el.nodeFilterBtn.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
}
|
||||
|
||||
function bindNodeFilterEvents() {
|
||||
if (!el.nodeFilterBtn) return;
|
||||
el.nodeFilterBtn.addEventListener('click', function (event) {
|
||||
event.stopPropagation();
|
||||
setNodeFilterPanelOpen(el.nodeFilterPanel.hidden);
|
||||
});
|
||||
document.addEventListener('click', function (event) {
|
||||
if (!el.nodeFilterWrap || el.nodeFilterPanel.hidden) return;
|
||||
if (!el.nodeFilterWrap.contains(event.target)) setNodeFilterPanelOpen(false);
|
||||
});
|
||||
document.addEventListener('keydown', function (event) {
|
||||
if (event.key === 'Escape' && el.nodeFilterPanel && !el.nodeFilterPanel.hidden) {
|
||||
setNodeFilterPanelOpen(false);
|
||||
}
|
||||
});
|
||||
if (el.nodeFilterSearch) {
|
||||
el.nodeFilterSearch.addEventListener('input', renderNodeFilterList);
|
||||
}
|
||||
if (el.nodeFilterAllBtn) el.nodeFilterAllBtn.addEventListener('click', function () {
|
||||
collectTransmitters().forEach(function (n) { state.transmitterFilter[n] = true; });
|
||||
recomputeTransmitterFilterEmpty();
|
||||
renderNodeFilterList();
|
||||
filterMessages();
|
||||
});
|
||||
if (el.nodeFilterNoneBtn) el.nodeFilterNoneBtn.addEventListener('click', function () {
|
||||
state.transmitterFilter = Object.create(null);
|
||||
state.transmitterFilterEmpty = true;
|
||||
renderNodeFilterList();
|
||||
filterMessages();
|
||||
});
|
||||
}
|
||||
|
||||
function renderMessageList() {
|
||||
if (!el.messageList) return;
|
||||
if (!state.dbc) {
|
||||
el.messageList.innerHTML = '<div class="status-empty">等待导入 DBC...</div>';
|
||||
updateBatchUi();
|
||||
return;
|
||||
}
|
||||
if (!state.filteredMessages.length) {
|
||||
el.messageList.innerHTML = '<div class="status-empty">没有匹配的报文。</div>';
|
||||
updateBatchUi();
|
||||
return;
|
||||
}
|
||||
var channelTxt = el.defaultChannel ? el.defaultChannel.value : '0';
|
||||
var rows = state.filteredMessages.map(function (msg, index) {
|
||||
var active = msg.key === state.selectedMessageKey ? ' active' : '';
|
||||
var checked = state.checkedKeys[msg.key] ? ' checked' : '';
|
||||
var expanded = !!state.expandedKeys[msg.key];
|
||||
var caret = expanded ? '\u25BE' : '\u25B8';
|
||||
var idHex = '0x' + padHex(msg.id, msg.isExtended ? 8 : 3);
|
||||
var comment = msg.comment || '';
|
||||
if (comment.length > 36) comment = comment.slice(0, 36) + '...';
|
||||
return '' +
|
||||
'<button class="message-row' + active + '" data-key="' + sanitizeHtml(msg.key) + '">' +
|
||||
var rowHtml = '' +
|
||||
'<div class="message-row' + active + '" data-key="' + sanitizeHtml(msg.key) + '" role="button" tabindex="0">' +
|
||||
'<span class="message-col message-col-check"><input type="checkbox" class="message-check" data-key="' + sanitizeHtml(msg.key) + '"' + checked + '></span>' +
|
||||
'<span class="message-col message-col-toggle"><button type="button" class="message-toggle' + (expanded ? ' expanded' : '') + '" data-key="' + sanitizeHtml(msg.key) + '" aria-label="展开/收起信号">' + caret + '</button></span>' +
|
||||
'<span class="message-col message-col-index">' + (index + 1) + '</span>' +
|
||||
'<span class="message-col message-col-name">' + sanitizeHtml(msg.name) + '</span>' +
|
||||
'<span class="message-col message-col-name" title="' + sanitizeHtml(msg.name) + '">' + sanitizeHtml(msg.name) + '</span>' +
|
||||
'<span class="message-col message-col-id">' + sanitizeHtml(idHex) + '</span>' +
|
||||
'<span class="message-col message-col-channel">CH' + sanitizeHtml(el.defaultChannel ? el.defaultChannel.value : '0') + '</span>' +
|
||||
'<span class="message-col message-col-channel">CH' + sanitizeHtml(channelTxt) + '</span>' +
|
||||
'<span class="message-col message-col-type">' + sanitizeHtml(msg.isExtended ? '扩展' : '标准') + '</span>' +
|
||||
'<span class="message-col message-col-dlc">' + sanitizeHtml(msg.dlc) + '</span>' +
|
||||
'<span class="message-col message-col-signal">' + sanitizeHtml(msg.signals.length) + '</span>' +
|
||||
'<span class="message-col message-col-node">' + sanitizeHtml(msg.transmitter || 'Vector__XXX') + '</span>' +
|
||||
'<span class="message-col message-col-node" title="' + sanitizeHtml(msg.transmitter || 'Vector__XXX') + '">' + sanitizeHtml(msg.transmitter || 'Vector__XXX') + '</span>' +
|
||||
'<span class="message-col message-col-comment" title="' + sanitizeHtml(msg.comment || '') + '">' + sanitizeHtml(comment || '-') + '</span>' +
|
||||
'</button>';
|
||||
'</div>';
|
||||
var expandHtml = '<div class="message-row-expand" data-key="' + sanitizeHtml(msg.key) + '"' + (expanded ? '' : ' hidden') + '>' +
|
||||
'<div class="signal-table-host"></div>' +
|
||||
'</div>';
|
||||
return rowHtml + expandHtml;
|
||||
}).join('');
|
||||
el.messageList.innerHTML =
|
||||
'<div class="message-table">' +
|
||||
'<div class="message-table-head">' +
|
||||
'<span class="message-col message-col-check"></span>' +
|
||||
'<span class="message-col message-col-toggle"></span>' +
|
||||
'<span class="message-col message-col-index">#</span>' +
|
||||
'<span class="message-col message-col-name">报文名称</span>' +
|
||||
'<span class="message-col message-col-id">标识符</span>' +
|
||||
@@ -398,14 +538,123 @@
|
||||
'</div>' +
|
||||
'<div class="message-table-body">' + rows + '</div>' +
|
||||
'</div>';
|
||||
var buttons = el.messageList.querySelectorAll('.message-row');
|
||||
for (var i = 0; i < buttons.length; i++) {
|
||||
buttons[i].addEventListener('click', function () {
|
||||
selectMessage(this.getAttribute('data-key'));
|
||||
|
||||
var rowEls = el.messageList.querySelectorAll('.message-row');
|
||||
for (var i = 0; i < rowEls.length; i++) {
|
||||
rowEls[i].addEventListener('click', handleMessageRowClick);
|
||||
rowEls[i].addEventListener('keydown', function (event) {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
selectMessage(this.getAttribute('data-key'));
|
||||
}
|
||||
});
|
||||
}
|
||||
var checks = el.messageList.querySelectorAll('.message-check');
|
||||
for (var j = 0; j < checks.length; j++) {
|
||||
checks[j].addEventListener('click', function (event) { event.stopPropagation(); });
|
||||
checks[j].addEventListener('change', function () {
|
||||
var k = this.getAttribute('data-key');
|
||||
if (this.checked) state.checkedKeys[k] = true;
|
||||
else delete state.checkedKeys[k];
|
||||
updateBatchUi();
|
||||
});
|
||||
}
|
||||
var toggles = el.messageList.querySelectorAll('.message-toggle');
|
||||
for (var t = 0; t < toggles.length; t++) {
|
||||
toggles[t].addEventListener('click', function (event) {
|
||||
event.stopPropagation();
|
||||
toggleMessageExpand(this.getAttribute('data-key'));
|
||||
});
|
||||
}
|
||||
|
||||
state.filteredMessages.forEach(function (msg) {
|
||||
if (state.expandedKeys[msg.key]) {
|
||||
var host = el.messageList.querySelector('.message-row-expand[data-key="' + cssEscape(msg.key) + '"] .signal-table-host');
|
||||
if (host) renderInlineSignalGrid(msg, host);
|
||||
}
|
||||
});
|
||||
updateBatchUi();
|
||||
}
|
||||
|
||||
function handleMessageRowClick(event) {
|
||||
if (event.target.closest('.message-check') || event.target.closest('.message-toggle')) return;
|
||||
var key = this.getAttribute('data-key');
|
||||
selectMessage(key);
|
||||
}
|
||||
|
||||
function toggleMessageExpand(key) {
|
||||
if (!state.dbc) return;
|
||||
var msg = null;
|
||||
for (var i = 0; i < state.dbc.messages.length; i++) {
|
||||
if (state.dbc.messages[i].key === key) { msg = state.dbc.messages[i]; break; }
|
||||
}
|
||||
if (!msg) return;
|
||||
var willExpand = !state.expandedKeys[key];
|
||||
if (willExpand) {
|
||||
state.expandedKeys[key] = true;
|
||||
ensureMessageDefaults(msg);
|
||||
} else {
|
||||
delete state.expandedKeys[key];
|
||||
}
|
||||
var panel = el.messageList.querySelector('.message-row-expand[data-key="' + cssEscape(key) + '"]');
|
||||
var toggle = el.messageList.querySelector('.message-toggle[data-key="' + cssEscape(key) + '"]');
|
||||
if (panel) {
|
||||
if (willExpand) {
|
||||
panel.hidden = false;
|
||||
var host = panel.querySelector('.signal-table-host');
|
||||
if (host) renderInlineSignalGrid(msg, host);
|
||||
} else {
|
||||
panel.hidden = true;
|
||||
}
|
||||
}
|
||||
if (toggle) {
|
||||
toggle.classList.toggle('expanded', willExpand);
|
||||
toggle.textContent = willExpand ? '\u25BE' : '\u25B8';
|
||||
}
|
||||
}
|
||||
|
||||
function renderInlineSignalGrid(message, host) {
|
||||
if (!host) return;
|
||||
ensureMessageDefaults(message);
|
||||
var activeSignals = getActiveSignals(message);
|
||||
if (!activeSignals.length) {
|
||||
host.innerHTML = '<div class="status-empty">当前报文没有信号定义。</div>';
|
||||
return;
|
||||
}
|
||||
var rows = activeSignals.map(function (signal, index) {
|
||||
return renderSignalRow(message, signal, index);
|
||||
}).join('');
|
||||
host.innerHTML =
|
||||
'<div class="signal-table signal-table-inline">' +
|
||||
'<div class="signal-table-head">' +
|
||||
'<span class="signal-cell signal-cell-index">#</span>' +
|
||||
'<span class="signal-cell signal-cell-name">信号名称</span>' +
|
||||
'<span class="signal-cell signal-cell-value">输入值</span>' +
|
||||
'<span class="signal-cell signal-cell-range">范围 / 枚举</span>' +
|
||||
'<span class="signal-cell signal-cell-meta">位定义</span>' +
|
||||
'<span class="signal-cell signal-cell-comment">注释</span>' +
|
||||
'</div>' +
|
||||
'<div class="signal-table-body">' + rows + '</div>' +
|
||||
'</div>';
|
||||
bindSignalInputs(host, message);
|
||||
}
|
||||
|
||||
function updateBatchUi() {
|
||||
var keys = Object.keys(state.checkedKeys);
|
||||
var count = keys.length;
|
||||
if (el.selectedCountPill) el.selectedCountPill.textContent = '已勾选 ' + count;
|
||||
if (el.sendCheckedBtn) {
|
||||
el.sendCheckedBtn.disabled = count === 0 || state.batchInFlight;
|
||||
el.sendCheckedBtn.textContent = state.batchInFlight ? '发送中...' : ('批量发送勾选 (' + count + ')');
|
||||
}
|
||||
if (el.selectAllBox) {
|
||||
var visible = state.filteredMessages || [];
|
||||
var allChecked = visible.length > 0 && visible.every(function (m) { return !!state.checkedKeys[m.key]; });
|
||||
var someChecked = visible.some(function (m) { return !!state.checkedKeys[m.key]; });
|
||||
el.selectAllBox.checked = allChecked;
|
||||
el.selectAllBox.indeterminate = !allChecked && someChecked;
|
||||
}
|
||||
}
|
||||
function selectMessage(key) {
|
||||
if (!state.dbc) return;
|
||||
var msg = null;
|
||||
@@ -417,15 +666,43 @@
|
||||
}
|
||||
if (!msg) return;
|
||||
state.selectedMessageKey = key;
|
||||
state.signalValues = {};
|
||||
msg.signals.forEach(function (signal) {
|
||||
state.signalValues[signal.name] = getDefaultSignalValue(signal);
|
||||
});
|
||||
ensureMessageDefaults(msg);
|
||||
renderMessageList();
|
||||
renderSelectedMessage();
|
||||
updateEncodedPreview();
|
||||
}
|
||||
|
||||
function ensureMessageDefaults(message) {
|
||||
if (!message) return null;
|
||||
var bag = state.signalValues[message.key];
|
||||
if (!bag) {
|
||||
bag = {};
|
||||
state.signalValues[message.key] = bag;
|
||||
}
|
||||
for (var i = 0; i < message.signals.length; i++) {
|
||||
var sig = message.signals[i];
|
||||
if (bag[sig.name] == null || bag[sig.name] === '') {
|
||||
bag[sig.name] = getDefaultSignalValue(sig);
|
||||
}
|
||||
}
|
||||
return bag;
|
||||
}
|
||||
|
||||
function getSignalValueMap(message) {
|
||||
if (!message) return {};
|
||||
return state.signalValues[message.key] || ensureMessageDefaults(message);
|
||||
}
|
||||
|
||||
function setSignalValue(message, signalName, value) {
|
||||
if (!message) return;
|
||||
var bag = state.signalValues[message.key];
|
||||
if (!bag) {
|
||||
bag = {};
|
||||
state.signalValues[message.key] = bag;
|
||||
}
|
||||
bag[signalName] = value;
|
||||
}
|
||||
|
||||
function getDefaultSignalValue(signal) {
|
||||
var enumKeys = Object.keys(signal.enumMap || {});
|
||||
if (enumKeys.length) return enumKeys[0];
|
||||
@@ -454,7 +731,8 @@
|
||||
if (!message) return [];
|
||||
var muxSignal = getMultiplexerSignal(message);
|
||||
if (!muxSignal) return message.signals.slice();
|
||||
var currentValue = Number(state.signalValues[muxSignal.name]);
|
||||
var bag = getSignalValueMap(message);
|
||||
var currentValue = Number(bag[muxSignal.name]);
|
||||
return message.signals.filter(function (signal) {
|
||||
if (!signal.multiplexRole || signal.multiplexRole === 'mux') return true;
|
||||
return signal.multiplexRole === 'value' && Number.isFinite(currentValue) && signal.multiplexValue === currentValue;
|
||||
@@ -462,62 +740,20 @@
|
||||
}
|
||||
|
||||
function renderSelectedMessage() {
|
||||
var msg = getSelectedMessage();
|
||||
var hasMessage = !!msg;
|
||||
if (el.emptyMessageState) el.emptyMessageState.style.display = hasMessage ? 'none' : '';
|
||||
if (el.messageWorkspace) el.messageWorkspace.style.display = hasMessage ? '' : 'none';
|
||||
if (!msg) return;
|
||||
|
||||
var idHex = '0x' + padHex(msg.id, msg.isExtended ? 8 : 3);
|
||||
el.selectedMessageTitle.textContent = msg.name;
|
||||
el.selectedMessageSubtitle.textContent = idHex + ' · ' + (msg.isExtended ? '扩展帧' : '标准帧');
|
||||
el.selectedMessageComment.textContent = msg.comment || '暂无报文注释。';
|
||||
var activeSignals = getActiveSignals(msg);
|
||||
var muxSignal = getMultiplexerSignal(msg);
|
||||
el.selectedMessageMeta.innerHTML = '' +
|
||||
metaCard('发送节点', msg.transmitter || 'Vector__XXX') +
|
||||
metaCard('DLC', String(msg.dlc)) +
|
||||
metaCard('信号数', activeSignals.length + ' / ' + msg.signals.length) +
|
||||
metaCard('通道', 'CH' + (el.defaultChannel ? el.defaultChannel.value : '0'));
|
||||
if (muxSignal && el.selectedMessageComment) {
|
||||
var muxValue = state.signalValues[muxSignal.name];
|
||||
el.selectedMessageComment.textContent =
|
||||
(msg.comment ? msg.comment + ' · ' : '') +
|
||||
'Mux ' + muxSignal.name + '=' + muxValue + ',当前展示并编码匹配该值的信号。';
|
||||
}
|
||||
renderSignalGrid(msg);
|
||||
// 顶部独立的“信号编辑”面板已移除,所有信号编辑现在通过报文行展开完成。
|
||||
// 这里保留函数以兼容旧调用方(selectMessage 等),不再触碰已删除的 DOM。
|
||||
}
|
||||
|
||||
function metaCard(label, value) {
|
||||
return '<div class="can-meta-card"><span>' + sanitizeHtml(label) + '</span><b>' + sanitizeHtml(value) + '</b></div>';
|
||||
}
|
||||
|
||||
function renderSignalGrid(message) {
|
||||
if (!el.signalGrid) return;
|
||||
var activeSignals = getActiveSignals(message);
|
||||
if (!activeSignals.length) {
|
||||
el.signalGrid.innerHTML = '<div class="status-empty">当前报文没有信号定义。</div>';
|
||||
return;
|
||||
}
|
||||
var rows = activeSignals.map(function (signal, index) {
|
||||
return renderSignalRow(signal, index);
|
||||
}).join('');
|
||||
el.signalGrid.innerHTML =
|
||||
'<div class="signal-table">' +
|
||||
'<div class="signal-table-head">' +
|
||||
'<span class="signal-cell signal-cell-index">#</span>' +
|
||||
'<span class="signal-cell signal-cell-name">信号名称</span>' +
|
||||
'<span class="signal-cell signal-cell-value">输入值</span>' +
|
||||
'<span class="signal-cell signal-cell-range">范围 / 枚举</span>' +
|
||||
'<span class="signal-cell signal-cell-meta">位定义</span>' +
|
||||
'<span class="signal-cell signal-cell-comment">注释</span>' +
|
||||
'</div>' +
|
||||
'<div class="signal-table-body">' + rows + '</div>' +
|
||||
'</div>';
|
||||
bindSignalInputs(message);
|
||||
function renderSignalGrid(/* message */) {
|
||||
// 旧的独立信号编辑表已移除,信号编辑全部走报文行内展开 renderInlineSignalGrid。
|
||||
}
|
||||
|
||||
function renderSignalRow(signal, index) {
|
||||
function renderSignalRow(message, signal, index) {
|
||||
var bag = getSignalValueMap(message);
|
||||
var enumKeys = Object.keys(signal.enumMap || {});
|
||||
var bitRange = signal.startBit + '|' + signal.length + ' @' + (signal.byteOrder === 'little' ? 'Intel' : 'Motorola');
|
||||
var rangeText = '';
|
||||
@@ -532,16 +768,16 @@
|
||||
if (comment.length > 48) comment = comment.slice(0, 48) + '...';
|
||||
var inputHtml = '';
|
||||
if (enumKeys.length) {
|
||||
inputHtml = '<select class="input signal-input signal-input-compact" data-signal="' + sanitizeHtml(signal.name) + '">' +
|
||||
inputHtml = '<select class="input signal-input signal-input-compact" data-msg="' + sanitizeHtml(message.key) + '" data-signal="' + sanitizeHtml(signal.name) + '">' +
|
||||
enumKeys.map(function (key) {
|
||||
var label = signal.enumMap[key];
|
||||
var selected = String(state.signalValues[signal.name]) === String(key) ? ' selected' : '';
|
||||
var selected = String(bag[signal.name]) === String(key) ? ' selected' : '';
|
||||
return '<option value="' + sanitizeHtml(key) + '"' + selected + '>' +
|
||||
sanitizeHtml(key + ' - ' + label) + '</option>';
|
||||
}).join('') + '</select>';
|
||||
} else {
|
||||
inputHtml = '<input class="input signal-input signal-input-compact" type="number" step="any" data-signal="' + sanitizeHtml(signal.name) +
|
||||
'" value="' + sanitizeHtml(state.signalValues[signal.name]) + '">';
|
||||
inputHtml = '<input class="input signal-input signal-input-compact" type="number" step="any" data-msg="' + sanitizeHtml(message.key) + '" data-signal="' + sanitizeHtml(signal.name) +
|
||||
'" value="' + sanitizeHtml(bag[signal.name]) + '">';
|
||||
}
|
||||
return '' +
|
||||
'<div class="signal-row">' +
|
||||
@@ -563,29 +799,41 @@
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function bindSignalInputs(message) {
|
||||
var inputs = el.signalGrid.querySelectorAll('.signal-input');
|
||||
function handleSignalInputEvent(evt) {
|
||||
var input = evt.currentTarget;
|
||||
var key = input.getAttribute('data-msg');
|
||||
var name = input.getAttribute('data-signal');
|
||||
if (!key || !name || !state.dbc) return;
|
||||
var message = null;
|
||||
for (var i = 0; i < state.dbc.messages.length; i++) {
|
||||
if (state.dbc.messages[i].key === key) { message = state.dbc.messages[i]; break; }
|
||||
}
|
||||
if (!message) return;
|
||||
setSignalValue(message, name, input.value);
|
||||
var signal = findSignal(message, name);
|
||||
if (signal && signal.multiplexRole === 'mux') {
|
||||
if (key === state.selectedMessageKey) renderSelectedMessage();
|
||||
var inlineGrid = document.querySelector('.message-row-expand[data-key="' + cssEscape(key) + '"] .signal-table-host');
|
||||
if (inlineGrid) renderInlineSignalGrid(message, inlineGrid);
|
||||
}
|
||||
if (key === state.selectedMessageKey) updateEncodedPreview();
|
||||
}
|
||||
|
||||
function cssEscape(value) {
|
||||
if (window.CSS && CSS.escape) return CSS.escape(value);
|
||||
return String(value).replace(/[^a-zA-Z0-9_-]/g, function (ch) { return '\\\\' + ch; });
|
||||
}
|
||||
|
||||
function bindSignalInputs(container, message) {
|
||||
if (!container) return;
|
||||
var inputs = container.querySelectorAll('.signal-input');
|
||||
for (var i = 0; i < inputs.length; i++) {
|
||||
inputs[i].addEventListener('input', function () {
|
||||
var name = this.getAttribute('data-signal');
|
||||
state.signalValues[name] = this.value;
|
||||
var signal = findSignal(message, name);
|
||||
if (signal && signal.multiplexRole === 'mux') {
|
||||
renderSelectedMessage();
|
||||
}
|
||||
updateEncodedPreview();
|
||||
});
|
||||
inputs[i].addEventListener('change', function () {
|
||||
var name = this.getAttribute('data-signal');
|
||||
state.signalValues[name] = this.value;
|
||||
var signal = findSignal(message, name);
|
||||
if (signal && signal.multiplexRole === 'mux') {
|
||||
renderSelectedMessage();
|
||||
}
|
||||
updateEncodedPreview();
|
||||
});
|
||||
inputs[i].addEventListener('input', handleSignalInputEvent);
|
||||
inputs[i].addEventListener('change', handleSignalInputEvent);
|
||||
}
|
||||
if (message && message.signals.length && message.key === state.selectedMessageKey) {
|
||||
updateEncodedPreview();
|
||||
}
|
||||
if (message && message.signals.length) updateEncodedPreview();
|
||||
}
|
||||
|
||||
function updateEncodedPreview() {
|
||||
@@ -597,7 +845,7 @@
|
||||
return;
|
||||
}
|
||||
try {
|
||||
state.generatedData = encodeMessageData(message, state.signalValues);
|
||||
state.generatedData = encodeMessageData(message, getSignalValueMap(message));
|
||||
var hex = bytesToHex(state.generatedData);
|
||||
el.generatedHex.textContent = hex || '--';
|
||||
el.generatedBytes.innerHTML = renderByteStrip(state.generatedData);
|
||||
@@ -840,6 +1088,79 @@
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function sendCheckedMessages() {
|
||||
if (!state.dbc) { showToast('请先导入 DBC', 'warning'); return; }
|
||||
var keys = Object.keys(state.checkedKeys);
|
||||
if (!keys.length) { showToast('请先勾选要发送的报文', 'warning'); return; }
|
||||
if (state.batchInFlight) return;
|
||||
state.batchInFlight = true;
|
||||
updateBatchUi();
|
||||
var channel = parseInt(el.defaultChannel.value, 10) || 0;
|
||||
var ok = 0, fail = 0;
|
||||
for (var i = 0; i < keys.length; i++) {
|
||||
var key = keys[i];
|
||||
var msg = null;
|
||||
for (var m = 0; m < state.dbc.messages.length; m++) {
|
||||
if (state.dbc.messages[m].key === key) { msg = state.dbc.messages[m]; break; }
|
||||
}
|
||||
if (!msg) continue;
|
||||
ensureMessageDefaults(msg);
|
||||
var idHex = '0x' + padHex(msg.id, msg.isExtended ? 8 : 3);
|
||||
var dataBytes;
|
||||
try {
|
||||
dataBytes = encodeMessageData(msg, getSignalValueMap(msg));
|
||||
} catch (err) {
|
||||
fail++;
|
||||
addHistoryEntry({
|
||||
time: formatTime(new Date()),
|
||||
mode: 'BATCH',
|
||||
ok: false,
|
||||
channel: channel,
|
||||
idHex: idHex,
|
||||
dlc: msg.dlc,
|
||||
dataHex: '',
|
||||
resultText: '编码失败: ' + err.message
|
||||
});
|
||||
continue;
|
||||
}
|
||||
var dataHex = bytesToHex(dataBytes);
|
||||
try {
|
||||
var result = await sendCanRequest({
|
||||
ch: channel,
|
||||
id: msg.id,
|
||||
dlc: msg.dlc,
|
||||
data: dataHex
|
||||
});
|
||||
if (result.status === 'ok') ok++; else fail++;
|
||||
addHistoryEntry({
|
||||
time: formatTime(new Date()),
|
||||
mode: 'BATCH',
|
||||
ok: result.status === 'ok',
|
||||
channel: channel,
|
||||
idHex: idHex,
|
||||
dlc: msg.dlc,
|
||||
dataHex: dataHex,
|
||||
resultText: msg.name + ' · ' + buildSendResponseText(result)
|
||||
});
|
||||
} catch (err) {
|
||||
fail++;
|
||||
addHistoryEntry({
|
||||
time: formatTime(new Date()),
|
||||
mode: 'BATCH',
|
||||
ok: false,
|
||||
channel: channel,
|
||||
idHex: idHex,
|
||||
dlc: msg.dlc,
|
||||
dataHex: dataHex,
|
||||
resultText: msg.name + ' · ' + err.message
|
||||
});
|
||||
}
|
||||
}
|
||||
state.batchInFlight = false;
|
||||
updateBatchUi();
|
||||
var tone = fail === 0 ? 'success' : (ok === 0 ? 'error' : 'warning');
|
||||
showToast('批量发送完成:成功 ' + ok + ',失败 ' + fail, tone);
|
||||
}
|
||||
async function sendSelectedMessage() {
|
||||
var msg = getSelectedMessage();
|
||||
if (!msg) {
|
||||
@@ -1001,15 +1322,38 @@
|
||||
showToast('已载入到原始发送区', 'success');
|
||||
}
|
||||
|
||||
async function readDbcText(file) {
|
||||
var buf = await file.arrayBuffer();
|
||||
var bytes = new Uint8Array(buf);
|
||||
var stripBom = function (s) {
|
||||
return (s && s.charCodeAt(0) === 0xFEFF) ? s.slice(1) : s;
|
||||
};
|
||||
try {
|
||||
var strictUtf8 = new TextDecoder('utf-8', { fatal: true });
|
||||
return stripBom(strictUtf8.decode(bytes));
|
||||
} catch (e) {
|
||||
try {
|
||||
var gb = new TextDecoder('gb18030');
|
||||
return stripBom(gb.decode(bytes));
|
||||
} catch (e2) {
|
||||
return stripBom(new TextDecoder('utf-8').decode(bytes));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDbcFile(file) {
|
||||
if (!file) return;
|
||||
var text = await file.text();
|
||||
var text = await readDbcText(file);
|
||||
var dbc = parseDbc(text);
|
||||
state.dbcFile = file;
|
||||
state.dbcText = text;
|
||||
state.dbc = dbc;
|
||||
state.selectedMessageKey = '';
|
||||
state.signalValues = {};
|
||||
state.checkedKeys = {};
|
||||
state.expandedKeys = {};
|
||||
state.transmitterFilter = Object.create(null);
|
||||
state.transmitterFilterEmpty = true;
|
||||
updateDbcFileInfo(file, dbc);
|
||||
buildSummary();
|
||||
filterMessages();
|
||||
@@ -1031,6 +1375,10 @@
|
||||
state.filteredMessages = [];
|
||||
state.selectedMessageKey = '';
|
||||
state.signalValues = {};
|
||||
state.checkedKeys = {};
|
||||
state.expandedKeys = {};
|
||||
state.transmitterFilter = Object.create(null);
|
||||
state.transmitterFilterEmpty = true;
|
||||
state.generatedData = new Uint8Array(0);
|
||||
if (el.dbcFileInput) el.dbcFileInput.value = '';
|
||||
if (el.dbcSearch) el.dbcSearch.value = '';
|
||||
@@ -1083,6 +1431,27 @@
|
||||
el.manualDlc.addEventListener('input', refreshManualHint);
|
||||
el.manualId.addEventListener('input', refreshManualHint);
|
||||
el.toolIp.addEventListener('change', testConnection);
|
||||
if (el.sendCheckedBtn) el.sendCheckedBtn.addEventListener('click', sendCheckedMessages);
|
||||
if (el.selectAllBox) el.selectAllBox.addEventListener('change', function () {
|
||||
var visible = state.filteredMessages || [];
|
||||
if (this.checked) {
|
||||
visible.forEach(function (m) { state.checkedKeys[m.key] = true; });
|
||||
} else {
|
||||
visible.forEach(function (m) { delete state.checkedKeys[m.key]; });
|
||||
}
|
||||
renderMessageList();
|
||||
});
|
||||
if (el.expandAllBtn) el.expandAllBtn.addEventListener('click', function () {
|
||||
var visible = state.filteredMessages || [];
|
||||
visible.forEach(function (m) { state.expandedKeys[m.key] = true; ensureMessageDefaults(m); });
|
||||
renderMessageList();
|
||||
});
|
||||
if (el.collapseAllBtn) el.collapseAllBtn.addEventListener('click', function () {
|
||||
var visible = state.filteredMessages || [];
|
||||
visible.forEach(function (m) { delete state.expandedKeys[m.key]; });
|
||||
renderMessageList();
|
||||
});
|
||||
bindNodeFilterEvents();
|
||||
}
|
||||
|
||||
function handleDbcError(err) {
|
||||
@@ -1093,6 +1462,8 @@
|
||||
showToast('DBC 解析失败: ' + err.message, 'error');
|
||||
}
|
||||
|
||||
|
||||
|
||||
function init() {
|
||||
cacheElements();
|
||||
bindEvents();
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
<!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>
|
||||
<button class="btn btn-outline btn-sm" id="btnModeBridge" type="button" title="CAN0 收到的每一帧按原帧格式自动转发到 CAN1 与 CAN2">CAN0→1/2 桥接</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>。<br><b>桥接模式</b>:CAN0 收到的每一帧(11/29 位、FD、BRS)自动由 CAN1 与 CAN2 转发出去。
|
||||
</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">TEC</span><span class="fb-val" id="fb0Tec">--</span></div>
|
||||
<div class="feedback-row"><span class="fb-label">REC</span><span class="fb-val" id="fb0Rec">--</span></div>
|
||||
<div class="feedback-row"><span class="fb-label">总线状态</span><span class="fb-val" id="fb0Bus">--</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">TEC</span><span class="fb-val" id="fb1Tec">--</span></div>
|
||||
<div class="feedback-row"><span class="fb-label">REC</span><span class="fb-val" id="fb1Rec">--</span></div>
|
||||
<div class="feedback-row"><span class="fb-label">总线状态</span><span class="fb-val" id="fb1Bus">--</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">TEC</span><span class="fb-val" id="fb2Tec">--</span></div>
|
||||
<div class="feedback-row"><span class="fb-label">REC</span><span class="fb-val" id="fb2Rec">--</span></div>
|
||||
<div class="feedback-row"><span class="fb-label">总线状态</span><span class="fb-val" id="fb2Bus">--</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>
|
||||
|
||||
<!-- ═══ 每 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>
|
||||
|
||||
<div class="toast" id="toast">
|
||||
<span class="toast-ico" id="toastIco">✓</span>
|
||||
<span id="toastMsg">操作成功</span>
|
||||
</div>
|
||||
|
||||
<script src="can_monitor.js?v=20260629b"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,236 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var state = { fbTimer: null };
|
||||
|
||||
var el = {};
|
||||
|
||||
var ids = [
|
||||
'monIp', 'monTestBtn', 'monConnPill', 'monConnTxt',
|
||||
'monConnIndicator', 'monConnIndicatorTxt', 'monHelper',
|
||||
'fbModePill', 'fbPollStatus',
|
||||
'fb0State', 'fb0Rx', 'fb0Tx', 'fb0Drop', 'fb0Err', 'fb0Tec', 'fb0Rec', 'fb0Bus', 'fb0Seq', 'fb0Delta',
|
||||
'fb0LastId', 'fb0Dlc', 'fb0Flags', 'fb0LastData',
|
||||
'fb1State', 'fb1Rx', 'fb1Tx', 'fb1Drop', 'fb1Err', 'fb1Tec', 'fb1Rec', 'fb1Bus', 'fb1Seq', 'fb1Delta',
|
||||
'fb1LastId', 'fb1Dlc', 'fb1Flags', 'fb1LastData',
|
||||
'fb2State', 'fb2Rx', 'fb2Tx', 'fb2Drop', 'fb2Err', 'fb2Tec', 'fb2Rec', 'fb2Bus', 'fb2Seq', 'fb2Delta',
|
||||
'fb2LastId', 'fb2Dlc', 'fb2Flags', 'fb2LastData',
|
||||
'btnModeAllPass', 'btnModeFilter', 'btnModeBridge',
|
||||
'idsnapChannel', 'idsnapStatus', 'idsnapBody',
|
||||
'toast', 'toastIco', 'toastMsg'
|
||||
];
|
||||
|
||||
function cacheElements() {
|
||||
ids.forEach(function (id) { el[id] = document.getElementById(id); });
|
||||
}
|
||||
|
||||
function showToast(msg, type) {
|
||||
type = type || 'success';
|
||||
if (!el.toast) return;
|
||||
el.toast.className = 'toast ' + (type === 'success' ? 'ok' : type === 'error' ? 'err' : 'warn') + ' show';
|
||||
el.toastIco.textContent = type === 'success' ? '\u2713' : type === 'error' ? '\u2715' : '\u26A0';
|
||||
el.toastMsg.textContent = msg;
|
||||
clearTimeout(showToast._t);
|
||||
showToast._t = setTimeout(function () { el.toast.classList.remove('show'); }, 3000);
|
||||
}
|
||||
|
||||
function sanitize(text) {
|
||||
return String(text == null ? '' : text)
|
||||
.replace(/&/g, '&').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 + 'Tec']) { el[p + 'Tec'].textContent = (ch.tec !== undefined && ch.tec !== null) ? ch.tec : '--'; el[p + 'Tec'].className = 'fb-val' + (ch.tec > 0 ? ' warn' : ''); }
|
||||
if (el[p + 'Rec']) { el[p + 'Rec'].textContent = (ch.rec !== undefined && ch.rec !== null) ? ch.rec : '--'; el[p + 'Rec'].className = 'fb-val' + (ch.rec > 0 ? ' warn' : ''); }
|
||||
if (el[p + 'Bus']) {
|
||||
if (ch.bus_state === undefined || ch.bus_state === null) {
|
||||
el[p + 'Bus'].textContent = '未知';
|
||||
el[p + 'Bus'].className = 'fb-val';
|
||||
} else {
|
||||
var bs = ch.bus_state;
|
||||
var parts = [];
|
||||
if (bs & 1) parts.push('INIT');
|
||||
if (bs & 2) parts.push('SLEEP');
|
||||
if (bs & 4) parts.push('ERR_PASSIVE');
|
||||
if (bs & 8) parts.push('BUS_OFF');
|
||||
if (bs & 16) parts.push('ERR_WARN');
|
||||
el[p + 'Bus'].textContent = (bs === 0) ? '正常' : (parts.length ? parts.join(' ') : '正常');
|
||||
el[p + 'Bus'].className = 'fb-val' + ((bs & 8) ? ' err' : (bs & (4|16)) ? ' warn' : '');
|
||||
}
|
||||
}
|
||||
if (el[p + 'Seq']) el[p + 'Seq'].textContent = ch.seq;
|
||||
if (el[p + 'Delta']) { el[p + 'Delta'].textContent = ch.count || 0; el[p + 'Delta'].className = 'fb-val'; }
|
||||
// 最新帧摘要
|
||||
var entries = ch.entries;
|
||||
if (entries && entries.length) {
|
||||
var last = entries[entries.length - 1];
|
||||
if (el[p + 'LastId']) { el[p + 'LastId'].textContent = last.id; el[p + 'LastId'].className = 'fb-val mono'; }
|
||||
if (el[p + 'Dlc']) el[p + 'Dlc'].textContent = last.dlc;
|
||||
if (el[p + 'Flags']) el[p + 'Flags'].textContent = (last.fd ? 'FD' : '-') + ' / ' + (last.brs ? 'BRS' : '-') + ' / ' + (last.esi ? 'ESI' : '-');
|
||||
if (el[p + 'LastData']) { el[p + 'LastData'].textContent = last.data || '--'; el[p + 'LastData'].className = 'fb-val mono fb-data-cell'; }
|
||||
} else {
|
||||
if (el[p + 'LastId']) { el[p + 'LastId'].textContent = '--'; el[p + 'LastId'].className = 'fb-val mono'; }
|
||||
if (el[p + 'Dlc']) el[p + 'Dlc'].textContent = '--';
|
||||
if (el[p + 'Flags']) el[p + 'Flags'].textContent = '--';
|
||||
if (el[p + 'LastData']) { el[p + 'LastData'].textContent = '--'; el[p + 'LastData'].className = 'fb-val mono fb-data-cell'; }
|
||||
}
|
||||
if (i === 0 && el.fbModePill) {
|
||||
var modeText = ch.mode === 2 ? '桥接(CAN0→1/2)' : (ch.mode === 1 ? '过滤' : '全收');
|
||||
el.fbModePill.textContent = '模式: ' + modeText;
|
||||
}
|
||||
}
|
||||
|
||||
function renderChannelEmpty(i) {
|
||||
var p = 'fb' + i;
|
||||
if (el[p + 'State']) { el[p + 'State'].textContent = 'SPI 离线'; el[p + 'State'].className = 'fb-val err'; }
|
||||
['Rx','Tx','Drop','Err','Tec','Rec','Bus','Seq','Delta','Dlc','Flags'].forEach(function (f) {
|
||||
if (el[p + f]) { el[p + f].textContent = '--'; el[p + f].className = 'fb-val'; }
|
||||
});
|
||||
if (el[p + 'LastId']) { el[p + 'LastId'].textContent = '--'; el[p + 'LastId'].className = 'fb-val mono'; }
|
||||
if (el[p + 'LastData']) { el[p + 'LastData'].textContent = '--'; el[p + 'LastData'].className = 'fb-val mono fb-data-cell'; }
|
||||
}
|
||||
function renderIdTable(entries, count) {
|
||||
var tbody = el.idsnapBody;
|
||||
if (!tbody) return;
|
||||
if (!entries || !entries.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" class="status-empty">无数据(CAN 未收到帧)</td></tr>';
|
||||
return;
|
||||
}
|
||||
var html = '';
|
||||
for (var i = 0; i < entries.length; i++) {
|
||||
var e = entries[i];
|
||||
html += '<tr>';
|
||||
html += '<td>' + (i + 1) + '</td>';
|
||||
html += '<td class="mono">' + sanitize(e.id) + '</td>';
|
||||
html += '<td>' + e.dlc + '</td>';
|
||||
html += '<td class="' + (e.fd ? 'flag-on' : 'flag-off') + '">' + (e.fd ? 'Y' : '-') + '</td>';
|
||||
html += '<td class="' + (e.brs ? 'flag-on' : 'flag-off') + '">' + (e.brs ? 'Y' : '-') + '</td>';
|
||||
html += '<td class="' + (e.esi ? 'flag-on' : 'flag-off') + '">' + (e.esi ? 'Y' : '-') + '</td>';
|
||||
html += '<td class="idsnap-data mono">' + sanitize(e.data || '--') + '</td>';
|
||||
html += '</tr>';
|
||||
}
|
||||
tbody.innerHTML = html;
|
||||
}
|
||||
|
||||
function renderIdTableEmpty() {
|
||||
var tbody = el.idsnapBody;
|
||||
if (tbody) tbody.innerHTML = '<tr><td colspan="7" class="status-empty">等待数据...</td></tr>';
|
||||
if (el.idsnapStatus) { el.idsnapStatus.textContent = '--'; el.idsnapStatus.className = 'pill'; }
|
||||
}
|
||||
|
||||
// ── 连接测试 ─────────────────────────────────────────────
|
||||
async function testConnection() {
|
||||
try {
|
||||
var resp = await fetch(getBaseUrl() + '/can/feedback', { signal: AbortSignal.timeout(3000) });
|
||||
if (!resp.ok) { setConnState(false, 'HTTP ' + resp.status); return; }
|
||||
setConnState(true, '已连接');
|
||||
showToast('ESP32 已连接', 'success');
|
||||
} catch (e) {
|
||||
setConnState(false, '连接失败');
|
||||
showToast('连接失败: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ── 模式切换 ─────────────────────────────────────────────
|
||||
async function setCanMode(mode) {
|
||||
try {
|
||||
var resp = await fetch(getBaseUrl() + '/can/mode', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ mode: mode }),
|
||||
signal: AbortSignal.timeout(3000)
|
||||
});
|
||||
var data = await resp.json();
|
||||
if (data.ok) {
|
||||
showToast('CAN 模式已切换: ' + (data.actual === 2 ? '桥接(CAN0→1/2)' : (data.actual === 1 ? '过滤' : '全收')), 'success');
|
||||
} else {
|
||||
showToast('模式切换失败', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
showToast('模式切换失败: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// ── 初始化 ───────────────────────────────────────────────
|
||||
function init() {
|
||||
cacheElements();
|
||||
setConnState(false, '未连接');
|
||||
if (el.monTestBtn) el.monTestBtn.addEventListener('click', testConnection);
|
||||
if (el.monIp) el.monIp.addEventListener('change', testConnection);
|
||||
if (el.btnModeAllPass) el.btnModeAllPass.addEventListener('click', function () { setCanMode(0); });
|
||||
if (el.btnModeFilter) el.btnModeFilter.addEventListener('click', function () { setCanMode(1); });
|
||||
if (el.btnModeBridge) el.btnModeBridge.addEventListener('click', function () { setCanMode(2); });
|
||||
if (el.idsnapChannel) el.idsnapChannel.addEventListener('change', pollFeedback);
|
||||
setTimeout(testConnection, 150);
|
||||
pollFeedback();
|
||||
state.fbTimer = setInterval(pollFeedback, 1000);
|
||||
}
|
||||
|
||||
init();
|
||||
})();
|
||||
@@ -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>
|
||||
|
||||
+439
-3
@@ -1,4 +1,4 @@
|
||||
/* ===== ESP32 OTA 控制台 — 工业调试台风格 ===== */
|
||||
/* ===== ESP32 OTA 控制台 — 工业调试台风格 ===== */
|
||||
|
||||
:root {
|
||||
--bg-root: #090e15;
|
||||
@@ -1013,13 +1013,27 @@ select option { background: var(--bg-panel); color: var(--text); }
|
||||
overflow: auto;
|
||||
}
|
||||
.message-table {
|
||||
min-width: 1200px;
|
||||
/* 列宽随左栏宽度自适应压缩,不再强制水平滚动 */
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
.message-table-head,
|
||||
.message-row {
|
||||
display: grid;
|
||||
grid-template-columns: 52px minmax(180px, 1.6fr) 150px 88px 88px 76px 76px 150px minmax(220px, 1fr);
|
||||
grid-template-columns:
|
||||
36px /* 勾选 */
|
||||
32px /* 展开 */
|
||||
40px /* # */
|
||||
minmax(0, 1.6fr) /* 报文名称 */
|
||||
96px /* 标识符 */
|
||||
52px /* 通道 */
|
||||
52px /* 类型 */
|
||||
46px /* DLC */
|
||||
50px /* 信号 */
|
||||
minmax(0, 1fr) /* 发送节点 */
|
||||
minmax(0, 2fr); /* 注释 */
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
.message-table-head {
|
||||
position: sticky;
|
||||
@@ -1708,3 +1722,425 @@ 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;
|
||||
}
|
||||
|
||||
/* === CAN message row expand / batch additions === */
|
||||
.message-col-check,
|
||||
.message-col-toggle {
|
||||
text-align: center;
|
||||
padding: 6px 4px;
|
||||
}
|
||||
.message-check {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
cursor: pointer;
|
||||
accent-color: var(--accent, #22d3ee);
|
||||
}
|
||||
.message-toggle {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid rgba(255,255,255,.12);
|
||||
background: rgba(255,255,255,.04);
|
||||
color: #c8d6e7;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
transition: background .12s ease, border-color .12s ease, color .12s ease;
|
||||
}
|
||||
.message-toggle:hover {
|
||||
background: rgba(34,211,238,.12);
|
||||
border-color: rgba(34,211,238,.4);
|
||||
color: #e6fbff;
|
||||
}
|
||||
.message-toggle.expanded {
|
||||
background: rgba(34,211,238,.18);
|
||||
border-color: rgba(34,211,238,.55);
|
||||
color: #e6fbff;
|
||||
}
|
||||
|
||||
.message-row-expand {
|
||||
grid-column: 1 / -1;
|
||||
background: rgba(255,255,255,.02);
|
||||
border-top: 1px dashed rgba(255,255,255,.06);
|
||||
border-bottom: 1px solid rgba(255,255,255,.04);
|
||||
padding: 10px 14px 14px;
|
||||
}
|
||||
.message-row-expand[hidden] {
|
||||
display: none;
|
||||
}
|
||||
.message-row-expand .signal-table-host {
|
||||
margin-top: 0;
|
||||
}
|
||||
.message-row-expand .signal-table {
|
||||
border-radius: 10px;
|
||||
}
|
||||
.message-row-expand .signal-table-body {
|
||||
max-height: 360px;
|
||||
}
|
||||
|
||||
.can-batch-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.checkbox-inline {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--text-dim, #93a4bf);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
.checkbox-inline input {
|
||||
accent-color: var(--accent, #22d3ee);
|
||||
cursor: pointer;
|
||||
}
|
||||
.card-head-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.btn.btn-sm {
|
||||
padding: 4px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.btn[disabled] {
|
||||
opacity: .55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* === 2026-06-30c: 报文列表与右侧操作栏并列布局调整 === */
|
||||
/* 报文列表移入 can-editor-layout 后,作为左列;侧栏作为右列;不再需要顶部独立信号编辑卡。 */
|
||||
.can-editor-layout > .can-table-card {
|
||||
margin-top: 0;
|
||||
}
|
||||
.can-sidepanel .card-body { padding-top: 14px; }
|
||||
.can-frame-actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.can-frame-actions .btn { flex: 1 1 auto; min-width: 120px; }
|
||||
.can-frame-panel-compact .can-frame-hex { word-break: break-all; }
|
||||
|
||||
|
||||
/* === 2026-06-30c: 报文列表压缩列宽 + 纵向滚动美化 === */
|
||||
.message-list {
|
||||
/* 提高可视高度,并改用美化的纵向滚动条 */
|
||||
max-height: calc(100vh - 300px);
|
||||
min-height: 420px;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(34,211,238,.35) transparent;
|
||||
}
|
||||
.message-list::-webkit-scrollbar { width: 8px; }
|
||||
.message-list::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
margin: 4px 0;
|
||||
}
|
||||
.message-list::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(180deg, rgba(34,211,238,.45), rgba(34,211,238,.18));
|
||||
border-radius: 6px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
.message-list::-webkit-scrollbar-thumb:hover {
|
||||
background: linear-gradient(180deg, rgba(34,211,238,.65), rgba(34,211,238,.28));
|
||||
background-clip: padding-box;
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
|
||||
/* 单元格统一省略号 */
|
||||
.message-row .message-col,
|
||||
.message-table-head .message-col {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* 紧凑列的内边距收一点,腾出空间给名称/注释 */
|
||||
.message-col-index,
|
||||
.message-col-channel,
|
||||
.message-col-type,
|
||||
.message-col-dlc,
|
||||
.message-col-signal {
|
||||
padding-left: 6px;
|
||||
padding-right: 6px;
|
||||
}
|
||||
.message-col-check,
|
||||
.message-col-toggle {
|
||||
padding-left: 4px;
|
||||
padding-right: 4px;
|
||||
}
|
||||
/* 注释列允许浅色降权显示 */
|
||||
.message-row .message-col-comment {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* === 2026-06-30c: 窄屏报文表降级(按位置缩为 0 宽,保持 grid 对齐) === */
|
||||
/* 列序:check(1) toggle(2) #(3) name(4) id(5) channel(6) type(7) dlc(8) signal(9) node(10) comment(11) */
|
||||
@media (max-width: 1280px) {
|
||||
.message-table-head,
|
||||
.message-row {
|
||||
grid-template-columns:
|
||||
36px 32px 40px
|
||||
minmax(0, 1.6fr) /* name */
|
||||
90px /* id */
|
||||
52px /* channel */
|
||||
0 /* type 隐藏 */
|
||||
46px /* dlc */
|
||||
0 /* signal 隐藏 */
|
||||
minmax(0, 0.9fr) /* node */
|
||||
minmax(0, 1.8fr); /* comment */
|
||||
}
|
||||
.message-col-type,
|
||||
.message-col-signal { padding: 0; border: 0; overflow: hidden; }
|
||||
}
|
||||
@media (max-width: 1024px) {
|
||||
.message-table-head,
|
||||
.message-row {
|
||||
grid-template-columns:
|
||||
36px 32px 36px
|
||||
minmax(0, 1.6fr)
|
||||
86px
|
||||
0
|
||||
0
|
||||
46px
|
||||
0
|
||||
0
|
||||
minmax(0, 1.8fr);
|
||||
}
|
||||
.message-col-channel,
|
||||
.message-col-node { padding: 0; border: 0; overflow: hidden; }
|
||||
}
|
||||
|
||||
|
||||
/* 行内信号编辑展开区允许局部横向滚动(信号字段较多时),不影响外层报文表 */
|
||||
.message-row-expand {
|
||||
overflow-x: auto;
|
||||
}
|
||||
.message-row-expand::-webkit-scrollbar { height: 6px; }
|
||||
.message-row-expand::-webkit-scrollbar-thumb {
|
||||
background: rgba(34,211,238,.35);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
|
||||
/* === 2026-06-30e: 工作台设置 + 最近发送 双列布局 === */
|
||||
.can-top-layout {
|
||||
margin-top: 18px;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 360px;
|
||||
gap: 18px;
|
||||
align-items: stretch;
|
||||
}
|
||||
/* 工作台卡和历史卡都放进 grid 后不需要单独的顶部外边距 */
|
||||
.can-top-layout > .can-toolbar-card,
|
||||
.can-top-layout > .can-history-card {
|
||||
margin-top: 0;
|
||||
}
|
||||
/* 历史卡固定高度,仅容纳约 3 条历史,超出滚动 */
|
||||
.can-history-card { align-self: start; }
|
||||
.can-history-card .card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.can-history-card .history-list {
|
||||
/* 约 3 条历史的高度:(每条 ~96px) * 3 + gap 10px * 2 ≈ 308px */
|
||||
max-height: 312px;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(34,211,238,.35) transparent;
|
||||
}
|
||||
.can-history-card .history-list::-webkit-scrollbar { width: 8px; }
|
||||
.can-history-card .history-list::-webkit-scrollbar-track { background: transparent; margin: 4px 0; }
|
||||
.can-history-card .history-list::-webkit-scrollbar-thumb {
|
||||
background: linear-gradient(180deg, rgba(34,211,238,.45), rgba(34,211,238,.18));
|
||||
border-radius: 6px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
.can-history-card .history-list::-webkit-scrollbar-thumb:hover {
|
||||
background: linear-gradient(180deg, rgba(34,211,238,.65), rgba(34,211,238,.28));
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
/* 窄屏堆叠 */
|
||||
@media (max-width: 1100px) {
|
||||
.can-top-layout { grid-template-columns: 1fr; }
|
||||
.can-history-card .history-list { max-height: 312px; }
|
||||
}
|
||||
|
||||
|
||||
/* === 2026-07-01a: 发送节点过滤下拉 === */
|
||||
.can-node-filter {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
.can-node-filter-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.can-node-filter-btn.active {
|
||||
border-color: rgba(34,211,238,.65);
|
||||
color: var(--accent);
|
||||
background: rgba(34,211,238,.08);
|
||||
}
|
||||
.can-node-filter-caret {
|
||||
font-size: 10px;
|
||||
opacity: .75;
|
||||
}
|
||||
.can-node-filter-panel {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
right: 0;
|
||||
z-index: 30;
|
||||
min-width: 260px;
|
||||
max-width: 320px;
|
||||
background: #182330;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 18px 38px rgba(0,0,0,.45);
|
||||
padding: 10px;
|
||||
}
|
||||
.can-node-filter-panel[hidden] { display: none; }
|
||||
.can-node-filter-head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.can-node-filter-search {
|
||||
width: 100%;
|
||||
padding: 6px 8px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.can-node-filter-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.can-node-filter-list {
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(34,211,238,.35) transparent;
|
||||
}
|
||||
.can-node-filter-list::-webkit-scrollbar { width: 8px; }
|
||||
.can-node-filter-list::-webkit-scrollbar-thumb {
|
||||
background: rgba(34,211,238,.35);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.can-node-filter-item {
|
||||
display: grid;
|
||||
grid-template-columns: 18px 1fr auto;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 6px 8px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: var(--text);
|
||||
transition: background .12s;
|
||||
}
|
||||
.can-node-filter-item:hover {
|
||||
background: rgba(255,255,255,.04);
|
||||
}
|
||||
.can-node-filter-item input[type="checkbox"] {
|
||||
margin: 0;
|
||||
}
|
||||
.can-node-filter-name {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
.can-node-filter-count {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
background: rgba(255,255,255,.05);
|
||||
border-radius: 999px;
|
||||
padding: 1px 8px;
|
||||
min-width: 26px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user