feat(ble): 完善BLE OTA架构并优化性能

- 实现BLE OTA数据路径分离:添加ble_ota_data.cpp处理重排序/工作线程/DATA_IN,
  和ble_ota_host.cpp处理GATT/GAP/广播/主机功能
- 将滑动ACK窗口从32增大到64,实测Windows WebBT环境下固件传输速度提升约10%
  (从40KB/s提升到44KB/s),内存消耗约30KB重排序缓冲区
- 优化调试日志级别,将OTA相关信息从INFO调整为DBG以减少日志冗余

refactor(can): 改进CAN反馈监听机制

- 将CAN反馈任务从1Hz主动轮询模式改为被动事件监听模式,
  使用SpiMaster::takeEvent()替代主动发送0xC1轮询请求
- 更新相关头文件注释说明架构变更,将"周期性轮询"改为"被动接收推送"
- 修改调试日志级别,将暂停/恢复状态日志从INFO调整为DBG
This commit is contained in:
小龙虾
2026-07-18 01:16:06 +08:00
parent 1b10f89d8c
commit b807645a20
47 changed files with 4361 additions and 2890 deletions
+1 -1
View File
@@ -318,6 +318,6 @@
<span id="toastMsg">操作成功</span>
</div>
<script type="module" src="js/can.js?v=20260717x"></script>
<script type="module" src="js/can.js?v=20260719b"></script>
</body>
</html>
-4
View File
@@ -142,10 +142,6 @@
letter-spacing: .12em;
text-transform: uppercase;
}
.spi-grid {
display: grid; grid-template-columns: 80px 1fr 80px 80px; gap: 10px;
}
/* === CAN WORKSPACE PAGE === */
/* 右侧监控抽屉 translate 出屏时,防止 body 被撑出横向滚动条 */
body.can-page {
+7 -7
View File
@@ -12,7 +12,7 @@
<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/can.css?v=20260718f">
<link rel="stylesheet" href="css/responsive.css?v=20260718b">
</head>
<body>
@@ -211,9 +211,9 @@
<div class="stage-node" id="s1"><div class="stage-dot">1</div><div class="stage-label">认证握手</div><div class="stage-detail" id="sd1"></div></div>
<div class="stage-node" id="s2"><div class="stage-dot">2</div><div class="stage-label">传输协商</div><div class="stage-detail" id="sd2"></div></div>
<div class="stage-node" id="s3"><div class="stage-dot">3</div><div class="stage-label">数据转发</div><div class="stage-detail" id="sd3"></div></div>
<div class="stage-node" id="s4"><div class="stage-dot">4</div><div class="stage-label">传输完成</div><div class="stage-detail" id="sd4"></div></div>
<div class="stage-node" id="s5"><div class="stage-dot">5</div><div class="stage-label">OTA 刷写</div><div class="stage-detail" id="sd5"></div></div>
<div class="stage-node" id="s6"><div class="stage-dot">6</div><div class="stage-label">校验验证</div><div class="stage-detail" id="sd6"></div></div>
<div class="stage-node" id="s4"><div class="stage-dot">4</div><div class="stage-label">收尾验证</div><div class="stage-detail" id="sd4"></div></div>
<div class="stage-node" id="s5"><div class="stage-dot">5</div><div class="stage-label">APP 启动</div><div class="stage-detail" id="sd5"></div></div>
<div class="stage-node" id="s6"><div class="stage-dot">6</div><div class="stage-label">OTA完成</div><div class="stage-detail" id="sd6"></div></div>
</div>
<div class="stage-status-bar" id="stageStatusBar">
<span class="ssb-dot" id="stageStatusDot"></span>
@@ -436,8 +436,8 @@
<span id="toastMsg">操作成功</span>
</div>
<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>
<script type="module" src="js/app.js?v=20260719b"></script>
<script type="module" src="js/ota/console-mode.js?v=20260719b"></script>
<script type="module" src="js/ble_ota.js?v=20260719b"></script>
</body>
</html>
+23 -25
View File
@@ -49,24 +49,6 @@ function formatPercent(value) {
if (!Number.isFinite(value)) return '--';
return value.toFixed(1).replace(/\.0$/, '') + '%';
}
function formatHttpErrorDetail(text) {
var detail = (text || '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
if (detail.length > 180) detail = detail.slice(0, 177) + '...';
return detail;
}
function isOtaBusyError(status, detail) {
var text = (detail || '').toLowerCase();
return status === 403 ||
status === 409 ||
status === 423 ||
text.indexOf('busy') >= 0 ||
text.indexOf('occupied') >= 0 ||
text.indexOf('another ota') >= 0 ||
text.indexOf('invalid state') >= 0 ||
text.indexOf('占用') >= 0 ||
text.indexOf('互斥') >= 0 ||
text.indexOf('系统忙') >= 0;
}
function getEspIp() {
var el = document.getElementById('espIp');
var ip = el && el.value ? el.value.trim() : '';
@@ -116,9 +98,11 @@ function applyDisconnectedRuntimeState() {
function applyRebootingRuntimeState() {
var pc = document.getElementById('pillConn');
var pct = document.getElementById('pillConnTxt');
pc.className = 'pill processing';
var elapsed = Math.round((Date.now() - window.rebootStartTime) / 1000);
pct.textContent = '重启中 ' + elapsed + 's';
if (pc) pc.className = 'pill processing';
if (pct) {
var elapsed = Math.round((Date.now() - window.rebootStartTime) / 1000);
pct.textContent = '重启中 ' + elapsed + 's';
}
var cs = document.getElementById('connStatus');
var cst = document.getElementById('connStatusTxt');
if (cs) cs.className = 'conn-indicator warn';
@@ -136,8 +120,8 @@ function applyUploadingRuntimeState() {
}
var pc = document.getElementById('pillConn');
var pct = document.getElementById('pillConnTxt');
pc.className = 'pill processing';
pct.textContent = '上传中';
if (pc) pc.className = 'pill processing';
if (pct) pct.textContent = '上传中';
var cs = document.getElementById('connStatus');
var cst = document.getElementById('connStatusTxt');
if (cs) cs.className = 'conn-indicator warn';
@@ -146,6 +130,21 @@ function applyUploadingRuntimeState() {
if (bc) bc.textContent = '上传中';
if (bc) bc.className = 'v warn';
}
/** 设备忙(OTA/中继/重启期间不响应轮询)的连接胶囊/状态条运行时态。
* 与上面三兄弟同模式,区别是不改进度/阶段,只标记连接胶囊为 processing + warn。 */
function applyDeviceBusyRuntimeState(pillText, statusText, badgeText) {
var pc = document.getElementById('pillConn');
var pct = document.getElementById('pillConnTxt');
if (pc) pc.className = 'pill processing';
if (pct) pct.textContent = pillText || '设备忙';
var cs = document.getElementById('connStatus');
var cst = document.getElementById('connStatusTxt');
if (cs) cs.className = 'conn-indicator warn';
if (cst) cst.textContent = statusText || '设备忙(OTA/重启中),保持 1s 轮询';
var bc = document.getElementById('bConn');
if (bc) bc.textContent = badgeText || '设备忙';
if (bc) bc.className = 'v warn';
}
// ==================== 挂载 ota/* 函数到 window(供其他模块通过 window.* 调用) ====================
window.OtaSessionState = OtaSessionState;
@@ -223,8 +222,7 @@ window.resetStageFlow = resetStageFlow;
window.applyDisconnectedRuntimeState = applyDisconnectedRuntimeState;
window.applyRebootingRuntimeState = applyRebootingRuntimeState;
window.applyUploadingRuntimeState = applyUploadingRuntimeState;
window.formatHttpErrorDetail = formatHttpErrorDetail;
window.isOtaBusyError = isOtaBusyError;
window.applyDeviceBusyRuntimeState = applyDeviceBusyRuntimeState;
// ==================== 顶层副作用(原 app.js 末尾的初始化) ====================
setupDropZone('uploadZoneEsp32', 'esp32');
+4 -2
View File
@@ -10,11 +10,13 @@ export const UUIDS = {
version: 0x0004,
};
export const UI_BUILD = '2026-07-18-ble-fix';
export const UI_BUILD = '2026-07-18-ble-log-trim';
export const BLE_OTA_NOTIFY_ACK = 0xA1;
export const BLE_OTA_NOTIFY_FINISH = 0xB1;
export const BLE_OTA_NOTIFY_SHA256 = 0xC1;
export const BLE_OTA_ACK_WINDOW = 32;
// ★ 须与后端 main/include/app_config.h BLE_OTA_ACK_WINDOW 保持一致
// 64:实测 Windows 瓶颈在每窗口 ACK 往返,窗口 32→64 提速 ~10%,再试 96 无收益。
export const BLE_OTA_ACK_WINDOW = 64;
export const BLE_OTA_FINISH_ESP32_DONE = 0x02;
export const BLE_OTA_FINISH_REBOOT_NOW = 0x07;
export const BLE_OTA_FINISH_GD32_RELAY_START = 0x10;
+68 -60
View File
@@ -1,17 +1,27 @@
// ==================== BLE OTA 文件准备与分包 ====================
import { state, els, bleLog, currentTarget, currentPayloadSize, setConnectionState } from './state.js';
//
// ★ 上传中拦截策略(全文件统一):file.js 是 BLE 通道文件操作的唯一入口,
// transfer.js / state.js / session.js / 外部 bleOta.notifyFileChange 都汇聚到这里。
// isBleUploading()(state.js)= state.uploading || state.autoFlashing;
// window.isUploading = WiFi 通道 OTA 会话是否处于 UPLOADING(ota/session.js 维护)。
// 两者任一为真即视为上传中,禁止重置进度条/阶段/清空文件,避免覆盖进行中的 OTA。
import { state, els, bleLog, currentTarget, currentPayloadSize, setConnectionState, isBleUploading } from './state.js';
import { setBleStage, setBleStageText, resetBleStages } from './stages.js';
import { setProgress } from './transfer.js';
import { hideBleVerifyInfo } from './verify.js';
import { formatBytes } from '../common/utils.js';
import { formatBytes, bytesToHex } from '../common/utils.js';
import { showToast } from '../common/toast.js';
/** 任一通道上传中(BLE 或 WiFi)。focus/change 等不分通道的事件用此判定。 */
function isAnyChannelUploading() {
return isBleUploading() || (typeof window.isUploading !== 'undefined' && !!window.isUploading);
}
export function updateFileInfo(file) {
// ★ 上传进行中禁止重置进度/阶段:目标切换、模式切换、syncBleFileFromSelectedTarget
// 等事件会在上传期间调用本函数,若不拦截会触发 finalizeFileInfo → setProgress(0, ...)
// 把进度条从"已确认 N/M 包"错误地刷回"等待选择文件 0%"。
var isUploading = state.uploading || state.autoFlashing;
if (isUploading && file && state.selectedFile) {
if (isBleUploading() && file && state.selectedFile) {
// 上传中且文件未变:仅刷新元数据(文件名/大小等),不碰进度条和阶段
bleLog('FILE', '上传进行中,跳过 updateFileInfo 进度重置', 'D');
return Promise.resolve();
@@ -33,13 +43,12 @@ export function updateFileInfo(file) {
else els.fileInfo.classList.remove('show');
}
// ★ HEX → BIN 自动转换:复用 app.js 公共函数 convertHexFileToBin
if (file && file.name.toLowerCase().endsWith('.hex') && typeof convertHexFileToBin === 'function') {
// ★ HEX → BIN 自动转换:复用 app.js 公共函数 window.convertHexFileToBin
if (file && file.name.toLowerCase().endsWith('.hex') && typeof window.convertHexFileToBin === 'function') {
return convertHexFileToBin(file).then(function(result) {
if (!result.converted) {
bleLog('FILE', file.name + ' 扩展名为 .hex 但内容不像 HEX 格式,按原始文件处理', 'W');
finalizeFileInfo(file);
computeFileSha256(file);
finalizeAndHash(file);
return;
}
state.selectedFile = result.file;
@@ -52,8 +61,7 @@ export function updateFileInfo(file) {
els.fileSize.textContent = formatBytes(result.file.size);
var newPackets = Math.ceil(result.file.size / payloadSize);
els.packetCount.textContent = String(newPackets);
finalizeFileInfo(result.file);
computeFileSha256(result.file);
finalizeAndHash(result.file);
}).catch(function(err) {
bleLog('FILE', 'HEX 解析失败: ' + err.message, 'E');
showToast('HEX 文件解析失败: ' + err.message, 'error');
@@ -65,16 +73,20 @@ export function updateFileInfo(file) {
}
// 非 HEX 文件直接完成
finalizeAndHash(file);
return Promise.resolve();
}
/** finalizeFileInfo + computeFileSha256 的统一出口:HEX 转换完成分支与非 HEX 分支共用。 */
function finalizeAndHash(file) {
finalizeFileInfo(file);
computeFileSha256(file);
return Promise.resolve();
}
function finalizeFileInfo(file) {
// ★ 上传进行中禁止重置进度条/阶段:防止目标切换等事件在上传期间
// 通过 finalizeFileInfo → setProgress(0, '等待选择文件') 覆盖上传进度。
var isUploading = state.uploading || state.autoFlashing;
if (isUploading) {
if (isBleUploading()) {
bleLog('FILE', '上传进行中,跳过 finalizeFileInfo 进度重置', 'D');
return;
}
@@ -82,10 +94,6 @@ function finalizeFileInfo(file) {
if (file) {
setProgress(0, `已选择 ${file.name}`);
bleLog('FILE', `已选择: ${file.name} (${formatBytes(file.size)}), 目标=${currentTarget().label}`);
if (state.server) {
setBleStage(3, '计算哈希...', 'active');
setBleStageText('正在计算文件 SHA-256...');
}
} else {
setProgress(0, '等待选择文件');
if (state.server) {
@@ -96,42 +104,51 @@ function finalizeFileInfo(file) {
}
}
/**
* 计算文件 SHA-256 并推进 BLE stage 流。
* stage 推进逻辑收敛在此函数内,避免与 finalizeFileInfo 隐式耦合:
* - 已连接且 file 非空:进入「计算中」态(stage 3 active + 状态栏文案)
* - 哈希算完:stage 3 done + 推进 stage 4 + 状态栏「文件就绪」
* - file 为空 / 未连接:不碰 stage(由 finalizeFileInfo 的 else 分支 + clearSelectedFile 负责)
*/
function computeFileSha256(file) {
var shaElem = document.getElementById('fileSha256');
if (file && shaElem) {
shaElem.textContent = '计算中...';
file.arrayBuffer().then(function(buf) {
return crypto.subtle.digest('SHA-256', buf);
}).then(function(hashBuffer) {
var hashArray = Array.from(new Uint8Array(hashBuffer));
var hashHex = hashArray.map(function(b) { return b.toString(16).padStart(2, '0'); }).join('');
shaElem.textContent = hashHex.toUpperCase();
state.fileSha256 = hashHex.toUpperCase();
bleLog('FILE', 'SHA-256: ' + hashHex.toUpperCase());
// If connected, advance to stage 3 done
if (state.server) {
setBleStage(3, 'SHA-256 已计算', null);
setBleStage(4, '', null);
setBleStageText('文件就绪,可以开始刷写');
}
}).catch(function(err) {
shaElem.textContent = '计算失败: ' + err.message;
state.fileSha256 = null;
});
} else if (shaElem) {
shaElem.textContent = '-';
if (!file || !shaElem) {
if (shaElem) shaElem.textContent = '-';
state.fileSha256 = null;
return;
}
// 已连接:进入「计算中」态(旧逻辑放在 finalizeFileInfo 里,现收敛至此)
if (state.server) {
setBleStage(3, '计算哈希...', 'active');
setBleStageText('正在计算文件 SHA-256...');
}
shaElem.textContent = '计算中...';
file.arrayBuffer().then(function(buf) {
return crypto.subtle.digest('SHA-256', buf);
}).then(function(hashBuffer) {
var hashHex = bytesToHex(new Uint8Array(hashBuffer)).toUpperCase();
shaElem.textContent = hashHex;
state.fileSha256 = hashHex;
bleLog('FILE', 'SHA-256: ' + hashHex);
if (state.server) {
setBleStage(3, 'SHA-256 已计算', null);
setBleStage(4, '', null);
setBleStageText('文件就绪,可以开始刷写');
}
}).catch(function(err) {
shaElem.textContent = '计算失败: ' + err.message;
state.fileSha256 = null;
});
}
export async function syncSelectedFileFromInput() {
// ★ 上传进行中禁止同步:window focus / file input change 事件会在上传期间
// 触发本函数,若不拦截会通过 updateFileInfo 把进度条重置为"等待选择文件 0%"。
// ★ 必须同时拦截 WiFi 通道上传 (app.js 全局 isUploading):focus 事件不分通道,
// WiFi 刷写时点击网页/切标签回来会触发本函数 → finalizeFileInfo → setProgress(0),
// 把进度"闪一下"归零,直到下一次 XHR progress 事件才恢复。
if (state.uploading || state.autoFlashing) return;
if (typeof isUploading !== 'undefined' && isUploading) return;
// ★ 必须同时拦截 WiFi 通道上传:focus 事件不分通道,WiFi 刷写时点击网页/切标签
// 回来会触发本函数 → finalizeFileInfo → setProgress(0),把进度"闪一下"归零,
// 直到下一次 XHR progress 事件才恢复。
if (isAnyChannelUploading()) return;
// FSAPI 选择的文件以 selectedFileHandle 为准,不从 input 元素同步,避免切标签页回来误清空
if (state.selectedFileHandle) return;
const file = (els.firmwareFile && els.firmwareFile.files && els.firmwareFile.files[0]) || null;
@@ -174,7 +191,7 @@ export async function pickFileViaFSAPI() {
export function clearSelectedFile() {
// ★ 上传进行中禁止清除文件:防止目标切换/模式切换在上传期间清空文件引用,
// 导致进度条被重置为"等待选择文件 0%"并中断上传数据源。
if (state.uploading || state.autoFlashing) {
if (isBleUploading()) {
bleLog('FILE', '上传进行中,拒绝清除文件', 'W');
return;
}
@@ -184,32 +201,23 @@ export function clearSelectedFile() {
state.fileSha256 = null;
state.deviceSha256 = null;
if (els.firmwareFile) els.firmwareFile.value = '';
updateFileInfo(null);
updateFileInfo(null); // 内部 finalizeFileInfo(null) 已重置进度条并恢复「连接就绪」stage
hideBleVerifyInfo();
// Reset stages back to connection-ready state
if (state.server) {
resetBleStages();
setBleStage(3, '', null);
setBleStageText('BLE 连接就绪,请选择固件文件');
}
bleLog('FILE', '文件已清除');
}
// 别名导出,与 window.bleOta.clearFile 对齐
export { clearSelectedFile as clearFile };
/**
* 外部文件变更通知(index.html 拖拽 / console-mode 切换等)。
* ★ 守卫集中在此:file.js 是 BLE 文件操作唯一入口,外部包装层(ble_ota.js)不再重复守卫。
*/
export async function notifyFileChange(file, handle) {
// ★ 上传进行中拒绝外部文件变更通知:防止 index.html 的 syncBleFileFromSelectedTarget
// 在上传期间调用本函数 → updateFileInfo → setProgress(0, ...) 覆盖上传进度。
if (state.uploading || state.autoFlashing) {
if (isAnyChannelUploading()) {
bleLog('FILE', '上传进行中,忽略外部 notifyFileChange', 'D');
return;
}
// ★ WiFi 通道上传期间同样拒绝:setConsoleMode('ble') 等外部触发会覆盖 WiFi 进度
if (typeof isUploading !== 'undefined' && isUploading) {
bleLog('FILE', 'WiFi 上传进行中,忽略外部 notifyFileChange', 'D');
return;
}
if (file) {
if (handle) state.selectedFileHandle = handle;
await updateFileInfo(file);
+33 -38
View File
@@ -29,7 +29,7 @@ import {
currentTarget,
sleep,
} from './state.js';
import { setBleStage, setBleStageError, setBleStageText, resetBleStages } from './stages.js';
import { setBleStage, setBleStageError, setBleStageText, setBleStageStatus, resetBleStages } from './stages.js';
import { setProgress, updateProgressFromAck } from './transfer.js';
import { showBleVerifyInfo } from './verify.js';
import {
@@ -44,6 +44,23 @@ import {
import { formatBytes } from '../common/utils.js';
import { showToast } from '../common/toast.js';
/**
* @description 中止当前 ACK 等待 Promise,并复位重连状态。
* 用于所有"错误类 FINISH"和"用户中止"路径,避免 ackWaiter 悬挂。
*/
function abortAckWaiter(reason) {
if (state.ackWaiter) {
const waiter = state.ackWaiter;
state.ackWaiter = null;
clearTimeout(waiter.timer);
waiter.reject(new Error(reason));
}
state.abortRequested = true;
state.expectedReboot = false;
state.reconnecting = false;
state.reconnectAttempts = 0;
}
/** @description 连接 BLE 设备(扫描→连接→发现服务→使能通知)。 */
export async function connectBle() {
if (!navigator.bluetooth) {
@@ -199,9 +216,10 @@ export function handleNotify(event) {
updateProgressFromAck(state.activeTotalSize, state.activePacketTotal, state.lastAckBytes);
}
/* 传输期少打日志:整窗 ACK 频繁时 hex 刷屏拖慢 UI/主线程 */
/* 传输期少打日志:整窗 ACK 频繁时 hex 刷屏拖慢 UI/主线程。
* ★ 降为 D 级:成功路径默认不显示,需要时切过滤级查看。 */
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)})`);
bleLog('ACK', `cmd=0x${ackCmd.toString(16).padStart(2, '0').toUpperCase()} seq=${ackSeq} bytes=${state.lastAckBytes} (${formatBytes(state.lastAckBytes)})`, 'D');
}
// ★ 放宽匹配条件:只要 cmd 匹配 + generation 匹配 + 字节数足够,即接受 ACK
@@ -269,10 +287,7 @@ export function handleNotify(event) {
if (typeof markPhaseDone === 'function') markPhaseDone('gd32Ota');
setProgress(100, '[GD32] OTA 转发完成,APP 已启动');
showBleVerifyInfo(state.fileSha256 || '-', state.deviceSha256 || '设备未上报', { text: 'GD32 OTA 完成', ok: true });
var gd32Bar = document.getElementById('stageStatusBar');
var gd32Text = document.getElementById('stageStatusText');
if (gd32Bar) gd32Bar.className = 'stage-status-bar ok';
if (gd32Text) gd32Text.textContent = 'GD32 刷写完成';
setBleStageStatus('ok', 'GD32 刷写完成');
state.lastUploadStatus = 'success';
showToast('GD32 OTA 刷写完成', 'success');
resolveOtaResult('success', { stage: stage, value: value });
@@ -283,21 +298,11 @@ export function handleNotify(event) {
// 中止可由用户主动点"中止"按钮(abortTask 发 0xFB)触发,
// 也可能是设备端因错误自动中止。两种情况都走同一清理路径。
if (stage === BLE_OTA_FINISH_ABORTED) {
state.abortRequested = true;
state.expectedReboot = false;
state.reconnecting = false;
state.reconnectAttempts = 0;
state.lastUploadStatus = 'aborted';
setProgress(0, 'OTA 已中止');
setBleStageError('OTA 已中止');
showToast('OTA 已中止(BLE 保持连接)', 'warning');
// 解除任何等待 ACK / OTA 结果的 Promise
if (state.ackWaiter) {
const waiter = state.ackWaiter;
state.ackWaiter = null;
clearTimeout(waiter.timer);
waiter.reject(new Error('OTA 已中止'));
}
abortAckWaiter('OTA 已中止');
rejectOtaResult('OTA 已中止', { stage: stage, value: value });
return;
}
@@ -328,10 +333,7 @@ export function handleNotify(event) {
state.expectedReboot = true;
setBleStage(finalizeStage, '设备重启', null);
// ★ ESP32 收尾完成 = 成功路径:保持 status bar 绿色,不要切回 active 蓝
var rebootBar = document.getElementById('stageStatusBar');
var rebootBarTxt = document.getElementById('stageStatusText');
if (rebootBar) rebootBar.className = 'stage-status-bar ok';
if (rebootBarTxt) rebootBarTxt.textContent = 'ESP32 刷写完成,等待重启';
setBleStageStatus('ok', 'ESP32 刷写完成,等待重启');
if (typeof markPhaseDone === 'function') {
markPhaseDone('esp32Ota');
}
@@ -393,16 +395,7 @@ export function handleNotify(event) {
}
// 错误类 FINISH 事件立即中断 waitForAck,并中止发送循环
if (stage >= 0x80) {
state.abortRequested = true; // ★ 阻止 fire-and-forget 继续发包
state.expectedReboot = false; // ★ 刷写失败,取消任何重连退避
state.reconnecting = false;
state.reconnectAttempts = 0;
if (state.ackWaiter) {
const waiter = state.ackWaiter;
state.ackWaiter = null;
clearTimeout(waiter.timer);
waiter.reject(new Error(`设备端 FINISH 错误: stage=0x${stage.toString(16).padStart(2, '0').toUpperCase()} value=${value}`));
}
abortAckWaiter(`设备端 FINISH 错误: stage=0x${stage.toString(16).padStart(2, '0').toUpperCase()} value=${value}`);
}
return;
}
@@ -421,7 +414,7 @@ export function handleNotify(event) {
return b.toString(16).padStart(2, '0');
}).join('').toUpperCase();
state.deviceSha256 = devSha;
bleLog('VERIFY', '设备端 SHA-256: ' + devSha);
bleLog('VERIFY', '设备端 SHA-256: ' + devSha, 'D');
// 设备端上报 SHA-256 后,前端仅做展示与状态同步。
var frontSha = state.fileSha256;
@@ -431,10 +424,7 @@ export function handleNotify(event) {
showBleVerifyInfo(frontSha, devSha, { text: '校验通过 — SHA-256 一致', ok: true });
setBleStage(verifyStage, 'SHA-256 ✓', null);
// ★ 校验通过 = 刷写成功:status bar 标绿 + 顶部胶囊推进
var okBar = document.getElementById('stageStatusBar');
var okTxt = document.getElementById('stageStatusText');
if (okBar) okBar.className = 'stage-status-bar ok';
if (okTxt) okTxt.textContent = '刷写成功 — SHA-256 校验通过';
setBleStageStatus('ok', '刷写成功 — SHA-256 校验通过');
if (typeof markPhaseDone === 'function') {
markPhaseDone(state.uploadTargetKey === 'gd32' ? 'gd32Ota' : 'esp32Ota');
}
@@ -471,9 +461,14 @@ export async function tryReconnect() {
state.reconnecting = true;
state.reconnectAttempts += 1;
var attempt = state.reconnectAttempts;
// 退避:1s, 2s, 4s, 8s, 上限 10s
// 退避:约 1s / 1.6s / 2.6s / 4.1s / 6.6s / 10.5s…,上限 10s
var backoff = Math.min(10000, 1000 * Math.pow(1.6, attempt - 1));
if (backoff > 200) await sleep(backoff);
// ★ 退避期间用户可能已手动断开(disconnectBle 会清 expectedReboot),需二次校验避免幽灵重连
if (!state.expectedReboot || !state.device) {
state.reconnecting = false;
return;
}
if (typeof connectionMode !== 'undefined' && connectionMode === 'ble') {
if (els.connState) els.connState.textContent = '重连中(' + attempt + ')';
if (els.connState && els.connState.parentElement) els.connState.parentElement.className = 'pill processing';
+78 -24
View File
@@ -4,11 +4,37 @@ export var bleStageIds = ['bs1','bs2','bs3','bs4','bs5','bs6'];
export var bleStageDetailIds = ['bsd1','bsd2','bsd3','bsd4','bsd5','bsd6'];
export var bleStageLabelIds = ['bsl5', 'bsl6'];
/* 状态栏 DOM 元素懒缓存(BLE OTA 高频调用路径用,避免每次 getElementById)。
* DOM 未就绪或节点被外部重建时回退到 getElementById(与 ota/progress.js 的 setProgress 同款模式)。 */
var stageStatusBarEl = null;
var stageStatusTextEl = null;
/* 当前激活阶段序号(1-based,0=无激活)。由 setBleStage 维护,供 setBleStageError
* 直接定位错误节点,避免依赖 className 字符串匹配(脆弱且易被外部改动打破)。 */
var currentBleStageIndex = 0;
function getStageStatusBar() {
if (!stageStatusBarEl || !stageStatusBarEl.isConnected) {
stageStatusBarEl = document.getElementById('stageStatusBar');
}
return stageStatusBarEl;
}
function getStageStatusText() {
if (!stageStatusTextEl || !stageStatusTextEl.isConnected) {
stageStatusTextEl = document.getElementById('stageStatusText');
}
return stageStatusTextEl;
}
export function updateBleStageLabels(cfg) {
/* JS 兜底默认值与 constants.js TARGETS.esp32/gd32 的 stage5Label/stage6Label 对齐:
* esp32: stage5Label='校验比对', stage6Label='完成触发'
* gd32: stage5Label='完成触发', stage6Label='校验比对'
* 实际 cfg 由 currentTarget() 返回,始终带 label,此分支仅为防御性兜底。 */
var stage5 = document.getElementById(bleStageLabelIds[0]);
var stage6 = document.getElementById(bleStageLabelIds[1]);
if (stage5) stage5.textContent = cfg.stage5Label || '完成触发';
if (stage6) stage6.textContent = cfg.stage6Label || '校验比对';
if (stage5) stage5.textContent = cfg.stage5Label || '校验比对';
if (stage6) stage6.textContent = cfg.stage6Label || '完成触发';
}
export function resetBleStages() {
@@ -20,15 +46,34 @@ export function resetBleStages() {
var el = document.getElementById(id);
if (el) el.textContent = '';
});
var bar = document.getElementById('stageStatusBar');
if (bar) { bar.className = 'stage-status-bar'; bar.innerHTML = '<span class="ssb-dot"></span><span class="ssb-text" id="stageStatusText">待机</span>'; }
currentBleStageIndex = 0;
/* ★ 只改 className + textContent,不重写 innerHTML:
* 1) 保留 #stageStatusDot(CSS dotPulse 动画依赖该节点,重写后 id 丢失);
* 2) 保留 #stageStatusText 节点引用,避免外部缓存引用失效;
* 3) 与 ota/progress.js、ota/stages.js、app.js 的状态栏操作约定一致。 */
var bar = getStageStatusBar();
if (bar) bar.className = 'stage-status-bar';
var txt = getStageStatusText();
if (txt) txt.textContent = '待机';
}
export function setBleStage(stageNum, detail, barState) {
// stageNum: 1-6, detail: sub-step text
// 终态 stageNum===6:全部 done(绿);中间步当前为 live(蓝)
var finished = stageNum >= 6;
for (var i = 0; i < bleStageIds.length; i++) {
// 终态 stageNum===末步:全部 done(绿);中间步当前为 live(蓝)
var stageCount = bleStageIds.length;
// 边界守卫:stageNum<1 直接清空所有节点(不推进 currentBleStageIndex),避免误判 active
if (!Number.isFinite(stageNum) || stageNum < 1) {
bleStageIds.forEach(function(id) {
var el = document.getElementById(id);
if (el) el.className = 'stage-node';
});
currentBleStageIndex = 0;
return;
}
if (stageNum > stageCount) stageNum = stageCount;
currentBleStageIndex = stageNum;
var finished = stageNum >= stageCount;
for (var i = 0; i < stageCount; i++) {
var el = document.getElementById(bleStageIds[i]);
if (!el) continue;
if (i + 1 < stageNum) el.className = 'stage-node done';
@@ -36,34 +81,43 @@ export function setBleStage(stageNum, detail, barState) {
else el.className = 'stage-node';
}
// Set detail text
if (stageNum >= 1 && stageNum <= 6) {
if (stageNum >= 1 && stageNum <= stageCount) {
var detailEl = document.getElementById(bleStageDetailIds[stageNum - 1]);
if (detailEl && detail !== undefined) detailEl.textContent = detail;
}
// Update status bar
var bar = document.getElementById('stageStatusBar');
var bar = getStageStatusBar();
if (bar) {
var cls = barState || (finished ? 'ok' : (stageNum > 0 ? 'active' : ''));
var cls = barState || (finished ? 'ok' : 'active');
bar.className = 'stage-status-bar' + (cls ? ' ' + cls : '');
}
}
export function setBleStageText(text) {
var txt = document.getElementById('stageStatusText');
var txt = getStageStatusText();
if (txt) txt.textContent = text;
}
export function setBleStageError(detail) {
// Mark current active stage as error
for (var i = 0; i < bleStageIds.length; i++) {
var el = document.getElementById(bleStageIds[i]);
if (el && el.className === 'stage-node live') {
el.className = 'stage-node err';
var detailEl = document.getElementById(bleStageDetailIds[i]);
if (detailEl && detail) detailEl.textContent = detail;
}
}
var bar = document.getElementById('stageStatusBar');
if (bar) bar.className = 'stage-status-bar err';
setBleStageText('错误: ' + (detail || ''));
/** @description 一次性更新状态栏的样式与文本(避免每次通知都重复 getElementById)。 */
export function setBleStageStatus(state, text) {
var bar = getStageStatusBar();
if (bar) bar.className = 'stage-status-bar' + (state ? ' ' + state : '');
var txt = getStageStatusText();
if (txt && text !== undefined) txt.textContent = text;
}
export function setBleStageError(detail) {
// 直接用 currentBleStageIndex 定位错误节点(由 setBleStage 维护),
// 不再依赖 className === 'stage-node live' 字符串匹配——后者在 className
// 顺序变化或被外部改动时会漏标错误阶段。
var stageCount = bleStageIds.length;
if (currentBleStageIndex >= 1 && currentBleStageIndex <= stageCount) {
var el = document.getElementById(bleStageIds[currentBleStageIndex - 1]);
if (el) el.className = 'stage-node err';
var detailEl = document.getElementById(bleStageDetailIds[currentBleStageIndex - 1]);
if (detailEl && detail) detailEl.textContent = detail;
}
var bar = getStageStatusBar();
if (bar) bar.className = 'stage-status-bar err';
setBleStageText('错误: ' + (detail || '未知错误'));
}
+17 -15
View File
@@ -71,6 +71,11 @@ export function currentTarget() {
return TARGETS[window.flashTarget] || TARGETS.esp32;
}
/** BLE 通道是否正在上传/自动刷写。集中判定,避免各处重复 state.uploading || state.autoFlashing。 */
export function isBleUploading() {
return !!(state.uploading || state.autoFlashing);
}
export function currentPayloadSize() {
const cfg = currentTarget();
return Math.max(32, Math.min(498, Number(cfg.recommendedPayload) || 480));
@@ -110,26 +115,23 @@ export function setReadyHint(html) {
export function setConnectionState(connected) {
const idle = connected && state.selectedFile && !state.uploading;
// 仅在 BLE 模式下更新共享连接指示器,避免覆盖 WiFi 轮询的状态
// 仅在 BLE 模式下更新共享连接指示器与 BLE 专用徽章,避免覆盖 WiFi 轮询的状态
if (typeof connectionMode !== 'undefined' && connectionMode === 'ble') {
if (els.connStatus) els.connStatus.className = connected ? 'conn-indicator ok' : 'conn-indicator err';
if (els.connStatusTxt) els.connStatusTxt.textContent = connected ? 'BLE 已连接' : 'BLE 未连接';
}
// BLE 专用连接徽章 — 仅在 BLE 模式下更新,避免覆盖 WiFi 状态
if (typeof connectionMode !== 'undefined' && connectionMode === 'ble') {
if (els.connState) els.connState.textContent = connected ? '已连接' : '未连接';
if (els.connState && els.connState.parentElement) {
els.connState.parentElement.className = connected ? 'pill online' : 'pill offline';
if (els.connState) {
els.connState.textContent = connected ? '已连接' : '未连接';
if (els.connState.parentElement) {
els.connState.parentElement.className = connected ? 'pill online' : 'pill offline';
}
}
els.connBadge.textContent = connected ? 'online' : 'offline';
els.connBadge.className = `v ${connected ? 'ok' : 'bad'}`;
els.gattState.textContent = connected ? 'GATT 已就绪' : '未就绪';
}
if (connected) {
els.connectBtn.disabled = true;
} else {
els.connectBtn.disabled = false;
if (els.connBadge) {
els.connBadge.textContent = connected ? 'online' : 'offline';
els.connBadge.className = `v ${connected ? 'ok' : 'bad'}`;
}
if (els.gattState) els.gattState.textContent = connected ? 'GATT 已就绪' : '未就绪';
}
els.connectBtn.disabled = connected;
els.disconnectBtn.disabled = !connected;
els.autoFlashBtn.disabled = !idle;
els.abortBtn.disabled = !state.uploading;
+22 -19
View File
@@ -55,6 +55,16 @@ function buildPacket(seq, totalSize, payload, cmd) {
return packet;
}
/** @description 将 64 字符十六进制 SHA-256 字符串转为 32 字节数组。 */
function sha256HexToBytes(shaHex) {
const hex = shaHex.toUpperCase();
const out = new Uint8Array(32);
for (let i = 0; i < 32; i++) {
out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
}
return out;
}
export function waitForAck(cmd, expectedSeq, timeoutMs = 8000, opts = {}) {
// ★ 中止快速退出:若 0x20 ABORTED 已先于本调用到达(ackWaiter 当时为 null),
// 这里立即 reject,避免在已中止的会话上盲等超时。
@@ -101,7 +111,7 @@ export async function uploadFirmware() {
const freshFile = await state.selectedFileHandle.getFile();
await updateFileInfo(freshFile);
var actualFile = state.selectedFile || freshFile;
bleLog('FILE', '已重新读取本地文件: ' + actualFile.name + ' (' + formatBytes(actualFile.size) + ')');
bleLog('FILE', '已重新读取本地文件: ' + actualFile.name + ' (' + formatBytes(actualFile.size) + ')', 'D');
} catch (e) {
bleLog('FILE', '重新读取文件失败: ' + e.message + ',将使用缓存版本', 'W');
}
@@ -151,13 +161,13 @@ export async function uploadFirmware() {
throw new Error('DATA_OUT 通知未开启,无法使用 ACK 模式');
}
bleLog('OTA', `开始准备固件: target=${cfg.label}, file=${file.name}, payload=${payloadSize}, cmd=0x${cfg.packetCmd.toString(16).padStart(2, '0').toUpperCase()}, ackWindow=${BLE_OTA_ACK_WINDOW}`);
bleLog('OTA', `开始准备固件: target=${cfg.label}, file=${file.name}, payload=${payloadSize}, cmd=0x${cfg.packetCmd.toString(16).padStart(2, '0').toUpperCase()}, ackWindow=${BLE_OTA_ACK_WINDOW}`, 'D');
setProgress(0, `[${cfg.label}] 正在读取固件文件...`);
setBleStage(4, '读取固件...', null);
const fileBytes = new Uint8Array(await file.arrayBuffer());
const totalSize = fileBytes.length;
const packetTotal = Math.ceil(totalSize / payloadSize);
bleLog('OTA', `固件已读取: totalSize=${totalSize}, packetTotal=${packetTotal}, file.size=${file.size}`);
bleLog('OTA', `固件已读取: totalSize=${totalSize}, packetTotal=${packetTotal}, file.size=${file.size}`, 'D');
if (totalSize !== file.size) {
bleLog('OTA', `⚠️ file.size(${file.size}) ≠ arrayBuffer(${totalSize}),以 arrayBuffer 为准,请确认页面已 Ctrl+F5 刷新`, 'W');
}
@@ -169,7 +179,7 @@ export async function uploadFirmware() {
fileSha256 = state.fileSha256.toLowerCase();
} else {
// 极端兜底:state.fileSha256 未初始化(理论上 updateFileInfo 总会先设置)
bleLog('OTA', '正在计算文件 SHA-256 哈希...');
bleLog('OTA', '正在计算文件 SHA-256 哈希...', 'D');
const hashBuffer = await crypto.subtle.digest('SHA-256', fileBytes.buffer);
const hashArray = Array.from(new Uint8Array(hashBuffer));
fileSha256 = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
@@ -186,7 +196,7 @@ export async function uploadFirmware() {
state.activePacketTotal = packetTotal;
els.sentCount.textContent = '0';
setProgress(0, `[${cfg.label}] 等待设备确认 0/${packetTotal} 包`);
bleLog('OTA', `开始写入 DATA_IN: build=${UI_BUILD}, target=${cfg.label}, file=${file.name}, total=${totalSize}, packets=${packetTotal}, payload=${payloadSize}, windowAck=${BLE_OTA_ACK_WINDOW}`);
bleLog('OTA', `开始写入 DATA_IN: build=${UI_BUILD}, target=${cfg.label}, file=${file.name}, total=${totalSize}, packets=${packetTotal}, payload=${payloadSize}, windowAck=${BLE_OTA_ACK_WINDOW}`, 'D');
const MAX_WINDOW_RETRIES = 3;
// 整分区预擦可长达数十秒;首包 ACK 超时必须覆盖 begin/erase
@@ -214,7 +224,7 @@ export async function uploadFirmware() {
if (seq === 0) {
try {
await state.dataInChar.writeValueWithResponse(packet);
bleLog('OTA', 'seq=0 已用 writeValueWithResponse 确认送达,等待设备 begin/预擦完成…', 'I');
bleLog('OTA', 'seq=0 已用 writeValueWithResponse 确认送达,等待设备 begin/预擦完成…', 'D');
setBleStageText('设备预擦 Flash 中,请等待…');
} catch (e) {
bleLog('OTA', `seq=0 写入失败: ${e.message}`, 'E');
@@ -233,7 +243,7 @@ export async function uploadFirmware() {
if (state.lastAckBytes < firstBytes) {
throw new Error('设备 begin 就绪 ACK 字节不足: got=' + state.lastAckBytes + ' need=' + firstBytes);
}
bleLog('OTA', '设备 begin 就绪 (ackBytes=' + state.lastAckBytes + '),继续发送窗口数据', 'I');
bleLog('OTA', '设备 begin 就绪 (ackBytes=' + state.lastAckBytes + '),继续发送窗口数据', 'D');
setBleStageText('正在写入 ' + packetTotal + ' 包数据...');
continue;
}
@@ -315,7 +325,6 @@ export async function uploadFirmware() {
} catch (e) {
bleLog('OTA', `重试 ACK 等待失败: ${e.message}`, 'W');
}
expectedBytes = Math.min((endSeq + 1) * payloadSize, totalSize);
}
if (state.lastAckBytes < expectedBytes) {
@@ -340,10 +349,7 @@ export async function uploadFirmware() {
if (cfg.label === 'ESP32') {
state.deviceSha256 = null;
const frontSha = fileSha256.toUpperCase();
const frontShaBytes = new Uint8Array(32);
for (let i = 0; i < 32; i++) {
frontShaBytes[i] = parseInt(frontSha.slice(i * 2, i * 2 + 2), 16);
}
const frontShaBytes = sha256HexToBytes(fileSha256);
const trigger = new Uint8Array(35);
trigger[0] = 0xF8;
trigger.set(frontShaBytes, 1);
@@ -355,7 +361,7 @@ export async function uploadFirmware() {
setBleStageText('发送 0xF8 + SHA-256,等待设备端校验...');
try {
await state.ctrlChar.writeValue(trigger);
bleLog('OTA', '0xF8 + 32B SHA-256 已发送,等待设备端校验');
bleLog('OTA', '0xF8 + 32B SHA-256 已发送,等待设备端校验', 'D');
} catch (e) {
clearOtaResultWaiter();
state.lastUploadStatus = 'failed';
@@ -381,10 +387,7 @@ export async function uploadFirmware() {
// GD32: send 0xF7 + 32B SHA-256 + session, then wait for SPI relay result.
const frontSha = fileSha256.toUpperCase();
const frontShaBytes = new Uint8Array(32);
for (let i = 0; i < 32; i++) {
frontShaBytes[i] = parseInt(frontSha.slice(i * 2, i * 2 + 2), 16);
}
const frontShaBytes = sha256HexToBytes(fileSha256);
state.deviceSha256 = null;
setBleStage(5, '发送触发...', 'active');
setBleStageText('发送 0xF7 + SHA-256,等待设备端缓存校验...');
@@ -402,7 +405,7 @@ export async function uploadFirmware() {
await state.ctrlChar.writeValue(cmd);
setBleStage(5, '已触发', null);
setBleStage(6, '等待设备端校验', 'active');
bleLog('OTA', `0xF7 + 32B SHA-256 已发送,等待设备端缓存校验与转发`);
bleLog('OTA', `0xF7 + 32B SHA-256 已发送,等待设备端缓存校验与转发`, 'D');
} catch (_) {
clearOtaResultWaiter();
state.lastUploadStatus = 'failed';
@@ -478,7 +481,7 @@ export async function autoFlash() {
if (changed) {
await updateFileInfo(freshFile);
var actualFile = state.selectedFile || freshFile;
bleLog('FILE', '已重新读取本地文件(已变更): ' + actualFile.name + ' (' + formatBytes(actualFile.size) + ')');
bleLog('FILE', '已重新读取本地文件(已变更): ' + actualFile.name + ' (' + formatBytes(actualFile.size) + ')', 'D');
}
} catch (e) {
bleLog('FILE', '重新读取文件失败: ' + e.message + ',将使用缓存版本', 'W');
+10 -10
View File
@@ -4,18 +4,18 @@ export function showBleVerifyInfo(frontSha, devSha, result) {
var panel = document.getElementById('stageVerifyInfo');
if (panel) { panel.className = 'stage-verify-info'; panel.style.display = 'block'; }
if (frontSha !== undefined) {
var el = document.getElementById('sviFrontSha');
if (el) el.textContent = frontSha || '-';
var frontEl = document.getElementById('sviFrontSha');
if (frontEl) frontEl.textContent = frontSha || '-';
}
if (devSha !== undefined) {
var el = document.getElementById('sviDeviceSha');
if (el) el.textContent = devSha || '-';
var devEl = document.getElementById('sviDeviceSha');
if (devEl) devEl.textContent = devSha || '-';
}
if (result !== undefined) {
var el = document.getElementById('sviResult');
if (el) {
el.textContent = result.text || '-';
el.className = 'svi-val' + (result.ok ? ' ok' : result.err ? ' err' : '');
if (result) {
var resultEl = document.getElementById('sviResult');
if (resultEl) {
resultEl.textContent = result.text || '-';
resultEl.className = 'svi-val' + (result.ok ? ' ok' : result.err ? ' err' : '');
}
}
}
@@ -23,4 +23,4 @@ export function showBleVerifyInfo(frontSha, devSha, result) {
export function hideBleVerifyInfo() {
var panel = document.getElementById('stageVerifyInfo');
if (panel) { panel.className = 'stage-verify-info'; panel.style.display = 'none'; }
}
}
+1 -17
View File
@@ -26,23 +26,7 @@ window.bleOta = {
},
disconnect: disconnectBle,
showToast: showToast,
notifyFileChange: async function(file, handle) {
// ★ 上传进行中拒绝外部文件变更通知:防止 index.html 的 syncBleFileFromSelectedTarget
// 在上传期间调用本函数 → updateFileInfo → setProgress(0, ...) 覆盖上传进度。
if (state.uploading || state.autoFlashing) {
bleLog('FILE', '上传进行中,忽略外部 notifyFileChange', 'D');
return;
}
// ★ WiFi 通道上传期间同样拒绝:setConsoleMode('ble') 等外部触发会覆盖 WiFi 进度
if (typeof window.isUploading !== 'undefined' && window.isUploading) {
bleLog('FILE', 'WiFi 上传进行中,忽略外部 notifyFileChange', 'D');
return;
}
if (file) {
if (handle) state.selectedFileHandle = handle;
await notifyFileChange(file, handle);
}
},
notifyFileChange: notifyFileChange,
clearFile: clearSelectedFile,
refreshButtons: function() {
setConnectionState(!!state.server);
+18 -9
View File
@@ -108,7 +108,11 @@ function setConnectionState(connected, text, buildVersion, buildTime, wifi, upti
async function testConnection() {
var baseUrl = getBaseUrl();
try {
var resp = await fetch(baseUrl + '/system/status', { signal: AbortSignal.timeout(3000) });
/* 状态端点禁用 HTTP 缓存,避免命中旧响应 */
var resp = await fetch(baseUrl + '/system/status', {
signal: AbortSignal.timeout(3000),
cache: 'no-store'
});
if (!resp.ok) {
setConnectionState(false, 'HTTP ' + resp.status, '', '', {}, null);
return;
@@ -209,7 +213,11 @@ async function handleDbcFiles(fileList) {
}
}
afterDbcSourcesChanged({ resetSelection: true });
/* 仅在确有新增/覆盖时才 reset 并刷新 UI;
* 全部解析失败时保留用户当前选中/勾选/信号值,只弹错误提示。 */
if (added || replaced) {
afterDbcSourcesChanged({ resetSelection: true });
}
if (added || replaced) {
var msg = 'DBC:';
@@ -317,10 +325,10 @@ function bindEvents() {
scheduleCanPersist();
});
}
el.sendSelectedBtn.addEventListener('click', sendSelectedMessage);
el.loadPreviewToRawBtn.addEventListener('click', loadPreviewToManual);
el.sendManualBtn.addEventListener('click', sendManualFrame);
el.resetManualBtn.addEventListener('click', function () {
if (el.sendSelectedBtn) el.sendSelectedBtn.addEventListener('click', sendSelectedMessage);
if (el.loadPreviewToRawBtn) el.loadPreviewToRawBtn.addEventListener('click', loadPreviewToManual);
if (el.sendManualBtn) el.sendManualBtn.addEventListener('click', sendManualFrame);
if (el.resetManualBtn) el.resetManualBtn.addEventListener('click', function () {
resetManualFields();
scheduleCanPersist();
});
@@ -328,9 +336,10 @@ function bindEvents() {
refreshManualHint();
scheduleCanPersist();
}
el.manualData.addEventListener('input', onManualFieldChange);
el.manualDlc.addEventListener('input', onManualFieldChange);
el.manualId.addEventListener('input', onManualFieldChange);
/* 手动帧三字段统一守卫,避免任一元素缺失时后续绑定被中断 */
['manualData', 'manualDlc', 'manualId'].forEach(function (id) {
if (el[id]) el[id].addEventListener('input', onManualFieldChange);
});
if (el.manualChannel) el.manualChannel.addEventListener('change', scheduleCanPersist);
if (el.sendCheckedBtn) el.sendCheckedBtn.addEventListener('click', sendCheckedMessages);
if (el.selectAllBox) el.selectAllBox.addEventListener('change', function () {
+13 -2
View File
@@ -88,7 +88,8 @@ export function bytesToHex(bytes) {
/** @description 转义 CSS 选择器中的特殊字符。 */
export 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; });
/* fallback:每个非安全字符前加单个反斜杠(CSS 转义),双引号属性值内可安全使用 */
return String(value).replace(/[^a-zA-Z0-9_-]/g, function (ch) { return '\\' + ch; });
}
/** @description 去除回车并 trim,用于 DBC 搜索等输入归一化。 */
@@ -232,7 +233,7 @@ export function parseDbc(text) {
signal.comment = comments.signals[String(msg.idRaw) + ':' + signal.name] || '';
signal.enumMap = valueTables[String(msg.idRaw) + ':' + signal.name] || {};
var dv = sigDefaults[String(msg.idRaw) + ':' + signal.name];
signal.defaultValue = (dv != null && Number.isFinite(Number(dv))) ? dv : null;
signal.defaultValue = (dv != null && Number.isFinite(Number(dv))) ? Number(dv) : null;
});
});
@@ -251,6 +252,16 @@ export function findSignal(message, signalName) {
return null;
}
/**
* @description 按 key 在当前合并 DBC 中查找报文(O(1),走 messageMap)。
* messageMap 由 rebuildMergedDbc() 维护,key 格式与 message.key / state.selectedMessageKey 一致。
* 无 DBC 或 key 不存在时返回 null。
*/
export function findMessageByKey(key) {
if (!state.dbc || !state.dbc.messageMap || !key) return null;
return state.dbc.messageMap[key] || null;
}
/** @description 解析 SG_ 行的 multiplex token(M / mN / 空)。 */
export function parseMultiplexToken(token) {
if (!token) return { role: null, value: null };
+29 -39
View File
@@ -7,14 +7,13 @@
* common/utils.js(escapeHtml/formatBytes)、
* signals.js(renderInlineSignalGrid/ensureMessageDefaults/updateEncodedPreview)。
*/
import { state, el, padHex, normalizeWhitespace, cssEscape } from './dbc.js?v=20260717x';
import { escapeHtml as sanitizeHtml, formatBytes } from '../common/utils.js?v=20260717x';
import { state, el, padHex, normalizeWhitespace, cssEscape, findMessageByKey } from './dbc.js?v=20260717x';
import { escapeHtml, 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 文件信息卡片(文件名/报文数/信号数/节点数/大小)。 */
/** @description 刷新多 DBC 摘要条与文件 chip 列表。 */
/** @description 更新 DBC 文件信息卡片(文件名/报文数/信号数/节点数/大小),并刷新多 DBC 摘要条与文件 chip 列表。 */
export function updateDbcFileInfo() {
var sources = state.dbcSources || [];
var has = sources.length > 0;
@@ -54,9 +53,9 @@ export function updateDbcFileInfo() {
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 class="dbc-file-chip" data-source-id="' + escapeHtml(s.id) + '" title="' + escapeHtml(s.name) + ' · ' + nMsg + ' 报文">' +
'<span class="dbc-file-chip-name">' + escapeHtml(s.name) + '</span>' +
'<button type="button" class="dbc-file-chip-remove" data-remove-id="' + escapeHtml(s.id) + '" title="移除此文件">×</button>' +
'</span>';
}).join('');
}
@@ -93,7 +92,7 @@ export function filterMessages() {
// ==================== 发送节点过滤 ====================
/** @description 收集 DBC 中所有发送节点(去重 + 排序)。 */
export function collectTransmitters() {
function collectTransmitters() {
var set = Object.create(null);
var list = [];
if (!state.dbc) return list;
@@ -106,10 +105,10 @@ export function collectTransmitters() {
}
/** @description 渲染发送节点过滤面板的复选框列表。 */
export function renderNodeFilterList() {
function renderNodeFilterList() {
if (!el.nodeFilterList) return;
var txs = collectTransmitters();
var kw = (el.nodeFilterSearch && el.nodeFilterSearch.value || '').trim().toLowerCase();
var kw = (el.nodeFilterSearch ? (el.nodeFilterSearch.value || '') : '').trim().toLowerCase();
if (kw) txs = txs.filter(function (n) { return n.toLowerCase().indexOf(kw) >= 0; });
if (!txs.length) {
el.nodeFilterList.innerHTML = '<div class="status-empty">无匹配节点</div>';
@@ -125,8 +124,8 @@ export function renderNodeFilterList() {
el.nodeFilterList.innerHTML = txs.map(function (name) {
var checked = !!state.transmitterFilter[name] ? ' checked' : '';
return '<label class="can-node-filter-item">' +
'<input type="checkbox" data-node="' + sanitizeHtml(name) + '"' + checked + '>' +
'<span class="can-node-filter-name">' + sanitizeHtml(name) + '</span>' +
'<input type="checkbox" data-node="' + escapeHtml(name) + '"' + checked + '>' +
'<span class="can-node-filter-name">' + escapeHtml(name) + '</span>' +
'<span class="can-node-filter-count">' + (counts[name] || 0) + '</span>' +
'</label>';
}).join('');
@@ -167,7 +166,7 @@ export function updateNodeFilterLabel() {
}
/** @description 打开/关闭发送节点过滤面板。 */
export function setNodeFilterPanelOpen(open) {
function setNodeFilterPanelOpen(open) {
if (!el.nodeFilterPanel || !el.nodeFilterBtn) return;
if (open) {
renderNodeFilterList();
@@ -245,23 +244,23 @@ export function renderMessageList() {
var comment = msg.comment || '';
if (comment.length > 36) comment = comment.slice(0, 36) + '...';
var rowHtml = '' +
'<div class="message-row' + active + '" data-key="' + sanitizeHtml(msg.key) + '" role="button" tabindex="0">' +
'<span class="message-col message-col-check"><input type="checkbox" class="message-check" data-key="' + sanitizeHtml(msg.key) + '"' + checked + '></span>' +
'<span class="message-col message-col-toggle"><button type="button" class="message-toggle' + (expanded ? ' expanded' : '') + '" data-key="' + sanitizeHtml(msg.key) + '" aria-label="展开/收起信号">' + caret + '</button></span>' +
'<div class="message-row' + active + '" data-key="' + escapeHtml(msg.key) + '" role="button" tabindex="0">' +
'<span class="message-col message-col-check"><input type="checkbox" class="message-check" data-key="' + escapeHtml(msg.key) + '"' + checked + '></span>' +
'<span class="message-col message-col-toggle"><button type="button" class="message-toggle' + (expanded ? ' expanded' : '') + '" data-key="' + escapeHtml(msg.key) + '" aria-label="展开/收起信号">' + caret + '</button></span>' +
'<span class="message-col message-col-index">' + (index + 1) + '</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>' +
'<span class="message-col message-col-dlc">' + sanitizeHtml(msg.dlc) + '</span>' +
'<span class="message-col message-col-signal">' + sanitizeHtml(msg.signals.length) + '</span>' +
'<span class="message-col message-col-node" title="' + sanitizeHtml(msg.transmitter || 'Vector__XXX') + '">' + sanitizeHtml(msg.transmitter || 'Vector__XXX') + '</span>' +
'<span class="message-col message-col-comment" title="' + sanitizeHtml(msg.comment || '') + '">' + sanitizeHtml(comment || '-') + '</span>' +
'<span class="message-col message-col-dbc" title="' + escapeHtml(msg.sourceName || '-') + '">' +
escapeHtml(msg.sourceName ? String(msg.sourceName).replace(/\.dbc$/i, '') : '-') + '</span>' +
'<span class="message-col message-col-name" title="' + escapeHtml(msg.name) + '">' +
escapeHtml(msg.name) + '</span>' +
'<span class="message-col message-col-id">' + escapeHtml(idHex) + '</span>' +
'<span class="message-col message-col-channel">CH' + escapeHtml(channelTxt) + '</span>' +
'<span class="message-col message-col-type">' + escapeHtml(msg.isExtended ? '扩展' : '标准') + '</span>' +
'<span class="message-col message-col-dlc">' + escapeHtml(msg.dlc) + '</span>' +
'<span class="message-col message-col-signal">' + escapeHtml(msg.signals.length) + '</span>' +
'<span class="message-col message-col-node" title="' + escapeHtml(msg.transmitter || 'Vector__XXX') + '">' + escapeHtml(msg.transmitter || 'Vector__XXX') + '</span>' +
'<span class="message-col message-col-comment" title="' + escapeHtml(msg.comment || '') + '">' + escapeHtml(comment || '-') + '</span>' +
'</div>';
var expandHtml = '<div class="message-row-expand" data-key="' + sanitizeHtml(msg.key) + '"' + (expanded ? '' : ' hidden') + '>' +
var expandHtml = '<div class="message-row-expand" data-key="' + escapeHtml(msg.key) + '"' + (expanded ? '' : ' hidden') + '>' +
'<div class="signal-table-host"></div>' +
'</div>';
return rowHtml + expandHtml;
@@ -331,10 +330,7 @@ function handleMessageRowClick(event) {
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; }
}
var msg = findMessageByKey(key);
if (!msg) return;
var willExpand = !state.expandedKeys[key];
if (willExpand) {
@@ -384,13 +380,7 @@ export function updateBatchUi() {
/** @description 选中指定 key 的报文,并刷新列表/信号编辑/编码预览。 */
export function selectMessage(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;
}
}
var msg = findMessageByKey(key);
if (!msg) return;
state.selectedMessageKey = key;
ensureMessageDefaults(msg);
+3 -8
View File
@@ -25,9 +25,7 @@ function safeJsonParse(raw) {
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];
}
if (state.transmitterFilter[k]) out[k] = true;
}
return out;
}
@@ -196,11 +194,8 @@ export function loadCanSession() {
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)) {
if (state.selectedMessageKey &&
(!state.dbc || !state.dbc.messageMap[state.selectedMessageKey])) {
state.selectedMessageKey = '';
}
+144 -157
View File
@@ -13,7 +13,7 @@
* 的 formatTime(含毫秒)不同,以保证发送历史时间戳输出格式一致。
*/
import { state, el, getBaseUrl, padHex, bytesToHex, parseNumericId } from './dbc.js?v=20260717x';
import { escapeHtml as sanitizeHtml } from '../common/utils.js?v=20260717x';
import { escapeHtml } 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';
@@ -29,13 +29,13 @@ function formatTime(date) {
}
// ==================== 发送错误码描述 ====================
export var CAN_SEND_ERR_DESC = {
var CAN_SEND_ERR_DESC = {
'-1': '参数错误(通道/ID/DLC 无效)',
'-2': 'SPI 通信失败(ESP32 与 GD32 通信异常)',
'-3': 'GD32 CAN 总线拒绝',
};
export var GD32_ERR_DESC = {
var GD32_ERR_DESC = {
0xE0: 'payload 过短',
0xE1: '通道号非法',
0xE2: 'BUS_OFF(总线离线,检查接线/终端电阻/对端上电)',
@@ -46,7 +46,7 @@ export var GD32_ERR_DESC = {
};
/** @description 构建发送响应的可读文本(成功显示 recv,失败拼接 CAN_SEND_ERR_DESC + GD32_ERR_DESC)。 */
export function buildSendResponseText(result) {
function buildSendResponseText(result) {
if (result.status === 'ok') {
return result.recv ? ('0x' + result.recv + ' (' + result.recv_len + ' 字节)') : '无响应数据';
}
@@ -133,11 +133,27 @@ function updateResponseBox(box, textEl, result, mode) {
el.manualHint.className = 'helper-note ' + (result.status === 'ok' ? 'ok' : 'err');
el.manualHint.innerHTML = result.status === 'ok'
? '原始帧已发送到设备,若有响应则显示在下方。'
: '设备返回错误码 <b>' + sanitizeHtml(result.code) + '</b>。';
: '设备返回错误码 <b>' + escapeHtml(result.code) + '</b>。';
}
}
// ==================== 发送历史 ====================
/**
* 构造一条历史条目。集中字段,避免多处重复拼对象。
*/
function makeHistoryEntry(opts) {
return {
time: formatTime(new Date()),
mode: opts.mode,
ok: opts.ok,
channel: opts.channel,
idHex: opts.idHex,
dlc: opts.dlc,
dataHex: opts.dataHex || '',
resultText: opts.resultText
};
}
/**
* @param {object} entry
* @param {{ silent?: boolean }} [opts] silent=true 时不立刻重绘/落盘(批量用)
@@ -162,11 +178,11 @@ export function renderHistory() {
var meta = 'CH' + item.channel + ' · ' + item.idHex + ' · DLC ' + item.dlc;
return '' +
'<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>' +
'<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>' +
'<span class="history-item-tag ' + (item.ok ? 'ok' : 'err') + '">' + escapeHtml(item.mode) + '</span>' +
'<span class="history-item-time">' + escapeHtml(item.time) + '</span>' +
'<span class="history-item-main" title="' + escapeHtml(meta) + '">' + escapeHtml(meta) + '</span>' +
'<span class="history-item-hex" title="' + escapeHtml(item.dataHex || '') + '">' + escapeHtml(item.dataHex || '--') + '</span>' +
'<span class="history-item-foot" title="' + escapeHtml(item.resultText || '') + '">' + escapeHtml(item.resultText) + '</span>' +
'</div>';
}).join('');
}
@@ -191,128 +207,115 @@ export async function sendCheckedMessages() {
var ok = 0, fail = 0;
var silent = { silent: true };
/* 先全部编码,避免发到一半才因编码失败打断节奏 */
var prepared = [];
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: msg.name + ' · 编码失败: ' + err.message
}, silent);
continue;
}
var dataHex = bytesToHex(dataBytes);
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 }
});
/* 按 key 建索引,避免勾选数百条时 O(n²) 扫描 messages */
var msgByKey = Object.create(null);
for (var mi = 0; mi < state.dbc.messages.length; mi++) {
var mm = state.dbc.messages[mi];
msgByKey[mm.key] = mm;
}
/* 分块批量接口;不支持时整段回退为无间隔单帧 */
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 {
/* 先全部编码,避免发到一半才因编码失败打断节奏 */
var prepared = [];
for (var i = 0; i < keys.length; i++) {
var key = keys[i];
var msg = msgByKey[key];
if (!msg) continue;
ensureMessageDefaults(msg);
var idHex = '0x' + padHex(msg.id, msg.isExtended ? 8 : 3);
var dataBytes;
try {
results = await sendCanBatchRequest(channel, frames);
if (results.length !== chunk.length) {
throw new Error('results 长度不匹配');
}
} catch (err) {
useBatchApi = false;
results = null;
/* 本 chunk 改单帧紧接 */
}
}
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);
dataBytes = encodeMessageData(msg, getSignalValueMap(msg));
} 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);
addHistoryEntry(makeHistoryEntry({
mode: 'BATCH', ok: false, channel: channel, idHex: idHex,
dlc: msg.dlc, dataHex: '',
resultText: msg.name + ' · 编码失败: ' + err.message
}), silent);
continue;
}
var dataHex = bytesToHex(dataBytes);
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 改单帧紧接 */
}
}
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(makeHistoryEntry({
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(makeHistoryEntry({
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(makeHistoryEntry({
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');
showToast('批量发送完成:成功 ' + ok + ',失败 ' + fail, tone);
renderHistory();
scheduleCanPersist();
var tone = fail === 0 ? 'success' : (ok === 0 ? 'error' : 'warning');
showToast('批量发送完成:成功 ' + ok + ',失败 ' + fail, tone);
} finally {
state.batchInFlight = false;
updateBatchUi();
}
}
// ==================== 选中报文发送 ====================
@@ -338,16 +341,12 @@ export async function sendSelectedMessage() {
try {
var result = await sendCanRequest(payload);
updateResponseBox(el.dbcResp, el.dbcRespText, result, 'dbc');
addHistoryEntry({
time: formatTime(new Date()),
mode: 'DBC',
ok: result.status === 'ok',
channel: channel,
addHistoryEntry(makeHistoryEntry({
mode: 'DBC', ok: result.status === 'ok', channel: channel,
idHex: '0x' + padHex(msg.id, msg.isExtended ? 8 : 3),
dlc: msg.dlc,
dataHex: bytesToHex(state.generatedData),
dlc: msg.dlc, dataHex: bytesToHex(state.generatedData),
resultText: buildSendResponseText(result)
});
}));
showToast(result.status === 'ok' ? 'DBC 报文发送成功' : '发送失败', result.status === 'ok' ? 'success' : 'error');
} catch (err) {
if (el.dbcResp) {
@@ -355,16 +354,12 @@ export async function sendSelectedMessage() {
el.dbcResp.className = 'can-response err';
el.dbcRespText.textContent = err.message;
}
addHistoryEntry({
time: formatTime(new Date()),
mode: 'DBC',
ok: false,
channel: channel,
addHistoryEntry(makeHistoryEntry({
mode: 'DBC', ok: false, channel: channel,
idHex: '0x' + padHex(msg.id, msg.isExtended ? 8 : 3),
dlc: msg.dlc,
dataHex: bytesToHex(state.generatedData),
dlc: msg.dlc, dataHex: bytesToHex(state.generatedData),
resultText: err.message
});
}));
showToast('发送失败: ' + err.message, 'error');
}
}
@@ -397,7 +392,7 @@ export function refreshManualHint() {
el.manualHint.innerHTML = '原始 HEX 合法,当前长度 <b>' + (dataHex.length / 2) + '</b> 字节。';
} catch (err) {
el.manualHint.className = 'helper-note err';
el.manualHint.innerHTML = sanitizeHtml(err.message);
el.manualHint.innerHTML = escapeHtml(err.message);
}
}
@@ -427,16 +422,12 @@ export async function sendManualFrame() {
data: dataHex
});
updateResponseBox(el.manualResp, el.manualRespText, result, 'manual');
addHistoryEntry({
time: formatTime(new Date()),
mode: 'RAW',
ok: result.status === 'ok',
channel: channel,
addHistoryEntry(makeHistoryEntry({
mode: 'RAW', ok: result.status === 'ok', channel: channel,
idHex: '0x' + padHex(id, id > 0x7FF ? 8 : 3),
dlc: dlc,
dataHex: dataHex,
dlc: dlc, dataHex: dataHex,
resultText: buildSendResponseText(result)
});
}));
showToast(result.status === 'ok' ? '原始帧发送成功' : '发送失败', result.status === 'ok' ? 'success' : 'error');
} catch (err) {
if (el.manualResp) {
@@ -444,16 +435,12 @@ export async function sendManualFrame() {
el.manualResp.className = 'can-response err';
el.manualRespText.textContent = err.message;
}
addHistoryEntry({
time: formatTime(new Date()),
mode: 'RAW',
ok: false,
channel: channel,
addHistoryEntry(makeHistoryEntry({
mode: 'RAW', ok: false, channel: channel,
idHex: '0x' + padHex(id, id > 0x7FF ? 8 : 3),
dlc: dlc,
dataHex: dataHex,
dlc: dlc, dataHex: dataHex,
resultText: err.message
});
}));
showToast('发送失败: ' + err.message, 'error');
}
}
+53 -19
View File
@@ -5,7 +5,7 @@
*
* 依赖:dbc.js(state/el/padHex/bytesToHex/cssEscape/findSignal)、common/utils.js(escapeHtml)。
*/
import { state, el, padHex, bytesToHex, cssEscape, findSignal } from './dbc.js?v=20260717x';
import { state, el, padHex, bytesToHex, cssEscape, findSignal, findMessageByKey } from './dbc.js?v=20260717x';
import { escapeHtml as sanitizeHtml } from '../common/utils.js?v=20260717x';
import { scheduleCanPersist } from './persist.js?v=20260717x';
@@ -53,10 +53,7 @@ function getDefaultSignalValue(signal) {
/** @description 获取当前选中的报文对象(无选中返回 null)。 */
export function getSelectedMessage() {
if (!state.dbc || !state.selectedMessageKey) return null;
for (var i = 0; i < state.dbc.messages.length; i++) {
if (state.dbc.messages[i].key === state.selectedMessageKey) return state.dbc.messages[i];
}
return null;
return findMessageByKey(state.selectedMessageKey);
}
function getMultiplexerSignal(message) {
@@ -111,6 +108,7 @@ export function renderInlineSignalGrid(message, host) {
function renderSignalRow(message, signal, index) {
var bag = getSignalValueMap(message);
var enumKeys = Object.keys(signal.enumMap || {});
var enumCount = enumKeys.length;
var bitRange = signal.startBit + '|' + signal.length + ' @' + (signal.byteOrder === 'little' ? 'Intel' : 'Motorola');
var rangeText = '';
if (Number.isFinite(signal.min) || Number.isFinite(signal.max)) {
@@ -123,7 +121,7 @@ function renderSignalRow(message, signal, index) {
var comment = signal.comment || '-';
if (comment.length > 48) comment = comment.slice(0, 48) + '...';
var inputHtml = '';
if (enumKeys.length) {
if (enumCount) {
inputHtml = '<select class="input signal-input signal-input-compact" data-msg="' + sanitizeHtml(message.key) + '" data-signal="' + sanitizeHtml(signal.name) + '">' +
enumKeys.map(function (key) {
var label = signal.enumMap[key];
@@ -145,7 +143,7 @@ function renderSignalRow(message, signal, index) {
'<span class="signal-cell signal-cell-value">' + inputHtml + '</span>' +
'<span class="signal-cell signal-cell-range">' +
'<b>' + sanitizeHtml(rangeText) + '</b>' +
(enumKeys.length ? '<small>' + sanitizeHtml(enumKeys.slice(0, 3).map(function (key) { return key + ':' + signal.enumMap[key]; }).join(' / ')) + (enumKeys.length > 3 ? ' ...' : '') + '</small>' : '<small>' + sanitizeHtml(factorText) + '</small>') +
(enumCount ? '<small>' + sanitizeHtml(enumKeys.slice(0, 3).map(function (key) { return key + ':' + signal.enumMap[key]; }).join(' / ')) + (enumCount > 3 ? ' ...' : '') + '</small>' : '<small>' + sanitizeHtml(factorText) + '</small>') +
'</span>' +
'<span class="signal-cell signal-cell-meta">' +
'<b>' + sanitizeHtml(bitRange) + '</b>' +
@@ -160,15 +158,16 @@ function handleSignalInputEvent(evt) {
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; }
}
var message = findMessageByKey(key);
if (!message) return;
setSignalValue(message, name, input.value);
var signal = findSignal(message, name);
if (signal && signal.multiplexRole === 'mux') {
var inlineGrid = document.querySelector('.message-row-expand[data-key="' + cssEscape(key) + '"] .signal-table-host');
/* MUX 值变更后需重建信号表格(激活信号集变化)。
* 限定在 el.messageList 作用域内查找,避免多列表/多 DBC 场景匹配到错误节点
*(与 messages.js 中 toggleMessageExpand 的作用域策略一致)。 */
var root = el.messageList || document;
var inlineGrid = root.querySelector('.message-row-expand[data-key="' + cssEscape(key) + '"] .signal-table-host');
if (inlineGrid) renderInlineSignalGrid(message, inlineGrid);
}
if (key === state.selectedMessageKey) updateEncodedPreview();
@@ -256,7 +255,7 @@ export function encodeMessageData(message, signalValues) {
return bytes;
}
/** @description 粗判信号位域是否至少有一部分落在 dlc 字节内(越界整段则跳过)。 */
/** @description 粗判信号位域是否完全落在 dlc 字节内(越界整段则跳过)。 */
function signalFitsInDlc(signal, dlc) {
if (!signal || dlc <= 0) return false;
var maxBit = dlc * 8 - 1;
@@ -264,9 +263,22 @@ function signalFitsInDlc(signal, dlc) {
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;
/* float:要求整段字节落在 DLC 内(startBit 必须字节对齐,由 writeFloatSignal 另校验) */
if (signal.valueType === 'float' || signal.valueType === 'double') {
var startByte = signal.startBit / 8;
var byteLen = signal.length / 8;
return startByte >= 0 && Number.isInteger(startByte) &&
startByte + byteLen <= dlc;
}
/* Motorola:用 buildMotorolaBitPositions 计算实际占用位序,检查最大位是否落在 DLC 内。
* 此前仅判 startByte,长跨字节信号末位可能越界 → 会被 setBitByIndex 抛错而非跳过,
* 与 little 分支"越界整段跳过"语义不一致;这里统一为"越界即跳过"。 */
if (signal.length <= 0 || signal.length > 64) return false;
var positions = buildMotorolaBitPositions(signal.startBit, signal.length);
for (var j = 0; j < positions.length; j++) {
if (positions[j] < 0 || positions[j] > maxBit) return false;
}
return true;
}
function parseSignalInput(signal, inputValue) {
@@ -274,6 +286,16 @@ function parseSignalInput(signal, inputValue) {
if (value === '' || value == null) value = getDefaultSignalValue(signal);
var num = Number(value);
if (!Number.isFinite(num)) throw new Error(signal.name + ' 不是合法数字');
/* enum 信号:取值是离散枚举 key(数字字符串),DBC 的 min/max 是物理连续值范围,
* 对 enum 不适用 —— 合法 enum 值可能落在 [min,max] 之外(例如 fault 枚举)。
* 因此 enum 信号只做"值必须 ∈ enumMap keys"的校验,跳过 min/max。 */
var enumKeys = Object.keys(signal.enumMap || {});
if (enumKeys.length) {
if (enumKeys.indexOf(String(value)) < 0) {
throw new Error(signal.name + ' 不是合法枚举值:' + value);
}
return { numeric: num, text: String(value).trim() };
}
if (Number.isFinite(signal.min) && num < signal.min) {
throw new Error(signal.name + ' 低于最小值 ' + signal.min);
}
@@ -296,6 +318,10 @@ function physicalToRawBigInt(signal, parsedValue) {
isIntegerLike(parsedValue.text)) {
return BigInt(parsedValue.text);
}
/* factor=0 是 DBC 异常情况(规范上 factor 不应为 0),此处为防御性兜底:
* - 有 offset:物理值直接减 offset 作为 raw(把 offset 视为"物理=raw 的偏移基线")
* - 无 offset:物理值即 raw
* 实际 DBC 不应走到此分支,但避免除零导致 NaN。 */
var rawNumber = signal.factor === 0
? Math.round(signal.offset ? (parsedValue.numeric - signal.offset) : parsedValue.numeric)
: Math.round((parsedValue.numeric - signal.offset) / signal.factor);
@@ -367,13 +393,21 @@ function writeFloatSignal(bytes, signal, physicalValue) {
if (signal.startBit % 8 !== 0) {
throw new Error(signal.name + ' 浮点信号起始位必须按字节对齐');
}
var buffer = new ArrayBuffer(signal.length / 8);
var startByte = signal.startBit / 8;
var byteLen = signal.length / 8;
/* 越界防御:signalFitsInDlc 已在 encodeMessageData 入口过滤,这里二次断言,
* 避免任何直接调用方(如未来新增的 decode 路径)漏检导致写越界。 */
if (startByte + byteLen > bytes.length) {
throw new Error(signal.name + ' 浮点信号写入越界:startByte=' + startByte +
' + length=' + byteLen + ' > 缓冲区 ' + bytes.length + ' 字节');
}
var buffer = new ArrayBuffer(byteLen);
var view = new DataView(buffer);
if (signal.length === 32) view.setFloat32(0, physicalValue, true);
else view.setFloat64(0, physicalValue, true);
var rawBytes = new Uint8Array(buffer);
for (var i = 0; i < rawBytes.length; i++) {
bytes[(signal.startBit / 8) + i] = rawBytes[i];
bytes[startByte + i] = rawBytes[i];
}
}
@@ -393,7 +427,7 @@ function setBitByIndex(bytes, bitIndex, bitValue) {
var bitInByte = bitIndex % 8;
if (byteIndex < 0 || byteIndex >= bytes.length) {
throw new Error('信号写入越界,bit=' + bitIndex +
'(缓冲区 ' + bytes.length + ' 字节 / DLC=' + bytes.length + ')');
'(缓冲区 ' + bytes.length + ' 字节)');
}
if (bitValue) bytes[byteIndex] |= (1 << bitInByte);
else bytes[byteIndex] &= ~(1 << bitInByte);
+26 -24
View File
@@ -2,8 +2,9 @@
* can_monitor.js - CAN 接收监控(独立页 can_monitor.html 与 can.html 右侧抽屉共用)
*/
import { showToast } from './common/toast.js?v=20260717x';
import { escapeHtml } from './common/utils.js';
var state = { fbTimer: null, fbAbortCtrl: null };
var state = { fbTimer: null, fbInFlight: false };
var el = {};
@@ -27,11 +28,6 @@ function cacheElements() {
ids.forEach(function (id) { el[id] = document.getElementById(id); });
}
function sanitize(text) {
return String(text == null ? '' : text)
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
function getBaseUrl() {
var ip = '';
if (el.monIp && el.monIp.value) {
@@ -63,15 +59,18 @@ function setConnState(ok, text) {
// ── 1Hz 轮询 /can/feedback ─────────────────────────────────
async function pollFeedback() {
if (state.fbAbortCtrl) { try { state.fbAbortCtrl.abort(); } catch (_e) {} }
state.fbAbortCtrl = new AbortController();
var fbPollTimer = setTimeout(function () { try { state.fbAbortCtrl.abort(); } catch (_e) {} }, 3000);
// 防重入:上一次 fetch 未完成则跳过本次 tick,避免并发请求叠加
if (state.fbInFlight) return;
state.fbInFlight = true;
var ac = new AbortController();
var timer = setTimeout(function () { ac.abort(); }, 3000);
try {
var resp = await fetch(getBaseUrl() + '/can/feedback', { signal: state.fbAbortCtrl.signal });
var resp = await fetch(getBaseUrl() + '/can/feedback', { signal: ac.signal });
if (!resp.ok) return;
var data = await resp.json();
var channels = data.channels || [];
var selectedCh = el.idsnapChannel ? parseInt(el.idsnapChannel.value, 10) : 0;
if (!Number.isFinite(selectedCh)) selectedCh = 0;
for (var i = 0; i < 3; i++) {
var ch = channels[i];
@@ -84,7 +83,7 @@ async function pollFeedback() {
var sel = channels[selectedCh];
if (sel && sel.valid && sel.entries) {
renderIdTable(sel.entries, sel.count || sel.entries.length);
renderIdTable(sel.entries);
if (el.idsnapStatus) {
el.idsnapStatus.textContent = (sel.entries.length) + ' 个 ID';
el.idsnapStatus.className = 'pill online';
@@ -98,15 +97,16 @@ async function pollFeedback() {
el.fbPollStatus.className = 'pill online';
}
setConnState(true, '已连接');
clearTimeout(fbPollTimer);
} catch (e) {
clearTimeout(fbPollTimer);
if (e && e.name === 'AbortError') return;
if (el.fbPollStatus) {
el.fbPollStatus.textContent = '轮询: 离线';
el.fbPollStatus.className = 'pill offline';
}
setConnState(false, '连接失败');
} finally {
clearTimeout(timer);
state.fbInFlight = false;
}
}
@@ -183,20 +183,22 @@ function renderIdTable(entries) {
tbody.innerHTML = '<tr><td colspan="7" class="status-empty">无数据(CAN 未收到帧)</td></tr>';
return;
}
var html = '';
var frag = document.createDocumentFragment();
for (var i = 0; i < entries.length; i++) {
var e = entries[i];
html += '<tr>';
html += '<td>' + (i + 1) + '</td>';
html += '<td class="mono">' + sanitize(e.id) + '</td>';
html += '<td>' + e.dlc + '</td>';
html += '<td class="' + (e.fd ? 'flag-on' : 'flag-off') + '">' + (e.fd ? 'Y' : '-') + '</td>';
html += '<td class="' + (e.brs ? 'flag-on' : 'flag-off') + '">' + (e.brs ? 'Y' : '-') + '</td>';
html += '<td class="' + (e.esi ? 'flag-on' : 'flag-off') + '">' + (e.esi ? 'Y' : '-') + '</td>';
html += '<td class="idsnap-data mono">' + sanitize(e.data || '--') + '</td>';
html += '</tr>';
var tr = document.createElement('tr');
tr.innerHTML =
'<td>' + (i + 1) + '</td>' +
'<td class="mono">' + escapeHtml(e.id) + '</td>' +
'<td>' + escapeHtml(e.dlc) + '</td>' +
'<td class="' + (e.fd ? 'flag-on' : 'flag-off') + '">' + (e.fd ? 'Y' : '-') + '</td>' +
'<td class="' + (e.brs ? 'flag-on' : 'flag-off') + '">' + (e.brs ? 'Y' : '-') + '</td>' +
'<td class="' + (e.esi ? 'flag-on' : 'flag-off') + '">' + (e.esi ? 'Y' : '-') + '</td>' +
'<td class="idsnap-data mono">' + escapeHtml(e.data || '--') + '</td>';
frag.appendChild(tr);
}
tbody.innerHTML = html;
tbody.innerHTML = '';
tbody.appendChild(frag);
}
function renderIdTableEmpty() {
+59 -51
View File
@@ -18,20 +18,25 @@ import { showToast } from './toast.js';
// ==================== 连接管理 ====================
export function updateConnectionUi(connected, text) {
var label = text || (connected ? '已连接' : '未连接');
// header pill
var pc = document.getElementById('pillConn');
var pct = document.getElementById('pillConnTxt');
pc.className = connected ? 'pill online' : 'pill offline';
pct.textContent = text || (connected ? '已连接' : '未连接');
if (pc) {
pc.className = connected ? 'pill online' : 'pill offline';
if (pct) pct.textContent = label;
}
// connection card indicator
var cs = document.getElementById('connStatus');
var cst = document.getElementById('connStatusTxt');
if (cs) cs.className = connected ? 'conn-indicator ok' : 'conn-indicator err';
if (cst) cst.textContent = connected ? (text || '已连接') : '未连接';
if (cst) cst.textContent = connected ? label : '未连接';
// primary badge
var bc = document.getElementById('bConn');
bc.textContent = connected ? '在线' : '离线';
bc.className = 'v ' + (connected ? 'ok' : 'err');
if (bc) {
bc.textContent = connected ? '在线' : '离线';
bc.className = 'v ' + (connected ? 'ok' : 'err');
}
}
export function updateHealthUi(healthCode, freeHeap) {
var healthNames = ['正常', '警告', '危险', '关机'];
@@ -141,15 +146,14 @@ function updateBootInitUi(data) {
if (!window._bootInitLogged) {
window._bootInitLogged = true;
var logLine = '启动自检: ' + summary + (failed ? ' · 失败/跳过: ' + failed : '');
try {
addLog(allOk ? 'I' : (criticalOk ? 'W' : 'E'), 'BOOT', logLine);
} catch (_) {}
addLog(allOk ? 'I' : (criticalOk ? 'W' : 'E'), 'BOOT', logLine);
}
ensureHealthBootToggleBound();
}
export function updateHeapAndTaskStatusUi(data) {
if (!data) return;
var formatPercent = window.formatPercent;
var ota = data.ota || {};
var setText = function (id, value) {
var el = document.getElementById(id);
@@ -214,9 +218,9 @@ export function updateHeapAndTaskStatusUi(data) {
return (Number(b.usage_pct) || 0) - (Number(a.usage_pct) || 0);
});
setText('cpuUsageText', 'CPU 已用 ' + window.formatPercent(usagePct));
setText('cpuUsagePct', window.formatPercent(usagePct));
setText('cpuIdlePct', window.formatPercent(idlePct));
setText('cpuUsageText', 'CPU 已用 ' + formatPercent(usagePct));
setText('cpuUsagePct', formatPercent(usagePct));
setText('cpuIdlePct', formatPercent(idlePct));
setText('cpuSampleMs', sampleMs > 0 ? sampleMs + ' ms' : '--');
setText('cpuTaskCount', String(tasks.length));
setCpuFill(usagePct);
@@ -234,13 +238,13 @@ export function updateHeapAndTaskStatusUi(data) {
'<div class="status-task cpu-task">' +
'<div class="status-task-head">' +
'<div class="status-task-name">' + escapeHtml(task.name || 'task') + '</div>' +
'<div class="status-task-ratio">' + escapeHtml(window.formatPercent(pct)) + '</div>' +
'<div class="status-task-ratio">' + escapeHtml(formatPercent(pct)) + '</div>' +
'</div>' +
'<div class="status-task-bar"><div class="status-task-bar-fill ' + fillClass + '" style="width:' + Math.max(0, Math.min(100, pct)) + '%"></div></div>' +
'<div class="status-task-meta">' +
'<div><span>任务号</span><b>' + escapeHtml(task.task_number) + '</b></div>' +
'<div><span>增量</span><b>' + escapeHtml(delta) + '</b></div>' +
'<div><span>占比</span><b>' + escapeHtml(window.formatPercent(pct)) + '</b></div>' +
'<div><span>占比</span><b>' + escapeHtml(formatPercent(pct)) + '</b></div>' +
'</div>' +
'</div>';
}).join('');
@@ -294,7 +298,7 @@ export function updateHeapAndTaskStatusUi(data) {
'<div class="status-task">' +
'<div class="status-task-head">' +
'<div class="status-task-name">' + escapeHtml(name) + '</div>' +
'<div class="status-task-ratio">' + escapeHtml(window.formatPercent(usagePct)) + '</div>' +
'<div class="status-task-ratio">' + escapeHtml(formatPercent(usagePct)) + '</div>' +
'</div>' +
'<div class="status-task-bar"><div class="status-task-bar-fill ' + fillClass + '" style="width:' + Math.max(0, Math.min(100, usagePct)) + '%"></div></div>' +
'<div class="status-task-meta">' +
@@ -351,38 +355,50 @@ export async function diagnoseNetwork() {
return result.join(';');
}
/**
* 统一的 /system/status 抓取器:独立 AbortController,超时后中断,
* 避免与递归调度的 pollStatus 互相干扰。
* 成功返回 data,HTTP 非 2xx 抛 Error(含 status),网络失败抛原 Error。
*/
async function fetchSystemStatus(timeoutMs) {
var ac = new AbortController();
var timer = setTimeout(function () { ac.abort(); }, timeoutMs || 3000);
try {
var resp = await fetch(window.getBaseUrl() + '/system/status', { signal: ac.signal });
if (!resp.ok) {
var err = new Error('HTTP ' + resp.status);
err.status = resp.status;
throw err;
}
return await resp.json();
} finally {
clearTimeout(timer);
}
}
/** @description 测试与 ESP32 的连接(GET /status)。 */
export async function testConnection() {
// 立即发一次请求检测
// 手动检测使用独立的 AbortController,避免打断递归调度的 pollStatus
var testAbort = new AbortController();
var testTimer = setTimeout(function() { try { testAbort.abort(); } catch(_e) {} }, 3000);
try {
var resp = await fetch(window.getBaseUrl() + '/system/status', {
signal: testAbort.signal
});
if (resp.ok) {
var data = await resp.json();
window.latestSystemStatus = data;
window.updateSystemStatusUi(data);
var wifi = data.wifi || {};
addLog('I', '连接', wifi.connected
? '已连接:' + (wifi.mode || 'STA') + ' ' + (wifi.ip || '')
: '未连接:' + (wifi.mode || 'STA'));
showToast(wifi.connected ? 'ESP32 当前在线' : 'ESP32 当前未连接',
wifi.connected ? 'success' : 'warning');
} else {
addLog('E', '连接', 'HTTP ' + resp.status);
showToast('设备响应异常: HTTP ' + resp.status, 'error');
}
var data = await fetchSystemStatus(3000);
window.latestSystemStatus = data;
window.updateSystemStatusUi(data);
var wifi = data.wifi || {};
addLog('I', '连接', wifi.connected
? '已连接:' + (wifi.mode || 'STA') + ' ' + (wifi.ip || '')
: '未连接:' + (wifi.mode || 'STA'));
showToast(wifi.connected ? 'ESP32 当前在线' : 'ESP32 当前未连接',
wifi.connected ? 'success' : 'warning');
} catch (e) {
if (e && e.status) {
addLog('E', '连接', 'HTTP ' + e.status);
showToast('设备响应异常: HTTP ' + e.status, 'error');
return;
}
addLog('E', '连接', '连接失败: ' + e.message);
// 连接失败时自动诊断
var diag = await diagnoseNetwork();
addLog('W', '诊断', diag);
showToast('无法连接: ' + diag, 'error');
} finally {
clearTimeout(testTimer);
}
}
export async function refreshSystemStatusNow(event) {
@@ -390,27 +406,19 @@ export async function refreshSystemStatusNow(event) {
event.preventDefault();
event.stopPropagation();
}
// 手动刷新使用独立的 AbortController,避免与递归调度的 pollStatus 互相干扰
var manualAbort = new AbortController();
var manualTimer = setTimeout(function() { try { manualAbort.abort(); } catch(_e) {} }, 3000);
try {
var resp = await fetch(window.getBaseUrl() + '/system/status', {
signal: manualAbort.signal
});
if (!resp.ok) {
addLog('E', '状态', '刷新失败: HTTP ' + resp.status);
showToast('状态刷新失败: HTTP ' + resp.status, 'error');
return;
}
var data = await resp.json();
var data = await fetchSystemStatus(3000);
window.latestSystemStatus = data;
window.updateSystemStatusUi(data);
window.consecutivePollFailures = 0;
addLog('I', '状态', '已手动刷新 /system/status');
} catch (e) {
if (e && e.status) {
addLog('E', '状态', '刷新失败: HTTP ' + e.status);
showToast('状态刷新失败: HTTP ' + e.status, 'error');
return;
}
addLog('E', '状态', '刷新失败: ' + e.message);
showToast('状态刷新失败', 'error');
} finally {
clearTimeout(manualTimer);
}
}
+69 -38
View File
@@ -1,70 +1,101 @@
/**
* log.js - 日志系统(带去重 + 自动滚动)
* log.js - 日志系统(带去重 + 自动滚动 + 上限裁剪)
*
* 日志环形缓冲、去重、过滤、自动滚动逻辑。
* 操作 DOM 元素 #logScroll / #logCount / #autoScrollBtn / .log-filter。
*/
import { formatTime } from './utils.js';
import { formatTime, escapeHtml } from './utils.js';
// ==================== 日志系统状态 ====================
let logFilter = 'all';
let logEntries = [];
let lastLogHash = null;
let autoScroll = true;
var MAX_LOGS = 1000; // 缓冲上限,超出后丢弃最旧条目
var logFilter = 'all';
var logEntries = [];
var lastLogHash = null;
var autoScroll = true;
// 缓存 DOM 引用,避免高频日志下重复 getElementById
var scrollEl = document.getElementById('logScroll');
var countEl = document.getElementById('logCount');
// ==================== 日志系统 (带去重 + 自动滚动) ====================
function makeLogHash(level, tag, message) {
return level + '|' + tag + '|' + message;
}
/** @description 添加一条日志条目到环形缓冲区和日志面板。 */
export function addLog(level, tag, message) {
var scroll = document.getElementById('logScroll');
var time = formatTime(new Date());
var hash = makeLogHash(level, tag, message);
if (hash === lastLogHash) return; // 去重
lastLogHash = hash;
var entry = { time: time, level: level, tag: tag, message: message };
logEntries.push(entry);
if (logFilter !== 'all' && level !== logFilter) return;
/** 构造一条日志 DOM 节点(tag/message 经 HTML 转义,防 XSS)。 */
function createLogLine(entry) {
var div = document.createElement('div');
div.className = 'log-line';
div.innerHTML =
'<span class="lt">[' + time + ']</span>' +
'<span class="lv ' + level + '">' + level + '</span>' +
'<span class="lg">[' + tag + ']</span>' +
'<span class="lm">' + message + '</span>';
scroll.appendChild(div);
if (autoScroll) scroll.scrollTop = scroll.scrollHeight;
document.getElementById('logCount').textContent = logEntries.length;
'<span class="lt">[' + escapeHtml(entry.time) + ']</span>' +
'<span class="lv ' + escapeHtml(entry.level) + '">' + escapeHtml(entry.level) + '</span>' +
'<span class="lg">[' + escapeHtml(entry.tag) + ']</span>' +
'<span class="lm">' + escapeHtml(entry.message) + '</span>';
return div;
}
/** 同步 #logCount 为当前可见条目数。 */
function syncCount() {
if (countEl) countEl.textContent = logEntries.length;
}
/** 超过上限时丢弃最旧条目,并同步移除其 DOM 节点。 */
function trimIfNeeded() {
while (logEntries.length > MAX_LOGS) {
var old = logEntries.shift();
if (old && old.div && old.div.parentNode) old.div.parentNode.removeChild(old.div);
}
}
/** @description 添加一条日志条目到环形缓冲区和日志面板。 */
export function addLog(level, tag, message) {
var hash = makeLogHash(level, tag, message);
if (hash === lastLogHash) return; // 去重
lastLogHash = hash;
var entry = {
time: formatTime(new Date()),
level: level,
tag: tag,
message: message,
div: null
};
logEntries.push(entry);
trimIfNeeded();
if (logFilter !== 'all' && level !== logFilter) {
syncCount();
return;
}
var div = createLogLine(entry);
entry.div = div;
if (scrollEl) {
scrollEl.appendChild(div);
if (autoScroll) scrollEl.scrollTop = scrollEl.scrollHeight;
}
syncCount();
}
export function clearLogs() {
document.getElementById('logScroll').innerHTML = '';
if (scrollEl) scrollEl.innerHTML = '';
logEntries = [];
lastLogHash = null;
document.getElementById('logCount').textContent = '0';
syncCount();
}
export function setLogFilter(level, btn) {
document.querySelectorAll('.log-filter').forEach(function (b) { b.classList.remove('active'); });
btn.classList.add('active');
if (btn) btn.classList.add('active');
logFilter = level;
var scroll = document.getElementById('logScroll');
scroll.innerHTML = '';
if (!scrollEl) return;
var frag = document.createDocumentFragment();
logEntries.forEach(function (e) {
if (level === 'all' || e.level === level) {
var div = document.createElement('div');
div.className = 'log-line';
div.innerHTML =
'<span class="lt">[' + e.time + ']</span>' +
'<span class="lv ' + e.level + '">' + e.level + '</span>' +
'<span class="lg">[' + e.tag + ']</span>' +
'<span class="lm">' + e.message + '</span>';
scroll.appendChild(div);
var div = createLogLine(e);
e.div = div;
frag.appendChild(div);
} else {
e.div = null;
}
});
if (autoScroll) scroll.scrollTop = scroll.scrollHeight;
scrollEl.innerHTML = '';
scrollEl.appendChild(frag);
if (autoScroll) scrollEl.scrollTop = scrollEl.scrollHeight;
}
export function toggleAutoScroll() {
autoScroll = !autoScroll;
var btn = document.getElementById('autoScrollBtn');
btn.textContent = autoScroll ? '\uD83D\uDCCC 自动滚动' : '\u23F8\uFE0F 暂停滚动';
if (btn) btn.textContent = autoScroll ? '\uD83D\uDCCC 自动滚动' : '\u23F8\uFE0F 暂停滚动';
}
+91 -71
View File
@@ -1,11 +1,11 @@
/**
* polling.js - 轮询状态 + Page Visibility 补偿 + ESP32 实时日志拉取
*
* 递归 setTimeout 调度 /system/status + setInterval 调度 /logs,避免 setInterval 慢响应被 abort。
* 递归 setTimeout 调度 /system/status + 递归 setTimeout 调度 /logs,避免 setInterval 慢响应被 abort / 重叠抢 socket。
*
* 模块级状态(仅本模块使用,不导出):
* statusTimerId / logsTimerId / statusAbortCtrl / statusPollInFlight / statusPollIntervalMs
* uploadPollChip / lastLogTimestamp / pageVisible
* statusTimerId / logsTimerId / statusAbortCtrl / statusPollInFlight / logsInFlight / statusPollIntervalMs
* uploadPollChip / pageVisible
*
* 共享状态(通过 window.* 引用):
* window.pollingPausedForUpload — 上传期间暂停轮询标志
@@ -24,6 +24,7 @@
* window.getEspIp / window.getBaseUrl
* window.updateSystemStatusUi / window.updateEsp32StagesFromPhase
* window.applyRebootingRuntimeState / window.applyDisconnectedRuntimeState / window.applyUploadingRuntimeState
* window.applyDeviceBusyRuntimeState
* window.otaSessionActive / window.otaSessionTransition / window.otaSessionReset
* window.updateUploadButtonLabel
*/
@@ -31,6 +32,17 @@ import { addLog } from './log.js';
import { showToast } from './toast.js';
import { updateConnectionUi, diagnoseNetwork } from './connection.js';
// ==================== 状态码常量(与后端 ota_coordinator.h / ota_relay.h 枚举对齐) ====================
// sys_state_code
const SYS_GD32_STORING = 1;
const SYS_GD32_RELAYING = 3;
const SYS_ESP32_RECEIVING = 5;
const SYS_ESP32_FINALIZING = 6;
const SYS_ESP32_REBOOTING = 7;
// relay_state(1~4 为握手/协商/传输/收尾,非 IDLE)
const RELAY_ACTIVE_MIN = 1;
const RELAY_ACTIVE_MAX = 4;
// ==================== 轮询状态 ====================
// 共享状态(初始化到 window,供 connection.js 等其他模块读写)
window.consecutivePollFailures = 0;
@@ -40,9 +52,10 @@ window.lastRelayState = 0; // 上次成功的 relay_state
// 模块级状态(仅本模块使用)
var statusTimerId = null;
var logsTimerId = null;
var statusAbortCtrl = null; // 当前 /system/status 请求的 AbortController(3s 超时用)
var statusAbortCtrl = null; // 当前 /system/status 请求的 AbortController(5s 超时用)
var statusPollIntervalMs = 1000; // 状态轮询目标间隔(中继期可由 setStatusPollIntervalMs 降到 400ms)
var statusPollInFlight = false; // 是否正在请求 /system/status,防止重叠
var logsInFlight = false; // 是否正在请求 /logs,防止重叠抢 socket
var pageVisible = !document.hidden; // 页面可见性:切到后台/最小化/切标签会变 false
var uploadPollChip = null; // 当前暂停轮询的上传目标,用于区分 WiFi/日志策略
@@ -62,8 +75,11 @@ export function setStatusPollIntervalMs(ms) {
* 传入 max(0, interval - 本轮耗时),使 CPU 采样窗口 sample_ms ≈ interval,
* 而不是「完成后 + interval」(会稳定偏到 1100+ ms)。
* 仍保证 in-flight 不重叠,避免 setInterval 慢响应被 abort。
* ★ 上传暂停期间(window.pollingPausedForUpload)不重挂定时器,
* 避免在途请求的 finally 把刚清掉的轮询又挂回来。
*/
export function scheduleStatusPoll(delayMs) {
if (window.pollingPausedForUpload) return;
if (statusTimerId) clearTimeout(statusTimerId);
var interval = statusPollIntervalMs;
statusTimerId = setTimeout(function() {
@@ -72,15 +88,24 @@ export function scheduleStatusPoll(delayMs) {
}, delayMs !== undefined ? delayMs : interval);
}
/** @description 调度下一次 /logs 轮询(递归 setTimeout,仿 scheduleStatusPoll)。 */
function scheduleLogsPoll(delayMs) {
if (logsTimerId) clearTimeout(logsTimerId);
logsTimerId = setTimeout(function() {
logsTimerId = null;
pollLogs();
}, delayMs !== undefined ? delayMs : statusPollIntervalMs);
}
/** @description 启动定时轮询(状态 + 日志)。 */
export function startPolling() {
window.consecutivePollFailures = 0;
statusPollIntervalMs = 1000;
if (statusTimerId) clearTimeout(statusTimerId);
if (logsTimerId) clearInterval(logsTimerId);
if (logsTimerId) clearTimeout(logsTimerId);
scheduleStatusPoll(0); // 立即开始第一次,完成后按 interval 递归调度
logsTimerId = setInterval(pollLogs, 1000);
scheduleLogsPoll(0);
}
export function pausePollingForUpload() {
if (window.pollingPausedForUpload) return;
@@ -96,7 +121,7 @@ export function pausePollingForUpload() {
statusTimerId = null;
}
if (logsTimerId) {
clearInterval(logsTimerId);
clearTimeout(logsTimerId);
logsTimerId = null;
}
if (statusAbortCtrl) {
@@ -111,16 +136,48 @@ export function resumePollingAfterUpload() {
uploadPollChip = null;
window.consecutivePollFailures = 0;
statusPollIntervalMs = 1000;
if (logsTimerId) clearInterval(logsTimerId);
logsTimerId = setInterval(pollLogs, statusPollIntervalMs);
if (logsTimerId) clearTimeout(logsTimerId);
// 上传结束立即刷新一次状态;若当前已在请求中,则让它完成后按新间隔继续
if (statusPollInFlight) {
if (statusTimerId) clearTimeout(statusTimerId);
} else {
scheduleStatusPoll(0);
}
pollLogs();
scheduleLogsPoll(0);
}
/**
* @description 统一的 ESP32 重启完成处理:清重启标志 → 推进阶段 6 → 状态机转 DONE → 刷新按钮。
* 抽出来供 pollStatus 成功分支与 pollLogs 启动 banner 分支复用,避免两处逻辑漂移。
* 以 isEsp32Rebooting 作幂等守卫:已处理过的重启不再重复弹 toast / 写日志 / 重复转 DONE。
* @param {string} source 日志来源描述(如 '启动 banner 收到'),用于区分日志文案。
*/
function markEsp32RebootComplete(source) {
if (!window.isEsp32Rebooting) return;
window.isEsp32Rebooting = false;
window.rebootObservedDown = false;
window.updateEsp32StagesFromPhase(6); // 启动完成
addLog('I', '系统', source ? 'ESP32 已恢复在线(' + source + ')' : 'ESP32 已恢复在线');
showToast('ESP32 已恢复在线', 'success');
// ★ 状态机:ESP32 重启完成 → DONE
if (window.otaSessionState === window.OtaSessionState.REBOOTING) {
window.otaSessionTransition(window.OtaSessionState.DONE);
}
if (typeof window.updateUploadButtonLabel === 'function') {
window.updateUploadButtonLabel();
}
}
/** @description 判断设备最近一次成功轮询时是否处于「忙」状态(OTA 存储/中继/接收/收尾/重启 或 relay 握手~收尾)。 */
function isLastDeviceBusy() {
var s = window.lastSysStateCode;
var r = window.lastRelayState;
return s === SYS_GD32_STORING || s === SYS_GD32_RELAYING ||
s === SYS_ESP32_RECEIVING || s === SYS_ESP32_FINALIZING ||
s === SYS_ESP32_REBOOTING ||
(r >= RELAY_ACTIVE_MIN && r <= RELAY_ACTIVE_MAX);
}
/** @description 轮询 ESP32 系统状态(GET /status)并更新 UI。 */
export async function pollStatus() {
// 递归 setTimeout 调度,正常情况下不会重叠;做个保险避免并发请求
@@ -153,25 +210,12 @@ export async function pollStatus() {
if (window.isEsp32Rebooting ||
window.otaSessionState === window.OtaSessionState.REBOOTING) {
var rbElapsed = Math.round((Date.now() - window.rebootStartTime) / 1000);
if (!window.rebootObservedDown && rbElapsed < 10) {
// pre-reboot 窗口:后端发响应后约 1s 才 esp_restart,期间设备仍响应轮询
// 保持"重启中"状态,不清除标志,不推进阶段
// ★ 真正的"重启完成"由 pollLogs 收到启动 banner(MAIN: ESP32-C3 OTA Relay v1.0 启动)触发
} else {
// 设备已重启并恢复在线(rebootObservedDown=true)
// 或超 10s 安全阈值仍未观察到下线(重启过快未捕获或未重启)——接受恢复
window.isEsp32Rebooting = false;
window.rebootObservedDown = false;
window.updateEsp32StagesFromPhase(6); // 启动完成
addLog('I', '系统', 'ESP32 已恢复在线');
showToast('ESP32 已恢复在线', 'success');
// ★ 状态机:ESP32 重启完成 → DONE
if (window.otaSessionState === window.OtaSessionState.REBOOTING) {
window.otaSessionTransition(window.OtaSessionState.DONE);
}
if (typeof window.updateUploadButtonLabel === 'function') {
window.updateUploadButtonLabel();
}
// pre-reboot 窗口:后端发响应后约 1s 才 esp_restart,期间设备仍响应轮询,
// 保持"重启中"状态,不清除标志,不推进阶段。
// ★ 真正的"重启完成"由 pollLogs 收到启动 banner 触发;
// 这里只在已观察到下线(rebootObservedDown)或超 10s 安全阈值时接受恢复。
if (window.rebootObservedDown || rbElapsed >= 10) {
markEsp32RebootComplete();
}
}
try { window.updateSystemStatusUi(data); } catch (e2) { console.warn('updateSystemStatusUi 异常:', e2); }
@@ -188,15 +232,7 @@ export async function pollStatus() {
// 后台期间 setInterval 会被 Chrome/Edge 节流到最低 1 分钟/次,
// 因此后台累积的失败计数不应递增,避免切回前台瞬间误触发 5-fail 诊断。
if (pageVisible) window.consecutivePollFailures++;
// 判断设备是否处于"已知忙/重启"状态(依据最近一次成功轮询的 sys_state_code):
// 1=GD32_STORING, 3=GD32_RELAYING, 5=ESP32_RECEIVING,
// 6=ESP32_FINALIZING, 7=ESP32_REBOOTING
// 也包含 relay_state 非 IDLE(1-4 表示握手/协商/传输/收尾)
var lastDeviceBusy =
window.lastSysStateCode === 1 || window.lastSysStateCode === 3 ||
window.lastSysStateCode === 5 || window.lastSysStateCode === 6 ||
window.lastSysStateCode === 7 ||
(window.lastRelayState >= 1 && window.lastRelayState <= 4);
var lastDeviceBusy = isLastDeviceBusy();
// OTA/传输/重启中屏蔽网关无响应的检测功能(设备忙或正在重启,无法响应轮询是正常的)
// 设备已知忙时:保持 1s 轮询,不触发降速诊断,避免错过设备恢复的即时反馈
// ★ 状态机:用统一的会话活跃/失败判断替代散布的标志。
@@ -216,14 +252,7 @@ export async function pollStatus() {
if (sessionFailed) {
if (lastDeviceBusy) {
// 设备端仍在跑(中继没停),标"设备忙"但不覆盖进度/阶段
var pcF = document.getElementById('pillConn');
var pctF = document.getElementById('pillConnTxt');
if (pcF) pcF.className = 'pill processing';
if (pctF) pctF.textContent = '设备忙';
var csF = document.getElementById('connStatus');
var cstF = document.getElementById('connStatusTxt');
if (csF) csF.className = 'conn-indicator warn';
if (cstF) cstF.textContent = '设备忙(OTA 后台运行中),等待回 IDLE';
window.applyDeviceBusyRuntimeState('设备忙', '设备忙(OTA 后台运行中),等待回 IDLE', '设备忙');
updateConnectionUi(true, '设备忙');
} else {
// 设备已回 IDLE:连接胶囊显示未连接,但失败进度保留
@@ -253,14 +282,7 @@ export async function pollStatus() {
} else if (sessionActive || lastDeviceBusy) {
// 设备在 OTA/中继/重启期间不响应是正常的:保持 1s 轮询,UI 标记为"设备忙"
// 不调用 applyRebootingRuntimeState(避免显示"重启中 Xs"误导)
var pc = document.getElementById('pillConn');
var pct = document.getElementById('pillConnTxt');
if (pc) pc.className = 'pill processing';
if (pct) pct.textContent = '设备忙';
var cs = document.getElementById('connStatus');
var cst = document.getElementById('connStatusTxt');
if (cs) cs.className = 'conn-indicator warn';
if (cst) cst.textContent = '设备忙(OTA/重启中),保持 1s 轮询';
window.applyDeviceBusyRuntimeState('设备忙', '设备忙(OTA/重启中),保持 1s 轮询', '设备忙');
updateConnectionUi(true, '设备忙');
} else {
updateConnectionUi(false, '未连接');
@@ -271,7 +293,9 @@ export async function pollStatus() {
statusAbortCtrl = null;
/* 固定周期调度:interval 从本轮开始计时扣减请求耗时。
* 旧逻辑「完成后 + 1000ms」→ 窗口稳定 1100+;现 sample_ms 贴近 1000/400。
* 本轮超时/很慢时 delay=0,立即补下一轮,仍不重叠。 */
* 本轮超时/很慢时 delay=0,立即补下一轮,仍不重叠。
* ★ 上传暂停期间不重挂(由 scheduleStatusPoll 内部判断),避免在途请求 abort 后
* 的 finally 把刚清掉的轮询又挂回来,导致上传中途再打 /system/status 抢 socket。 */
var elapsed = Date.now() - pollStartedAt;
var delay = Math.max(0, statusPollIntervalMs - elapsed);
scheduleStatusPoll(delay);
@@ -297,10 +321,11 @@ document.addEventListener('visibilitychange', function () {
pollLogs();
});
// ==================== ESP32 实时日志拉取 (增量) ====================
var lastLogTimestamp = 0;
/** @description 轮询 ESP32 日志(GET /logs)并追加到日志面板。 */
export async function pollLogs() {
// ★ ESP32 上传期间也允许日志轮询
// ★ ESP32 上传期间也允许日志轮询;in-flight 守卫避免与 /system/status、/ota/upload 抢 socket
if (logsInFlight) return;
logsInFlight = true;
try {
var resp = await fetch(window.getBaseUrl() + '/logs', {
signal: AbortSignal.timeout(5000)
@@ -308,28 +333,17 @@ export async function pollLogs() {
if (!resp.ok) return;
var logs = await resp.json();
if (!Array.isArray(logs) || logs.length === 0) return;
var levelMap = { E: 'E', W: 'W', I: 'I', D: 'D' };
logs.forEach(function (line) {
var m = line.match(/^\[([EWID])\]\[([^\]]+)\]\s*(.*)$/);
if (m) {
addLog(levelMap[m[1]] || 'I', m[2], m[3].trimEnd());
addLog(m[1], m[2], m[3].trimEnd());
// ★ 复用设备启动 banner 作为 ESP32 重启完成的权威信号。
// esp_restart() 是硬件复位,不保证 TCP FIN/RST;靠轮询超时/网络错误判断
// "已下线"不可靠。设备启动后会推 [I][MAIN] ESP32-C3 OTA Relay v1.0 启动,
// 收到这行即说明设备已重启回来,直接推进到阶段 6(启动完成)。
/* 与 main 启动 banner 对齐:ESP32-C3 OTA Relay <FW_VERSION> 启动(勿写死 v1.0) */
if (window.isEsp32Rebooting && m[2] === 'MAIN' && /OTA Relay .*启动/.test(m[3])) {
window.isEsp32Rebooting = false;
window.rebootObservedDown = false;
window.updateEsp32StagesFromPhase(6); // 启动完成
addLog('I', '系统', 'ESP32 已恢复在线(启动 banner 收到)');
showToast('ESP32 已恢复在线', 'success');
if (window.otaSessionState === window.OtaSessionState.REBOOTING) {
window.otaSessionTransition(window.OtaSessionState.DONE);
}
if (typeof window.updateUploadButtonLabel === 'function') {
window.updateUploadButtonLabel();
}
markEsp32RebootComplete('启动 banner 收到');
}
/*
* 设备已报「全部数据包已转发」时,立刻补 UI 100% 进度。
@@ -347,7 +361,13 @@ export async function pollLogs() {
addLog('I', 'ESP32', line.trimEnd());
}
});
} catch (e) { /* 静默 */ }
} catch (e) { /* 静默 */ } finally {
logsInFlight = false;
// 递归调度下一轮:固定周期扣减本轮耗时,避免慢响应时重叠抢 socket
if (!window.pollingPausedForUpload) {
scheduleLogsPoll(statusPollIntervalMs);
}
}
}
setTimeout(function () {
window.updateUploadButtonLabel();
+14 -9
View File
@@ -2,32 +2,37 @@
* toast.js - Toast 提示组件
*
* showToast 函数,操作 DOM 元素 #toast / #toastIco / #toastMsg。
* 支持 info / success / warning / error 类型,内置 5s 去重防抖。
* 支持 success / error / warning 三类;info 走 warning 同款样式(历史兼容,无专属配色)。
* 内置 5s 去重防抖,相同 (type|message) 5s 内不重复弹出。
*/
/** @description 显示 Toast 提示消息,支持 info/success/warning/error 类型。 */
/** @description 显示 Toast 提示消息,支持 success/error/warning(info 同 warning)。 */
const TOAST_DEDUPE_MS = 5000;
const TOAST_AUTOHIDE_MS = 3000;
var _toastHideTimer = null;
var _toastLastKey = '';
var _toastLastAt = 0;
export function showToast(message, type) {
type = type || 'success';
var toast = document.getElementById('toast');
if (!toast) return;
// 相同文案 5s 内不重复弹,避免轮询把 ✓ 刷成「疯狂跳」
var key = type + '|' + message;
var now = Date.now();
if (key === _toastLastKey && (now - _toastLastAt) < 5000) {
if (key === _toastLastKey && (now - _toastLastAt) < TOAST_DEDUPE_MS) {
return;
}
_toastLastKey = key;
_toastLastAt = now;
const toast = document.getElementById('toast');
const ico = document.getElementById('toastIco');
const msg = document.getElementById('toastMsg');
var ico = document.getElementById('toastIco');
var msg = document.getElementById('toastMsg');
toast.className = 'toast ' + (type === 'success' ? 'ok' : type === 'error' ? 'err' : 'warn') + ' show';
ico.textContent = type === 'success' ? '\u2713' : type === 'error' ? '\u2715' : '\u26A0';
msg.textContent = message;
if (ico) ico.textContent = type === 'success' ? '\u2713' : type === 'error' ? '\u2715' : '\u26A0';
if (msg) msg.textContent = message;
if (_toastHideTimer) clearTimeout(_toastHideTimer);
_toastHideTimer = setTimeout(function () {
toast.classList.remove('show');
_toastHideTimer = null;
}, 3000);
}, TOAST_AUTOHIDE_MS);
}
+16
View File
@@ -42,3 +42,19 @@ export function escapeHtml(text) {
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
/**
* @description 将字节数组(Uint8Array 或 ArrayBuffer)转为小写 hex 字符串。
* SHA-256 等哈希显示前需要 toUpperCase()。
* @param {Uint8Array|ArrayBuffer} bytes
* @returns {string}
*/
export function bytesToHex(bytes) {
var arr = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes);
var out = '';
for (var i = 0; i < arr.length; i++) {
var h = arr[i].toString(16);
if (h.length < 2) h = '0' + h;
out += h;
}
return out;
}
+75 -66
View File
@@ -10,7 +10,7 @@
* 复用的函数(通过 window.* 引用):
* window.updateUploadButtonLabel / window.showToast / window.addLog
*/
import { formatBytes } from '../common/utils.js';
import { formatBytes, bytesToHex } from '../common/utils.js';
import { showToast } from '../common/toast.js';
import { addLog } from '../common/log.js';
@@ -33,24 +33,31 @@ var IDB_STORE = 'file_handles';
* 刷新后权限常为 prompt,requestPermission 必须在用户手势里才成功。
* 此处暂存待授权 handle,点击上传区时再 requestPermission 并直接 getFile。
*/
window._pendingFwHandles = window._pendingFwHandles || { esp32: null, gd32: null };
window._pendingFwHandles = { esp32: null, gd32: null };
/** 模块级 IndexedDB 连接缓存,避免每次 put/get 都重开 */
var _idbDbPromise = null;
function openHandleDb() {
return new Promise(function (resolve, reject) {
if (!window.indexedDB) {
reject(new Error('indexedDB unavailable'));
return;
}
var req = indexedDB.open(IDB_NAME, 1);
req.onupgradeneeded = function () {
var db = req.result;
if (!db.objectStoreNames.contains(IDB_STORE)) {
db.createObjectStore(IDB_STORE);
if (!_idbDbPromise) {
_idbDbPromise = new Promise(function (resolve, reject) {
if (!window.indexedDB) {
reject(new Error('indexedDB unavailable'));
return;
}
};
req.onsuccess = function () { resolve(req.result); };
req.onerror = function () { reject(req.error || new Error('idb open failed')); };
});
var req = indexedDB.open(IDB_NAME, 1);
req.onupgradeneeded = function () {
var db = req.result;
if (!db.objectStoreNames.contains(IDB_STORE)) {
db.createObjectStore(IDB_STORE);
}
};
req.onsuccess = function () { resolve(req.result); };
req.onerror = function () { reject(req.error || new Error('idb open failed')); };
});
// 失败时清空缓存,下次允许重试
_idbDbPromise.catch(function () { _idbDbPromise = null; });
}
return _idbDbPromise;
}
function idbPutHandle(chip, handle) {
@@ -97,8 +104,8 @@ export function loadPersistedFlashTarget() {
}
}
/** 保存 FSAPI handle,刷新后可默认恢复该文件 */
export function persistFileHandle(chip, handle) {
/** 保存 FSAPI handle,刷新后可默认恢复该文件(仅模块内使用,不导出) */
function persistFileHandle(chip, handle) {
if (chip !== 'esp32' && chip !== 'gd32') return Promise.resolve();
return idbPutHandle(chip, handle || null);
}
@@ -136,6 +143,13 @@ function loadFileMeta(chip) {
return map[chip] || null;
}
/** 安全调用 window.updateUploadButtonLabel(恢复路径可能在 app.js 初始化前触发) */
function safeUpdateUploadButtonLabel() {
if (typeof window.updateUploadButtonLabel === 'function') {
window.updateUploadButtonLabel();
}
}
/** 仅 query,不 request(刷新无用户手势时 request 会失败) */
async function queryHandlePermission(handle) {
if (!handle || typeof handle.getFile !== 'function') return 'denied';
@@ -157,7 +171,7 @@ async function queryHandlePermission(handle) {
*/
export async function resumePersistedFirmwareWithGesture(chip) {
if (chip !== 'esp32' && chip !== 'gd32') return false;
var handle = (window._pendingFwHandles && window._pendingFwHandles[chip]) ||
var handle = (window._pendingFwHandles[chip]) ||
(chip === 'esp32' ? window.esp32FileHandle : window.gd32FileHandle) ||
null;
if (!handle) {
@@ -183,7 +197,7 @@ export async function resumePersistedFirmwareWithGesture(chip) {
}
var file = await handle.getFile();
applyRestoredFile(chip, handle, file, { silent: true });
if (window._pendingFwHandles) window._pendingFwHandles[chip] = null;
window._pendingFwHandles[chip] = null;
addLog('I', '文件', chip.toUpperCase() + ' 已重新授权并恢复: ' + file.name);
showToast(chip.toUpperCase() + ' 已恢复上次固件', 'success');
return true;
@@ -208,16 +222,14 @@ function applyRestoredFile(chip, handle, file, options) {
if (typeof window.syncBleFileFromSelectedTarget === 'function') {
window.syncBleFileFromSelectedTarget();
}
if (typeof window.updateUploadButtonLabel === 'function') {
window.updateUploadButtonLabel();
}
safeUpdateUploadButtonLabel();
}
/** 无权限时仍展示上次文件名,提示点上传区一键恢复 */
function showPendingReauthUi(chip, handle, meta) {
if (chip === 'esp32') window.esp32FileHandle = handle;
else window.gd32FileHandle = handle;
if (window._pendingFwHandles) window._pendingFwHandles[chip] = handle;
window._pendingFwHandles[chip] = handle;
if (window.sharedFirmwareFiles) {
window.sharedFirmwareFiles[chip] = { file: null, handle: handle };
}
@@ -234,9 +246,7 @@ function showPendingReauthUi(chip, handle, meta) {
if (sizeEl) sizeEl.textContent = meta && meta.size ? formatBytes(meta.size) : '--';
var shaEl = document.getElementById('fileSha256' + suffix);
if (shaEl) shaEl.textContent = '刷新后需点击授权';
if (typeof window.updateUploadButtonLabel === 'function') {
window.updateUploadButtonLabel();
}
safeUpdateUploadButtonLabel();
}
/**
@@ -260,7 +270,7 @@ export async function restorePersistedFirmwareSelection() {
if (perm === 'granted') {
var file = await handle.getFile();
applyRestoredFile(chip, handle, file, { silent: true, skipHandlePersist: true });
if (window._pendingFwHandles) window._pendingFwHandles[chip] = null;
window._pendingFwHandles[chip] = null;
addLog('I', '文件', chip.toUpperCase() + ' 已恢复刷新前的固件: ' + file.name);
continue;
}
@@ -281,9 +291,7 @@ export async function restorePersistedFirmwareSelection() {
if (typeof window.syncBleFileFromSelectedTarget === 'function') {
window.syncBleFileFromSelectedTarget();
}
if (typeof window.updateUploadButtonLabel === 'function') {
window.updateUploadButtonLabel();
}
safeUpdateUploadButtonLabel();
}
export function handleFileSelect(event, chip) {
@@ -292,7 +300,7 @@ export function handleFileSelect(event, chip) {
/* 传统 input 无 handle:清掉旧 FSAPI 句柄,避免热重载读到过期路径 */
if (chip === 'esp32') window.esp32FileHandle = null;
else window.gd32FileHandle = null;
if (window._pendingFwHandles) window._pendingFwHandles[chip] = null;
window._pendingFwHandles[chip] = null;
persistFileHandle(chip, null);
processFile(file, chip);
}
@@ -303,7 +311,7 @@ export async function pickFileViaFSAPI(chip) {
* 仅在「刷新后待授权」或「尚无已加载 File」时尝试一键恢复。
* 已有可用文件时再点上传区 → 打开选文件对话框(可换文件)。
*/
var pending = window._pendingFwHandles && window._pendingFwHandles[chip];
var pending = window._pendingFwHandles[chip];
var hasLoadedFile = !!(chip === 'esp32' ? window.esp32File : window.gd32File);
if (pending || !hasLoadedFile) {
var resumed = await resumePersistedFirmwareWithGesture(chip);
@@ -321,7 +329,7 @@ export async function pickFileViaFSAPI(chip) {
? { 'application/octet-stream': ['.bin'] }
: { 'application/octet-stream': ['.bin', '.hex'] };
var prevHandle = chip === 'esp32' ? window.esp32FileHandle : window.gd32FileHandle;
if (!prevHandle && window._pendingFwHandles) {
if (!prevHandle) {
prevHandle = window._pendingFwHandles[chip] || null;
}
var opts = {
@@ -339,8 +347,8 @@ export async function pickFileViaFSAPI(chip) {
var file = await handle.getFile();
if (chip === 'esp32') window.esp32FileHandle = handle;
else window.gd32FileHandle = handle;
if (window._pendingFwHandles) window._pendingFwHandles[chip] = null;
processFile(file, chip);
window._pendingFwHandles[chip] = null;
await processFile(file, chip);
persistFileHandle(chip, handle);
persistFileMeta(chip, file);
persistFlashTarget(chip);
@@ -382,7 +390,7 @@ export function setupDropZone(zoneId, chip) {
* @returns {{ data: Uint8Array, baseAddr: number, endAddr: number }}
*/
export function parseIntelHex(hexText) {
var baseAddr = 0; // 当前扩展地址
var baseAddr = 0; // 当前扩展地址(uint32)
var segments = []; // { offset, data } 列表
var minAddr = 0xFFFFFFFF;
var maxAddr = 0;
@@ -391,13 +399,13 @@ export function parseIntelHex(hexText) {
var line = lines[i].trim();
if (!line || line[0] !== ':') continue;
// 解析行: :LLAAAATT[DD...]CC
var byteCount = parseInt(line.substr(1, 2), 16);
var addr16 = parseInt(line.substr(3, 4), 16);
var recType = parseInt(line.substr(7, 2), 16);
var byteCount = parseInt(line.slice(1, 3), 16);
var addr16 = parseInt(line.slice(3, 7), 16);
var recType = parseInt(line.slice(7, 9), 16);
// 校验和验证
var sum = 0;
for (var j = 1; j < line.length; j += 2) {
sum += parseInt(line.substr(j, 2), 16);
sum += parseInt(line.slice(j, j + 2), 16);
}
if ((sum & 0xFF) !== 0) {
throw new Error('HEX 行 ' + (i + 1) + ' 校验和错误: ' + line);
@@ -406,17 +414,17 @@ export function parseIntelHex(hexText) {
// EOF
break;
} else if (recType === 0x04) {
// 扩展线性地址 (upper 16 bits)
baseAddr = parseInt(line.substr(9, 4), 16) << 16;
// 扩展线性地址 (upper 16 bits),>>>0 归整为 uint32
baseAddr = (parseInt(line.slice(9, 13), 16) << 16) >>> 0;
} else if (recType === 0x02) {
// 扩展段地址
baseAddr = (parseInt(line.substr(9, 4), 16) << 4) & 0xFFFFF;
// 扩展段地址:seg << 4,不加掩码(避免截断 0x10FFEF 等合法地址)
baseAddr = (parseInt(line.slice(9, 13), 16) << 4) >>> 0;
} else if (recType === 0x00) {
// 数据记录
var absAddr = baseAddr + addr16;
var absAddr = (baseAddr + addr16) >>> 0;
var data = new Uint8Array(byteCount);
for (var k = 0; k < byteCount; k++) {
data[k] = parseInt(line.substr(9 + k * 2, 2), 16);
data[k] = parseInt(line.slice(9 + k * 2, 11 + k * 2), 16);
}
segments.push({ offset: absAddr, data: data });
if (absAddr < minAddr) minAddr = absAddr;
@@ -459,15 +467,15 @@ export function isIntelHexText(text) {
* 公共 HEX→BIN 转换:如果文件是 .hex 格式,自动解析为 bin。
* BLE / WiFi 共用此函数,避免转换逻辑重复。
* @param {File} file 原始文件
* @returns {Promise<{file: File, converted: boolean, baseAddr: number|null}>}
* @returns {Promise<{file: File, converted: boolean, baseAddr: number|null, endAddr: number|null}>}
*/
export async function convertHexFileToBin(file) {
if (!file || !file.name.toLowerCase().endsWith('.hex')) {
return { file: file, converted: false, baseAddr: null };
return { file: file, converted: false, baseAddr: null, endAddr: null };
}
var text = await file.text();
if (!isIntelHexText(text)) {
return { file: file, converted: false, baseAddr: null };
return { file: file, converted: false, baseAddr: null, endAddr: null };
}
var result = parseIntelHex(text);
var binName = file.name.replace(/\.hex$/i, '.bin');
@@ -498,8 +506,7 @@ function _computeAndCacheFileSha(file, chip, optArrayBuffer) {
return bufPromise.then(function(buf) {
return crypto.subtle.digest('SHA-256', buf);
}).then(function(hashBuffer) {
var hashArray = Array.from(new Uint8Array(hashBuffer));
var hashHex = hashArray.map(function(b) { return b.toString(16).padStart(2, '0'); }).join('').toUpperCase();
var hashHex = bytesToHex(new Uint8Array(hashBuffer)).toUpperCase();
if (shaElem) shaElem.textContent = hashHex;
var meta = { size: file.size, lastModified: file.lastModified, name: file.name };
if (chip === 'esp32') {
@@ -546,10 +553,13 @@ export function ensureFileShaCached(file, chip, optArrayBuffer) {
// ==================== 文件处理 ====================
/**
* @description 处理用户选择的固件文件(HEX→BIN 转换、SHA-256 计算、文件校验)。
* 对 GD32 .hex 走异步转换;返回 Promise 以便调用方 await
* (避免上游用 setTimeout 硬等,见 upload-esp32.js)。
* @param {File} file
* @param {'esp32'|'gd32'} chip
* @param {{silent?: boolean, skipHandlePersist?: boolean}=} options
* silent: 恢复场景不弹 toast;skipHandlePersist: 已从 IDB 读出时不再写回
* @returns {Promise<void>}
*/
export function processFile(file, chip, options) {
var opts = options || {};
@@ -565,7 +575,7 @@ export function processFile(file, chip, options) {
}
if (chip === 'gd32' && isHexByName) {
// GD32 固件: .hex 文件自动转换为 .bin,然后替换文件对象
convertHexFileToBin(file).then(function(result) {
return convertHexFileToBin(file).then(function(result) {
if (!result.converted) {
// 文件名 .hex 但内容不像,直接用原始文件
addLog('W', '文件', file.name + ' 扩展名为 .hex 但内容不像 HEX 格式,按原始文件处理');
@@ -574,9 +584,8 @@ export function processFile(file, chip, options) {
document.getElementById('fileName' + suffix).textContent = file.name;
document.getElementById('fileType' + suffix).textContent = 'Binary(?)';
document.getElementById('fileSize' + suffix).textContent = formatBytes(file.size);
window.updateUploadButtonLabel();
ensureFileShaCached(file, 'gd32');
return;
safeUpdateUploadButtonLabel();
return ensureFileShaCached(file, 'gd32');
}
window.gd32File = result.file;
var baseHex = '0x' + result.baseAddr.toString(16).toUpperCase().padStart(8, '0');
@@ -587,16 +596,16 @@ export function processFile(file, chip, options) {
document.getElementById('fileName' + suffix).textContent = result.file.name + ' (由 ' + file.name + ' 转换)';
document.getElementById('fileType' + suffix).textContent = 'Binary (HEX转换)';
document.getElementById('fileSize' + suffix).textContent = formatBytes(result.file.size);
window.updateUploadButtonLabel();
ensureFileShaCached(result.file, 'gd32');
safeUpdateUploadButtonLabel();
return ensureFileShaCached(result.file, 'gd32');
}).catch(function(err) {
addLog('E', '文件', 'HEX 解析失败: ' + err.message);
if (!silent) showToast('HEX 文件解析失败: ' + err.message, 'error');
window.gd32File = null;
document.getElementById('fileInfo' + suffix).classList.remove('show');
window.updateUploadButtonLabel();
safeUpdateUploadButtonLabel();
throw err; // 让上游 await 感知失败
});
return; // 异步,回调里设置 gd32File
}
// .bin 文件或 ESP32 文件: 直接使用
if (chip === 'esp32') window.esp32File = file;
@@ -606,7 +615,7 @@ export function processFile(file, chip, options) {
document.getElementById('fileType' + suffix).textContent =
isHexByName ? 'Intel HEX' : 'Binary';
document.getElementById('fileSize' + suffix).textContent = formatBytes(file.size);
window.updateUploadButtonLabel();
safeUpdateUploadButtonLabel();
if (silent) {
addLog('I', '文件', chip.toUpperCase() + ' 已恢复: ' + file.name + ' (' + formatBytes(file.size) + ')');
} else {
@@ -615,7 +624,7 @@ export function processFile(file, chip, options) {
}
// 选文件时就把 SHA-256 算好并缓存(ESP32/GD32 都算),
// 上传时若文件未变 ensureFileShaCached 命中直接复用。
ensureFileShaCached(file, chip);
return Promise.resolve(ensureFileShaCached(file, chip));
}
/** @description 清除已选固件文件并重置对应 UI 状态。 */
@@ -628,13 +637,13 @@ export function clearFile(chip) {
window.gd32File = null; window.gd32FileHandle = null;
window._gd32FileSha256 = null; window._gd32FileShaMeta = null;
}
if (window._pendingFwHandles) window._pendingFwHandles[chip] = null;
window._pendingFwHandles[chip] = null;
persistFileHandle(chip, null);
persistFileMeta(chip, null);
document.getElementById('fileInput' + suffix).value = '';
document.getElementById('fileInfo' + suffix).classList.remove('show');
var shaElem = document.getElementById('fileSha256' + suffix);
if (shaElem) shaElem.textContent = '-';
window.updateUploadButtonLabel();
var shaEl = document.getElementById('fileSha256' + suffix);
if (shaEl) shaEl.textContent = '-';
safeUpdateUploadButtonLabel();
addLog('I', '文件', chip.toUpperCase() + ' 文件已清除');
}
+64 -34
View File
@@ -12,6 +12,17 @@
*/
import { addLog } from '../common/log.js';
// ==================== 常量 ====================
// ESP32 OTA 阶段 DOM 节点 id(es1..es6 + esd1..esd6)
var ESP32_STAGE_IDS = ['es1', 'es2', 'es3', 'es4', 'es5', 'es6'];
var ESP32_DETAIL_IDS = ['esd1', 'esd2', 'esd3', 'esd4', 'esd5', 'esd6'];
// 顶部步骤 pill → phase 名映射
var STEP_MAP = { esp32Ota: 'ppEsp32', gd32Ota: 'ppGd32Ota' };
// 进度条 DOM 元素懒缓存(首次访问后复用,DOM 未就绪时回退到 getElementById)
var progressFillEl = null;
var progressTxtEl = null;
var progressPctEl = null;
// ==================== 折叠面板 ====================
export function toggleCollapse(headEl) {
headEl.closest('.card').classList.toggle('collapsed');
@@ -29,25 +40,37 @@ export function switchStageFlow(target) {
if (target === 'esp32') {
if (esp32El) esp32El.style.display = '';
if (gd32El) gd32El.style.display = 'none';
} else {
} else if (target === 'gd32') {
if (esp32El) esp32El.style.display = 'none';
if (gd32El) gd32El.style.display = '';
} else {
// null / 'ble' / 未知:隐藏两套面板(reset 语义,避免残留显示)
if (esp32El) esp32El.style.display = 'none';
if (gd32El) gd32El.style.display = 'none';
}
// 重置 ESP32 阶段节点
['es1','es2','es3','es4','es5','es6'].forEach(function(id) {
resetEsp32StageNodes();
resetGd32StageNodes();
}
/** @description 把 ESP32 阶段节点(es1..es6 / esd1..esd6)复位到初始态。 */
function resetEsp32StageNodes() {
ESP32_STAGE_IDS.forEach(function (id) {
var el = document.getElementById(id);
if (el) el.className = 'stage-node';
});
['esd1','esd2','esd3','esd4','esd5','esd6'].forEach(function(id) {
ESP32_DETAIL_IDS.forEach(function (id) {
var el = document.getElementById(id);
if (el) el.textContent = '';
});
// 重置 GD32 阶段节点
['s1','s2','s3','s4','s5','s6'].forEach(function(id) {
}
/** @description 把 GD32 阶段节点(s1..s6 / sd1..sd6)复位到初始态。 */
function resetGd32StageNodes() {
['s1', 's2', 's3', 's4', 's5', 's6'].forEach(function (id) {
var el = document.getElementById(id);
if (el) el.className = 'stage-node';
});
['sd1','sd2','sd3','sd4','sd5','sd6'].forEach(function(id) {
['sd1', 'sd2', 'sd3', 'sd4', 'sd5', 'sd6'].forEach(function (id) {
var el = document.getElementById(id);
if (el) el.textContent = '';
});
@@ -59,10 +82,8 @@ export const esp32PhaseLabels = ['', '上传中', '写入中', '校验中', '应
export function updateEsp32StagesFromPhase(phase) {
window.esp32OtaPhase = phase;
var ids = ['es1', 'es2', 'es3', 'es4', 'es5', 'es6'];
var detailIds = ['esd1', 'esd2', 'esd3', 'esd4', 'esd5', 'esd6'];
var all = ids.map(function(id) { return document.getElementById(id); });
var details = detailIds.map(function(id) { return document.getElementById(id); });
var all = ESP32_STAGE_IDS.map(function (id) { return document.getElementById(id); });
var details = ESP32_DETAIL_IDS.map(function (id) { return document.getElementById(id); });
all.forEach(function (s) { if (s) s.className = 'stage-node'; });
details.forEach(function (d) { if (d) d.textContent = ''; });
var statusBar = document.getElementById('stageStatusBar');
@@ -78,7 +99,7 @@ export function updateEsp32StagesFromPhase(phase) {
if (!all[i]) continue;
if (i < phase - 1) {
all[i].className = 'stage-node done';
} else if (i === phase - 1 && phase >= 1) {
} else if (i === phase - 1) {
all[i].className = (phase >= 6) ? 'stage-node done' : 'stage-node live';
if (details[i]) details[i].textContent = esp32PhaseLabels[phase] || '';
}
@@ -95,9 +116,11 @@ export function updateEsp32StagesFromPhase(phase) {
}
export function updateEsp32StagesError() {
var ids = ['es1', 'es2', 'es3', 'es4', 'es5', 'es6'];
var all = ids.map(function(id) { return document.getElementById(id); });
var all = ESP32_STAGE_IDS.map(function (id) { return document.getElementById(id); });
var details = ESP32_DETAIL_IDS.map(function (id) { return document.getElementById(id); });
all.forEach(function (s) { if (s) s.className = 'stage-node'; });
// ★ 失败态:阶段下方文案也要清空,避免残留"上传中/校验中"与红色样式并存
details.forEach(function (d) { if (d) d.textContent = ''; });
var statusBar = document.getElementById('stageStatusBar');
var statusText = document.getElementById('stageStatusText');
if (statusBar) statusBar.className = 'stage-status-bar err';
@@ -117,7 +140,14 @@ export function updateEsp32StagesError() {
export function cleanupAfterOtaFailure(reason) {
// 幂等:已在 FAILED 且无活跃 XHR 时只刷新文案,避免 abort 事件二次递归清状态
var alreadyFailed = window.otaSessionState === window.OtaSessionState.FAILED;
var chip = window.activeUploadChip || window.otaSessionTarget || window.currentOtaTarget || window.flashTarget || 'esp32';
/* chip 判定优先级(权威来源顺序):
* activeUploadChip — XHR 上传开始时由 startUpload 写入,WiFi 路径权威
* otaSessionTarget — OTA 会话状态机目标(仅 WiFi 走 OtaSessionState,BLE 不走)
* currentOtaTarget — switchStageFlow 设置的当前面板目标
* flashTarget — 用户选择的目标(BLE 路径未设上述两者时兜底)
* 'esp32' — 最终默认值 */
var chip = window.activeUploadChip || window.otaSessionTarget ||
window.currentOtaTarget || window.flashTarget || 'esp32';
// 0. 状态机:转入 FAILED(先于标志重置)
window.otaSessionTransition(window.OtaSessionState.FAILED, reason);
@@ -142,16 +172,12 @@ export function cleanupAfterOtaFailure(reason) {
setActivePhase(null);
window.currentOtaTarget = null;
// 4. 恢复上传按钮(两枚都解禁,避免 chip 判错时另一侧仍灰)
// 4. 恢复上传按钮(两枚都解禁并恢复原文案,避免 chip 判错或一侧残留"上传中")
['uploadEsp32Btn', 'uploadGd32Btn'].forEach(function (btnId) {
var btn = document.getElementById(btnId);
if (!btn) return;
btn.disabled = false;
if (window.uploadBtnOrigText &&
((chip === 'gd32' && btnId === 'uploadGd32Btn') ||
(chip !== 'gd32' && btnId === 'uploadEsp32Btn'))) {
btn.textContent = window.uploadBtnOrigText;
}
if (window.uploadBtnOrigText) btn.textContent = window.uploadBtnOrigText;
});
window.activeUploadChip = null;
window.otaSessionTarget = null;
@@ -175,9 +201,8 @@ export function cleanupAfterOtaFailure(reason) {
export function setActivePhase(phase) {
if (phase === 'gd32Upload' || phase === 'gd32Relay') phase = 'gd32Ota';
window.activePhase = phase;
var stepMap = { esp32Ota: 'ppEsp32', gd32Ota: 'ppGd32Ota' };
Object.keys(stepMap).forEach(function(p) {
var el = document.getElementById(stepMap[p]);
Object.keys(STEP_MAP).forEach(function (p) {
var el = document.getElementById(STEP_MAP[p]);
if (!el) return;
el.classList.remove('active', 'done');
if (p === phase) el.classList.add('active');
@@ -190,8 +215,7 @@ export function setActivePhase(phase) {
/** @description 标记指定阶段为完成,更新进度条颜色。 */
export function markPhaseDone(phase) {
if (phase === 'gd32Upload' || phase === 'gd32Relay') phase = 'gd32Ota';
var stepMap = { esp32Ota: 'ppEsp32', gd32Ota: 'ppGd32Ota' };
var el = document.getElementById(stepMap[phase]);
var el = document.getElementById(STEP_MAP[phase]);
if (el) { el.classList.remove('active'); el.classList.add('done'); }
// All done? turn progress bar green
if (phase === 'gd32Ota' || phase === 'esp32Ota') {
@@ -200,14 +224,20 @@ export function markPhaseDone(phase) {
}
}
/** @description 更新进度条百分比和文本显示。 */
/** @description 更新进度条百分比和文本显示。对 pct 做边界校验,过滤 NaN/undefined。 */
export function setProgress(pct, text) {
var fillEl = document.getElementById('progressFill');
var txtEl = document.getElementById('progressTxt');
var pctEl = document.getElementById('progressPct');
if (fillEl) fillEl.style.width = pct + '%';
if (txtEl) txtEl.textContent = text;
if (pctEl) pctEl.textContent = pct + '%';
// 数值校验:BLE ACK 高频调用路径偶尔传入 undefined/NaN,需兜底避免出现 "undefined%"
var n = Number(pct);
if (!Number.isFinite(n)) n = 0;
if (n < 0) n = 0;
else if (n > 100) n = 100;
// 懒缓存 DOM 元素引用;DOM 未就绪时回退到 getElementById(兼容早期调用)
if (!progressFillEl || !progressFillEl.isConnected) progressFillEl = document.getElementById('progressFill');
if (!progressTxtEl || !progressTxtEl.isConnected) progressTxtEl = document.getElementById('progressTxt');
if (!progressPctEl || !progressPctEl.isConnected) progressPctEl = document.getElementById('progressPct');
if (progressFillEl) progressFillEl.style.width = n + '%';
if (progressTxtEl) progressTxtEl.textContent = text;
if (progressPctEl) progressPctEl.textContent = n + '%';
}
/** @description 重置进度条和阶段状态到初始值。 */
@@ -216,7 +246,7 @@ export function resetProgress() {
window.activePhase = null;
window.esp32OtaPhase = 0;
switchStageFlow(null);
['ppEsp32', 'ppGd32Ota'].forEach(function(id) {
['ppEsp32', 'ppGd32Ota'].forEach(function (id) {
var el = document.getElementById(id);
if (el) el.classList.remove('active', 'done');
});
-104
View File
@@ -1,104 +0,0 @@
/**
* spi.js - SPI 帧构建与发送
*
* 操作 DOM: #spiType / #spiData / #spiTimeout / #spiHint / #spiResp / #spiRespText
*
* 复用的函数(通过 window.* 引用):
* window.getBaseUrl / window.showToast / window.addLog
*/
import { showToast } from '../common/toast.js';
import { addLog } from '../common/log.js';
function updateSpiHint() {
var typeHex = document.getElementById('spiType').value.trim();
var dataHex = document.getElementById('spiData').value.trim().replace(/\s/g, '');
var hint = document.getElementById('spiHint');
if (!typeHex || !/^[0-9a-fA-F]{1,2}$/.test(typeHex)) {
hint.className = 'helper-note err';
hint.innerHTML = 'type 需填 1 字节 hex(如 01, B0)。';
return;
}
if (dataHex && !/^[0-9a-fA-F]*$/.test(dataHex)) {
hint.className = 'helper-note err';
hint.innerHTML = '数据包含非 HEX 字符。';
return;
}
if (dataHex && dataHex.length % 2 !== 0) {
hint.className = 'helper-note err';
hint.innerHTML = 'HEX 长度必须为偶数。';
return;
}
var bytes = dataHex ? dataHex.length / 2 : 0;
var totalOnWire = 5 + bytes;
hint.className = 'helper-note ok';
hint.innerHTML = 'type=<code>0x' + typeHex.toUpperCase() + '</code>' +
(bytes > 0 ? ',payload: <b>' + bytes + '</b>B' : ',无 payload') +
',线上 <b>' + totalOnWire + '</b>B(SOF+TYPE+LEN+PAYLOAD+CRC)';
}
export async function sendSpiFrame() {
var typeHex = document.getElementById('spiType').value.trim();
var dataStr = document.getElementById('spiData').value.trim();
var timeout = parseInt(document.getElementById('spiTimeout').value) || 1000;
if (!typeHex || !/^[0-9a-fA-F]{1,2}$/.test(typeHex)) {
showToast('type 需填 1 字节 hex', 'error');
return;
}
var dataHex = dataStr.replace(/\s/g, '');
if (dataHex && !/^[0-9a-fA-F]*$/.test(dataHex)) {
showToast('数据包含非 HEX 字符', 'error');
return;
}
if (dataHex && dataHex.length % 2 !== 0) {
showToast('HEX 数据长度必须为偶数', 'error');
return;
}
var txBytes = dataHex ? dataHex.length / 2 : 0;
addLog('I', 'SPI', 'type=0x' + typeHex.toUpperCase() +
(txBytes > 0 ? ', payload=' + dataHex.toUpperCase() : ', 无负载'));
var body = { type: typeHex, data: dataHex.toUpperCase(), timeout_ms: timeout };
try {
var resp = await fetch(window.getBaseUrl() + '/spi/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: AbortSignal.timeout(timeout + 2000)
});
var result = await resp.json();
if (result.status === 'ok') {
var recvHex = result.recv || '';
var respTypeName = {'80': 'ACK', '81': 'WAIT', '82': 'ERROR'};
var respTypeLabel = respTypeName[result.resp_type] || ('0x' + result.resp_type);
addLog('I', 'SPI', '→ ' + respTypeLabel +
(recvHex ? ' 0x' + recvHex : '') + ' (' + (result.resp_len || 0) + 'B)');
var rd = document.getElementById('spiResp');
rd.style.display = 'block';
document.getElementById('spiRespText').textContent =
'type=0x' + result.type + ' payload=' + result.payload_len + 'B → ' + respTypeLabel +
(recvHex ? ' 0x' + recvHex : '') +
' (' + (result.resp_len || 0) + 'B)';
showToast('SPI 发送成功', 'success');
} else {
addLog('E', 'SPI', '发送失败: code=' + result.code + ' ' + (result.message || ''));
var rd = document.getElementById('spiResp');
rd.style.display = 'block';
document.getElementById('spiRespText').textContent =
'错误: code=' + result.code + ' ' + (result.message || '');
showToast('SPI 发送失败', 'error');
}
} catch (e) {
addLog('E', 'SPI', '发送失败: ' + e.message);
showToast('SPI 发送失败: ' + e.message, 'error');
}
}
export function clearSpiFields() {
document.getElementById('spiType').value = '01';
document.getElementById('spiData').value = '';
document.getElementById('spiTimeout').value = '1000';
document.getElementById('spiResp').style.display = 'none';
updateSpiHint();
}
// 导出 updateSpiHint 供入口挂载 window.updateSpiHint
export { updateSpiHint };
+131 -87
View File
@@ -4,28 +4,31 @@
* 将设备端 relay_state / sys_state 映射到 GD32 阶段节点 UI,
* 并驱动 updateSystemStatusUi 综合 UI 更新。
*
* 结构:
* applyDashboardUi — 堆/连接/版本/温湿度等常驻面板
* applyGd32OtaSessionUi — GD32 会话进度/完成/失败(唯一 UI 应用路径)
* updateSystemStatusUi — 入口:仪表盘 →(非 BLE/非 ESP32-OTA)会话
*
* 共享状态(通过 window.* 引用):
* window.latestSystemStatus / window.currentOtaTarget / window.esp32OtaPhase
* window.otaSessionState / window.otaSessionTarget / window.isUploading
* window.gd32CompleteAnnounced / window.gd32RelaySessionLive
* window.lastSysStateCode / window.lastRelayState
*
* 复用的函数(通过 window.* 引用):
* window.updateHeapAndTaskStatusUi / window.updateConnectionUi / window.updateHealthUi
* window.updateStagesFromRelayState / window.setActivePhase / window.setProgress
* window.setActivePhase / window.setProgress
* window.markGd32OtaUiComplete / window.cleanupAfterOtaFailure / window.otaSessionReset
* window.stopRelayProgressWatch / window.pollLogs / window.formatBytes / window.formatUptime
* window.stopRelayProgressWatch / window.pollLogs / window.emitRelayProgress
*/
import { formatBytes, formatUptime } from '../common/utils.js';
import { updateConnectionUi, updateHealthUi, updateHeapAndTaskStatusUi } from '../common/connection.js';
import { setActivePhase, setProgress, markPhaseDone } from './progress.js';
import {
otaSessionActive, otaSessionReset, otaSessionTransition, OtaSessionState
} from './session.js';
import { setActivePhase, setProgress } from './progress.js';
import { otaSessionReset, OtaSessionState } from './session.js';
// Relay state comes from relay_state_t in main/relay/ota_relay.h:
// 0=IDLE, 1=HANDSHAKING, 2=SIZE_NEG, 3=TRANSFERRING,
// 4=FINISHING, 5=DONE, 255=ERROR.
// 与 index.html #gd32Stages s1..s6 一一对应(rs→节点序号):
// 1→s1, 2→s2, 3→s3, 4→s4, 5→s1..s6 全 done。
const relayStateDetailMap = {
0: '待机',
1: '认证握手',
@@ -36,48 +39,56 @@ const relayStateDetailMap = {
255: '错误'
};
/** 上次刷到阶段条的 displayRelayState,未变则跳过 DOM 重建 */
var lastDisplayedRelayState = undefined;
/**
* 将 relay_state 映射到 GD32 阶段节点 (s1..s6)。
* @param {number|null|undefined} rs
*/
export function updateStagesFromRelayState(rs) {
var ids = ['s1', 's2', 's3', 's4', 's5', 's6'];
var detailIds = ['sd1', 'sd2', 'sd3', 'sd4', 'sd5', 'sd6'];
var all = ids.map(function(id) { return document.getElementById(id); });
var details = detailIds.map(function(id) { return document.getElementById(id); });
// reset all
all.forEach(function (s) { if (s) s.className = 'stage-node'; });
details.forEach(function (d) { if (d) d.textContent = ''; });
// status bar
var statusBar = document.getElementById('stageStatusBar');
var statusText = document.getElementById('stageStatusText');
if (rs === undefined || rs === null) {
if (statusBar) statusBar.className = 'stage-status-bar';
if (statusText) statusText.textContent = '待机';
lastDisplayedRelayState = rs;
return;
}
if (rs === 255) {
all.forEach(function (s) { if (s) s.className = 'stage-node err'; });
if (statusBar) statusBar.className = 'stage-status-bar err';
if (statusText) statusText.textContent = '错误';
lastDisplayedRelayState = 255;
return;
}
/* rs → 节点:1..4 直接对应 s1..s4;rs=5 DONE → 点亮完成链(s1..s6 全 done) */
var activeStage = 0;
if (rs === 1) activeStage = 1;
else if (rs === 2) activeStage = 2;
else if (rs === 3) activeStage = 3;
else if (rs === 4) activeStage = 5;
else if (rs === 4) activeStage = 4;
else if (rs === 5) activeStage = 6;
for (var i = 0; i < activeStage - 1; i++) {
if (all[i]) all[i].className = 'stage-node done';
}
if (rs === 5) {
/* DONE:s1..s5 已在上方标 done,s5/s6 一并完成 */
if (all[4]) all[4].className = 'stage-node done';
if (all[5]) all[5].className = 'stage-node done';
if (details[5]) details[5].textContent = relayStateDetailMap[5];
} else if (activeStage >= 1 && activeStage <= 6) {
if (all[activeStage - 1]) all[activeStage - 1].className = 'stage-node live';
if (details[activeStage - 1]) {
details[activeStage - 1].textContent = relayStateDetailMap[rs] || '';
}
}
// Show detail text for the active stage
var detailText = relayStateDetailMap[rs] || '';
if (activeStage >= 1 && activeStage <= 6 && details[activeStage - 1]) {
details[activeStage - 1].textContent = detailText;
}
// Update status bar
if (statusBar && statusText) {
if (rs === 0) {
statusBar.className = 'stage-status-bar';
@@ -87,27 +98,33 @@ export function updateStagesFromRelayState(rs) {
statusText.textContent = (relayStateDetailMap[rs] || '未知') + ' (' + rs + ')';
}
}
lastDisplayedRelayState = rs;
}
export function updateSystemStatusUi(data) {
if (!data) return;
window.latestSystemStatus = data;
function setDisplayedRelayState(rs) {
if (lastDisplayedRelayState === rs) return;
updateStagesFromRelayState(rs);
}
/**
* 常驻仪表盘:堆、连接、健康、版本、温湿度、IP/RSSI、OTA cache。
* @param {object} data /system/status JSON
* @param {object} ota data.ota
* @param {object} wifi data.wifi
*/
function applyDashboardUi(data, ota, wifi) {
updateHeapAndTaskStatusUi(data);
var wifi = data.wifi || {};
var ota = data.ota || {};
var healthCode = data.health;
// Use global relayStateDetailMap defined in updateStagesFromRelayState section
var connected = !!wifi.connected;
var connText = connected ? (wifi.mode || 'STA') + ' 已连接' : (wifi.mode || 'STA') + ' 未连接';
updateConnectionUi(connected, connText);
updateHealthUi(healthCode, data.free_heap);
updateHealthUi(data.health, data.free_heap);
if (data.version) {
var pillVerEl = document.getElementById('pillVer');
if (pillVerEl) {
pillVerEl.textContent = 'v' + data.version;
pillVerEl.dataset.tooltip = data.build_time ? ("编译: " + data.build_time) : '';
pillVerEl.dataset.tooltip = data.build_time ? ('编译: ' + data.build_time) : '';
}
// GD32 版本
var mGd32App = document.getElementById('mGd32App');
var mGd32Boot = document.getElementById('mGd32Boot');
if (mGd32App) {
@@ -124,24 +141,26 @@ export function updateSystemStatusUi(data) {
mGd32Boot.textContent = ota.gd32_boot;
mGd32Boot.className = 'v ok';
} else {
mGd32Boot.textContent = ota.gd32_app_alive ? '未知' : '离线';
/* BOOT 版本与 APP alive 无直接关系,无版本时统一「未知」 */
mGd32Boot.textContent = '未知';
mGd32Boot.className = 'v';
}
}
}
// temperature
var mTempEl = document.getElementById('mTemp');
if (mTempEl) mTempEl.textContent =
Number.isFinite(data.temperature) ? data.temperature.toFixed(1) + '\u00B0C' : '--';
// uptime
if (mTempEl) {
mTempEl.textContent =
Number.isFinite(data.temperature) ? data.temperature.toFixed(1) + '\u00B0C' : '--';
}
var uptimeEl = document.getElementById('pillUptime');
if (uptimeEl) uptimeEl.textContent = '\u23F1 ' + formatUptime(data.uptime_sec);
// wifi / signal
var pillIPEl = document.getElementById('pillIP');
if (pillIPEl) pillIPEl.textContent = wifi.ip || '--';
var rssi = (wifi.rssi !== undefined && wifi.rssi !== -127) ? wifi.rssi + ' dBm' : '--';
var pillRSSIEl = document.getElementById('pillRSSI');
if (pillRSSIEl) pillRSSIEl.textContent = rssi;
var mOtaCache = document.getElementById('mOtaCache');
var otaStored = ota.stored_size || 0;
if (mOtaCache) {
@@ -153,19 +172,14 @@ export function updateSystemStatusUi(data) {
mOtaCache.className = 'v';
}
}
// 共享进度条按当前连接模式归属:BLE 模式下由 ble_ota.js 的设备 ACK
// 驱动包数、阶段和完成状态,HTTP 状态轮询不能覆盖这套实时进度。
if (window.connectionMode === 'ble') {
return;
}
// relay state - skip GD32 relay UI when ESP32 OTA is active
if (window.currentOtaTarget === 'esp32' && window.esp32OtaPhase > 0) {
return;
}
// ★ 状态机守卫:本会话已 FAILED 时,禁止后端状态覆盖失败 UI。
// 后端可能仍在跑 GD32 中继(网络断开不会停止设备端流程),
// 若不拦截,会把进度条/阶段从"网络错误"错误地刷回"数据转发"。
// 只有设备真正回 IDLE 才允许自动复位状态机(见下方 else 分支)。
}
/**
* GD32 OTA 会话 UI:阶段节点 + 进度条 + 完成/失败。
* 门控逻辑保持与历史实现一致,勿随意放宽完成条件。
* @param {object} ota data.ota
*/
function applyGd32OtaSessionUi(ota) {
var rs = ota.relay_state;
var sysState = ota.sys_state || '';
var sysStateCode = Number.isFinite(ota.sys_state_code) ? ota.sys_state_code : -1;
@@ -173,42 +187,41 @@ export function updateSystemStatusUi(data) {
var isGd32Relaying = sysState === 'GD32_RELAYING' || sysStateCode === 3;
var isGd32Complete = sysState === 'GD32_COMPLETE' || sysStateCode === 4;
var isGd32Error = sysState === 'ERROR' || sysStateCode === 8 || rs === 255;
// OTA cache progress (GD32 upload to ESP32 flash)
var total = ota.total_size || 0;
var stored = ota.stored_size || 0;
var storageProgress = total > 0 ? Math.min(100, Math.round(stored * 100 / total)) : 0;
// GD32 relay forwarding progress — 以 /system/status 的 relay_seq 为准,不依赖日志
var relaySeq = ota.relay_seq || 0;
var relayTotal = ota.relay_total_packets || 0;
var relayProgress = relayTotal > 0 ? Math.min(100, Math.round(relaySeq * 100 / relayTotal)) : 0;
var gd32AppAlive = ota.gd32_app_alive;
/* ★ FAILED 守卫:禁止后端状态覆盖失败 UI;设备回 IDLE 才自动复位 */
if (window.otaSessionState === OtaSessionState.FAILED) {
var _deviceIdle = (sysStateCode === 0 || sysStateCode === 2 || sysStateCode === 4) &&
(rs === 0 || rs === 5);
if (!_deviceIdle) {
// 会话已失败,但设备可能仍在中继:继续用 relay_seq 刷进度,避免“假卡死”
var deviceIdle = (sysStateCode === 0 || sysStateCode === 2 || sysStateCode === 4) &&
(rs === 0 || rs === 5);
if (!deviceIdle) {
if ((rs === 3 || isGd32Relaying) && relayTotal > 0) {
setActivePhase('gd32Ota');
var failRelayOverall = 60 + Math.round(relayProgress * 0.30);
setProgress(failRelayOverall,
'已转发 ' + relaySeq + ' / ' + relayTotal + ' 包(设备仍在跑)');
updateStagesFromRelayState(3);
setDisplayedRelayState(3);
} else {
updateStagesFromRelayState(255);
setDisplayedRelayState(255);
}
return;
}
// 设备已回 IDLE —— 自动复位状态机,恢复正常 UI
otaSessionReset();
window.stopRelayProgressWatch();
// 落到下方正常分支刷新 UI
/* 落到下方正常分支刷新 UI */
}
var displayRelayState = isGd32Complete ? 5 : (isGd32Relaying && rs === 0 ? 1 : rs);
if (window.currentOtaTarget !== 'esp32') { updateStagesFromRelayState(displayRelayState); }
if (window.currentOtaTarget !== 'esp32') {
setDisplayedRelayState(displayRelayState);
}
/* 本会话正在 GD32 路径(含仅 RELAYING 而 target 被清的情况) */
var gd32Session =
window.otaSessionState === OtaSessionState.RELAYING ||
window.currentOtaTarget === 'gd32' ||
@@ -225,25 +238,28 @@ export function updateSystemStatusUi(data) {
window.cleanupAfterOtaFailure(failDetail);
window.pollLogs();
return;
} else if (isGd32Error) {
// 非当前 OTA 会话的错误:仅标记阶段指示器,不触发完整清理
updateStagesFromRelayState(255);
}
if (isGd32Error) {
setDisplayedRelayState(255);
var sbErr = document.getElementById('stageStatusBar');
var stErr = document.getElementById('stageStatusText');
if (sbErr) sbErr.className = 'stage-status-bar err';
if (stErr) stErr.textContent = 'GD32 错误(待机)';
} else if (window.otaSessionState === OtaSessionState.UPLOADING) {
return;
}
if (window.otaSessionState === OtaSessionState.UPLOADING) {
/* 上传进行中:不要用旧缓存/旧完成态覆盖进度条 */
} else if (window.otaSessionState === OtaSessionState.RELAYING) {
/* 必须先看到本会话真实中继(HANDSHAKE~FINISHING),才允许 DONE;
* 禁止一进 RELAYING 就吃掉上一次的 rs=5 / app_alive。 */
return;
}
if (window.otaSessionState === OtaSessionState.RELAYING) {
/* 必须先看到本会话真实中继,才允许 DONE */
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) &&
@@ -257,7 +273,11 @@ export function updateSystemStatusUi(data) {
window.pollLogs();
return;
}
/* 包进度:进入 FINISHING/COMPLETE 时先补 100%,再走完成/收尾 UI */
/*
* 包进度/完成:统一走 emitRelayProgress + markGd32OtaUiComplete。
* FINISHING 时 force100 后只改文案,禁止 setProgress(95) 回跳。
*/
if (window.gd32RelaySessionLive && relayTotal > 0 &&
(relaySeq >= relayTotal || rs >= 4 || isGd32Complete)) {
if (typeof window.emitRelayProgress === 'function') {
@@ -267,32 +287,29 @@ export function updateSystemStatusUi(data) {
typeof window.emitRelayProgress === 'function') {
window.emitRelayProgress(relaySeq, relayTotal);
}
if (window.gd32RelaySessionLive &&
(isGd32Complete || rs === 5 ||
(gd32AppAlive && relayTotal > 0 && relaySeq >= relayTotal && rs >= 3))) {
if (!window.gd32CompleteAnnounced) {
window.markGd32OtaUiComplete('GD32 刷写完成');
}
} else if (rs === 1) {
// HANDSHAKING
return;
}
if (rs === 1) {
setActivePhase('gd32Ota');
setProgress(50, '握手与认证中...');
} else if (rs === 4) {
// FINISHING:转发已 100%,进度条进入收尾段(勿再落回中间百分比)
/* 收尾:保持 100%,只更新文案(避免 100→95 回跳) */
setActivePhase('gd32Ota');
if (!window.gd32CompleteAnnounced) {
setProgress(95, gd32AppAlive ? '收尾中…' : '收尾中,等待 APP 启动...');
setProgress(100, gd32AppAlive ? '收尾中…' : '收尾中,等待 APP 启动...');
}
} else if (rs === 3 || (relayTotal > 0 && relaySeq > 0 && rs !== 5 && rs !== 4)) {
// TRANSFERRING(不含 FINISHING)
} else if (rs === 3 && relaySeq <= 0) {
setActivePhase('gd32Ota');
var relayOverall = 60 + Math.round(relayProgress * 0.30);
var relayLabel = relayTotal > 0
? ('已转发 ' + relaySeq + ' / ' + relayTotal + ' 包 (' + relayProgress + '%)')
: '数据包传输中...';
setProgress(relayOverall, relayLabel);
setProgress(60, '数据包传输中...');
} else if (rs === 2 || (isGd32Relaying && relaySeq === 0)) {
// SIZE_NEG
setActivePhase('gd32Ota');
setProgress(55, relayTotal > 0
? ('大小协商中(共 ' + relayTotal + ' 包)')
@@ -301,13 +318,40 @@ export function updateSystemStatusUi(data) {
setActivePhase('gd32Ota');
setProgress(48, '已触发中继,等待设备进入握手…');
}
} else if (isGd32Storing || (total > 0 && stored < total && !window.isUploading &&
window.otaSessionState !== OtaSessionState.DONE &&
window.otaSessionState !== OtaSessionState.IDLE)) {
return;
}
if (isGd32Storing || (total > 0 && stored < total && !window.isUploading &&
window.otaSessionState !== OtaSessionState.DONE &&
window.otaSessionState !== OtaSessionState.IDLE)) {
setActivePhase('gd32Ota');
var storeOverall = Math.min(45, Math.round(storageProgress * 0.45));
if (!window.isUploading) {
setProgress(storeOverall, '已缓存 ' + formatBytes(stored) + ' / ' + formatBytes(total) + '');
setProgress(storeOverall, '已缓存 ' + formatBytes(stored) + ' / ' + formatBytes(total));
}
}
}
}
/**
* /system/status → UI 总入口。
* @param {object} data
*/
export function updateSystemStatusUi(data) {
if (!data) return;
window.latestSystemStatus = data;
var wifi = data.wifi || {};
var ota = data.ota || {};
applyDashboardUi(data, ota, wifi);
/* BLE:仪表盘可更新,进度/阶段由 ble 路径独占 */
if (window.connectionMode === 'ble') {
return;
}
/* ESP32 OTA 进行中:勿用 GD32 relay 覆盖阶段条 */
if (window.currentOtaTarget === 'esp32' && window.esp32OtaPhase > 0) {
return;
}
applyGd32OtaSessionUi(ota);
}
+3 -2
View File
@@ -162,8 +162,9 @@ export async function startUpload(chip) {
file = freshFile;
} else {
if (freshFile.name.toLowerCase().endsWith('.hex')) {
window.processFile(freshFile, 'gd32');
await new Promise(function(resolve) { setTimeout(resolve, 200); });
// processFile 现已返回 Promise,await 即可拿到转换后的 gd32File,
// 不再用 setTimeout 硬等(避免 HEX 稍大时拿到 null)。
await window.processFile(freshFile, 'gd32');
file = window.gd32File;
} else {
window.gd32File = freshFile;
+34 -113
View File
@@ -1,7 +1,11 @@
/**
* upload-gd32.js - GD32 中继 OTA 触发 + relay 态跟踪
* upload-gd32.js - GD32 中继 OTA 触发 + 进度辅助
*
* 包含:relay 进度观察定时器、markGd32OtaUiComplete、triggerGd32Relay。
* 进度/完成/失败 UI 的唯一应用路径:pollStatus → updateSystemStatusUi(stages.js)。
* 本文件只负责:
* - 触发 /ota/relay
* - 中继期加速 status 轮询(400ms)
* - emitRelayProgress / markGd32OtaUiComplete 等被 stages 调用的辅助函数
*
* 共享状态(通过 window.* 引用):
* window.gd32CompleteAnnounced / window.relayWatchLastSeq / window.gd32RelaySessionLive
@@ -9,7 +13,7 @@
* window.otaSessionState / window.lastSysStateCode / window.lastRelayState
*
* 复用的函数(通过 window.* 引用):
* window.otaSessionTransition / window.OtaSessionState / window.otaSessionActive
* window.otaSessionTransition / window.OtaSessionState
* window.markPhaseDone / window.setProgress / window.updateSystemStatusUi
* window.cleanupAfterOtaFailure / window.scheduleStatusPoll / window.pollLogs
* window.resumePollingAfterUpload / window.getBaseUrl / window.showToast / window.addLog
@@ -17,28 +21,22 @@
import { showToast } from '../common/toast.js';
import { addLog } from '../common/log.js';
import {
otaSessionTransition, otaSessionActive, OtaSessionState
otaSessionTransition, OtaSessionState
} from './session.js';
import { markPhaseDone, setProgress } from './progress.js';
/**
* GD32 中继进度观察:以 /system/status 为唯一真相源。
* 日志环只有 64 条,中继/擦除期间 INFO 会被挤掉,时间戳还是浏览器本地时间——
* 绝不能用「最后一条日志停在 SEND_SIZE」判断卡死。
*
* 时序约定(避免「设备已 FINISH / 完成,UI 还在 94%」):
* 1) 包进度变化先刷进度条 + 日志;
* 2) 一旦 rseq>=rtot 或 rs>=4(FINISHING),先强制打出 100% 再进收尾/完成;
* 3) markGd32OtaUiComplete 前再兜底一次 100%。
*/
let relayWatchTimerId = null;
/** 中继期 status 轮询间隔(ms);结束后由 stopRelayProgressWatch 恢复 1000 */
const RELAY_STATUS_POLL_MS = 400;
const NORMAL_STATUS_POLL_MS = 1000;
window.gd32CompleteAnnounced = false;
window.relayWatchLastSeq = -1;
/** 本会话是否已观察到真实中继态(1~4),避免旧 relay_state=5 残留导致一点击就“完成” */
window.gd32RelaySessionLive = false;
/**
* 包进度变更时更新进度条并写一条「转发」日志(同 seq 不重复)。
* 包进度变更时更新进度条(同 seq 不重复)。
* 由 stages.js 在 status 轮询路径调用;勿在本文件再起独立定时器刷进度。
* @param {number} rseq
* @param {number} rtot
* @param {{force100?: boolean}} [opts]
@@ -79,7 +77,7 @@ export function ensureRelayProgressComplete() {
}
export function markGd32OtaUiComplete(msg) {
// 已公告过:禁止再改 className / setProgress / toast,否则定时器重触发动效会「疯狂跳 ✓」
// 已公告过:禁止再改 className / setProgress / toast,否则 status 重触发动效会「疯狂跳 ✓」
if (window.gd32CompleteAnnounced) {
return;
}
@@ -111,106 +109,33 @@ export function markGd32OtaUiComplete(msg) {
showToast(msg || 'GD32 刷写完成', 'success');
}
/**
* 结束中继加速轮询,恢复常态 1s status 间隔。
* 不再持有独立 setInterval(进度/完成/失败统一由 stages 在 pollStatus 路径处理)。
*/
export function stopRelayProgressWatch() {
if (relayWatchTimerId) {
clearInterval(relayWatchTimerId);
relayWatchTimerId = null;
}
/* 中继结束:恢复常态 1s status 轮询 */
if (typeof window.setStatusPollIntervalMs === 'function') {
window.setStatusPollIntervalMs(1000);
window.setStatusPollIntervalMs(NORMAL_STATUS_POLL_MS);
}
}
/**
* 中继进度观察:只读 pollStatus 写入的 latestSystemStatus,不再另起 /system/status。
* 中继期 status 加速到 400ms,使「转发 N/M」更贴近设备真实进度。
* 中继进度跟踪:只加速 /system/status 轮询,不再另起 400ms 定时器。
* UI 真相源仍是 pollStatus → updateSystemStatusUi(stages.js)。
*/
export function startRelayProgressWatch() {
stopRelayProgressWatch();
if (window.pollingPausedForUpload) {
window.resumePollingAfterUpload();
}
/* 中继期加速 status,缩小 UI 进度相对设备日志的滞后 */
if (typeof window.setStatusPollIntervalMs === 'function') {
window.setStatusPollIntervalMs(400);
window.setStatusPollIntervalMs(RELAY_STATUS_POLL_MS);
}
if (typeof window.scheduleStatusPoll === 'function') {
window.scheduleStatusPoll(0);
}
if (typeof window.pollLogs === 'function') {
window.pollLogs();
}
window.scheduleStatusPoll(0);
window.pollLogs();
addLog('I', 'OTA', '跟踪中继进度:看进度条与 relay_seq(/system/status),勿只看日志');
relayWatchTimerId = setInterval(function () {
var active = window.otaSessionState === OtaSessionState.RELAYING ||
(window.otaSessionState === OtaSessionState.FAILED &&
(window.lastSysStateCode === 3 || window.lastRelayState >= 1 && window.lastRelayState <= 4));
if (!active) {
if (window.otaSessionState === OtaSessionState.DONE) {
stopRelayProgressWatch();
}
return;
}
var data = window.latestSystemStatus;
if (!data || !data.ota) return;
var o = data.ota || {};
var code = Number(o.sys_state_code);
var rstate = Number(o.relay_state);
var alive = !!o.gd32_app_alive;
var rseq = Number(o.relay_seq) || 0;
var rtot = Number(o.relay_total_packets) || 0;
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;
}
/*
* 顺序:先刷包进度(含强制 100%),再判定完成。
* FINISHING/DONE 时若 status 滞后未到 total,仍按 total 打 100%。
*/
var transferDone = rtot > 0 && (rseq >= rtot || rstate >= 4 || code === 4);
if (transferDone) {
emitRelayProgress(rtot, rtot, { force100: true });
} else if (rtot > 0 && rseq > 0) {
emitRelayProgress(rseq, rtot);
}
if (window.gd32RelaySessionLive &&
(code === 4 || rstate === 5 ||
(alive && rtot > 0 && rseq >= rtot && rstate >= 3))) {
markGd32OtaUiComplete('GD32 刷写完成');
window.pollLogs();
}
}, 400);
}
/** @description 触发 GD32 中继 OTA 流程(通过 HTTP 接口)。 */
@@ -222,8 +147,7 @@ export async function triggerGd32Relay() {
window.cleanupAfterOtaFailure('SHA-256 缺失');
return;
}
addLog('I', 'OTA', 'GD32 SHA-256: ' + sha256 + ' → 触发中继...');
showToast('正在触发 GD32 OTA', 'success');
addLog('I', 'OTA', '触发 GD32 中继…');
setProgress(48, '校验 SHA 并触发 GD32 中继…');
try {
// 仅触发中继(设备端异步跑),不阻塞到刷写结束;SHA 读分区可能数秒
@@ -235,11 +159,7 @@ export async function triggerGd32Relay() {
});
var text = await resp.text();
if (resp.ok) {
try {
var data = JSON.parse(text);
if (data.sha256) addLog('I', 'OTA', '设备端 SHA-256: ' + data.sha256);
} catch (_) {}
addLog('I', 'OTA', 'GD32 OTA 已触发;进度看状态栏包数(勿只看日志时间戳)');
addLog('I', 'OTA', 'GD32 OTA 已触发;进度看状态栏包数');
showToast('GD32 OTA 已触发', 'success');
setProgress(50, '中继已启动,同步进度…');
window.gd32CompleteAnnounced = false;
@@ -250,7 +170,8 @@ export async function triggerGd32Relay() {
try {
var err = JSON.parse(text);
if (err.device) {
addLog('E', 'OTA', 'GD32 SHA-256 不匹配: 设备=' + err.device + ' 前端=' + sha256);
addLog('E', 'OTA', 'GD32 SHA-256 不匹配: 设备=' + err.device +
' 前端=' + sha256);
showToast('SHA-256 不匹配', 'error');
window.cleanupAfterOtaFailure('SHA-256 不匹配');
return;
@@ -287,4 +208,4 @@ export async function triggerGd32Relay() {
showToast('GD32 触发失败', 'error');
window.cleanupAfterOtaFailure('触发异常');
}
}
}