feat(can): 添加CAN总线错误计数器和总线状态监控
添加了硬件发送错误计数器(tec)、接收错误计数器(rec)和总线状态位图(bus_state)字段, 用于更全面的CAN总线状态监控。同时新增CAN_RX_MODE_BRIDGE_0_TO_12模式,支持CAN0到 CAN1和CAN2的桥接功能。 BREAKING CHANGE: CAN反馈协议格式扩展,增加了3个字节的状态信息。
This commit is contained in:
@@ -4,11 +4,11 @@
|
||||
// 固件版本
|
||||
#define FW_VERSION_MAJOR 0
|
||||
#define FW_VERSION_MINOR 3
|
||||
#define FW_VERSION_PATCH 35
|
||||
#define FW_VERSION_STRING "0.3.35"
|
||||
#define FW_VERSION_PATCH 36
|
||||
#define FW_VERSION_STRING "0.3.36"
|
||||
|
||||
// 编译时间
|
||||
#define BUILD_TIME "2026-06-29 01:50:43"
|
||||
#define BUILD_TIME "2026-07-02 01:59:27"
|
||||
|
||||
// Git 信息
|
||||
#define GIT_HASH "a93dba6"
|
||||
#define GIT_HASH "6335d05"
|
||||
|
||||
@@ -57,19 +57,7 @@ static void taskLoop(void *)
|
||||
if (rc == 0) {
|
||||
publish(ch, fb);
|
||||
|
||||
const bool prevOk = s_prev_poll_ok[ch];
|
||||
const bool changed = (!s_prev_valid[ch]) ||
|
||||
(fb.stats.rx_count != s_prev_stats[ch].rx_count) ||
|
||||
(fb.stats.tx_count != s_prev_stats[ch].tx_count) ||
|
||||
(fb.stats.rx_drop_count != s_prev_stats[ch].rx_drop_count) ||
|
||||
(fb.stats.error_count != s_prev_stats[ch].error_count) ||
|
||||
(fb.stats.mode != s_prev_stats[ch].mode) ||
|
||||
(fb.stats.snapshot_seq != s_prev_stats[ch].snapshot_seq);
|
||||
const bool abnormal = (fb.stats.rx_drop_count != 0U) ||
|
||||
(fb.stats.error_count != 0U) ||
|
||||
(fb.stats.mode != 0U);
|
||||
|
||||
if (!prevOk || changed || abnormal) {
|
||||
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),
|
||||
|
||||
@@ -477,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);
|
||||
}
|
||||
@@ -738,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; ///< 中继进度快照
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/*
|
||||
/*
|
||||
* http_handlers.cpp - HTTP 请求处理器实现
|
||||
* 从 wifi_ota.cpp 提取的 HTTP handler 函数
|
||||
*/
|
||||
@@ -746,13 +746,17 @@ esp_err_t can_feedback_handler(httpd_req_t *req)
|
||||
if (valid[i]) {
|
||||
p += sprintf(p,
|
||||
",\"mode\":%u,\"seq\":%lu,\"rx\":%lu,\"tx\":%lu,"
|
||||
"\"drop\":%lu,\"err\":%lu,\"count\":%u,\"entries\":[",
|
||||
"\"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];
|
||||
@@ -836,8 +840,12 @@ esp_err_t can_mode_handler(httpd_req_t *req)
|
||||
int requested = (int)cJSON_GetNumberValue(mode_item);
|
||||
cJSON_Delete(root);
|
||||
|
||||
can_rx_mode_t target = (requested == 1) ? CAN_RX_MODE_FILTER_ID
|
||||
: CAN_RX_MODE_ALL_PASS;
|
||||
can_rx_mode_t 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);
|
||||
|
||||
@@ -1258,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(),
|
||||
@@ -1278,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",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/*
|
||||
/*
|
||||
* canfd_bridge.cpp - CANFD 3通道通信桥接实现(M4: 从 SpiMaster 提取)
|
||||
*/
|
||||
#include <string.h>
|
||||
@@ -109,7 +109,7 @@ int canFdPollFeedback(can_channel_t channel,
|
||||
out->valid = 0;
|
||||
uint8_t entryIdx = 0U;
|
||||
|
||||
constexpr uint8_t kFixedHeader = 25U; /* ch+page+total+cnt+mode+seq+rx+tx+drop+err */
|
||||
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 };
|
||||
@@ -134,6 +134,9 @@ int canFdPollFeedback(can_channel_t channel,
|
||||
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];
|
||||
}
|
||||
|
||||
@@ -250,7 +253,7 @@ 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) {
|
||||
if (mode != CAN_RX_MODE_ALL_PASS && mode != CAN_RX_MODE_FILTER_ID && mode != CAN_RX_MODE_BRIDGE_0_TO_12) {
|
||||
return -1;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
/*
|
||||
/*
|
||||
* canfd_bridge.h - CANFD 3通道通信桥接(M4: 从 SpiMaster 提取)
|
||||
*
|
||||
* 封装 CANFD 帧格式构建 + SPI 传输,与 SpiMaster 的 OTA 协议职责解耦。
|
||||
@@ -91,6 +91,7 @@ typedef struct {
|
||||
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;
|
||||
|
||||
/**
|
||||
@@ -104,6 +105,9 @@ typedef struct {
|
||||
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;
|
||||
|
||||
/**
|
||||
|
||||
+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();
|
||||
|
||||
+102
-95
@@ -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=20260628a">
|
||||
<link rel="stylesheet" href="style.css?v=20260701a">
|
||||
</head>
|
||||
<body class="can-page">
|
||||
<div class="container can-container">
|
||||
@@ -27,110 +27,121 @@
|
||||
</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">
|
||||
@@ -142,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>
|
||||
@@ -189,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>
|
||||
@@ -206,6 +213,6 @@
|
||||
<span id="toastMsg">操作成功</span>
|
||||
</div>
|
||||
|
||||
<script src="can.js?v=20260628a"></script>
|
||||
<script src="can.js?v=20260701a"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
+464
-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) {
|
||||
|
||||
+16
-7
@@ -42,6 +42,7 @@
|
||||
<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>
|
||||
@@ -51,7 +52,7 @@
|
||||
<span id="monConnIndicatorTxt">等待连接测试</span>
|
||||
</div>
|
||||
<div class="helper-note" id="monHelper">
|
||||
页面每 1 秒通过 <code>/can/feedback</code> 拉取三路 CAN 通道快照。无 RX 的通道显示 <b>空(无 RX)</b>。
|
||||
页面每 1 秒通过 <code>/can/feedback</code> 拉取三路 CAN 通道快照。无 RX 的通道显示 <b>空(无 RX)</b>。<br><b>桥接模式</b>:CAN0 收到的每一帧(11/29 位、FD、BRS)自动由 CAN1 与 CAN2 转发出去。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -70,6 +71,9 @@
|
||||
<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>
|
||||
@@ -85,6 +89,9 @@
|
||||
<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>
|
||||
@@ -100,6 +107,9 @@
|
||||
<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>
|
||||
@@ -112,12 +122,6 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="toast" id="toast">
|
||||
<span class="toast-ico" id="toastIco">✓</span>
|
||||
<span id="toastMsg">操作成功</span>
|
||||
|
||||
<!-- ═══ 每 ID 快照表 ═══ -->
|
||||
<section class="card" id="idSnapshotSection" style="margin-top:18px;">
|
||||
<div class="card-head"><span class="ico">🔬</span> 每 ID 快照表(1Hz 刷新)
|
||||
@@ -149,6 +153,11 @@
|
||||
|
||||
</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>
|
||||
+48
-7
@@ -9,10 +9,13 @@
|
||||
'monIp', 'monTestBtn', 'monConnPill', 'monConnTxt',
|
||||
'monConnIndicator', 'monConnIndicatorTxt', 'monHelper',
|
||||
'fbModePill', 'fbPollStatus',
|
||||
'fb0State', 'fb0Rx', 'fb0Tx', 'fb0Drop', 'fb0Err', 'fb0Seq', 'fb0Delta',
|
||||
'fb1State', 'fb1Rx', 'fb1Tx', 'fb1Drop', 'fb1Err', 'fb1Seq', 'fb1Delta',
|
||||
'fb2State', 'fb2Rx', 'fb2Tx', 'fb2Drop', 'fb2Err', 'fb2Seq', 'fb2Delta',
|
||||
'btnModeAllPass', 'btnModeFilter',
|
||||
'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'
|
||||
];
|
||||
@@ -103,17 +106,54 @@
|
||||
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'; }
|
||||
if (i === 0 && el.fbModePill) el.fbModePill.textContent = '模式: ' + (ch.mode === 1 ? '过滤' : '全收');
|
||||
// 最新帧摘要
|
||||
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','Seq','Delta'].forEach(function (f) {
|
||||
['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;
|
||||
@@ -168,7 +208,7 @@
|
||||
});
|
||||
var data = await resp.json();
|
||||
if (data.ok) {
|
||||
showToast('CAN 模式已切换: ' + (data.actual === 1 ? '过滤' : '全收'), 'success');
|
||||
showToast('CAN 模式已切换: ' + (data.actual === 2 ? '桥接(CAN0→1/2)' : (data.actual === 1 ? '过滤' : '全收')), 'success');
|
||||
} else {
|
||||
showToast('模式切换失败', 'error');
|
||||
}
|
||||
@@ -185,6 +225,7 @@
|
||||
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();
|
||||
|
||||
+374
-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;
|
||||
@@ -1773,3 +1787,360 @@ code { color: #d3f9ff; font-family: var(--font-mono); }
|
||||
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