diff --git a/main/include/version.h b/main/include/version.h index edc48db..2b02fd4 100644 --- a/main/include/version.h +++ b/main/include/version.h @@ -4,11 +4,11 @@ // 固件版本 #define FW_VERSION_MAJOR 0 #define FW_VERSION_MINOR 3 -#define FW_VERSION_PATCH 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" diff --git a/main/monitor/can_feedback_task.cpp b/main/monitor/can_feedback_task.cpp index 73c5079..757a4e7 100644 --- a/main/monitor/can_feedback_task.cpp +++ b/main/monitor/can_feedback_task.cpp @@ -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(ch), diff --git a/main/ota/ota_coordinator.cpp b/main/ota/ota_coordinator.cpp index f4082b9..38ccfde 100644 --- a/main/ota/ota_coordinator.cpp +++ b/main/ota/ota_coordinator.cpp @@ -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); diff --git a/main/ota/ota_coordinator.h b/main/ota/ota_coordinator.h index 4360e02..fe0a3a1 100644 --- a/main/ota/ota_coordinator.h +++ b/main/ota/ota_coordinator.h @@ -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; ///< 中继进度快照 diff --git a/main/server/http_handlers.cpp b/main/server/http_handlers.cpp index bc12c7d..bb00a20 100644 --- a/main/server/http_handlers.cpp +++ b/main/server/http_handlers.cpp @@ -1,4 +1,4 @@ -/* +/* * http_handlers.cpp - HTTP 请求处理器实现 * 从 wifi_ota.cpp 提取的 HTTP handler 函数 */ @@ -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", diff --git a/main/transport/canfd_bridge.cpp b/main/transport/canfd_bridge.cpp index f3e952d..fb8be27 100644 --- a/main/transport/canfd_bridge.cpp +++ b/main/transport/canfd_bridge.cpp @@ -1,4 +1,4 @@ -/* +/* * canfd_bridge.cpp - CANFD 3通道通信桥接实现(M4: 从 SpiMaster 提取) */ #include @@ -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(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; } diff --git a/main/transport/canfd_bridge.h b/main/transport/canfd_bridge.h index 351df37..320a6a7 100644 --- a/main/transport/canfd_bridge.h +++ b/main/transport/canfd_bridge.h @@ -1,4 +1,4 @@ -/* +/* * canfd_bridge.h - CANFD 3通道通信桥接(M4: 从 SpiMaster 提取) * * 封装 CANFD 帧格式构建 + SPI 传输,与 SpiMaster 的 OTA 协议职责解耦。 @@ -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; /** diff --git a/tools/app.js b/tools/app.js index 5897bf6..d914cf4 100644 --- a/tools/app.js +++ b/tools/app.js @@ -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(); diff --git a/tools/can.html b/tools/can.html index a796cd0..127f88f 100644 --- a/tools/can.html +++ b/tools/can.html @@ -4,7 +4,7 @@ ESP32 CAN 通信控制台 - +
@@ -27,110 +27,121 @@
-
-
🧰 工作台设置
-
-
-
- -
- - +
+
+
🧰 工作台设置
+
+
+
+ +
+ + +
+
+ +
+ + +
+ +
+ +
+
📥
+

导入 DBC

+

点击或拖拽 .dbc 文件

+
+ +
+ +
-
- - -
- -
- -
-
📥
-

导入 DBC

-

点击或拖拽 .dbc 文件

+
+
+ 📄 - + +
+
+
报文0
+
信号0
+
节点0
+
大小0 B
-
- - -
-
- 📄 - - -
-
-
报文0
-
信号0
-
节点0
-
大小0 B
+
+
+
🕘 最近发送
+
+
+
尚未发送任何 CAN 帧。
+
+
-
-
- - 等待连接测试 -
-
- 当前页面会直接调用 /system/status 和 /can/send。 -
-
- 当前解析器会自动识别常见 DBC 的 BO_、SG_、VAL_、 - CM_ 和 SIG_VALTYPE_。 +
+
+
+ 📚 + 报文列表 +
+ 已勾选 0 +
+ +
+ + + +
-
- -
-
📚 报文列表
等待导入 DBC...
-
- - -
-
-
🎚️ 信号编辑
-
-
-
📡
-

先导入 DBC,再选择一条 CAN 报文

-

上方按表格选报文,下方按信号名直接编辑,右侧查看编码结果并发送。

-
- - -
-
-
🕘 最近发送
-
-
-
尚未发送任何 CAN 帧。
-
-
-
@@ -206,6 +213,6 @@ 操作成功 - + diff --git a/tools/can.js b/tools/can.js index 1552f38..e196a9d 100644 --- a/tools/can.js +++ b/tools/can.js @@ -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 = '
无匹配节点
'; + 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 ''; + }).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 = '
等待导入 DBC...
'; + updateBatchUi(); return; } if (!state.filteredMessages.length) { el.messageList.innerHTML = '
没有匹配的报文。
'; + 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 '' + - '' + '' + (index + 1) + '' + - '' + sanitizeHtml(msg.name) + '' + + '' + sanitizeHtml(msg.name) + '' + '' + sanitizeHtml(idHex) + '' + - 'CH' + sanitizeHtml(el.defaultChannel ? el.defaultChannel.value : '0') + '' + + 'CH' + sanitizeHtml(channelTxt) + '' + '' + sanitizeHtml(msg.isExtended ? '扩展' : '标准') + '' + '' + sanitizeHtml(msg.dlc) + '' + '' + sanitizeHtml(msg.signals.length) + '' + - '' + sanitizeHtml(msg.transmitter || 'Vector__XXX') + '' + + '' + sanitizeHtml(msg.transmitter || 'Vector__XXX') + '' + '' + sanitizeHtml(comment || '-') + '' + - ''; + ''; + var expandHtml = '
' + + '
' + + '
'; + return rowHtml + expandHtml; }).join(''); el.messageList.innerHTML = '
' + '
' + + '' + + '' + '#' + '报文名称' + '标识符' + @@ -398,14 +538,123 @@ '
' + '
' + rows + '
' + '
'; - 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 = '
当前报文没有信号定义。
'; + return; + } + var rows = activeSignals.map(function (signal, index) { + return renderSignalRow(message, signal, index); + }).join(''); + host.innerHTML = + '
' + + '
' + + '#' + + '信号名称' + + '输入值' + + '范围 / 枚举' + + '位定义' + + '注释' + + '
' + + '
' + rows + '
' + + '
'; + 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 '
' + sanitizeHtml(label) + '' + sanitizeHtml(value) + '
'; } - function renderSignalGrid(message) { - if (!el.signalGrid) return; - var activeSignals = getActiveSignals(message); - if (!activeSignals.length) { - el.signalGrid.innerHTML = '
当前报文没有信号定义。
'; - return; - } - var rows = activeSignals.map(function (signal, index) { - return renderSignalRow(signal, index); - }).join(''); - el.signalGrid.innerHTML = - '
' + - '
' + - '#' + - '信号名称' + - '输入值' + - '范围 / 枚举' + - '位定义' + - '注释' + - '
' + - '
' + rows + '
' + - '
'; - 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 = '' + 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 ''; }).join('') + ''; } else { - inputHtml = ''; + inputHtml = ''; } return '' + '
' + @@ -563,29 +799,41 @@ '
'; } - 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) { diff --git a/tools/can_monitor.html b/tools/can_monitor.html index 59db554..6c9e857 100644 --- a/tools/can_monitor.html +++ b/tools/can_monitor.html @@ -42,6 +42,7 @@
+
@@ -51,7 +52,7 @@ 等待连接测试
- 页面每 1 秒通过 /can/feedback 拉取三路 CAN 通道快照。无 RX 的通道显示 空(无 RX)。 + 页面每 1 秒通过 /can/feedback 拉取三路 CAN 通道快照。无 RX 的通道显示 空(无 RX)。
桥接模式:CAN0 收到的每一帧(11/29 位、FD、BRS)自动由 CAN1 与 CAN2 转发出去。
@@ -70,6 +71,9 @@ + + + @@ -85,6 +89,9 @@ + + + @@ -100,6 +107,9 @@ + + + @@ -112,12 +122,6 @@ - - -
- ✓ - 操作成功 -
🔬 每 ID 快照表(1Hz 刷新) @@ -149,6 +153,11 @@
+
+ ✓ + 操作成功 +
+ \ No newline at end of file diff --git a/tools/can_monitor.js b/tools/can_monitor.js index b204ac9..4408425 100644 --- a/tools/can_monitor.js +++ b/tools/can_monitor.js @@ -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(); diff --git a/tools/style.css b/tools/style.css index fd61325..a6e95ac 100644 --- a/tools/style.css +++ b/tools/style.css @@ -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; +}