feat(main): 添加启动模块自检与汇总功能

- 新增 SystemBoot 类用于跟踪各模块初始化状态,提供位图标记与报告功能
- 在 main.cpp 中重构初始化流程,按顺序初始化各模块并记录状态
- 添加 system_boot.cpp 模块文件到构建配置
- CAN反馈任务增加冷启动延迟,避免与GD32初始化窗口冲突

refactor(ota): 优化OTA协调器线程安全机制

- 修改 lock()/unlock() 方法返回take结果,确保mutex正确释放
- 修复多任务并发时mutex死锁问题,避免vTaskPriorityDisinherit断言
- 统一finalize失败后的状态复位逻辑,确保协调器回到IDLE状态
- 优化忙状态超时检测与自愈机制

perf(boot): 优化冷启动时序与资源管理

- 简化README中冷启动优化说明,聚焦核心逻辑
- 优化BOD阈值设置为LVL5,改进电源稳定性
- 增加BLE OTA工作线程优先级,减少丢包情况
- 优化堆告警节流机制,在OTA期间降低告警频率

fix(relay): 改进GD32应用存活检测逻辑

- 调整GD32 APP存活验证时序,增加会话建立验证
- 修复HELLO建立session后未验证role的问题
- 优化FINISH后等待APP启动的静默时间与重试机制
- 统一GD32探测逻辑到OTA中继任务

refactor(server): 优化HTTP服务端内存与性能

- 使用静态缓冲区替代动态分配,减少堆碎片化风险
- 优化CAN批量发送期间暂停反馈任务的机制
- 调整BLE连接参数请求逻辑,提升传输性能
- 改进错误处理流程,避免堆紧张时的额外分配

bump(version): 更新固件版本至0.3.50

- 更新版本号从0.3.49到0.3.50
- 更新编译时间戳与Git哈希值
This commit is contained in:
小龙虾
2026-07-17 02:02:36 +08:00
parent 6364eacfb1
commit 1b10f89d8c
39 changed files with 851 additions and 331 deletions
+6 -6
View File
@@ -4,11 +4,11 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ESP32 CAN 通信控制台</title>
<link rel="stylesheet" href="css/base.css?v=20260717w">
<link rel="stylesheet" href="css/layout.css?v=20260717w">
<link rel="stylesheet" href="css/components.css?v=20260717w">
<link rel="stylesheet" href="css/can.css?v=20260717w">
<link rel="stylesheet" href="css/responsive.css?v=20260717w">
<link rel="stylesheet" href="css/base.css?v=20260717x">
<link rel="stylesheet" href="css/layout.css?v=20260717x">
<link rel="stylesheet" href="css/components.css?v=20260717x">
<link rel="stylesheet" href="css/can.css?v=20260717x">
<link rel="stylesheet" href="css/responsive.css?v=20260717x">
</head>
<body class="can-page">
<div class="container can-container">
@@ -318,6 +318,6 @@
<span id="toastMsg">操作成功</span>
</div>
<script type="module" src="js/can.js?v=20260717w"></script>
<script type="module" src="js/can.js?v=20260717x"></script>
</body>
</html>
+167 -1
View File
@@ -122,6 +122,9 @@
font-size: 20px; font-weight: 700; word-break: break-word;
}
.metric .v.highlight { color: var(--accent); }
.metric .v.ok { color: var(--green); }
.metric .v.warn { color: var(--amber); }
.metric .v.err { color: var(--red); }
.status-kv-grid {
display: grid;
@@ -150,7 +153,134 @@
word-break: break-word;
font-family: var(--font-mono);
}
.status-section-title { margin-top: 14px; margin-bottom: 8px; font-size: 11px; color: var(--text-label); text-transform: uppercase; letter-spacing: .08em; }
/* === 健康状态 → 启动自检(点击展开,非常驻) === */
.badge-stat-click {
cursor: pointer;
user-select: none;
transition: border-color .15s ease, background .15s ease;
}
.badge-stat-click:hover,
.badge-stat-click:focus-visible {
outline: none;
border-color: color-mix(in srgb, var(--accent) 40%, var(--border));
background: color-mix(in srgb, var(--accent) 8%, var(--bg-elevated));
}
.badge-stat-click.is-open {
border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
}
.health-boot-hint {
font-size: 9px;
letter-spacing: 0;
text-transform: none;
color: var(--text-dim);
font-weight: 500;
margin-left: 2px;
}
.badge-stat-click.is-open .health-boot-hint {
color: var(--accent);
}
.health-boot-panel {
margin-top: 12px;
padding: 12px 14px;
border-radius: var(--radius-sm);
border: 1px solid var(--border);
background: var(--bg-elevated);
}
.health-boot-panel.collapsed,
.health-boot-panel[hidden] {
display: none;
}
.health-boot-head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
margin-bottom: 6px;
}
.health-boot-title {
font-size: 11px;
color: var(--text-label);
text-transform: uppercase;
letter-spacing: .08em;
font-weight: 600;
}
.health-boot-summary {
font-size: 13px;
font-weight: 700;
color: var(--text);
text-align: right;
}
.health-boot-summary.ok { color: var(--green); }
.health-boot-summary.warn { color: var(--amber); }
.health-boot-summary.err { color: var(--red); }
.health-boot-failed {
font-size: 12px;
color: var(--text-dim);
margin-bottom: 8px;
}
.health-boot-failed b {
color: var(--text);
font-weight: 600;
}
.health-boot-failed b.ok { color: var(--green); }
.health-boot-failed b.warn { color: var(--amber); }
.health-boot-failed b.err { color: var(--red); }
/* === BOOT MODULE CHIPS (/system/status boot) === */
.boot-module-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 4px;
min-height: 8px;
}
.boot-mod {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 4px 8px;
border-radius: 999px;
border: 1px solid var(--border);
background: var(--bg-root);
font-size: 11px;
font-weight: 600;
color: var(--text-dim);
line-height: 1.2;
}
.boot-mod .boot-mod-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--text-dim);
flex-shrink: 0;
}
.boot-mod.ok {
color: var(--green);
border-color: color-mix(in srgb, var(--green) 35%, var(--border));
}
.boot-mod.ok .boot-mod-dot { background: var(--green); }
.boot-mod.fail {
color: var(--red);
border-color: color-mix(in srgb, var(--red) 35%, var(--border));
}
.boot-mod.fail .boot-mod-dot { background: var(--red); }
.boot-mod.fail.optional {
color: var(--amber);
border-color: color-mix(in srgb, var(--amber) 35%, var(--border));
}
.boot-mod.fail.optional .boot-mod-dot { background: var(--amber); }
.status-section-title {
margin-top: 14px;
margin-bottom: 8px;
font-size: 11px;
color: var(--text-label);
text-transform: uppercase;
letter-spacing: .08em;
font-family: var(--font);
font-weight: 600;
}
/* ===== 堆概况 ===== */
.heap-group {
@@ -179,6 +309,7 @@
text-transform: uppercase;
letter-spacing: 0.06em;
margin-bottom: 10px;
font-family: var(--font);
}
.heap-bar-row {
@@ -196,6 +327,7 @@
font-weight: 600;
white-space: nowrap;
color: var(--text);
font-family: var(--font);
}
/* 百分比数值与文案同一行;进度条单独占下一行 */
@@ -208,6 +340,7 @@
text-align: right;
font-variant-numeric: tabular-nums;
color: var(--text-dim);
font-family: var(--font-mono);
}
.heap-bar-track {
@@ -257,6 +390,39 @@
font-size: 11px;
}
/* 抽屉内数值/任务名/JSON 强制等宽,避免被其它样式冲掉 */
.sys-drawer-body .heap-bar-pct,
.sys-drawer-body .heap-detail-row b,
.sys-drawer-body .status-task-name,
.sys-drawer-body .status-task-ratio,
.sys-drawer-body .status-task-meta b,
.sys-drawer-body .status-json {
font-family: var(--font-mono);
}
.sys-drawer-body .status-section-title,
.sys-drawer-body .heap-group-title,
.sys-drawer-body .heap-bar-text {
font-family: var(--font);
color: inherit;
}
.sys-drawer-body .status-section-title {
color: var(--text-label);
font-size: 11px;
font-weight: 600;
}
.sys-drawer-body .heap-bar-text {
color: var(--text);
font-size: 13px;
font-weight: 600;
}
.sys-drawer-body .status-json {
color: var(--text-dim);
font-size: 11px;
line-height: 1.6;
white-space: pre-wrap;
word-break: break-word;
}
.status-task-list {
display: grid;
gap: 8px;
+21 -10
View File
@@ -9,11 +9,11 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>ESP32 OTA 中继控制台</title>
<link rel="stylesheet" href="css/base.css?v=20260716q">
<link rel="stylesheet" href="css/layout.css?v=20260716q">
<link rel="stylesheet" href="css/components.css?v=20260716q">
<link rel="stylesheet" href="css/can.css?v=20260716q">
<link rel="stylesheet" href="css/responsive.css?v=20260716q">
<link rel="stylesheet" href="css/base.css?v=20260718b">
<link rel="stylesheet" href="css/layout.css?v=20260718b">
<link rel="stylesheet" href="css/components.css?v=20260718b">
<link rel="stylesheet" href="css/can.css?v=20260718b">
<link rel="stylesheet" href="css/responsive.css?v=20260718b">
</head>
<body>
<div class="container">
@@ -244,9 +244,11 @@
<div class="v err" id="bConn">离线</div>
<div class="k">连接状态</div>
</div>
<div class="badge-stat">
<div class="badge-stat badge-stat-click" id="healthBadge"
role="button" tabindex="0" title="点击查看启动自检"
aria-expanded="false" aria-controls="healthBootPanel">
<div class="v" id="mHealth">--</div>
<div class="k">健康状态</div>
<div class="k">健康状态 <span class="health-boot-hint">▾ 自检</span></div>
</div>
<div class="badge-stat">
<div class="v err" id="connBadge">offline</div>
@@ -257,6 +259,15 @@
<div class="k">GATT 状态</div>
</div>
</div>
<!-- 启动自检:点健康状态展开,默认折叠 -->
<div class="health-boot-panel collapsed" id="healthBootPanel" hidden>
<div class="health-boot-head">
<span class="health-boot-title">启动自检</span>
<span class="health-boot-summary" id="mBootResult">--</span>
</div>
<div class="health-boot-failed">失败/跳过:<b id="mBootFailed">--</b></div>
<div class="boot-module-list" id="bootModuleList" aria-label="启动模块列表"></div>
</div>
</div>
</div>
@@ -425,8 +436,8 @@
<span id="toastMsg">操作成功</span>
</div>
<script type="module" src="js/app.js?v=20260716q"></script>
<script type="module" src="js/ota/console-mode.js?v=20260716q"></script>
<script type="module" src="js/ble_ota.js?v=20260716q"></script>
<script type="module" src="js/app.js?v=20260718b"></script>
<script type="module" src="js/ota/console-mode.js?v=20260718b"></script>
<script type="module" src="js/ble_ota.js?v=20260718d"></script>
</body>
</html>
+1 -1
View File
@@ -10,7 +10,7 @@ export const UUIDS = {
version: 0x0004,
};
export const UI_BUILD = '2026-07-16-ble-begin-ready';
export const UI_BUILD = '2026-07-18-ble-fix';
export const BLE_OTA_NOTIFY_ACK = 0xA1;
export const BLE_OTA_NOTIFY_FINISH = 0xB1;
export const BLE_OTA_NOTIFY_SHA256 = 0xC1;
+4 -2
View File
@@ -199,8 +199,10 @@ export function handleNotify(event) {
updateProgressFromAck(state.activeTotalSize, state.activePacketTotal, state.lastAckBytes);
}
const ackText = `[ACK] cmd=0x${ackCmd.toString(16).padStart(2, '0').toUpperCase()} seq=${ackSeq} bytes=${state.lastAckBytes} (${formatBytes(state.lastAckBytes)}) raw=${hex}`;
bleLog('ACK', ackText);
/* 传输期少打日志:整窗 ACK 频繁时 hex 刷屏拖慢 UI/主线程 */
if (!state.uploading || (ackSeq % 16) === 0 || ackSeq < 2) {
bleLog('ACK', `cmd=0x${ackCmd.toString(16).padStart(2, '0').toUpperCase()} seq=${ackSeq} bytes=${state.lastAckBytes} (${formatBytes(state.lastAckBytes)})`);
}
// ★ 放宽匹配条件:只要 cmd 匹配 + generation 匹配 + 字节数足够,即接受 ACK
// 不再要求 ackSeq >= expectedSeq,因为 ESP32 可能用较低的 seq 发送 ACK
+1
View File
@@ -238,6 +238,7 @@ export async function uploadFirmware() {
continue;
}
/* Windows WebBT 同时只允许一个 GATT 操作,必须逐包 await,禁止 Promise.all 并发写 */
await state.dataInChar.writeValueWithoutResponse(packet);
}
+12 -16
View File
@@ -20,7 +20,7 @@
import {
state, el, getBaseUrl, getDeviceIp, setDeviceIp, parseDbc, readDbcText, buildSummary,
rebuildMergedDbc, makeDbcSourceId
} from './can/dbc.js?v=20260717w';
} from './can/dbc.js?v=20260717x';
import {
updateDbcFileInfo,
filterMessages,
@@ -30,12 +30,11 @@ import {
recomputeTransmitterFilterEmpty,
updateNodeFilterLabel,
updateBatchUi
} from './can/messages.js?v=20260717w';
} from './can/messages.js?v=20260717x';
import {
ensureMessageDefaults,
renderSelectedMessage,
updateEncodedPreview
} from './can/signals.js?v=20260717w';
} from './can/signals.js?v=20260717x';
import {
renderHistory,
sendSelectedMessage,
@@ -44,15 +43,15 @@ import {
sendManualFrame,
resetManualFields,
loadPreviewToManual
} from './can/send.js?v=20260717w';
} from './can/send.js?v=20260717x';
import {
scheduleCanPersist,
loadCanSession,
bindCanPersistLifecycle
} from './can/persist.js?v=20260717w';
import { escapeHtml as sanitizeHtml, formatUptime } from './common/utils.js?v=20260717w';
import { showToast } from './common/toast.js?v=20260717w';
import { initCanMonitor } from './can_monitor.js?v=20260717w';
} from './can/persist.js?v=20260717x';
import { escapeHtml as sanitizeHtml, formatUptime } from './common/utils.js?v=20260717x';
import { showToast } from './common/toast.js?v=20260717x';
import { initCanMonitor } from './can_monitor.js?v=20260717x';
// ==================== 本地工具 ====================
// 原 can.js 的 formatUptime 输出格式与 common/utils.js 略有差异(d>0 时只到 h),
@@ -63,10 +62,10 @@ import { initCanMonitor } from './can_monitor.js?v=20260717w';
function cacheElements() {
[
'toolIp', 'defaultChannel', 'manualChannel', 'manualId', 'manualDlc', 'manualData',
'dbcDropZone', 'dbcFileInput', 'dbcFileInfo', 'dbcFileName', 'dbcMessageCount',
'dbcDropZone', 'dbcFileInput', 'dbcFileInfo', 'dbcMessageCount',
'dbcSignalCount', 'dbcNodeCount', 'dbcFileSize', 'dbcFileCount', 'dbcFileChips',
'dbcSearch', 'dbcHint', 'clearDbcBtn',
'importDbcBtn', 'replaceDbcBtn',
'importDbcBtn',
'messageList', 'sendSelectedBtn', 'generatedHex', 'generatedBytes', 'encodeHint',
'dbcResp', 'dbcRespText', 'loadPreviewToRawBtn', 'manualHint', 'sendManualBtn',
'resetManualBtn', 'manualResp', 'manualRespText', 'historyList', 'toast', 'toastIco',
@@ -149,11 +148,10 @@ function afterDbcSourcesChanged(opts) {
if (opts.resetSelection || !state.selectedMessageKey) {
selectMessage(state.dbc.messages[0].key);
} else {
renderSelectedMessage();
updateEncodedPreview();
}
} else {
renderSelectedMessage();
updateEncodedPreview();
}
if (el.dbcHint) {
el.dbcHint.hidden = true;
@@ -268,7 +266,6 @@ function bindEvents() {
if (el.manualChannel) el.manualChannel.value = el.defaultChannel.value;
if (el.canChannelPill) el.canChannelPill.textContent = 'CH' + el.defaultChannel.value;
renderMessageList();
renderSelectedMessage();
updateEncodedPreview();
scheduleCanPersist();
});
@@ -277,7 +274,6 @@ function bindEvents() {
if (el.dbcFileInput) el.dbcFileInput.click();
}
if (el.importDbcBtn) el.importDbcBtn.addEventListener('click', openDbcPicker);
if (el.replaceDbcBtn) el.replaceDbcBtn.addEventListener('click', openDbcPicker);
if (el.dbcDropZone) {
el.dbcDropZone.addEventListener('dragover', function (event) {
event.preventDefault();
@@ -389,7 +385,7 @@ function init() {
selectMessage(state.dbc.messages[0].key);
}
} else {
renderSelectedMessage();
updateEncodedPreview();
}
updateBatchUi();
refreshManualHint();
+1 -1
View File
@@ -4,7 +4,7 @@
* 包含:共享 state/el、基础工具、DBC 解析、cssEscape、readDbcText。
*
* 本模块是 can 子系统的基础层,不依赖其他 can 子模块;state/el 在此导出,
* 供 messages/signals/send/feedback 共享(ES Module 的 live binding 保证引用一致)。
* 供 messages/signals/send 共享(ES Module 的 live binding 保证引用一致)。
*/
// ==================== 共享状态 ====================
-10
View File
@@ -1,10 +0,0 @@
/**
* feedback.js - 1Hz 反馈轮询(预留模块)
*
* 重新导出 common/connection.js 的连接管理函数,供 can 入口文件按需使用。
* 1Hz 轮询 /can/feedback 或 ID 快照功能可在本模块按需实现。
*/
import { updateConnectionUi, updateHealthUi, updateHeapAndTaskStatusUi, testConnection, refreshSystemStatusNow } from '../common/connection.js';
// 重新导出 common/connection.js 的连接管理函数,供 can 入口文件按需使用。
export { updateConnectionUi, updateHealthUi, updateHeapAndTaskStatusUi, testConnection, refreshSystemStatusNow };
+5 -6
View File
@@ -5,12 +5,12 @@
*
* 依赖:dbc.js(state/el/padHex/normalizeWhitespace/cssEscape)、
* common/utils.js(escapeHtml/formatBytes)、
* signals.js(renderInlineSignalGrid/ensureMessageDefaults/renderSelectedMessage/updateEncodedPreview)。
* signals.js(renderInlineSignalGrid/ensureMessageDefaults/updateEncodedPreview)。
*/
import { state, el, padHex, normalizeWhitespace, cssEscape } from './dbc.js?v=20260717v';
import { escapeHtml as sanitizeHtml, formatBytes } from '../common/utils.js?v=20260717v';
import { renderInlineSignalGrid, ensureMessageDefaults, renderSelectedMessage, updateEncodedPreview } from './signals.js?v=20260717v';
import { scheduleCanPersist } from './persist.js?v=20260717v';
import { state, el, padHex, normalizeWhitespace, cssEscape } from './dbc.js?v=20260717x';
import { escapeHtml as sanitizeHtml, formatBytes } from '../common/utils.js?v=20260717x';
import { renderInlineSignalGrid, ensureMessageDefaults, updateEncodedPreview } from './signals.js?v=20260717x';
import { scheduleCanPersist } from './persist.js?v=20260717x';
// ==================== DBC 文件信息 ====================
/** @description 更新 DBC 文件信息卡片(文件名/报文数/信号数/节点数/大小)。 */
@@ -395,7 +395,6 @@ export function selectMessage(key) {
state.selectedMessageKey = key;
ensureMessageDefaults(msg);
renderMessageList();
renderSelectedMessage();
updateEncodedPreview();
scheduleCanPersist();
}
+1 -12
View File
@@ -6,7 +6,7 @@
*
* 依赖:dbc.js(state/el/parseDbc/rebuildMergedDbc)
*/
import { state, el, parseDbc, rebuildMergedDbc } from './dbc.js?v=20260717v';
import { state, el, parseDbc, rebuildMergedDbc } from './dbc.js?v=20260717x';
var LS_KEY = 'dcu_can_session_v1';
var MAX_HISTORY = 200;
@@ -135,17 +135,6 @@ export function scheduleCanPersist() {
}, 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 }}
+9 -9
View File
@@ -12,12 +12,12 @@
* 注意:formatTime 保留本地实现(HH:MM:SS,不含毫秒),与 common/utils.js
* 的 formatTime(含毫秒)不同,以保证发送历史时间戳输出格式一致。
*/
import { state, el, getBaseUrl, padHex, bytesToHex, parseNumericId } from './dbc.js?v=20260717w';
import { escapeHtml as sanitizeHtml } from '../common/utils.js?v=20260717w';
import { showToast } from '../common/toast.js?v=20260717w';
import { updateBatchUi } from './messages.js?v=20260717w';
import { ensureMessageDefaults, getSignalValueMap, getSelectedMessage, updateEncodedPreview, encodeMessageData } from './signals.js?v=20260717w';
import { scheduleCanPersist } from './persist.js?v=20260717w';
import { state, el, getBaseUrl, padHex, bytesToHex, parseNumericId } from './dbc.js?v=20260717x';
import { escapeHtml as sanitizeHtml } from '../common/utils.js?v=20260717x';
import { showToast } from '../common/toast.js?v=20260717x';
import { updateBatchUi } from './messages.js?v=20260717x';
import { ensureMessageDefaults, getSignalValueMap, getSelectedMessage, updateEncodedPreview, encodeMessageData } from './signals.js?v=20260717x';
import { scheduleCanPersist } from './persist.js?v=20260717x';
// ==================== 本地工具(保持与原 can.js 一致) ====================
function formatTime(date) {
@@ -109,7 +109,7 @@ async function sendCanBatchRequest(channel, frames) {
if (!json.results || !Array.isArray(json.results)) {
throw new Error('批量响应缺少 results');
}
/* 设备中途 fatal 会 skipped 剩余帧(少分配堆);前端补齐等长 results */
/* 设备可能返回 skipped;补齐与 frames 等长的 results */
var results = json.results.slice();
var need = frames.length - results.length;
if (need > 0) {
@@ -172,12 +172,12 @@ export function renderHistory() {
}
// ==================== 批量发送 ====================
/** 单批最多帧数(与设备 /can/send_batch 上限 128 对齐;一次 HTTP 连发,避免分块成“一阵一阵”) */
/** 单批最多帧数(与设备 /can/send_batch 上限一致) */
var BATCH_CHUNK = 128;
/**
* @description 批量发送所有勾选的报文。
* 优先一次 HTTP /can/send_batch(设备侧帧间 5ms);失败则回退单帧(无额外间隔)。
* 优先 POST /can/send_batch;不支持时回退逐帧 /can/send。
* 批量过程中不重绘历史,结束后统一刷新。
*/
export async function sendCheckedMessages() {
+4 -15
View File
@@ -1,14 +1,13 @@
/**
* signals.js - 信号编辑器渲染 + 信号编解码(raw ↔ physical)
*
* 包含:信号行渲染、信号值存取、编码写入、浮点/整数/Motorola/Intel 位操作、
* renderSelectedMessage shim。
* 包含:行内信号表格、信号值存取、编码写入、浮点/整数/Motorola/Intel 位操作。
*
* 依赖:dbc.js(state/el/padHex/bytesToHex/cssEscape/findSignal)、common/utils.js(escapeHtml)。
*/
import { state, el, padHex, bytesToHex, cssEscape, findSignal } from './dbc.js?v=20260717v';
import { escapeHtml as sanitizeHtml } from '../common/utils.js?v=20260717v';
import { scheduleCanPersist } from './persist.js?v=20260717v';
import { state, el, padHex, bytesToHex, cssEscape, findSignal } from './dbc.js?v=20260717x';
import { escapeHtml as sanitizeHtml } from '../common/utils.js?v=20260717x';
import { scheduleCanPersist } from './persist.js?v=20260717x';
// ==================== 信号值存取 ====================
/** @description 确保报文有 signalValues bag,并为每个信号填充默认值。返回 bag。 */
@@ -81,15 +80,6 @@ export function getActiveSignals(message) {
});
}
// ==================== 旧版 shim ====================
/**
* @description 旧版"顶部独立信号编辑面板"的兼容 shim。
* 该面板已移除,信号编辑现在通过报文行展开完成;
* 仍被 selectMessage、handleDbcFile、clearDbc、handleSignalInputEvent 调用,
* 保留空函数以避免修改所有调用方。
*/
export function renderSelectedMessage() {}
// ==================== 信号行渲染 ====================
/** @description 在报文展开行内渲染信号表格(inline grid)。 */
export function renderInlineSignalGrid(message, host) {
@@ -178,7 +168,6 @@ function handleSignalInputEvent(evt) {
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);
}
+1 -1
View File
@@ -1,7 +1,7 @@
/**
* can_monitor.js - CAN 接收监控(独立页 can_monitor.html 与 can.html 右侧抽屉共用)
*/
import { showToast } from './common/toast.js?v=20260717v';
import { showToast } from './common/toast.js?v=20260717x';
var state = { fbTimer: null, fbAbortCtrl: null };
+100 -4
View File
@@ -49,11 +49,105 @@ export function updateHealthUi(healthCode, freeHeap) {
mh.textContent = health;
mh.className = 'v ' + (healthCode === 0 ? 'ok' : healthCode === 1 ? 'warn' : 'err');
}
ensureHealthBootToggleBound();
}
/**
* 每次 pollStatus 成功后刷新堆/栈/CPU(固定周期约 1000ms,中继期 400ms;
* sample_ms 由设备两次采样墙钟差给出;WiFi 上传 ESP32 期间 pause 不刷)。
*/
/** 健康状态 badge 点击展开/收起启动自检面板(只绑一次)。 */
function ensureHealthBootToggleBound() {
var badge = document.getElementById('healthBadge');
var panel = document.getElementById('healthBootPanel');
if (!badge || !panel || badge.dataset.bootToggleBound === '1') return;
badge.dataset.bootToggleBound = '1';
function toggle() {
var open = panel.classList.contains('collapsed') || panel.hasAttribute('hidden');
if (open) {
panel.hidden = false;
panel.classList.remove('collapsed');
badge.classList.add('is-open');
badge.setAttribute('aria-expanded', 'true');
} else {
panel.hidden = true;
panel.classList.add('collapsed');
badge.classList.remove('is-open');
badge.setAttribute('aria-expanded', 'false');
}
}
badge.addEventListener('click', function (e) {
e.preventDefault();
toggle();
});
badge.addEventListener('keydown', function (e) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggle();
}
});
}
/** 启动自检内容(/system/status.boot);挂在健康状态展开区,非常驻。 */
function updateBootInitUi(data) {
var boot = data && data.boot ? data.boot : null;
var resultEl = document.getElementById('mBootResult');
var failedEl = document.getElementById('mBootFailed');
var listEl = document.getElementById('bootModuleList');
if (!boot) {
if (resultEl) {
resultEl.textContent = '--';
resultEl.className = 'health-boot-summary';
}
if (failedEl) {
failedEl.textContent = '--';
failedEl.className = '';
}
if (listEl) listEl.innerHTML = '';
return;
}
var criticalOk = !!boot.critical_ok;
var allOk = !!boot.all_ok;
var summary = boot.summary || (allOk ? 'OK (all modules)' : (criticalOk ? 'OK (critical)' : 'FAIL'));
var failed = (boot.failed && String(boot.failed).trim()) ? String(boot.failed) : '';
var tone = allOk ? 'ok' : (criticalOk ? 'warn' : 'err');
if (resultEl) {
resultEl.textContent = summary;
resultEl.className = 'health-boot-summary ' + tone;
resultEl.title = 'mask=0x' + (Number(boot.mask) || 0).toString(16);
}
if (failedEl) {
failedEl.textContent = failed || (allOk ? '无' : '--');
failedEl.className = failed ? tone : 'ok';
}
if (listEl) {
var modules = Array.isArray(boot.modules) ? boot.modules : [];
if (!modules.length) {
listEl.innerHTML = '';
} else {
listEl.innerHTML = modules.map(function (m) {
var ok = !!m.ok;
var critical = !!m.critical;
var cls = 'boot-mod ' + (ok ? 'ok' : (critical ? 'fail' : 'fail optional'));
var tip = (m.name || '') + (ok ? ' 成功' : ' 失败') + (critical ? ' · 关键' : ' · 非关键');
return '<span class="' + cls + '" title="' + escapeHtml(tip) + '">' +
'<span class="boot-mod-dot"></span>' +
escapeHtml(m.name || '?') +
'</span>';
}).join('');
}
}
if (!window._bootInitLogged) {
window._bootInitLogged = true;
var logLine = '启动自检: ' + summary + (failed ? ' · 失败/跳过: ' + failed : '');
try {
addLog(allOk ? 'I' : (criticalOk ? 'W' : 'E'), 'BOOT', logLine);
} catch (_) {}
}
ensureHealthBootToggleBound();
}
export function updateHeapAndTaskStatusUi(data) {
if (!data) return;
var ota = data.ota || {};
@@ -62,6 +156,8 @@ export function updateHeapAndTaskStatusUi(data) {
if (el) el.textContent = value;
};
updateBootInitUi(data);
// ---- 堆概况:已用 / 总计 + 占用条 ----
function setFill(id, pct) {
var el = document.getElementById(id);
+25 -1
View File
@@ -208,7 +208,13 @@ export function updateSystemStatusUi(data) {
var displayRelayState = isGd32Complete ? 5 : (isGd32Relaying && rs === 0 ? 1 : rs);
if (window.currentOtaTarget !== 'esp32') { updateStagesFromRelayState(displayRelayState); }
if (isGd32Error && (window.currentOtaTarget === 'gd32' || window.otaSessionTarget === 'gd32')) {
/* 本会话正在 GD32 路径(含仅 RELAYING 而 target 被清的情况) */
var gd32Session =
window.otaSessionState === OtaSessionState.RELAYING ||
window.currentOtaTarget === 'gd32' ||
window.otaSessionTarget === 'gd32';
if (isGd32Error && gd32Session) {
var failDetail = 'GD32 OTA 失败';
if (relayTotal > 0) {
failDetail += '(已转发 ' + relaySeq + '/' + relayTotal +
@@ -218,6 +224,7 @@ export function updateSystemStatusUi(data) {
}
window.cleanupAfterOtaFailure(failDetail);
window.pollLogs();
return;
} else if (isGd32Error) {
// 非当前 OTA 会话的错误:仅标记阶段指示器,不触发完整清理
updateStagesFromRelayState(255);
@@ -233,6 +240,23 @@ export function updateSystemStatusUi(data) {
if (isGd32Relaying || (rs >= 1 && rs <= 4)) {
window.gd32RelaySessionLive = true;
}
/*
* 设备侧 ERROR 常被 status 软清为 IDLE,前端收不到 code=8。
* 已见真实中继、设备已回 IDLE、且未宣布完成 → 按失败收尾,释放会话。
*/
var deviceIdleAfterRelay =
(sysStateCode === 0 || sysStateCode === 2) &&
(rs === 0 || rs === 5) &&
window.gd32RelaySessionLive &&
!window.gd32CompleteAnnounced;
if (deviceIdleAfterRelay) {
window.cleanupAfterOtaFailure(
'GD32 中继已结束但未成功(设备已回 IDLE' +
(relayTotal > 0 ? ',已转发 ' + relaySeq + '/' + relayTotal : '') + ')'
);
window.pollLogs();
return;
}
/* 包进度:进入 FINISHING/COMPLETE 时先补 100%,再走完成/收尾 UI */
if (window.gd32RelaySessionLive && relayTotal > 0 &&
(relaySeq >= relayTotal || rs >= 4 || isGd32Complete)) {
+26 -5
View File
@@ -58,6 +58,31 @@ export async function startUpload(chip) {
if (!file) return;
// ★ 状态机守卫:用统一的会话活跃判断替代裸 isUploading。
// FAILED/DONE 会话需要先复位才能发起新上传(下方 otaSessionReset)。
// RELAYING 可能因 ERROR 被设备软清为 IDLE 而前端未收尾,先探测再决定。
if (otaSessionActive()) {
var busyState = window.otaSessionState;
if (busyState === OtaSessionState.RELAYING || busyState === OtaSessionState.VERIFYING) {
try {
var staleProbe = await fetch(window.getBaseUrl() + '/system/status', {
signal: AbortSignal.timeout(2000)
});
if (staleProbe.ok) {
var staleSt = await staleProbe.json();
var so = staleSt.ota || {};
var ssc0 = Number.isFinite(so.sys_state_code) ? so.sys_state_code : -1;
var rs0 = Number.isFinite(so.relay_state) ? so.relay_state : -1;
/* 设备已空闲/完成/错误残留:前端会话是陈旧活跃态,强制清掉 */
var deviceFree = (ssc0 === 0 || ssc0 === 2 || ssc0 === 4 || ssc0 === 8) &&
(rs0 === 0 || rs0 === 5 || rs0 === 255 || rs0 < 0);
if (deviceFree) {
addLog('W', '上传', '检测到陈旧会话 ' + busyState +
'(设备已 ' + (so.sys_state || ssc0) + '),自动清理后继续');
otaSessionReset();
}
}
} catch (_) { /* 探测失败则仍按占用拒绝 */ }
}
}
if (otaSessionActive()) {
var busyChip = (window.otaSessionTarget || window.activeUploadChip || window.flashTarget || chip || 'ota').toUpperCase();
addLog('W', '上传', '已有 ' + busyChip + ' OTA 会话进行中 (' + window.otaSessionState + '),拒绝新的 ' + chip.toUpperCase() + ' 上传');
@@ -70,11 +95,7 @@ export async function startUpload(chip) {
window.otaSessionTarget = chip;
window.pausePollingForUpload();
// ★ 必须先抢锁再 await:守卫同步设置,await 期间重复点击才能被拦下。
// 旧实现把 isUploading 设在 await handle.getFile() 之后 → 用户在 BLE 期间
// 狂点 WiFi 上传按钮时,多个 startUpload 都能通过守卫,最终并发 xhr.send,
// 设备端互斥被绕过、403 路径走漏。
// ★ 状态机:进入 UPLOADING 态(内部同步 isUploading = true)
/* 先进入 UPLOADING(同步 isUploading),再 await 读文件,防止重复提交 */
otaSessionTransition(OtaSessionState.UPLOADING);
window.activeUploadChip = chip;
/* 新上传:清掉上次完成标志,避免立刻被 status 残留判成“又完成了” */
+30
View File
@@ -159,9 +159,39 @@ export function startRelayProgressWatch() {
if (rstate >= 1 && rstate <= 4) {
window.gd32RelaySessionLive = true;
}
/* 中继失败:必须把前端会话从 RELAYING 拉到 FAILED,否则无法新开上传 */
if (code === 8 || rstate === 255) {
stopRelayProgressWatch();
window.pollLogs();
if (window.otaSessionState === OtaSessionState.RELAYING ||
window.otaSessionState === OtaSessionState.UPLOADING) {
var failMsg = 'GD32 中继失败(relay_state=' + rstate +
', sys=' + (o.sys_state || code) + ')';
if (typeof window.cleanupAfterOtaFailure === 'function') {
window.cleanupAfterOtaFailure(failMsg);
} else {
otaSessionTransition(OtaSessionState.FAILED, failMsg);
}
}
return;
}
/*
* ERROR 可能在 status 路径被软清为 IDLE,前端收不到 code=8。
* 本会话已见过真实中继,设备却已回 IDLE 且未宣布完成 → 按失败清理。
*/
var idleAfterRelay = (code === 0 || code === 2) &&
(rstate === 0 || rstate === 5) &&
window.gd32RelaySessionLive &&
!window.gd32CompleteAnnounced &&
window.otaSessionState === OtaSessionState.RELAYING;
if (idleAfterRelay) {
stopRelayProgressWatch();
window.pollLogs();
if (typeof window.cleanupAfterOtaFailure === 'function') {
window.cleanupAfterOtaFailure('GD32 中继已结束但未成功(设备已回 IDLE)');
} else {
otaSessionTransition(OtaSessionState.FAILED, 'GD32 中继已结束但未成功');
}
return;
}
/*