feat(protocol): 更新SPI Link协议至v2版本并优化CAN通信
- 将SPI OTA帧格式升级为SPI Link v2,采用新的帧格式: | SOF0 0xA5 | SOF1 0x5A | VER 0x02 | FLAGS | SESSION 4B LE | TX_SEQ | ACK_SEQ | CREDIT | TYPE | LEN 2B LE | RSV 2B | PAYLOAD | CRC32 4B LE | - 新增V2_OVERHEAD=20字节开销,APP最大payload为236字节,BOOT为108字节 - 修改帧类型定义:ENTER_BOOT从0xB5合并至0xB0,新增CAN业务类型0xC0~C3 - 更新CAN协议文档,支持SPI Link v2-only模式,调整最大业务payload计算 - 扩展0xC1帧功能,支持双向通信和1Hz主动推送ID快照 - 添加0xC3完整ID快照帧类型,包含seq/rx_count等完整字段 - 更新协议文档中的错误码定义和反馈payload格式 refactor(main): 重构主程序入口并添加CAN反馈任务 - 在main.cpp中添加CAN反馈任务,更新启动流程注释 - 创建createCriticalTask辅助函数,统一关键任务创建逻辑 - 调整SPI初始化顺序,确保CAN反馈任务依赖正确 - 优化错误处理机制,关键任务失败时立即重启 - 将GD32版本探测移至ota_relay任务处理 - 更新版本号至0.3.48,调整编译时间和Git哈希 config: 更新SPI和BLE配置参数 - 添加SPI主机相关配置参数,包括CS GPIO、READY GPIO等引脚定义 - 配置SPI缓冲区大小:APP为256字节,BOOT为128字节 - 更新BLE滑动窗口参数,ACK窗口设为32,数据队列深度为72 - 调整SPI工作队列大小至5120字节,增加任务堆栈监控说明
This commit is contained in:
+91
-7
@@ -9,8 +9,9 @@
|
||||
|
||||
// ==================== 共享状态 ====================
|
||||
export var state = {
|
||||
dbcFile: null,
|
||||
dbcText: '',
|
||||
/** @type {{ id: string, name: string, size: number, dbc: object }[]} */
|
||||
dbcSources: [],
|
||||
/** 合并视图 { messages, nodeNames, messageMap };由 rebuildMergedDbc() 维护 */
|
||||
dbc: null,
|
||||
filteredMessages: [],
|
||||
selectedMessageKey: '',
|
||||
@@ -30,10 +31,32 @@ export var state = {
|
||||
export var el = {};
|
||||
|
||||
// ==================== 基础工具 ====================
|
||||
/** @description 拼接设备基础 URL(http://<ip>),ip 取自 el.toolIp,默认 192.168.31.127。 */
|
||||
var LS_ESP_IP = 'dcu_esp_ip';
|
||||
var DEFAULT_ESP_IP = '192.168.31.127';
|
||||
|
||||
/** @description 设备 IP:优先 localStorage(与 OTA 页共用),否则默认。 */
|
||||
export function getDeviceIp() {
|
||||
try {
|
||||
var saved = localStorage.getItem(LS_ESP_IP);
|
||||
if (saved && saved.trim()) return saved.trim();
|
||||
} catch (_) {}
|
||||
if (el.toolIp && el.toolIp.value && el.toolIp.value.trim()) {
|
||||
return el.toolIp.value.trim();
|
||||
}
|
||||
return DEFAULT_ESP_IP;
|
||||
}
|
||||
|
||||
/** @description 记住设备 IP(status 回报或其它页写入)。 */
|
||||
export function setDeviceIp(ip) {
|
||||
if (!ip || !String(ip).trim()) return;
|
||||
var v = String(ip).trim();
|
||||
try { localStorage.setItem(LS_ESP_IP, v); } catch (_) {}
|
||||
if (el.toolIp) el.toolIp.value = v;
|
||||
}
|
||||
|
||||
/** @description 拼接设备基础 URL(http://<ip>)。 */
|
||||
export function getBaseUrl() {
|
||||
var ip = (el.toolIp && el.toolIp.value ? el.toolIp.value.trim() : '') || '192.168.31.127';
|
||||
return 'http://' + ip;
|
||||
return 'http://' + getDeviceIp();
|
||||
}
|
||||
|
||||
/** @description 解析手动输入的 CAN ID(支持 0x/hex/十进制)。 */
|
||||
@@ -239,15 +262,76 @@ export function parseMultiplexToken(token) {
|
||||
return { role: null, value: null };
|
||||
}
|
||||
|
||||
/**
|
||||
* 将多个 DBC 源合并为统一 messages 列表。
|
||||
* 每条报文 key = sourceId:idRaw:name,避免不同文件同 ID/同名冲突。
|
||||
*/
|
||||
export function rebuildMergedDbc() {
|
||||
if (!state.dbcSources.length) {
|
||||
state.dbc = null;
|
||||
return null;
|
||||
}
|
||||
var messages = [];
|
||||
var nodeSet = Object.create(null);
|
||||
var nodeNames = [];
|
||||
var messageMap = {};
|
||||
for (var s = 0; s < state.dbcSources.length; s++) {
|
||||
var src = state.dbcSources[s];
|
||||
var dbc = src.dbc;
|
||||
if (!dbc || !dbc.messages) continue;
|
||||
for (var n = 0; n < (dbc.nodeNames || []).length; n++) {
|
||||
var nn = dbc.nodeNames[n];
|
||||
if (nn && !nodeSet[nn]) {
|
||||
nodeSet[nn] = 1;
|
||||
nodeNames.push(nn);
|
||||
}
|
||||
}
|
||||
for (var i = 0; i < dbc.messages.length; i++) {
|
||||
var msg = dbc.messages[i];
|
||||
var merged = {
|
||||
idRaw: msg.idRaw,
|
||||
name: msg.name,
|
||||
dlc: msg.dlc,
|
||||
transmitter: msg.transmitter,
|
||||
signals: msg.signals,
|
||||
comment: msg.comment,
|
||||
id: msg.id,
|
||||
isExtended: msg.isExtended,
|
||||
sourceId: src.id,
|
||||
sourceName: src.name,
|
||||
key: src.id + ':' + msg.idRaw + ':' + msg.name
|
||||
};
|
||||
messages.push(merged);
|
||||
messageMap[merged.key] = merged;
|
||||
}
|
||||
}
|
||||
state.dbc = { messages: messages, nodeNames: nodeNames, messageMap: messageMap };
|
||||
return state.dbc;
|
||||
}
|
||||
|
||||
/** @description 生成唯一 source id(同名文件可多次添加时追加 #2) */
|
||||
export function makeDbcSourceId(fileName) {
|
||||
var base = String(fileName || 'dbc').replace(/[^\w.\-]+/g, '_');
|
||||
var id = base;
|
||||
var n = 2;
|
||||
while (state.dbcSources.some(function (s) { return s.id === id; })) {
|
||||
id = base + '#' + n;
|
||||
n++;
|
||||
}
|
||||
return id;
|
||||
}
|
||||
|
||||
/** @description 更新顶部 DBC / 报文数量摘要 pill。 */
|
||||
export function buildSummary() {
|
||||
if (!state.dbc) {
|
||||
if (!state.dbc || !state.dbcSources.length) {
|
||||
if (el.dbcSummaryPill) el.dbcSummaryPill.textContent = '未导入 DBC';
|
||||
if (el.messageSummaryPill) el.messageSummaryPill.textContent = '0 报文';
|
||||
return;
|
||||
}
|
||||
var nFiles = state.dbcSources.length;
|
||||
if (el.dbcSummaryPill) {
|
||||
el.dbcSummaryPill.textContent = (state.dbc.nodeNames.length || 0) + ' 节点';
|
||||
el.dbcSummaryPill.textContent =
|
||||
nFiles + ' 文件 · ' + (state.dbc.nodeNames.length || 0) + ' 节点';
|
||||
}
|
||||
if (el.messageSummaryPill) {
|
||||
el.messageSummaryPill.textContent = state.dbc.messages.length + ' 报文';
|
||||
|
||||
+67
-21
@@ -7,30 +7,59 @@
|
||||
* common/utils.js(escapeHtml/formatBytes)、
|
||||
* signals.js(renderInlineSignalGrid/ensureMessageDefaults/renderSelectedMessage/updateEncodedPreview)。
|
||||
*/
|
||||
import { state, el, padHex, normalizeWhitespace, cssEscape } from './dbc.js';
|
||||
import { escapeHtml as sanitizeHtml, formatBytes } from '../common/utils.js';
|
||||
import { renderInlineSignalGrid, ensureMessageDefaults, renderSelectedMessage, updateEncodedPreview } from './signals.js';
|
||||
import { state, el, padHex, normalizeWhitespace, cssEscape } from './dbc.js?v=20260717t';
|
||||
import { escapeHtml as sanitizeHtml, formatBytes } from '../common/utils.js?v=20260717t';
|
||||
import { renderInlineSignalGrid, ensureMessageDefaults, renderSelectedMessage, updateEncodedPreview } from './signals.js?v=20260717t';
|
||||
import { scheduleCanPersist } from './persist.js?v=20260717t';
|
||||
|
||||
// ==================== DBC 文件信息 ====================
|
||||
/** @description 更新 DBC 文件信息卡片(文件名/报文数/信号数/节点数/大小)。 */
|
||||
export function updateDbcFileInfo(file, dbc) {
|
||||
if (!el.dbcFileInfo) return;
|
||||
if (!file || !dbc) {
|
||||
el.dbcFileInfo.classList.remove('show');
|
||||
el.dbcFileName.textContent = '-';
|
||||
el.dbcMessageCount.textContent = '0';
|
||||
el.dbcSignalCount.textContent = '0';
|
||||
el.dbcNodeCount.textContent = '0';
|
||||
el.dbcFileSize.textContent = '0 B';
|
||||
/** @description 刷新多 DBC 摘要条与文件 chip 列表。 */
|
||||
export function updateDbcFileInfo() {
|
||||
var sources = state.dbcSources || [];
|
||||
var has = sources.length > 0;
|
||||
var dbc = state.dbc;
|
||||
|
||||
if (el.dbcFileInfo) {
|
||||
el.dbcFileInfo.hidden = !has;
|
||||
el.dbcFileInfo.classList.toggle('show', has);
|
||||
}
|
||||
/* 有文件时仍显示「添加」,文案改成添加;「全部清除」紧挨添加按钮 */
|
||||
if (el.importDbcBtn) {
|
||||
el.importDbcBtn.hidden = false;
|
||||
el.importDbcBtn.textContent = has ? '添加 DBC' : '导入 DBC';
|
||||
}
|
||||
if (el.clearDbcBtn) el.clearDbcBtn.hidden = !has;
|
||||
if (el.dbcDropZone) el.dbcDropZone.classList.toggle('has-dbc', has);
|
||||
|
||||
if (!has || !dbc) {
|
||||
if (el.dbcFileCount) el.dbcFileCount.textContent = '0';
|
||||
if (el.dbcMessageCount) el.dbcMessageCount.textContent = '0';
|
||||
if (el.dbcSignalCount) el.dbcSignalCount.textContent = '0';
|
||||
if (el.dbcNodeCount) el.dbcNodeCount.textContent = '0';
|
||||
if (el.dbcFileSize) el.dbcFileSize.textContent = '0 B';
|
||||
if (el.dbcFileChips) el.dbcFileChips.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
var signalCount = dbc.messages.reduce(function (sum, msg) { return sum + msg.signals.length; }, 0);
|
||||
el.dbcFileInfo.classList.add('show');
|
||||
el.dbcFileName.textContent = file.name || '-';
|
||||
el.dbcMessageCount.textContent = String(dbc.messages.length);
|
||||
el.dbcSignalCount.textContent = String(signalCount);
|
||||
el.dbcNodeCount.textContent = String(dbc.nodeNames.length);
|
||||
el.dbcFileSize.textContent = formatBytes(file.size || 0);
|
||||
var totalSize = sources.reduce(function (sum, s) { return sum + (s.size || 0); }, 0);
|
||||
if (el.dbcFileCount) el.dbcFileCount.textContent = String(sources.length);
|
||||
if (el.dbcMessageCount) el.dbcMessageCount.textContent = String(dbc.messages.length);
|
||||
if (el.dbcSignalCount) el.dbcSignalCount.textContent = String(signalCount);
|
||||
if (el.dbcNodeCount) el.dbcNodeCount.textContent = String(dbc.nodeNames.length);
|
||||
if (el.dbcFileSize) el.dbcFileSize.textContent = formatBytes(totalSize);
|
||||
|
||||
if (el.dbcFileChips) {
|
||||
el.dbcFileChips.innerHTML = sources.map(function (s) {
|
||||
var nMsg = (s.dbc && s.dbc.messages) ? s.dbc.messages.length : 0;
|
||||
return '' +
|
||||
'<span class="dbc-file-chip" data-source-id="' + sanitizeHtml(s.id) + '" title="' + sanitizeHtml(s.name) + ' · ' + nMsg + ' 报文">' +
|
||||
'<span class="dbc-file-chip-name">' + sanitizeHtml(s.name) + '</span>' +
|
||||
'<button type="button" class="dbc-file-chip-remove" data-remove-id="' + sanitizeHtml(s.id) + '" title="移除此文件">×</button>' +
|
||||
'</span>';
|
||||
}).join('');
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 报文过滤 ====================
|
||||
@@ -109,6 +138,7 @@ export function renderNodeFilterList() {
|
||||
else delete state.transmitterFilter[name];
|
||||
recomputeTransmitterFilterEmpty();
|
||||
filterMessages();
|
||||
scheduleCanPersist();
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -143,9 +173,11 @@ export function setNodeFilterPanelOpen(open) {
|
||||
renderNodeFilterList();
|
||||
el.nodeFilterPanel.hidden = false;
|
||||
el.nodeFilterBtn.setAttribute('aria-expanded', 'true');
|
||||
if (el.nodeFilterWrap) el.nodeFilterWrap.classList.add('is-open');
|
||||
} else {
|
||||
el.nodeFilterPanel.hidden = true;
|
||||
el.nodeFilterBtn.setAttribute('aria-expanded', 'false');
|
||||
if (el.nodeFilterWrap) el.nodeFilterWrap.classList.remove('is-open');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -173,12 +205,14 @@ export function bindNodeFilterEvents() {
|
||||
recomputeTransmitterFilterEmpty();
|
||||
renderNodeFilterList();
|
||||
filterMessages();
|
||||
scheduleCanPersist();
|
||||
});
|
||||
if (el.nodeFilterNoneBtn) el.nodeFilterNoneBtn.addEventListener('click', function () {
|
||||
state.transmitterFilter = Object.create(null);
|
||||
state.transmitterFilterEmpty = true;
|
||||
renderNodeFilterList();
|
||||
filterMessages();
|
||||
scheduleCanPersist();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -186,8 +220,13 @@ export function bindNodeFilterEvents() {
|
||||
/** @description 渲染报文列表表格(表头 + 行 + 展开区)。 */
|
||||
export function renderMessageList() {
|
||||
if (!el.messageList) return;
|
||||
if (!state.dbc) {
|
||||
el.messageList.innerHTML = '<div class="status-empty">等待导入 DBC...</div>';
|
||||
if (!state.dbc || !state.dbcSources.length) {
|
||||
el.messageList.innerHTML =
|
||||
'<div class="dbc-empty-drop" id="dbcEmptyDrop">' +
|
||||
'<div class="upload-ico">📥</div>' +
|
||||
'<h4>导入 DBC 开始编辑报文</h4>' +
|
||||
'<p>支持一次选择或拖入 <b>多个</b> <code>.dbc</code> 文件</p>' +
|
||||
'</div>';
|
||||
updateBatchUi();
|
||||
return;
|
||||
}
|
||||
@@ -210,7 +249,10 @@ export function renderMessageList() {
|
||||
'<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" title="' + sanitizeHtml(msg.name) + '">' + sanitizeHtml(msg.name) + '</span>' +
|
||||
'<span class="message-col message-col-dbc" title="' + sanitizeHtml(msg.sourceName || '-') + '">' +
|
||||
sanitizeHtml(msg.sourceName ? String(msg.sourceName).replace(/\.dbc$/i, '') : '-') + '</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(channelTxt) + '</span>' +
|
||||
'<span class="message-col message-col-type">' + sanitizeHtml(msg.isExtended ? '扩展' : '标准') + '</span>' +
|
||||
@@ -230,6 +272,7 @@ export function renderMessageList() {
|
||||
'<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-dbc">DBC名称</span>' +
|
||||
'<span class="message-col message-col-name">报文名称</span>' +
|
||||
'<span class="message-col message-col-id">标识符</span>' +
|
||||
'<span class="message-col message-col-channel">通道</span>' +
|
||||
@@ -260,6 +303,7 @@ export function renderMessageList() {
|
||||
if (this.checked) state.checkedKeys[k] = true;
|
||||
else delete state.checkedKeys[k];
|
||||
updateBatchUi();
|
||||
scheduleCanPersist();
|
||||
});
|
||||
}
|
||||
var toggles = el.messageList.querySelectorAll('.message-toggle');
|
||||
@@ -314,6 +358,7 @@ function toggleMessageExpand(key) {
|
||||
toggle.classList.toggle('expanded', willExpand);
|
||||
toggle.textContent = willExpand ? '\u25BE' : '\u25B8';
|
||||
}
|
||||
scheduleCanPersist();
|
||||
}
|
||||
|
||||
// ==================== 批量 UI ====================
|
||||
@@ -352,4 +397,5 @@ export function selectMessage(key) {
|
||||
renderMessageList();
|
||||
renderSelectedMessage();
|
||||
updateEncodedPreview();
|
||||
scheduleCanPersist();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
/**
|
||||
* persist.js - CAN 控制台会话持久化(刷新后恢复)
|
||||
*
|
||||
* 保存到 localStorage:多 DBC 原文、信号编辑值、勾选/展开、发送节点过滤、
|
||||
* 最近发送历史、通道/搜索/原始帧表单。
|
||||
*
|
||||
* 依赖:dbc.js(state/el/parseDbc/rebuildMergedDbc)
|
||||
*/
|
||||
import { state, el, parseDbc, rebuildMergedDbc } from './dbc.js?v=20260717t';
|
||||
|
||||
var LS_KEY = 'dcu_can_session_v1';
|
||||
var MAX_HISTORY = 200;
|
||||
var SAVE_DEBOUNCE_MS = 350;
|
||||
var saveTimer = null;
|
||||
var restoring = false;
|
||||
|
||||
function safeJsonParse(raw) {
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function transmitterFilterToPlain() {
|
||||
var out = {};
|
||||
for (var k in state.transmitterFilter) {
|
||||
if (Object.prototype.hasOwnProperty.call(state.transmitterFilter, k) || state.transmitterFilter[k]) {
|
||||
out[k] = !!state.transmitterFilter[k];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function collectUiFields() {
|
||||
return {
|
||||
defaultChannel: el.defaultChannel ? el.defaultChannel.value : '0',
|
||||
manualChannel: el.manualChannel ? el.manualChannel.value : '0',
|
||||
manualId: el.manualId ? el.manualId.value : '',
|
||||
manualDlc: el.manualDlc ? el.manualDlc.value : '8',
|
||||
manualData: el.manualData ? el.manualData.value : '',
|
||||
dbcSearch: el.dbcSearch ? el.dbcSearch.value : ''
|
||||
};
|
||||
}
|
||||
|
||||
function applyUiFields(ui) {
|
||||
if (!ui) return;
|
||||
if (el.defaultChannel && ui.defaultChannel != null) {
|
||||
el.defaultChannel.value = String(ui.defaultChannel);
|
||||
}
|
||||
if (el.manualChannel && ui.manualChannel != null) {
|
||||
el.manualChannel.value = String(ui.manualChannel);
|
||||
} else if (el.manualChannel && el.defaultChannel) {
|
||||
el.manualChannel.value = el.defaultChannel.value;
|
||||
}
|
||||
if (el.manualId && ui.manualId != null) el.manualId.value = String(ui.manualId);
|
||||
if (el.manualDlc && ui.manualDlc != null) el.manualDlc.value = String(ui.manualDlc);
|
||||
if (el.manualData && ui.manualData != null) el.manualData.value = String(ui.manualData);
|
||||
if (el.dbcSearch && ui.dbcSearch != null) el.dbcSearch.value = String(ui.dbcSearch);
|
||||
if (el.canChannelPill && el.defaultChannel) {
|
||||
el.canChannelPill.textContent = 'CH' + el.defaultChannel.value;
|
||||
}
|
||||
}
|
||||
|
||||
function buildPayload(options) {
|
||||
options = options || {};
|
||||
var sources = (state.dbcSources || []).map(function (s) {
|
||||
return {
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
size: s.size || 0,
|
||||
text: s.text || ''
|
||||
};
|
||||
}).filter(function (s) { return s.text; });
|
||||
|
||||
var history = Array.isArray(state.history) ? state.history.slice(0, MAX_HISTORY) : [];
|
||||
if (options.historyLimit != null) {
|
||||
history = history.slice(0, options.historyLimit);
|
||||
}
|
||||
|
||||
return {
|
||||
v: 1,
|
||||
ts: Date.now(),
|
||||
dbcSources: sources,
|
||||
selectedMessageKey: state.selectedMessageKey || '',
|
||||
signalValues: state.signalValues || {},
|
||||
checkedKeys: state.checkedKeys || {},
|
||||
expandedKeys: state.expandedKeys || {},
|
||||
transmitterFilter: transmitterFilterToPlain(),
|
||||
history: history,
|
||||
ui: collectUiFields()
|
||||
};
|
||||
}
|
||||
|
||||
function writePayload(payload) {
|
||||
localStorage.setItem(LS_KEY, JSON.stringify(payload));
|
||||
}
|
||||
|
||||
/** @description 立即写入 localStorage(空间不足时逐步丢弃历史/信号)。 */
|
||||
export function saveCanSessionNow() {
|
||||
if (restoring) return;
|
||||
if (saveTimer) {
|
||||
clearTimeout(saveTimer);
|
||||
saveTimer = null;
|
||||
}
|
||||
try {
|
||||
writePayload(buildPayload());
|
||||
return;
|
||||
} catch (e1) {
|
||||
/* quota:先砍历史 */
|
||||
}
|
||||
try {
|
||||
writePayload(buildPayload({ historyLimit: 40 }));
|
||||
return;
|
||||
} catch (e2) {
|
||||
/* 再砍信号值,只保 DBC + 勾选 */
|
||||
}
|
||||
try {
|
||||
var slim = buildPayload({ historyLimit: 0 });
|
||||
slim.signalValues = {};
|
||||
slim.history = [];
|
||||
writePayload(slim);
|
||||
} catch (e3) {
|
||||
/* 放弃 */
|
||||
}
|
||||
}
|
||||
|
||||
/** @description 防抖保存(编辑信号/勾选等高频操作)。 */
|
||||
export function scheduleCanPersist() {
|
||||
if (restoring) return;
|
||||
if (saveTimer) clearTimeout(saveTimer);
|
||||
saveTimer = setTimeout(function () {
|
||||
saveTimer = null;
|
||||
saveCanSessionNow();
|
||||
}, SAVE_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
/** @description 清空持久化(全部清除 DBC 时)。 */
|
||||
export function clearCanSessionPersist() {
|
||||
if (saveTimer) {
|
||||
clearTimeout(saveTimer);
|
||||
saveTimer = null;
|
||||
}
|
||||
try {
|
||||
localStorage.removeItem(LS_KEY);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 从 localStorage 恢复会话到 state + 表单。
|
||||
* @returns {{ restored: boolean, dbcCount: number, historyCount: number }}
|
||||
*/
|
||||
export function loadCanSession() {
|
||||
var empty = { restored: false, dbcCount: 0, historyCount: 0 };
|
||||
var raw = null;
|
||||
try {
|
||||
raw = localStorage.getItem(LS_KEY);
|
||||
} catch (_) {
|
||||
return empty;
|
||||
}
|
||||
if (!raw) return empty;
|
||||
var data = safeJsonParse(raw);
|
||||
if (!data || data.v !== 1) return empty;
|
||||
|
||||
restoring = true;
|
||||
try {
|
||||
state.dbcSources = [];
|
||||
var list = Array.isArray(data.dbcSources) ? data.dbcSources : [];
|
||||
for (var i = 0; i < list.length; i++) {
|
||||
var src = list[i];
|
||||
if (!src || !src.text) continue;
|
||||
try {
|
||||
var dbc = parseDbc(src.text);
|
||||
if (!dbc.messages || !dbc.messages.length) continue;
|
||||
state.dbcSources.push({
|
||||
id: src.id || ('restored_' + i),
|
||||
name: src.name || ('saved_' + (i + 1) + '.dbc'),
|
||||
size: src.size || src.text.length,
|
||||
text: src.text,
|
||||
dbc: dbc
|
||||
});
|
||||
} catch (_) {
|
||||
/* 单文件损坏跳过 */
|
||||
}
|
||||
}
|
||||
rebuildMergedDbc();
|
||||
|
||||
state.signalValues = (data.signalValues && typeof data.signalValues === 'object')
|
||||
? data.signalValues : {};
|
||||
state.checkedKeys = (data.checkedKeys && typeof data.checkedKeys === 'object')
|
||||
? data.checkedKeys : {};
|
||||
state.expandedKeys = (data.expandedKeys && typeof data.expandedKeys === 'object')
|
||||
? data.expandedKeys : {};
|
||||
state.selectedMessageKey = data.selectedMessageKey || '';
|
||||
state.history = Array.isArray(data.history) ? data.history.slice(0, MAX_HISTORY) : [];
|
||||
|
||||
state.transmitterFilter = Object.create(null);
|
||||
if (data.transmitterFilter && typeof data.transmitterFilter === 'object') {
|
||||
Object.keys(data.transmitterFilter).forEach(function (k) {
|
||||
state.transmitterFilter[k] = !!data.transmitterFilter[k];
|
||||
});
|
||||
}
|
||||
var anyTx = false;
|
||||
for (var tk in state.transmitterFilter) {
|
||||
if (state.transmitterFilter[tk]) { anyTx = true; break; }
|
||||
}
|
||||
state.transmitterFilterEmpty = !anyTx;
|
||||
|
||||
/* 选中键若不存在则清空,交由调用方选第一条 */
|
||||
if (state.selectedMessageKey && state.dbc && state.dbc.messageMap) {
|
||||
if (!state.dbc.messageMap[state.selectedMessageKey]) {
|
||||
state.selectedMessageKey = '';
|
||||
}
|
||||
} else if (state.selectedMessageKey && (!state.dbc || !state.dbc.messages.length)) {
|
||||
state.selectedMessageKey = '';
|
||||
}
|
||||
|
||||
applyUiFields(data.ui);
|
||||
|
||||
return {
|
||||
restored: true,
|
||||
dbcCount: state.dbcSources.length,
|
||||
historyCount: state.history.length
|
||||
};
|
||||
} finally {
|
||||
restoring = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** @description 绑定 beforeunload / 可见性,降低丢失。 */
|
||||
export function bindCanPersistLifecycle() {
|
||||
window.addEventListener('beforeunload', function () {
|
||||
saveCanSessionNow();
|
||||
});
|
||||
document.addEventListener('visibilitychange', function () {
|
||||
if (document.visibilityState === 'hidden') saveCanSessionNow();
|
||||
});
|
||||
}
|
||||
+148
-57
@@ -12,11 +12,12 @@
|
||||
* 注意:formatTime 保留本地实现(HH:MM:SS,不含毫秒),与 common/utils.js
|
||||
* 的 formatTime(含毫秒)不同,以保证发送历史时间戳输出格式一致。
|
||||
*/
|
||||
import { state, el, getBaseUrl, padHex, bytesToHex, parseNumericId } from './dbc.js';
|
||||
import { escapeHtml as sanitizeHtml } from '../common/utils.js';
|
||||
import { showToast } from '../common/toast.js';
|
||||
import { updateBatchUi } from './messages.js';
|
||||
import { ensureMessageDefaults, getSignalValueMap, getSelectedMessage, updateEncodedPreview, encodeMessageData } from './signals.js';
|
||||
import { state, el, getBaseUrl, padHex, bytesToHex, parseNumericId } from './dbc.js?v=20260717t';
|
||||
import { escapeHtml as sanitizeHtml } from '../common/utils.js?v=20260717t';
|
||||
import { showToast } from '../common/toast.js?v=20260717t';
|
||||
import { updateBatchUi } from './messages.js?v=20260717t';
|
||||
import { ensureMessageDefaults, getSignalValueMap, getSelectedMessage, updateEncodedPreview, encodeMessageData } from './signals.js?v=20260717t';
|
||||
import { scheduleCanPersist } from './persist.js?v=20260717t';
|
||||
|
||||
// ==================== 本地工具(保持与原 can.js 一致) ====================
|
||||
function formatTime(date) {
|
||||
@@ -84,6 +85,33 @@ async function sendCanRequest(payload) {
|
||||
return json;
|
||||
}
|
||||
|
||||
/**
|
||||
* @description 一次 HTTP 连续发送多帧(设备 /can/send_batch)。
|
||||
* @returns {Promise<object[]>} 与 frames 等长的 results
|
||||
*/
|
||||
async function sendCanBatchRequest(channel, frames) {
|
||||
var resp = await fetch(getBaseUrl() + '/can/send_batch', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ch: channel, frames: frames }),
|
||||
signal: AbortSignal.timeout(Math.max(15000, frames.length * 500))
|
||||
});
|
||||
var text = await resp.text();
|
||||
var json = {};
|
||||
try {
|
||||
json = text ? JSON.parse(text) : {};
|
||||
} catch (err) {
|
||||
throw new Error('批量响应不是合法 JSON: ' + text);
|
||||
}
|
||||
if (!resp.ok) {
|
||||
throw new Error('HTTP ' + resp.status + ': ' + (json.message || text || '批量请求失败'));
|
||||
}
|
||||
if (!json.results || !Array.isArray(json.results)) {
|
||||
throw new Error('批量响应缺少 results');
|
||||
}
|
||||
return json.results;
|
||||
}
|
||||
|
||||
// ==================== 响应框 ====================
|
||||
function updateResponseBox(box, textEl, result, mode) {
|
||||
if (!box || !textEl) return;
|
||||
@@ -99,13 +127,20 @@ function updateResponseBox(box, textEl, result, mode) {
|
||||
}
|
||||
|
||||
// ==================== 发送历史 ====================
|
||||
function addHistoryEntry(entry) {
|
||||
/**
|
||||
* @param {object} entry
|
||||
* @param {{ silent?: boolean }} [opts] silent=true 时不立刻重绘/落盘(批量用)
|
||||
*/
|
||||
function addHistoryEntry(entry, opts) {
|
||||
state.history.unshift(entry);
|
||||
if (state.history.length > 20) state.history.length = 20;
|
||||
/* 批量发送会快速产生大量条目,上限放宽便于回看 */
|
||||
if (state.history.length > 200) state.history.length = 200;
|
||||
if (opts && opts.silent) return;
|
||||
renderHistory();
|
||||
scheduleCanPersist();
|
||||
}
|
||||
|
||||
/** @description 渲染发送历史列表。 */
|
||||
/** @description 渲染发送历史列表(单行紧凑:状态|时间|CH·ID·DLC|HEX|结果)。 */
|
||||
export function renderHistory() {
|
||||
if (!el.historyList) return;
|
||||
if (!state.history.length) {
|
||||
@@ -113,24 +148,27 @@ export function renderHistory() {
|
||||
return;
|
||||
}
|
||||
el.historyList.innerHTML = state.history.map(function (item) {
|
||||
var meta = 'CH' + item.channel + ' · ' + item.idHex + ' · DLC ' + item.dlc;
|
||||
return '' +
|
||||
'<div class="history-item">' +
|
||||
'<div class="history-item-top">' +
|
||||
'<div class="history-item history-item-row' + (item.ok ? '' : ' is-err') + '">' +
|
||||
'<span class="history-item-tag ' + (item.ok ? 'ok' : 'err') + '">' + sanitizeHtml(item.mode) + '</span>' +
|
||||
'<span class="history-item-time">' + sanitizeHtml(item.time) + '</span>' +
|
||||
'</div>' +
|
||||
'<div class="history-item-main">' +
|
||||
'<b>CH' + sanitizeHtml(item.channel) + '</b> · ' +
|
||||
sanitizeHtml(item.idHex) + ' · DLC ' + sanitizeHtml(item.dlc) +
|
||||
'</div>' +
|
||||
'<div class="history-item-hex">' + sanitizeHtml(item.dataHex || '--') + '</div>' +
|
||||
'<div class="history-item-foot">' + sanitizeHtml(item.resultText) + '</div>' +
|
||||
'<span class="history-item-main" title="' + sanitizeHtml(meta) + '">' + sanitizeHtml(meta) + '</span>' +
|
||||
'<span class="history-item-hex" title="' + sanitizeHtml(item.dataHex || '') + '">' + sanitizeHtml(item.dataHex || '--') + '</span>' +
|
||||
'<span class="history-item-foot" title="' + sanitizeHtml(item.resultText || '') + '">' + sanitizeHtml(item.resultText) + '</span>' +
|
||||
'</div>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// ==================== 批量发送 ====================
|
||||
/** @description 批量发送所有勾选的报文(30ms 帧间间隔防 GD32 SPI RXORERR)。 */
|
||||
/** 单批最多帧数(与设备 /can/send_batch 上限一致) */
|
||||
var BATCH_CHUNK = 48;
|
||||
|
||||
/**
|
||||
* @description 批量发送所有勾选的报文。
|
||||
* 优先一次 HTTP /can/send_batch 在设备侧紧接连发;失败则回退单帧紧接。
|
||||
* 批量过程中不重绘历史,结束后统一刷新。
|
||||
*/
|
||||
export async function sendCheckedMessages() {
|
||||
if (!state.dbc) { showToast('请先导入 DBC', 'warning'); return; }
|
||||
var keys = Object.keys(state.checkedKeys);
|
||||
@@ -140,10 +178,10 @@ export async function sendCheckedMessages() {
|
||||
updateBatchUi();
|
||||
var channel = parseInt(el.defaultChannel.value, 10) || 0;
|
||||
var ok = 0, fail = 0;
|
||||
/* BATCH 间隔:GD32 SPI 从机用 RBNE 中断逐字节接收,无 RX DMA,
|
||||
* 连续高频 CS 事务易触发 RXORERR -> 帧字节丢失 -> CRC 失败 -> 0xEE。
|
||||
* 30ms 间隔给 GD32 足够时间完成 onCsRelease 清理 + EXTI 重新待命。 */
|
||||
var BATCH_INTERVAL_MS = 30;
|
||||
var silent = { silent: true };
|
||||
|
||||
/* 先全部编码,避免发到一半才因编码失败打断节奏 */
|
||||
var prepared = [];
|
||||
for (var i = 0; i < keys.length; i++) {
|
||||
var key = keys[i];
|
||||
var msg = null;
|
||||
@@ -166,47 +204,100 @@ export async function sendCheckedMessages() {
|
||||
idHex: idHex,
|
||||
dlc: msg.dlc,
|
||||
dataHex: '',
|
||||
resultText: '编码失败: ' + err.message
|
||||
});
|
||||
resultText: msg.name + ' · 编码失败: ' + err.message
|
||||
}, silent);
|
||||
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
|
||||
});
|
||||
var dlc = (typeof msg.dlc === 'number') ? msg.dlc : dataBytes.length;
|
||||
prepared.push({
|
||||
msg: msg,
|
||||
idHex: idHex,
|
||||
dlc: dlc,
|
||||
dataHex: dataHex,
|
||||
frame: { id: msg.id, dlc: dlc, data: dataHex }
|
||||
});
|
||||
}
|
||||
|
||||
/* 分块批量接口;不支持时整段回退为无间隔单帧 */
|
||||
var useBatchApi = true;
|
||||
for (var off = 0; off < prepared.length; ) {
|
||||
var chunk = prepared.slice(off, off + BATCH_CHUNK);
|
||||
off += chunk.length;
|
||||
var frames = chunk.map(function (p) { return p.frame; });
|
||||
var results = null;
|
||||
|
||||
if (useBatchApi) {
|
||||
try {
|
||||
results = await sendCanBatchRequest(channel, frames);
|
||||
if (results.length !== chunk.length) {
|
||||
throw new Error('results 长度不匹配');
|
||||
}
|
||||
} catch (err) {
|
||||
useBatchApi = false;
|
||||
results = null;
|
||||
/* 本 chunk 改单帧紧接 */
|
||||
}
|
||||
}
|
||||
/* 帧间间隔,避免 GD32 SPI RXORERR(最后一帧不需要等) */
|
||||
if (i < keys.length - 1) {
|
||||
await new Promise(function (r) { setTimeout(r, BATCH_INTERVAL_MS); });
|
||||
|
||||
if (results) {
|
||||
for (var r = 0; r < chunk.length; r++) {
|
||||
var item = chunk[r];
|
||||
var result = results[r] || { status: 'error', code: -3 };
|
||||
if (result.status === 'ok') ok++; else fail++;
|
||||
addHistoryEntry({
|
||||
time: formatTime(new Date()),
|
||||
mode: 'BATCH',
|
||||
ok: result.status === 'ok',
|
||||
channel: channel,
|
||||
idHex: item.idHex,
|
||||
dlc: item.dlc,
|
||||
dataHex: item.dataHex || '',
|
||||
resultText: item.msg.name + ' · ' + buildSendResponseText(result)
|
||||
}, silent);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
/* 回退:逐帧 HTTP,帧间无 sleep、无重试 */
|
||||
for (var s = 0; s < chunk.length; s++) {
|
||||
var one = chunk[s];
|
||||
try {
|
||||
var oneResult = await sendCanRequest({
|
||||
ch: channel,
|
||||
id: one.frame.id,
|
||||
dlc: one.frame.dlc,
|
||||
data: one.frame.data
|
||||
});
|
||||
if (oneResult.status === 'ok') ok++; else fail++;
|
||||
addHistoryEntry({
|
||||
time: formatTime(new Date()),
|
||||
mode: 'BATCH',
|
||||
ok: oneResult.status === 'ok',
|
||||
channel: channel,
|
||||
idHex: one.idHex,
|
||||
dlc: one.dlc,
|
||||
dataHex: one.dataHex || '',
|
||||
resultText: one.msg.name + ' · ' + buildSendResponseText(oneResult)
|
||||
}, silent);
|
||||
} catch (err) {
|
||||
fail++;
|
||||
addHistoryEntry({
|
||||
time: formatTime(new Date()),
|
||||
mode: 'BATCH',
|
||||
ok: false,
|
||||
channel: channel,
|
||||
idHex: one.idHex,
|
||||
dlc: one.dlc,
|
||||
dataHex: one.dataHex || '',
|
||||
resultText: one.msg.name + ' · ' + err.message
|
||||
}, silent);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
renderHistory();
|
||||
scheduleCanPersist();
|
||||
state.batchInFlight = false;
|
||||
updateBatchUi();
|
||||
var tone = fail === 0 ? 'success' : (ok === 0 ? 'error' : 'warning');
|
||||
|
||||
+31
-4
@@ -6,8 +6,9 @@
|
||||
*
|
||||
* 依赖:dbc.js(state/el/padHex/bytesToHex/cssEscape/findSignal)、common/utils.js(escapeHtml)。
|
||||
*/
|
||||
import { state, el, padHex, bytesToHex, cssEscape, findSignal } from './dbc.js';
|
||||
import { escapeHtml as sanitizeHtml } from '../common/utils.js';
|
||||
import { state, el, padHex, bytesToHex, cssEscape, findSignal } from './dbc.js?v=20260717t';
|
||||
import { escapeHtml as sanitizeHtml } from '../common/utils.js?v=20260717t';
|
||||
import { scheduleCanPersist } from './persist.js?v=20260717t';
|
||||
|
||||
// ==================== 信号值存取 ====================
|
||||
/** @description 确保报文有 signalValues bag,并为每个信号填充默认值。返回 bag。 */
|
||||
@@ -182,6 +183,7 @@ function handleSignalInputEvent(evt) {
|
||||
if (inlineGrid) renderInlineSignalGrid(message, inlineGrid);
|
||||
}
|
||||
if (key === state.selectedMessageKey) updateEncodedPreview();
|
||||
scheduleCanPersist();
|
||||
}
|
||||
|
||||
function bindSignalInputs(container, message) {
|
||||
@@ -240,10 +242,21 @@ function renderByteStrip(bytes) {
|
||||
// ==================== 信号编解码(raw ↔ physical) ====================
|
||||
/** @description 将报文 + 信号值编码为 Uint8Array。 */
|
||||
export function encodeMessageData(message, signalValues) {
|
||||
var bytes = new Uint8Array(message.dlc);
|
||||
var dlc = message && message.dlc != null ? Number(message.dlc) : 0;
|
||||
if (!Number.isFinite(dlc) || dlc < 0) dlc = 0;
|
||||
if (dlc > 64) dlc = 64;
|
||||
/* DLC=0:无数据区(如 CANFD_BOOT),不写信号,直接空帧 */
|
||||
if (dlc === 0) {
|
||||
return new Uint8Array(0);
|
||||
}
|
||||
var bytes = new Uint8Array(dlc);
|
||||
var activeSignals = getActiveSignals(message);
|
||||
for (var i = 0; i < activeSignals.length; i++) {
|
||||
var signal = activeSignals[i];
|
||||
/* 信号完全落在 DLC 外则跳过,避免把空帧/定义异常拖成批量失败 */
|
||||
if (!signalFitsInDlc(signal, dlc)) {
|
||||
continue;
|
||||
}
|
||||
var parsed = parseSignalInput(signal, signalValues[signal.name]);
|
||||
if (signal.valueType === 'float' || signal.valueType === 'double') {
|
||||
writeFloatSignal(bytes, signal, parsed.numeric);
|
||||
@@ -254,6 +267,19 @@ export function encodeMessageData(message, signalValues) {
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/** @description 粗判信号位域是否至少有一部分落在 dlc 字节内(越界整段则跳过)。 */
|
||||
function signalFitsInDlc(signal, dlc) {
|
||||
if (!signal || dlc <= 0) return false;
|
||||
var maxBit = dlc * 8 - 1;
|
||||
if (signal.byteOrder === 'little') {
|
||||
var end = signal.startBit + signal.length - 1;
|
||||
return signal.startBit >= 0 && signal.startBit <= maxBit && end <= maxBit;
|
||||
}
|
||||
/* Motorola:用起始位所在字节粗判;更严的检查仍在 setBitByIndex */
|
||||
var startByte = Math.floor(signal.startBit / 8);
|
||||
return startByte >= 0 && startByte < dlc && signal.length > 0;
|
||||
}
|
||||
|
||||
function parseSignalInput(signal, inputValue) {
|
||||
var value = inputValue;
|
||||
if (value === '' || value == null) value = getDefaultSignalValue(signal);
|
||||
@@ -377,7 +403,8 @@ function setBitByIndex(bytes, bitIndex, bitValue) {
|
||||
var byteIndex = Math.floor(bitIndex / 8);
|
||||
var bitInByte = bitIndex % 8;
|
||||
if (byteIndex < 0 || byteIndex >= bytes.length) {
|
||||
throw new Error('信号写入越界,bit=' + bitIndex);
|
||||
throw new Error('信号写入越界,bit=' + bitIndex +
|
||||
'(缓冲区 ' + bytes.length + ' 字节 / DLC=' + bytes.length + ')');
|
||||
}
|
||||
if (bitValue) bytes[byteIndex] |= (1 << bitInByte);
|
||||
else bytes[byteIndex] &= ~(1 << bitInByte);
|
||||
|
||||
Reference in New Issue
Block a user