refactor(debug): 重构调试日志环形缓冲区管理

- 将日志环形缓冲区从静态数组改为动态堆分配
- 实现启动时大容量缓冲区和运行时小容量缓冲区的切换机制
- 添加日志环满时优先保留错误和警告信息的清理策略
- 优化内存使用,在日志为空时避免不必要的内存分配
- 移除调试日志中的冗余字段显示

feat(api): 更新CAN接口为批量发送模式

- 将 `/can/send` 接口替换为 `/can/send_batch`,支持1到N帧的批量发送
- 更新API文档中的接口描述和使用示例
- 修改请求参数格式为 `{"ch":0,"frames":[{...}]}` 返回格式为 `{"status":"ok","results":[...]}`
- 单帧发送也需要通过send_batch接口实现

feat(monitor): 新增GD32 ADC调试反馈处理功能

- 添加gd32_adc_feedback组件用于解析0xC4类型的SPI反馈
- 实现ADC数据解析包括温度、电压等参数的计算和转换
- 在can_feedback_task中集成ADC反馈分发逻辑
- 添加线程安全的数据获取和年龄检查接口

refactor(config): 优化任务栈大小配置并调整OTA块大小

- 重新评估各任务栈使用情况,优化HTTP、WiFi、SPI等工作任务的栈大小
- 将HTTP服务器栈从8KB调整至7KB,WiFi OTA任务栈从4KB调整至3KB
- OTA块大小从2KB调整回4KB以提升传输吞吐性能
- 重新整理配置文件结构,移动WiFi模式配置位置
This commit is contained in:
小龙虾
2026-07-20 03:10:42 +08:00
parent b807645a20
commit 00b716c8db
66 changed files with 4803 additions and 1074 deletions
+3 -15
View File
@@ -12,7 +12,7 @@ import { showToast } from './common/toast.js';
import { addLog, clearLogs, setLogFilter, toggleAutoScroll } from './common/log.js';
import {
updateConnectionUi, updateHealthUi, updateHeapAndTaskStatusUi,
testConnection, refreshSystemStatusNow, diagnoseNetwork
testConnection, refreshSystemStatusNow, diagnoseNetwork, systemStatusUrl
} from './common/connection.js';
import {
scheduleStatusPoll, startPolling, pausePollingForUpload,
@@ -103,10 +103,6 @@ function applyRebootingRuntimeState() {
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';
if (cst) cst.textContent = 'ESP32 正在重启...';
var bc = document.getElementById('bConn');
if (bc) bc.textContent = '重启中';
if (bc) bc.className = 'v warn';
@@ -122,25 +118,16 @@ function applyUploadingRuntimeState() {
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 = '固件上传中,设备忙...';
var bc = document.getElementById('bConn');
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';
@@ -205,6 +192,7 @@ window.updateHealthUi = updateHealthUi;
window.updateHeapAndTaskStatusUi = updateHeapAndTaskStatusUi;
window.testConnection = testConnection;
window.refreshSystemStatusNow = refreshSystemStatusNow;
window.systemStatusUrl = systemStatusUrl;
window.scheduleStatusPoll = scheduleStatusPoll;
window.setStatusPollIntervalMs = setStatusPollIntervalMs;
window.startPolling = startPolling;
+32 -32
View File
@@ -9,8 +9,9 @@ import { state, els, bleLog, currentTarget, currentPayloadSize, setConnectionSta
import { setBleStage, setBleStageText, resetBleStages } from './stages.js';
import { setProgress } from './transfer.js';
import { hideBleVerifyInfo } from './verify.js';
import { formatBytes, bytesToHex } from '../common/utils.js';
import { formatBytes } from '../common/utils.js';
import { showToast } from '../common/toast.js';
import { computeFileSha } from '../ota/file-handle.js';
/** 任一通道上传中(BLE 或 WiFi)。focus/change 等不分通道的事件用此判定。 */
function isAnyChannelUploading() {
@@ -18,11 +19,8 @@ function isAnyChannelUploading() {
}
export function updateFileInfo(file) {
// ★ 上传进行中禁止重置进度/阶段:目标切换、模式切换、syncBleFileFromSelectedTarget
// 等事件会在上传期间调用本函数,若不拦截会触发 finalizeFileInfo → setProgress(0, ...)
// 把进度条从"已确认 N/M 包"错误地刷回"等待选择文件 0%"。
// ★ 上传进行中禁止重置进度/阶段
if (isBleUploading() && file && state.selectedFile) {
// 上传中且文件未变:仅刷新元数据(文件名/大小等),不碰进度条和阶段
bleLog('FILE', '上传进行中,跳过 updateFileInfo 进度重置', 'D');
return Promise.resolve();
}
@@ -43,9 +41,9 @@ export function updateFileInfo(file) {
else els.fileInfo.classList.remove('show');
}
// ★ HEX → BIN 自动转换:复用 app.js 公共函数 window.convertHexFileToBin
// ★ HEX → BIN:必须用 window.convertHexFileToBin(本模块未 import,裸名会 ReferenceError)
if (file && file.name.toLowerCase().endsWith('.hex') && typeof window.convertHexFileToBin === 'function') {
return convertHexFileToBin(file).then(function(result) {
return window.convertHexFileToBin(file).then(function(result) {
if (!result.converted) {
bleLog('FILE', file.name + ' 扩展名为 .hex 但内容不像 HEX 格式,按原始文件处理', 'W');
finalizeAndHash(file);
@@ -55,7 +53,6 @@ export function updateFileInfo(file) {
var baseHex = '0x' + result.baseAddr.toString(16).toUpperCase().padStart(8, '0');
bleLog('FILE', 'HEX → BIN 转换完成: ' + file.name + ' → ' + result.file.name + ' (基地址 ' + baseHex + ', ' + formatBytes(result.file.size) + ')');
showToast('HEX 已自动转换为 BIN', 'success');
// 更新 UI 显示转换后的信息
els.fileName.textContent = result.file.name + ' (由 ' + file.name + ' 转换)';
els.fileType.textContent = 'Binary (HEX转换)';
els.fileSize.textContent = formatBytes(result.file.size);
@@ -72,20 +69,16 @@ export function updateFileInfo(file) {
});
}
// 非 HEX 文件直接完成
finalizeAndHash(file);
return Promise.resolve();
}
/** finalizeFileInfo + computeFileSha256 的统一出口:HEX 转换完成分支与非 HEX 分支共用。 */
function finalizeAndHash(file) {
finalizeFileInfo(file);
computeFileSha256(file);
computeBleFileSha(file);
}
function finalizeFileInfo(file) {
// ★ 上传进行中禁止重置进度条/阶段:防止目标切换等事件在上传期间
// 通过 finalizeFileInfo → setProgress(0, '等待选择文件') 覆盖上传进度。
if (isBleUploading()) {
bleLog('FILE', '上传进行中,跳过 finalizeFileInfo 进度重置', 'D');
return;
@@ -105,40 +98,43 @@ function finalizeFileInfo(file) {
}
/**
* 计算文件 SHA-256 并推进 BLE stage 流。
* stage 推进逻辑收敛在此函数内,避免与 finalizeFileInfo 隐式耦合:
* - 已连接且 file 非空:进入「计算中」态(stage 3 active + 状态栏文案)
* - 哈希算完:stage 3 done + 推进 stage 4 + 状态栏「文件就绪」
* - file 为空 / 未连接:不碰 stage(由 finalizeFileInfo 的 else 分支 + clearSelectedFile 负责)
* BLE 侧:读对应芯片槽;槽里已有则直接用,没有才算一次。
* 不跨芯片、不互切。
*/
function computeFileSha256(file) {
function computeBleFileSha(file) {
var shaElem = document.getElementById('fileSha256');
if (!file || !shaElem) {
if (!file) {
if (shaElem) shaElem.textContent = '-';
state.fileSha256 = null;
return;
}
// 已连接:进入「计算中」态(旧逻辑放在 finalizeFileInfo 里,现收敛至此)
var chip = (window.flashTarget === 'gd32') ? 'gd32' : 'esp32';
var existing = chip === 'gd32' ? window._gd32FileSha256 : window._esp32FileSha256;
if (existing) {
state.fileSha256 = existing;
if (shaElem) shaElem.textContent = existing;
if (state.server) {
setBleStage(3, 'SHA-256 已计算', null);
setBleStage(4, '', null);
setBleStageText('文件就绪,可以开始刷写');
}
return;
}
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;
computeFileSha(file, chip).then(function(hashHex) {
if (!hashHex) {
state.fileSha256 = null;
return;
}
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;
});
}
@@ -219,7 +215,11 @@ export async function notifyFileChange(file, handle) {
return;
}
if (file) {
if (handle) state.selectedFileHandle = handle;
/* 始终更新 handle(含 null),避免切目标后仍热重载到上一芯片的文件 */
state.selectedFileHandle = handle || null;
await updateFileInfo(file);
} else {
state.selectedFileHandle = null;
await updateFileInfo(null);
}
}
+16 -14
View File
@@ -3,8 +3,6 @@ import { TARGETS } from './constants.js';
import { addLog } from '../common/log.js';
import { updateBleStageLabels, resetBleStages } from './stages.js';
import { hideBleVerifyInfo } from './verify.js';
import { updateFileInfo } from './file.js';
export const state = {
device: null,
server: null,
@@ -28,7 +26,7 @@ export const state = {
otaResultWaiter: null,
otaRunId: 0,
lastEsp32DoneSession: null,
fileSha256: null,
fileSha256: null, // 当前 BLE 会话用的 SHA(与对应芯片槽同步)
deviceSha256: null,
expectedReboot: false, // 发送 0xFA 后置 true,重连后清
reconnecting: false, // 自动重连中
@@ -46,8 +44,6 @@ export const els = {
abortBtn: $('abortBtn'),
uploadZone: $('uploadZone'),
firmwareFile: $('firmwareFile'),
connStatus: $('connStatus'), // 共享连接指示器
connStatusTxt: $('connStatusTxt'), // 共享连接指示器文本
connState: $('connState'),
connBadge: $('connBadge'),
deviceName: $('deviceName'),
@@ -115,10 +111,12 @@ export function setReadyHint(html) {
export function setConnectionState(connected) {
const idle = connected && state.selectedFile && !state.uploading;
// 仅在 BLE 模式下更新共享连接指示器与 BLE 专用徽章,避免覆盖 WiFi 轮询的状态
// BLE 模式:连接状态只写顶栏胶囊,避免下方重复文案
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 未连接';
var pc = document.getElementById('pillConn');
var pct = document.getElementById('pillConnTxt');
if (pc) pc.className = connected ? 'pill online' : 'pill offline';
if (pct) pct.textContent = connected ? 'BLE 已连接' : 'BLE 未连接';
if (els.connState) {
els.connState.textContent = connected ? '已连接' : '未连接';
if (els.connState.parentElement) {
@@ -174,17 +172,21 @@ export function resetUiAfterDisconnect() {
export function refreshTargetUi() {
const cfg = currentTarget();
updateBleStageLabels(cfg);
// ★ 上传进行中不调用 updateFileInfo:避免目标切换时进度条被重置。
// 阶段标签更新(updateBleStageLabels)安全,不碰进度条。
if (state.selectedFile && !state.uploading && !state.autoFlashing) {
updateFileInfo(state.selectedFile);
/* 与 WiFi 一致:文件由 syncBleFileFromSelectedTarget 按 flashTarget 绑定;
* 此处只刷新标签/包数/阶段说明。 */
if (state.selectedFile) {
const payloadSize = currentPayloadSize();
const packets = Math.ceil(state.selectedFile.size / payloadSize);
if (els.packetCount) els.packetCount.textContent = String(packets);
}
setConnectionState(!!state.server);
// 同步目标胶囊标签
var espTab = document.getElementById('targetEsp32Tab');
var gdTab = document.getElementById('targetGd32Tab');
var isEsp = cfg.label === 'ESP32';
if (espTab) espTab.classList.toggle('active', isEsp);
if (gdTab) gdTab.classList.toggle('active', !isEsp);
bleLog('CFG', `目标切换为 ${cfg.label}: packetCmd=0x${cfg.packetCmd.toString(16).padStart(2, '0').toUpperCase()}, payload=${cfg.recommendedPayload}`);
var autoBtn = document.getElementById('autoFlashBtn');
if (autoBtn && !state.uploading && !state.autoFlashing) {
autoBtn.textContent = isEsp ? '一键刷写 ESP32' : '一键刷写 GD32';
}
}
+13 -36
View File
@@ -6,6 +6,7 @@ import { showBleVerifyInfo, hideBleVerifyInfo } from './verify.js';
import { clearOtaResultWaiter, waitForOtaResult, nextOtaSessionId } from './session.js';
import { enableNotifications } from './gatt.js';
import { syncSelectedFileFromInput, updateFileInfo } from './file.js';
import { computeFileSha } from '../ota/file-handle.js';
import { formatBytes } from '../common/utils.js';
import { showToast } from '../common/toast.js';
@@ -105,17 +106,15 @@ export function waitForAck(cmd, expectedSeq, timeoutMs = 8000, opts = {}) {
/** @description 分块上传固件到 ESP32(通过 BLE GATT 写入)。 */
export async function uploadFirmware() {
// 每次刷写前尝试重新从本地读取文件
// 热重载:有 handle 则重新读盘(本地编译会改同路径固件);否则用已选/input
if (state.selectedFileHandle) {
try {
const freshFile = await state.selectedFileHandle.getFile();
await updateFileInfo(freshFile);
var actualFile = state.selectedFile || freshFile;
bleLog('FILE', '已重新读取本地文件: ' + actualFile.name + ' (' + formatBytes(actualFile.size) + ')', 'D');
} catch (e) {
bleLog('FILE', '重新读取文件失败: ' + e.message + ',将使用缓存版本', 'W');
}
} else {
} else if (!state.selectedFile) {
await syncSelectedFileFromInput();
}
if (!state.selectedFile || !state.dataInChar) return false;
@@ -172,22 +171,14 @@ export async function uploadFirmware() {
bleLog('OTA', `⚠️ file.size(${file.size}) ≠ arrayBuffer(${totalSize}),以 arrayBuffer 为准,请确认页面已 Ctrl+F5 刷新`, 'W');
}
// SHA-256 已在文件选择时(updateFileInfo)计算并存入 state.fileSha256。
// 此处不再重复打日志(避免与 [FILE] SHA-256 重复),仅保证 fileSha256 变量有值。
var fileSha256;
if (state.fileSha256) {
fileSha256 = state.fileSha256.toLowerCase();
} else {
// 极端兜底:state.fileSha256 未初始化(理论上 updateFileInfo 总会先设置)
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('');
state.fileSha256 = fileSha256.toUpperCase();
}
// Update SHA display in file info
/* 上传前始终按本次读到的内容重算 SHA,不沿用选文件时的旧值 */
var chipKey = (cfg.label === 'GD32') ? 'gd32' : 'esp32';
var shaHex = await computeFileSha(file, chipKey, fileBytes.buffer);
if (!shaHex) throw new Error('SHA-256 计算失败');
state.fileSha256 = shaHex;
var fileSha256 = shaHex.toLowerCase();
var shaElem = document.getElementById('fileSha256');
if (shaElem) shaElem.textContent = fileSha256.toUpperCase();
if (shaElem) shaElem.textContent = shaHex.toUpperCase();
setBleStage(4, '写入中...', null);
setBleStageText(`正在写入 ${packetTotal} 包数据...`);
@@ -464,29 +455,15 @@ export async function uploadFirmware() {
/** @description 自动刷写流程:上传 ESP32 固件 → SHA验证 → 触发 GD32 中继。 */
export async function autoFlash() {
// 每次刷写前尝试重新从本地读取文件(支持热重载:用户重新编译后无需重新选择)
/* 与 uploadFirmware 一致:有 handle 则重新读盘,SHA 在 upload 内按内容重算 */
if (state.selectedFileHandle) {
try {
const freshFile = await state.selectedFileHandle.getFile();
// 仅在文件变化时才刷新元数据并重算 SHA-256
// (name/size/lastModified 任一不同即视为变化)
// 避免对同一文件重复触发 updateFileInfo → 产生重复的 [FILE] SHA-256 日志
const cached = state.selectedFile;
// HEX 文件转换后 cached.name 是 .bin 而 freshFile.name 是 .hex,
// 因此 HEX 文件用 lastModified 判断是否变化,避免每次都重新转换
const isHex = freshFile.name.toLowerCase().endsWith('.hex');
const changed = !cached ||
(isHex ? false : (cached.name !== freshFile.name || cached.size !== freshFile.size)) ||
cached.lastModified !== freshFile.lastModified;
if (changed) {
await updateFileInfo(freshFile);
var actualFile = state.selectedFile || freshFile;
bleLog('FILE', '已重新读取本地文件(已变更): ' + actualFile.name + ' (' + formatBytes(actualFile.size) + ')', 'D');
}
await updateFileInfo(freshFile);
} catch (e) {
bleLog('FILE', '重新读取文件失败: ' + e.message + ',将使用缓存版本', 'W');
}
} else {
} else if (!state.selectedFile) {
await syncSelectedFileFromInput();
}
if (!state.selectedFile || !state.server) return;
+1
View File
@@ -24,6 +24,7 @@ window.bleOta = {
resetBleStages();
bleLog('SYS', `Web Bluetooth 刷写控制台已就绪 (${UI_BUILD})`);
},
state: state,
disconnect: disconnectBle,
showToast: showToast,
notifyFileChange: notifyFileChange,
+2 -2
View File
@@ -43,7 +43,7 @@ import {
sendManualFrame,
resetManualFields,
loadPreviewToManual
} from './can/send.js?v=20260717x';
} from './can/send.js?v=20260720b';
import {
scheduleCanPersist,
loadCanSession,
@@ -51,7 +51,7 @@ import {
} from './can/persist.js?v=20260717x';
import { escapeHtml as sanitizeHtml, formatUptime } from './common/utils.js?v=20260717x';
import { showToast } from './common/toast.js?v=20260717x';
import { initCanMonitor } from './can_monitor.js?v=20260717x';
import { initCanMonitor } from './can_monitor.js?v=20260719s';
// ==================== 本地工具 ====================
// 原 can.js 的 formatUptime 输出格式与 common/utils.js 略有差异(d>0 时只到 h),
+49 -84
View File
@@ -1,8 +1,8 @@
/**
* send.js - CAN 帧构建与发送(DBC 报文 / 原始帧 / 批量发送 / 发送历史)
*
* 包含:sendCanRequest、响应框、历史、批量/选中/手动发送、
* 手动帧校验/重置/载入预览、CAN_SEND_ERR_DESC / GD32_ERR_DESC / buildSendResponseText。
* 包含:sendCanBatchRequest / sendCanOneFrame(仅 /can/send_batch)、响应框、历史、
* 批量/选中/手动发送、手动帧校验/重置/载入预览、错误码说明。
*
* 依赖:dbc.js(state/el/getBaseUrl/padHex/bytesToHex/parseNumericId)、
* common/toast.js(showToast)、common/utils.js(escapeHtml)、
@@ -28,6 +28,9 @@ function formatTime(date) {
return hh + ':' + mm + ':' + ss;
}
/** 简单延时 helper,用于批次间间隔 */
function sleep(ms) { return new Promise(function (r) { setTimeout(r, ms); }); }
// ==================== 发送错误码描述 ====================
var CAN_SEND_ERR_DESC = {
'-1': '参数错误(通道/ID/DLC 无效)',
@@ -64,54 +67,36 @@ function buildSendResponseText(result) {
return '发送失败: ' + desc + ' (code=' + result.code + ')';
}
// ==================== HTTP 请求 ====================
async function sendCanRequest(payload) {
var resp = await fetch(getBaseUrl() + '/can/send', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
signal: AbortSignal.timeout(5000)
});
var text = await resp.text();
var json = {};
try {
json = text ? JSON.parse(text) : {};
} catch (err) {
throw new Error('响应不是合法 JSON: ' + text);
}
if (!resp.ok) {
throw new Error('HTTP ' + resp.status + ': ' + (json.message || text || '请求失败'));
}
return json;
}
// ==================== HTTP 请求(唯一入口 /can/send_batch)====================
/**
* @description 一次 HTTP 连续发送多帧(设备 /can/send_batch)。
* @description 同通道发送 1~N 帧。单帧也走 batch(frames 长度 1)。
* @returns {Promise<object[]>} 与 frames 等长的 results
*/
async function sendCanBatchRequest(channel, frames) {
var n = frames && frames.length ? frames.length : 0;
/* 单帧设备侧 timeout 2s;多帧按帧数留余量 */
var timeoutMs = (n <= 1) ? 8000 : Math.max(15000, n * 500);
var resp = await fetch(getBaseUrl() + '/can/send_batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ch: channel, frames: frames }),
signal: AbortSignal.timeout(Math.max(15000, frames.length * 500))
signal: AbortSignal.timeout(timeoutMs)
});
var text = await resp.text();
var json = {};
try {
json = text ? JSON.parse(text) : {};
} catch (err) {
throw new Error('批量响应不是合法 JSON: ' + text);
throw new Error('CAN 响应不是合法 JSON: ' + text);
}
if (!resp.ok) {
throw new Error('HTTP ' + resp.status + ': ' + (json.message || text || '批量请求失败'));
throw new Error('HTTP ' + resp.status + ': ' + (json.message || text || '发送失败'));
}
if (!json.results || !Array.isArray(json.results)) {
throw new Error('批量响应缺少 results');
throw new Error('响应缺少 results');
}
/* 设备可能返回 skipped;补齐与 frames 等长的 results */
var results = json.results.slice();
var need = frames.length - results.length;
var need = n - results.length;
if (need > 0) {
var pad = {
status: 'error',
@@ -123,6 +108,12 @@ async function sendCanBatchRequest(channel, frames) {
return results;
}
/** 单帧便捷封装:POST /can/send_batch,取 results[0] */
async function sendCanOneFrame(channel, frame) {
var results = await sendCanBatchRequest(channel, [frame]);
return results[0] || { status: 'error', code: -3 };
}
// ==================== 响应框 ====================
function updateResponseBox(box, textEl, result, mode) {
if (!box || !textEl) return;
@@ -188,12 +179,16 @@ export function renderHistory() {
}
// ==================== 批量发送 ====================
/** 单批最多帧数(与设备 /can/send_batch 上限一致) */
var BATCH_CHUNK = 128;
/**
* 单批最多帧数。实测一次发送 >9 帧失败率明显上升,故按 9 帧分批;
* 批次之间留 BATCH_INTER_CHUNK_DELAY_MS 间隔,给 CAN/SPI 总线喘息时间。
*/
var BATCH_CHUNK = 32;
/** 批次间延时(ms),仅在还有后续批次时等待 */
var BATCH_INTER_CHUNK_DELAY_MS = 200;
/**
* @description 批量发送所有勾选的报文。
* 优先 POST /can/send_batch;不支持时回退逐帧 /can/send。
* @description 批量发送所有勾选的报文(仅 /can/send_batch)。
* 批量过程中不重绘历史,结束后统一刷新。
*/
export async function sendCheckedMessages() {
@@ -246,28 +241,12 @@ export async function sendCheckedMessages() {
});
}
/* 分块批量接口;不支持时整段回退为无间隔单帧 */
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) {
try {
var results = await sendCanBatchRequest(channel, frames);
for (var r = 0; r < chunk.length; r++) {
var item = chunk[r];
var result = results[r] || { status: 'error', code: -3 };
@@ -278,27 +257,10 @@ export async function sendCheckedMessages() {
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) {
} catch (err) {
for (var s = 0; s < chunk.length; s++) {
fail++;
var one = chunk[s];
addHistoryEntry(makeHistoryEntry({
mode: 'BATCH', ok: false, channel: channel,
idHex: one.idHex, dlc: one.dlc, dataHex: one.dataHex,
@@ -306,6 +268,11 @@ export async function sendCheckedMessages() {
}), silent);
}
}
/* 批次间间隔:还有下一批时等待,给 CAN/SPI 总线喘息时间 */
if (off < prepared.length) {
await sleep(BATCH_INTER_CHUNK_DELAY_MS);
}
}
renderHistory();
@@ -319,7 +286,7 @@ export async function sendCheckedMessages() {
}
// ==================== 选中报文发送 ====================
/** @description 发送当前选中报文(DBC 编码后 POST /can/send)。 */
/** @description 发送当前选中报文(DBC 编码后 POST /can/send_batch,1 帧)。 */
export async function sendSelectedMessage() {
var msg = getSelectedMessage();
if (!msg) {
@@ -332,19 +299,18 @@ export async function sendSelectedMessage() {
return;
}
var channel = parseInt(el.defaultChannel.value, 10) || 0;
var payload = {
ch: channel,
id: msg.id,
dlc: msg.dlc,
data: bytesToHex(state.generatedData)
};
var dataHex = bytesToHex(state.generatedData);
try {
var result = await sendCanRequest(payload);
var result = await sendCanOneFrame(channel, {
id: msg.id,
dlc: msg.dlc,
data: dataHex
});
updateResponseBox(el.dbcResp, el.dbcRespText, result, 'dbc');
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: dataHex,
resultText: buildSendResponseText(result)
}));
showToast(result.status === 'ok' ? 'DBC 报文发送成功' : '发送失败', result.status === 'ok' ? 'success' : 'error');
@@ -357,7 +323,7 @@ export async function sendSelectedMessage() {
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: dataHex,
resultText: err.message
}));
showToast('发送失败: ' + err.message, 'error');
@@ -415,8 +381,7 @@ export async function sendManualFrame() {
}
try {
var result = await sendCanRequest({
ch: channel,
var result = await sendCanOneFrame(channel, {
id: id,
dlc: dlc,
data: dataHex
+25
View File
@@ -74,6 +74,17 @@ async function pollFeedback() {
for (var i = 0; i < 3; i++) {
var ch = channels[i];
var card = document.getElementById('fbCh' + i);
/* enabled 来自 GD32 HELLO/PING 身份;未知时默认按开启展示 */
var enabled = !ch || ch.enabled === undefined || ch.enabled === null || !!ch.enabled;
if (card) {
card.classList.toggle('ch-disabled', !enabled);
card.title = enabled ? '' : 'GD32 CONFIG_CAN_CH' + i + '_ENABLE=false(HELLO/PING 身份)';
}
if (!enabled) {
renderChannelDisabled(i);
continue;
}
if (!ch || !ch.valid) {
renderChannelEmpty(i);
continue;
@@ -176,6 +187,20 @@ function renderChannelEmpty(i) {
if (el[p + 'LastData']) { el[p + 'LastData'].textContent = '--'; el[p + 'LastData'].className = 'fb-val mono fb-data-cell'; }
}
/** GD32 编译关闭该通道(APP PING feat can_chN=false) */
function renderChannelDisabled(i) {
var p = 'fb' + i;
if (el[p + 'State']) {
el[p + 'State'].textContent = '固件未启用';
el[p + 'State'].className = 'fb-val warn';
}
['Rx', 'Tx', 'Drop', 'Err', 'Tec', 'Rec', 'Bus', 'Seq', 'Delta', 'Dlc', 'Flags'].forEach(function (f) {
if (el[p + f]) { el[p + f].textContent = '--'; el[p + f].className = 'fb-val'; }
});
if (el[p + 'LastId']) { el[p + 'LastId'].textContent = '--'; el[p + 'LastId'].className = 'fb-val mono'; }
if (el[p + 'LastData']) { el[p + 'LastData'].textContent = '--'; el[p + 'LastData'].className = 'fb-val mono fb-data-cell'; }
}
function renderIdTable(entries) {
var tbody = el.idsnapBody;
if (!tbody) return;
+616 -20
View File
@@ -1,7 +1,7 @@
/**
* connection.js - 连接管理 + 系统状态显示
*
* 操作 DOM 元素 #pillConn / #pillConnTxt / #connStatus / #connStatusTxt / #bConn 等。
* 操作 DOM 元素 #pillConn / #pillConnTxt / #bConn 等(连接文案仅顶栏)。
*
* 共享状态(通过 window.* 引用):
* window.latestSystemStatus — 最近一次 /system/status 响应
@@ -15,23 +15,21 @@
import { formatBytes, formatTime, formatUptime, escapeHtml } from './utils.js';
import { addLog } from './log.js';
import { showToast } from './toast.js';
import {
appendHeapLowRecords, loadHeapLowHistory, clearHeapLowHistory, HEAP_HISTORY_MAX
} from './heap-low-history.js';
// ==================== 连接管理 ====================
export function updateConnectionUi(connected, text) {
var label = text || (connected ? '已连接' : '未连接');
// header pill
// 仅顶栏连接胶囊(下方连接卡不再重复显示文案)
var pc = document.getElementById('pillConn');
var pct = document.getElementById('pillConnTxt');
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 ? label : '未连接';
// primary badge
// 系统状态卡徽章
var bc = document.getElementById('bConn');
if (bc) {
bc.textContent = connected ? '在线' : '离线';
@@ -41,10 +39,7 @@ export function updateConnectionUi(connected, text) {
export function updateHealthUi(healthCode, freeHeap) {
var healthNames = ['正常', '警告', '危险', '关机'];
var health = healthCode !== undefined ? (healthNames[healthCode] || '未知') : '未知';
var connStatusTxt = document.getElementById('connStatusTxt');
if (connStatusTxt && window.latestSystemStatus && window.latestSystemStatus.wifi) {
connStatusTxt.textContent = (window.latestSystemStatus.wifi.connected ? '设备在线' : '设备离线') + ' · 系统' + health;
}
/* 连接文案由 updateConnectionUi / 顶栏 pill 负责,不在此拼「设备在线 · 系统正常」 */
if (freeHeap !== undefined) {
var mHeapEl = document.getElementById('mHeap');
if (mHeapEl) mHeapEl.textContent = formatBytes(freeHeap);
@@ -252,22 +247,167 @@ export function updateHeapAndTaskStatusUi(data) {
}
}
updateCpuUsageUi(data.cpu);
/* 合并 memory:layout(?layout=1,缓存)+ 动态 heap_*(每次轮询) */
function mergeMemoryView(rawMem) {
rawMem = rawMem || {};
if (rawMem.layout && typeof rawMem.layout === 'object') {
window._memLayout = rawMem.layout;
}
var L = window._memLayout || {};
/* 兼容旧固件:静态字段仍在 memory 根上 */
function pick(key) {
if (rawMem[key] !== undefined) return rawMem[key];
if (L[key] !== undefined) return L[key];
return undefined;
}
return {
chip: pick('chip'),
sram_total: pick('sram_total'),
sram_soc: pick('sram_soc'),
static_reserved: pick('static_reserved'),
reserved_tail: pick('reserved_tail'),
iram: pick('iram'),
data: pick('data'),
bss: pick('bss'),
heap_total: pick('heap_total'),
dma_total: pick('dma_total'),
rtc_total: pick('rtc_total'),
task_stack_total: pick('task_stack_total'),
default_heap_total: pick('default_heap_total'),
heap_used: rawMem.heap_used,
heap_free: rawMem.heap_free,
heap_min_free: rawMem.heap_min_free,
heap_largest_free: rawMem.heap_largest_free,
alloc_blocks: rawMem.alloc_blocks,
free_blocks: rawMem.free_blocks,
dma_free: rawMem.dma_free,
rtc_free: rawMem.rtc_free,
default_heap_free: rawMem.default_heap_free
};
}
/* 芯片 SRAM 全景:IRAM + .data + .bss + 堆已用 + 堆空闲(链接符号拆分静态段) */
function updateSramOverview(mem, fallback) {
mem = mergeMemoryView(mem);
var sramTotal = Number(mem.sram_total);
var sramSoc = Number(mem.sram_soc);
var staticRes = Number(mem.static_reserved);
var iram = Number(mem.iram);
var dataSz = Number(mem.data);
var bss = Number(mem.bss);
var heapTotal = Number(mem.heap_total);
var heapUsed = Number(mem.heap_used);
var heapFree = Number(mem.heap_free);
var reservedTail = Number(mem.reserved_tail);
var hasSections = Number.isFinite(iram) || Number.isFinite(dataSz) || Number.isFinite(bss);
/* 旧固件无 memory 字段时,用 internal_* 兜底 */
if (!Number.isFinite(sramTotal) || sramTotal <= 0) {
heapFree = Number(fallback.internal_free_heap);
heapTotal = Number(fallback.internal_heap_total);
if (!Number.isFinite(heapTotal) || heapTotal <= 0) {
setText('memSramUsage', '--');
setText('memSramPct', '--');
setText('mIram', '--');
setText('mData', '--');
setText('mBss', '--');
setText('mHeapUsed', '--');
setText('mHeapFree', '--');
setText('mSramSoc', '--');
setText('mReservedTail', '--');
setText('mTaskStackTotal', '--');
setText('mDmaFree', '--');
setText('mRtcFree', '--');
setText('mAllocBlocks', '--');
['memSegIram', 'memSegData', 'memSegBss', 'memSegUsed', 'memSegFree'].forEach(function (id) {
var el = document.getElementById(id);
if (el) el.style.width = '0%';
});
return;
}
heapUsed = heapTotal - heapFree;
sramTotal = heapTotal;
staticRes = 0;
iram = dataSz = bss = 0;
hasSections = false;
}
if (!Number.isFinite(heapUsed) || heapUsed < 0) {
heapUsed = Math.max(0, (Number.isFinite(heapTotal) ? heapTotal : 0) - (Number.isFinite(heapFree) ? heapFree : 0));
}
if (!Number.isFinite(heapFree) || heapFree < 0) heapFree = 0;
if (!Number.isFinite(staticRes) || staticRes < 0) staticRes = 0;
if (!Number.isFinite(heapTotal) || heapTotal < 0) heapTotal = heapUsed + heapFree;
if (!Number.isFinite(iram) || iram < 0) iram = 0;
if (!Number.isFinite(dataSz) || dataSz < 0) dataSz = 0;
if (!Number.isFinite(bss) || bss < 0) bss = 0;
/* 无分段字段时,整块静态灰条仍可用 static_reserved */
if (!hasSections && staticRes > 0) {
bss = staticRes;
}
var usedAll = staticRes + heapUsed;
if (!Number.isFinite(staticRes) || staticRes <= 0) {
usedAll = iram + dataSz + bss + heapUsed;
staticRes = iram + dataSz + bss;
}
var pctAll = sramTotal > 0 ? (usedAll * 100 / sramTotal) : 0;
setText('memSramUsage', '已占 ' + formatBytes(usedAll) + ' / 应用 ' + formatBytes(sramTotal));
setText('memSramPct', pctAll.toFixed(1) + '%');
setText('mIram', formatBytes(iram));
setText('mData', formatBytes(dataSz));
setText('mBss', formatBytes(bss));
setText('mHeapUsed', formatBytes(heapUsed));
setText('mHeapFree', formatBytes(heapFree));
setText('mSramSoc', Number.isFinite(sramSoc) && sramSoc > 0 ? formatBytes(sramSoc) : '--');
setText('mReservedTail', Number.isFinite(reservedTail) ? formatBytes(reservedTail) : '--');
setText('mTaskStackTotal', Number.isFinite(Number(mem.task_stack_total))
? formatBytes(Number(mem.task_stack_total)) : '--');
function setSeg(id, bytes) {
var el = document.getElementById(id);
if (!el) return;
var pct = sramTotal > 0 ? (bytes * 100 / sramTotal) : 0;
el.style.width = Math.max(0, Math.min(100, pct)) + '%';
}
setSeg('memSegIram', iram);
setSeg('memSegData', dataSz);
setSeg('memSegBss', bss);
setSeg('memSegUsed', heapUsed);
setSeg('memSegFree', heapFree);
if (Number.isFinite(Number(mem.dma_free)) && Number.isFinite(Number(mem.dma_total))) {
setText('mDmaFree', formatBytes(Number(mem.dma_free)) + ' / ' + formatBytes(Number(mem.dma_total)));
} else {
setText('mDmaFree', '--');
}
if (Number.isFinite(Number(mem.rtc_free)) && Number.isFinite(Number(mem.rtc_total))) {
var rt = Number(mem.rtc_total);
setText('mRtcFree', rt > 0
? (formatBytes(Number(mem.rtc_free)) + ' / ' + formatBytes(rt))
: '未并入堆');
} else {
setText('mRtcFree', '--');
}
setText('mAllocBlocks', Number.isFinite(Number(mem.alloc_blocks)) ? String(Number(mem.alloc_blocks)) : '--');
}
updateSramOverview(data.memory, data);
setText('mInternalHeap', data.internal_free_heap !== undefined ? formatBytes(data.internal_free_heap) : '--');
setText('mInternalMinHeap', data.internal_min_free_heap !== undefined ? formatBytes(data.internal_min_free_heap) : '--');
setText('mInternalLargestBlock', data.internal_largest_free_block !== undefined ? formatBytes(data.internal_largest_free_block) : '--');
setBar('memInternalUsage', 'memInternalFill', 'memInternalPct', data.internal_free_heap, data.internal_heap_total);
/* 最低空闲再创新低 → 尽快把设备 1 槽快照拉进 IndexedDB(多条历史靠浏览器攒) */
onStatusHeapMinMaybeChanged(data);
setText('mLargestBlock', data.largest_free_block !== undefined ? formatBytes(data.largest_free_block) : '--');
bindHeapLowTooltip();
setText('sOtaState', (ota.sys_state || '--') + (Number.isFinite(ota.sys_state_code) ? ' (' + ota.sys_state_code + ')' : ''));
setText('sRelayState', Number.isFinite(ota.relay_state) ? String(ota.relay_state) : '--');
if (Number.isFinite(ota.sys_state_code)) window.lastSysStateCode = ota.sys_state_code;
if (Number.isFinite(ota.relay_state)) window.lastRelayState = ota.relay_state;
var storedText = '--';
if (ota.total_size || ota.stored_size) {
storedText = formatBytes(ota.stored_size || 0) + ' / ' + formatBytes(ota.total_size || 0);
}
setText('sOtaStored', storedText);
setText('sStatusUpdated', formatTime(new Date()));
var taskList = document.getElementById('taskStackList');
@@ -355,16 +495,28 @@ export async function diagnoseNetwork() {
return result.join(';');
}
/**
* /system/status URL:尚无缓存布局时带 ?layout=1(静态 IRAM/.data/.bss/堆容量只取一次)。
* 设备重启/换固件后清 window._memLayout 再拉。
*/
export function systemStatusUrl(forceLayout) {
var base = (window.getBaseUrl ? window.getBaseUrl() : '') + '/system/status';
if (forceLayout || !window._memLayout) {
return base + (base.indexOf('?') >= 0 ? '&' : '?') + 'layout=1';
}
return base;
}
/**
* 统一的 /system/status 抓取器:独立 AbortController,超时后中断,
* 避免与递归调度的 pollStatus 互相干扰。
* 成功返回 data,HTTP 非 2xx 抛 Error(含 status),网络失败抛原 Error。
*/
async function fetchSystemStatus(timeoutMs) {
async function fetchSystemStatus(timeoutMs, forceLayout) {
var ac = new AbortController();
var timer = setTimeout(function () { ac.abort(); }, timeoutMs || 3000);
try {
var resp = await fetch(window.getBaseUrl() + '/system/status', { signal: ac.signal });
var resp = await fetch(systemStatusUrl(!!forceLayout), { signal: ac.signal });
if (!resp.ok) {
var err = new Error('HTTP ' + resp.status);
err.status = resp.status;
@@ -407,7 +559,8 @@ export async function refreshSystemStatusNow(event) {
event.stopPropagation();
}
try {
var data = await fetchSystemStatus(3000);
/* 手动刷新强制带 layout,便于换固件后更新静态段 */
var data = await fetchSystemStatus(3000, true);
window.latestSystemStatus = data;
window.updateSystemStatusUi(data);
window.consecutivePollFailures = 0;
@@ -422,3 +575,446 @@ export async function refreshSystemStatusNow(event) {
showToast('状态刷新失败', 'error');
}
}
// ============================================================
// 最低空闲 hover 弹窗:拉 /system/heap_low_log(设备 1 条)+ IndexedDB 历史
// ============================================================
// 行为:鼠标移到 #mInternalMinHeap 上时浮现 tooltip,离开时关闭。
// 首次进入懒加载一次接口,1s 内复用缓存防鼠标抖动反复请求。
// 弹窗固定在元素右下,越界时自动翻到左侧/上侧。
var heapLowLogCache = null; // null=未加载,[]/data=已加载
var heapLowLogLoading = false;
var heapLowLogLoadAt = 0; // 上次成功加载时间戳(ms)
var heapLowTooltipEl = null; // 当前显示中的 tooltip 元素
var HEAP_LOW_STATE_NAMES = ['IDLE','GD32_STORING','GD32_STORED','GD32_RELAYING','GD32_COMPLETE','ESP32_RECEIVING','ESP32_FINALIZING','ESP32_REBOOTING','ERROR'];
var HEAP_LOW_CHANNEL_NAMES = ['WIFI','BLE'];
function fmtUptime(ms) {
if (!Number.isFinite(ms) || ms <= 0) return '--';
var s = Math.floor(ms / 1000);
var h = Math.floor(s / 3600);
var m = Math.floor((s % 3600) / 60);
var sec = s % 60;
if (h > 0) return h + 'h' + String(m).padStart(2,'0') + 'm' + String(sec).padStart(2,'0') + 's';
if (m > 0) return m + 'm' + String(sec).padStart(2,'0') + 's';
return sec + 's';
}
/** OTA 忙态:HTTP socket/堆紧张,勿后台拉 heap_low_log(与 status 抢缓冲/超时 abort)。 */
function isOtaBusyForHeapLowPoll() {
if (window.pollingPausedForUpload) return true;
var c = Number(window.lastSysStateCode);
/* STORING=1 RELAYING=3 RECEIVING=5 FINALIZING=6 REBOOTING=7 */
return c === 1 || c === 3 || c === 5 || c === 6 || c === 7;
}
function isFetchAbortError(e) {
if (!e) return false;
if (e.name === 'AbortError') return true;
var msg = String(e.message || e);
return /aborted|AbortError/i.test(msg);
}
/** 拉取 /system/heap_low_log。点击触发频率低,不缓存,保证每次看到最新数据
* (与每秒轮询的 #mInternalMinHeap 表格保持一致)。仅对"请求中"去重避免重复并发。
* 拉到后增量写入 IndexedDB 持久化,突破设备仅 1 条槽位的限制。 */
async function loadHeapLowLog(force) {
if (heapLowLogLoading) return heapLowLogCache;
/* 用户点开 tooltip 时 force=true 仍允许;后台/创新低轮询勿在 OTA 中抢 HTTP */
if (!force && isOtaBusyForHeapLowPoll()) {
return heapLowLogCache;
}
heapLowLogLoading = true;
try {
var ac = new AbortController();
var timer = setTimeout(function(){ ac.abort(); }, 3000);
var resp;
try {
resp = await fetch(window.getBaseUrl() + '/system/heap_low_log', { signal: ac.signal });
} finally { clearTimeout(timer); }
if (!resp.ok) throw new Error('HTTP ' + resp.status);
heapLowLogCache = await resp.json();
heapLowLogLoadAt = Date.now();
// 增量持久化:把设备当前记录与 IndexedDB 已有的去重后写入。
// 失败/不可用静默降级(appendHeapLowRecords 内部已 catch)。
if (heapLowLogCache && Array.isArray(heapLowLogCache.records) && heapLowLogCache.records.length) {
appendHeapLowRecords(heapLowLogCache.records).then(function (n) {
if (n > 0) {
addLog('I', '内存', '堆低水位历史已存 ' + n + ' 条新记录');
}
});
}
} catch (e) {
/* 超时 abort / 导航取消:OTA 或 socket 紧时常见,不刷 WARN */
if (!isFetchAbortError(e)) {
addLog('W', '内存', '拉取 /system/heap_low_log 失败: ' + (e && e.message ? e.message : e));
}
// 失败时不清缓存,避免下次再次进入就重打接口
} finally {
heapLowLogLoading = false;
}
return heapLowLogCache;
}
function buildHeapLowRowHtml(r, idx, segTag) {
var ts = fmtUptime(r.ts_ms);
var state = HEAP_LOW_STATE_NAMES[r.sys_state] || ('#' + r.sys_state);
var conn = '';
if (r.ble_connected) conn += 'BLE ';
if (r.wifi_connected) conn += 'WiFi';
if (!conn) conn = '--';
var otaProgress = '--';
if (r.ota_total_size > 0) {
otaProgress = formatBytes(r.ota_stored_size) + '/' + formatBytes(r.ota_total_size);
}
var curTask = escapeHtml(r.current_task || 'health_mon');
// 排除法诊断:OTA 资源优先;否则按 hook 最后一击类别(勿把「连接=WiFi」当触发任务)
var diag = '--';
var diagCls = '';
var suspects = [];
if (Number(r.ble_data_queue_depth) > 0) suspects.push('BLE队列:' + r.ble_data_queue_depth);
if (Number(r.ble_reorder_cached) > 0) suspects.push('reorder:' + r.ble_reorder_cached);
if (Number(r.spi_pending_jobs) > 0) suspects.push('SPI队列:' + r.spi_pending_jobs);
var hookCls = (r.hook_task || '').toString();
var hookSz = Number(r.hook_alloc_size) || 0;
if (suspects.length) {
diag = escapeHtml(suspects.join(' '));
diagCls = 'has-suspect';
} else if (Number(r.hook_fired) === 1 && (hookCls === 'net?' || (hookSz >= 48 && hookSz < 2048))) {
// 最后一击是小/中块:HTTP 回包 / UDP 日志 send 前 lwip 会再 alloc pbuf,常被误当成 WiFi 主因
diag = '最后一击≈网络发送';
diagCls = 'idle-suspect';
} else if (Number(r.hook_fired) === 1 && (hookCls === 'bulk' || hookSz >= 2048)) {
diag = '最后一击≈大块alloc';
diagCls = 'has-suspect';
} else {
var stateName = HEAP_LOW_STATE_NAMES[r.sys_state] || ('#' + r.sys_state);
if (stateName === 'IDLE' || stateName === 'GD32_COMPLETE' || stateName === 'GD32_STORED' || stateName === 'ERROR') {
diag = '空闲期(主因未定位)';
diagCls = 'idle-suspect';
} else {
diag = '非数据路径';
diagCls = 'idle-suspect';
}
}
// 任务栈子表(默认隐藏,点击主行展开)
var stacks = Array.isArray(r.task_stacks) ? r.task_stacks : [];
var stackRows = stacks.map(function(s) {
var nm = escapeHtml(s.name || '?');
var rem = Number.isFinite(s.remaining) ? formatBytes(s.remaining) : '--';
var cls = (Number(s.remaining) < 1024) ? 'crit' :
(Number(s.remaining) < 2048) ? 'warn' : '';
return '<tr class="heap-low-sub-row ' + cls + '"><td colspan="8" class="heap-low-sub-cell">' +
nm + '<span class="heap-low-stack-rem">' + rem + '</span></td></tr>';
}).join('');
var stackSummary = stacks.length
? (stacks[0].name + ' ' + formatBytes(stacks[0].remaining) + ' <span class="heap-low-expand-hint">▾' + stacks.length + '</span>')
: '--';
// ★ 行 id 用 segTag 前缀避免设备当前段与历史段 idx 冲突
var rowId = segTag + '_' + idx;
return '<tr class="heap-low-main-row" data-idx="' + rowId + '" data-seg="' + segTag + '">' +
'<td>' + ts + '</td>' +
'<td>' + formatBytes(r.min_free_heap) + '</td>' +
'<td>' + formatBytes(r.free_heap) + '</td>' +
'<td>' + formatBytes(r.largest_free_block) + '</td>' +
'<td>' + state + '</td>' +
'<td>' + conn + '</td>' +
'<td>' + otaProgress + '</td>' +
'<td>' + formatBytes(r.default_free_heap) + '</td>' +
'<td class="' + diagCls + '">' + diag + '</td>' +
'<td>' + stackSummary + '</td>' +
'</tr>' +
'<tr class="heap-low-stack-detail" data-idx="' + rowId + '" hidden><td colspan="10">' +
'<div class="heap-low-detail-meta">' +
'<span>采样任务: <b>' + curTask + '</b> <span class="heap-low-caller-hint">(巡检发现者,不是 alloc 触发者)</span></span>' +
'<span>内部堆分配数: <b>' + (Number.isFinite(r.alloc_count) ? r.alloc_count : '--') + '</b></span>' +
'<span>IP事件计数: <b>' + (Number.isFinite(r.ip_event_count) ? r.ip_event_count : '--') + '</b></span>' +
(r.saved_at_ms ? '<span>保存于: <b>' + formatTime(new Date(r.saved_at_ms)) + '</b></span>' : '') +
'</div>' +
'<div class="heap-low-hook-meta">' +
'<div class="heap-low-hook-title">heap hook:破新低时的「最后一击」alloc' +
(Number(r.hook_fired) === 1
? ''
: ' <span class="heap-low-caller-hint">(hook 未命中)</span>') +
'</div>' +
'<span>分配类别: <b>' + escapeHtml(r.hook_task || '--') + '</b> <span class="heap-low-caller-hint">net?=小中块/疑网络 · bulk=大块 · tiny=更小</span></span>' +
'<span>本次alloc: <b>' + (Number.isFinite(r.hook_alloc_size) && Number(r.hook_alloc_size) > 0 ? formatBytes(r.hook_alloc_size) : '--') + '</b></span>' +
'<span>alloc后est空闲: <b>' + (Number.isFinite(r.hook_free_after) && Number(r.hook_free_after) > 0 ? formatBytes(r.hook_free_after) : '--') + '</b></span>' +
'<span>调用者地址: <b class="heap-low-caller">0x' + (Number.isFinite(r.hook_caller_addr) && Number(r.hook_caller_addr) > 0 ? r.hook_caller_addr.toString(16).toUpperCase() : '--') + '</b>' +
' <span class="heap-low-caller-hint">(addr2line)</span></span>' +
'</div>' +
'<table class="heap-low-stack-table"><thead><tr><th>任务</th><th>剩余栈</th></tr></thead>' +
'<tbody>' + (stackRows || '<tr><td colspan="2">无</td></tr>') + '</tbody>' +
'</table>' +
'</td></tr>';
}
function buildHeapLowSectionHtml(records, segTag, title, totalCount) {
if (!records || !records.length) return '';
var rows = records.map(function (r, i) { return buildHeapLowRowHtml(r, i, segTag); }).join('');
return '<div class="heap-low-section heap-low-section-' + segTag + '">' +
'<div class="heap-low-section-title">' + title +
' <span class="heap-low-sub">' + records.length + ' 条' +
(totalCount !== undefined && totalCount > records.length ? ' / 共 ' + totalCount : '') +
'</span>' +
'</div>' +
'<table class="heap-low-table">' +
'<thead><tr>' +
'<th>启动后</th><th>最低空闲</th><th>当时空闲</th><th>最大块</th>' +
'<th>OTA状态</th><th>连接</th><th>OTA进度</th><th>默认堆</th>' +
'<th>诊断</th><th>最危险栈 ▾</th>' +
'</tr></thead>' +
'<tbody>' + rows + '</tbody>' +
'</table>' +
'</div>';
}
function buildHeapLowTooltipHtml(data, history) {
var live = (data && Array.isArray(data.records)) ? data.records : [];
var hist = Array.isArray(history) ? history : [];
var bodyHtml;
if (!live.length && !hist.length) {
bodyHtml = '<div class="heap-low-empty">尚无"最低空闲创新低"记录</div>';
} else {
// 设备当前段:live 已是最新在前
var liveHtml = buildHeapLowSectionHtml(live, 'live', '设备当前(仅最新 1 条)', hist.length + live.length);
// 历史段:hist 升序(旧→新),显示时反转成新的在前
var histDesc = hist.slice().reverse();
var liveKeys = {};
live.forEach(function (r) {
liveKeys[r.ts_ms + '|' + r.min_free_heap + '|' + r.free_heap + '|' + (r.alloc_count || 0)] = true;
});
var histOnly = histDesc.filter(function (r) {
return !liveKeys[r.ts_ms + '|' + r.min_free_heap + '|' + r.free_heap + '|' + (r.alloc_count || 0)];
});
var histHtml = histOnly.length
? buildHeapLowSectionHtml(histOnly, 'hist', '前端累积历史', hist.length)
: '';
bodyHtml = liveHtml + histHtml;
}
return '' +
'<div class="heap-low-title">堆"最低空闲"创新低记录' +
'<span class="heap-low-sub">设备 ' + live.length + ' 条 + 历史 ' + hist.length + ' 条</span>' +
'<button class="heap-low-clear" type="button" title="清空浏览器历史(不访问设备)">清空</button>' +
'<button class="heap-low-close" type="button" aria-label="关闭" title="关闭">&times;</button>' +
'</div>' +
'<div class="heap-low-table-wrap">' + bodyHtml + '</div>' +
'<div class="heap-low-tip">设备只保留最新 1 条;多条历史在浏览器 IndexedDB(最多 '
+ HEAP_HISTORY_MAX
+ ' 条,约 5s 拉取 + status 新低时立即拉取)。「清空」只清本地历史,不访问设备。状态栏「最低空闲」需重启才清。</div>';
}
function positionHeapLowTooltip(anchorEl, tipEl) {
// 默认放右下;越界时翻左侧/上侧。tip 先 measure 再定位。
tipEl.style.visibility = 'hidden';
tipEl.style.left = '0px';
tipEl.style.top = '0px';
document.body.appendChild(tipEl);
var r = anchorEl.getBoundingClientRect();
var tw = tipEl.offsetWidth;
var th = tipEl.offsetHeight;
var pad = 8;
var left = r.right + pad;
if (left + tw > window.innerWidth - 8) left = r.left - tw - pad;
if (left < 8) left = Math.max(8, r.right - tw);
var top = r.bottom + pad;
if (top + th > window.innerHeight - 8) top = r.top - th - pad;
if (top < 8) top = 8;
tipEl.style.left = left + 'px';
tipEl.style.top = top + 'px';
tipEl.style.visibility = 'visible';
}
function showHeapLowTooltip(anchorEl) {
// 并行加载:设备当前 1 条 + 浏览器 IndexedDB 累积历史
// force=true:用户主动查看,OTA 期也允许试一次(失败静默)
Promise.all([
loadHeapLowLog(true).catch(function () { return { records: [] }; }),
loadHeapLowHistory().catch(function () { return []; })
]).then(function (results) {
var data = results[0] || { records: [] };
var history = results[1] || [];
if (heapLowTooltipEl) heapLowTooltipEl.remove();
var el = document.createElement('div');
el.className = 'heap-low-tooltip';
el.innerHTML = buildHeapLowTooltipHtml(data, history);
heapLowTooltipEl = el;
positionHeapLowTooltip(anchorEl, el);
// 关闭按钮:显式关闭并阻止 click 冒泡触发"点击外部关闭"
var closeBtn = el.querySelector('.heap-low-close');
if (closeBtn) {
closeBtn.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
hideHeapLowTooltip();
});
}
var clearBtn = el.querySelector('.heap-low-clear');
if (clearBtn) {
clearBtn.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
clearHeapLowRecordsUi(clearBtn, anchorEl);
});
}
// 点击主行展开/收起对应任务栈详情子表
var mainRows = el.querySelectorAll('.heap-low-main-row');
mainRows.forEach(function(row) {
row.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
var idx = row.getAttribute('data-idx');
if (idx === null) return;
var detail = el.querySelector('.heap-low-stack-detail[data-idx="' + idx + '"]');
if (detail) detail.hidden = !detail.hidden;
});
});
});
}
/**
* 清空浏览器侧堆低水位历史(IndexedDB)。
* 不再 POST 设备:设备仅 1 槽,多条历史全在本地;状态栏 min_free 仍要重启才清。
*/
async function clearHeapLowRecordsUi(btn, anchorEl) {
if (btn && btn.disabled) return;
if (!window.confirm(
'清空浏览器中的堆低水位历史(IndexedDB,最多 '
+ HEAP_HISTORY_MAX
+ ' 条)?\n\n不会访问设备。状态栏「最低空闲」芯片累计需重启才清零。'
)) {
return;
}
if (btn) {
btn.disabled = true;
btn.textContent = '清空中…';
}
var localOk = false;
try {
localOk = !!(await clearHeapLowHistory());
} catch (e2) {
localOk = false;
}
heapLowLogCache = null;
heapLowLogLoadAt = 0;
lastSeenInternalMinHeap = null;
if (localOk) {
addLog('I', '内存', '已清空堆低水位本地历史');
showToast('已清空本地历史', 'success');
} else {
addLog('E', '内存', '清空本地历史失败');
showToast('清空失败', 'error');
if (btn) {
btn.disabled = false;
btn.textContent = '清空';
}
return;
}
if (anchorEl) {
showHeapLowTooltip(anchorEl);
} else {
hideHeapLowTooltip();
}
}
function hideHeapLowTooltip() {
if (heapLowTooltipEl) { heapLowTooltipEl.remove(); heapLowTooltipEl = null; }
}
/** 绑定到 #mInternalMinHeap 元素(仅绑一次)。click 切换显示/隐藏。 */
export function bindHeapLowTooltip() {
var el = document.getElementById('mInternalMinHeap');
if (!el || el.dataset.heapLowBound === '1') return;
el.dataset.heapLowBound = '1';
el.style.cursor = 'pointer';
el.title = '点击查看堆低水位创新低记录';
// 点击触发:已显示则关闭,未显示则拉接口显示
el.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
if (heapLowTooltipEl) {
hideHeapLowTooltip();
} else {
showHeapLowTooltip(el);
}
});
// 点击 tooltip 外部关闭(点击 tooltip 自身或其子元素不关闭)
document.addEventListener('click', function(e) {
if (!heapLowTooltipEl) return;
if (e.target === el) return; // 触发元素自身已单独处理
var node = e.target;
while (node && node !== document) {
if (node === heapLowTooltipEl) return;
node = node.parentNode;
}
hideHeapLowTooltip();
}, true);
// 页面整体滚动/resize 时关闭(tooltip 内部滚动不触发,由 e.target 父链判断)
window.addEventListener('scroll', function(e) {
if (!heapLowTooltipEl) return;
var node = e.target;
while (node && node !== document) {
if (node === heapLowTooltipEl) return;
node = node.parentNode;
}
hideHeapLowTooltip();
}, true);
window.addEventListener('resize', function() {
if (heapLowTooltipEl) hideHeapLowTooltip();
});
// 后台拉取 heap_low_log → IndexedDB 多条历史(设备仅 1 槽)
startHeapLowBackgroundPoll();
}
// ==================== 堆低水位历史:后台拉取 + 弹窗合并显示 ====================
var heapLowBgTimerId = null;
/** 与 health_mon 5s 巡检对齐,尽量在下一次创新低覆盖前把当前槽拉进 IDB */
var HEAP_LOW_BG_INTERVAL_MS = 5000;
/** 上次从 /system/status 见到的 min_free,用于检测新低并立刻拉 log */
var lastSeenInternalMinHeap = null;
/**
* status 轮询里调用:若 internal_min_free_heap 再创新低,立刻拉设备槽写入 IDB。
* 这样不必等 5s 后台定时,连续创新低时也能多攒几条(每条仍须设备侧先记一次)。
*/
export function onStatusHeapMinMaybeChanged(data) {
if (!data) return;
var minH = data.internal_min_free_heap;
if (minH === undefined) minH = data.min_free_heap;
minH = Number(minH);
if (!Number.isFinite(minH)) return;
if (lastSeenInternalMinHeap === null) {
lastSeenInternalMinHeap = minH;
return;
}
if (minH < lastSeenInternalMinHeap) {
lastSeenInternalMinHeap = minH;
/* force=false:OTA 忙态跳过,避免与上传/status 抢 socket 后 abort 刷屏 */
if (!document.hidden && !isOtaBusyForHeapLowPoll()) {
loadHeapLowLog(false).catch(function () {});
}
} else if (minH > lastSeenInternalMinHeap) {
/* 设备重启后 min 回升:同步基线,不强制拉 */
lastSeenInternalMinHeap = minH;
}
}
/** 启动后台拉取。多次调用幂等。 */
function startHeapLowBackgroundPoll() {
if (heapLowBgTimerId) return;
function tick() {
heapLowBgTimerId = null;
if (!document.hidden && !isOtaBusyForHeapLowPoll()) {
loadHeapLowLog(false).catch(function () {});
}
heapLowBgTimerId = setTimeout(tick, HEAP_LOW_BG_INTERVAL_MS);
}
heapLowBgTimerId = setTimeout(tick, 3000);
}
+196
View File
@@ -0,0 +1,196 @@
/**
* heap-low-history.js - 堆"最低空闲创新低"记录的浏览器端持久化
*
* 目的:设备 RAM 只保留最新 1 条创新低;长时间挂机早期记录会被覆盖。
* 前端定时拉 /system/heap_low_log,发现新记录就追加到 IndexedDB,
* 弹窗里合并显示「设备当前 1 条 + 浏览器累积历史」,掉电/重启不丢本地史。
*
* 存储设计:
* - 单 objectStore 'heap_low_records',keyPath 'id'(自增)
* - 每条记录附前端写入时的时间戳 saved_at_ms(wall clock)
* - 设备重启会重置 ts_ms(启动后毫秒)和 min_free_heap(水位清零),
* 所以去重 key 用 (ts_ms, min_free_heap, free_heap, alloc_count) 组合,
* 重启后第一条 min_free_heap 必然高于上一次会话最低值 → 自然不会误并入。
* - 上限 HEAP_HISTORY_MAX 条,超出按 saved_at_ms 升序删最早的。
*/
var IDB_NAME = 'esp_dcu_heap_low';
var IDB_STORE = 'heap_low_records';
var IDB_VERSION = 1; // 独立库,与 file-handle.js 的 dcu_ota_fw_v1 不冲突
/** 浏览器可保存的创新低条数上限(设备仅 1 槽;多条全靠这里)。 */
export var HEAP_HISTORY_MAX = 500;
var _dbPromise = null;
function openDb() {
if (!_dbPromise) {
_dbPromise = new Promise(function (resolve, reject) {
if (!window.indexedDB) {
reject(new Error('indexedDB unavailable'));
return;
}
var req = indexedDB.open(IDB_NAME, IDB_VERSION);
req.onupgradeneeded = function () {
var db = req.result;
if (!db.objectStoreNames.contains(IDB_STORE)) {
var store = db.createObjectStore(IDB_STORE, { keyPath: 'id', autoIncrement: true });
// 去重查询索引:同一会话内 (ts_ms,min_free_heap,free_heap,alloc_count) 唯一
store.createIndex('dedup', ['ts_ms', 'min_free_heap', 'free_heap', 'alloc_count'], { unique: false });
store.createIndex('saved_at', 'saved_at_ms', { unique: false });
}
};
req.onsuccess = function () { resolve(req.result); };
req.onerror = function () { reject(req.error || new Error('idb open failed')); };
});
_dbPromise.catch(function () { _dbPromise = null; });
}
return _dbPromise;
}
/** 计算单条记录的去重 key 字符串(用于 in-memory 比对,避免每次都走 IDB 索引) */
function dedupKey(r) {
return r.ts_ms + '|' + r.min_free_heap + '|' + r.free_heap + '|' + (r.alloc_count || 0);
}
/** 把设备拉到的一批 records(最新在前)增量写入 IndexedDB,去重。
* 返回 Promise<number>:本次实际新增的条数。 */
export function appendHeapLowRecords(records) {
if (!Array.isArray(records) || !records.length) return Promise.resolve(0);
return openDb().then(function (db) {
return new Promise(function (resolve, reject) {
var tx = db.transaction(IDB_STORE, 'readwrite');
var store = tx.objectStore(IDB_STORE);
var idx = store.index('dedup');
var newCount = 0;
var savedAt = Date.now();
// 顺序处理:每条先查 dedup 索引,不存在则 put
var i = 0;
function next() {
if (i >= records.length) {
// 全部处理完后再裁剪到上限
pruneToMax(store, function () {
tx.oncomplete ? null : null;
});
return;
}
var r = records[i++];
var key = [r.ts_ms, r.min_free_heap, r.free_heap, r.alloc_count || 0];
var getReq = idx.get(key);
getReq.onsuccess = function () {
if (!getReq.result) {
// 新记录:写入
var rec = Object.assign({}, r, { saved_at_ms: savedAt });
var putReq = store.put(rec);
putReq.onsuccess = function () { newCount++; next(); };
putReq.onerror = function () { next(); }; // 单条失败不阻断
} else {
next(); // 已存在跳过
}
};
getReq.onerror = function () { next(); };
}
next();
tx.oncomplete = function () { resolve(newCount); };
tx.onerror = function () { reject(tx.error); };
tx.onabort = function () { reject(tx.error || new Error('tx aborted')); };
});
}).catch(function (e) {
// IndexedDB 不可用(无痕模式等)静默降级
return 0;
});
}
/** 删最早的记录直到条数 <= HEAP_HISTORY_MAX。需在 readwrite tx 内调用。 */
function pruneToMax(store, done) {
var countReq = store.count();
countReq.onsuccess = function () {
var total = countReq.result;
if (total <= HEAP_HISTORY_MAX) { done(); return; }
var toRemove = total - HEAP_HISTORY_MAX;
// 按 saved_at 索引升序游标删前 toRemove 条
var idx = store.index('saved_at');
var curReq = idx.openCursor();
var removed = 0;
curReq.onsuccess = function (ev) {
var cursor = ev.target.result;
if (!cursor || removed >= toRemove) { done(); return; }
var delReq = store.delete(cursor.primaryKey);
delReq.onsuccess = function () { removed++; cursor.continue(); };
delReq.onerror = function () { cursor.continue(); };
};
curReq.onerror = function () { done(); };
};
countReq.onerror = function () { done(); };
}
/** 读全部累积历史,按设备时间 ts_ms 升序(旧的在前),便于弹窗"历史段"显示。
* 返回 Promise<Array>。IndexedDB 不可用时返回 []。 */
export function loadHeapLowHistory() {
return openDb().then(function (db) {
return new Promise(function (resolve, reject) {
var tx = db.transaction(IDB_STORE, 'readonly');
var store = tx.objectStore(IDB_STORE);
var all = [];
var curReq = store.openCursor();
curReq.onsuccess = function (ev) {
var cursor = ev.target.result;
if (!cursor) return;
all.push(cursor.value);
cursor.continue();
};
curReq.onerror = function () { reject(curReq.error); };
tx.oncomplete = function () {
// 按 ts_ms 升序;同 ts_ms 时按 saved_at 升序(保证稳定)
all.sort(function (a, b) {
if (a.ts_ms !== b.ts_ms) return a.ts_ms - b.ts_ms;
return (a.saved_at_ms || 0) - (b.saved_at_ms || 0);
});
resolve(all);
};
tx.onerror = function () { reject(tx.error); };
});
}).catch(function () { return []; });
}
/** 清空全部历史(用于"清空"按钮,可选实现)。返回 Promise<boolean>。 */
export function clearHeapLowHistory() {
return openDb().then(function (db) {
return new Promise(function (resolve) {
var tx = db.transaction(IDB_STORE, 'readwrite');
var req = tx.objectStore(IDB_STORE).clear();
req.onsuccess = function () { resolve(true); };
req.onerror = function () { resolve(false); };
});
}).catch(function () { return false; });
}
/** 返回当前累积条数(仅用于 UI 提示,不需要数据)。返回 Promise<number>。 */
export function countHeapLowHistory() {
return openDb().then(function (db) {
return new Promise(function (resolve) {
var tx = db.transaction(IDB_STORE, 'readonly');
var req = tx.objectStore(IDB_STORE).count();
req.onsuccess = function () { resolve(req.result || 0); };
req.onerror = function () { resolve(0); };
});
}).catch(function () { return 0; });
}
/** 计算给定 records 数组(设备拉到的最新在前)与参考集合(已存历史)相比的新条数。
* 纯内存比对,不写库,用于先判断是否需要写。 */
export function diffNewCount(records, existingHistory) {
if (!Array.isArray(records) || !records.length) return 0;
var seen = {};
if (Array.isArray(existingHistory)) {
for (var i = 0; i < existingHistory.length; ++i) {
seen[dedupKey(existingHistory[i])] = true;
}
}
var n = 0;
for (var j = 0; j < records.length; ++j) {
if (!seen[dedupKey(records[j])]) ++n;
}
return n;
}
+32 -9
View File
@@ -156,6 +156,8 @@ function markEsp32RebootComplete(source) {
if (!window.isEsp32Rebooting) return;
window.isEsp32Rebooting = false;
window.rebootObservedDown = false;
/* 重启后重新拉 memory.layout(换固件后静态段可能变化) */
window._memLayout = null;
window.updateEsp32StagesFromPhase(6); // 启动完成
addLog('I', '系统', source ? 'ESP32 已恢复在线(' + source + ')' : 'ESP32 已恢复在线');
showToast('ESP32 已恢复在线', 'success');
@@ -187,7 +189,11 @@ export async function pollStatus() {
statusAbortCtrl = new AbortController();
var pollTimer = setTimeout(function() { try { statusAbortCtrl.abort(); } catch(_e) {} }, 5000);
try {
var resp = await fetch(window.getBaseUrl() + '/system/status', {
/* 首次/无缓存:?layout=1 取静态内存布局;之后只轮询动态字段 */
var statusUrl = (window.systemStatusUrl
? window.systemStatusUrl(false)
: (window.getBaseUrl() + '/system/status' + (window._memLayout ? '' : '?layout=1')));
var resp = await fetch(statusUrl, {
signal: statusAbortCtrl.signal
});
if (resp.ok) {
@@ -229,24 +235,41 @@ export async function pollStatus() {
clearTimeout(pollTimer);
// 自身 AbortController 触发的取消不算失败,静默返回
if (e && e.name === "AbortError") return;
// 先判定 OTA 是否进行中:OTA 期间所有失败都不计入 consecutivePollFailures,
// 也不触发诊断。避免"上传完成→finalize→重启"窗口期失败计数累积,
// 切回 IDLE 瞬间立即触发 5-fail 诊断,干扰进度显示。
var lastDeviceBusy0 = isLastDeviceBusy();
var sessionActive0 = window.otaSessionActive();
var sessionFailed0 = window.otaSessionState === window.OtaSessionState.FAILED;
var otaOngoing0 = window.pollingPausedForUpload ||
sessionActive0 || sessionFailed0 ||
window.isEsp32Rebooting || !!window.currentOtaTarget || lastDeviceBusy0;
// 后台期间 setInterval 会被 Chrome/Edge 节流到最低 1 分钟/次,
// 因此后台累积的失败计数不应递增,避免切回前台瞬间误触发 5-fail 诊断。
if (pageVisible) window.consecutivePollFailures++;
var lastDeviceBusy = isLastDeviceBusy();
// OTA 期间也不递增:设备忙/重启无法响应是正常的,不该计入"诊断触发计数"。
if (pageVisible && !otaOngoing0) window.consecutivePollFailures++;
var lastDeviceBusy = lastDeviceBusy0;
// OTA/传输/重启中屏蔽网关无响应的检测功能(设备忙或正在重启,无法响应轮询是正常的)
// 设备已知忙时:保持 1s 轮询,不触发降速诊断,避免错过设备恢复的即时反馈
// ★ 状态机:用统一的会话活跃/失败判断替代散布的标志。
// FAILED 态不触发诊断降速(让用户尽快看到设备恢复),但也不覆盖失败 UI。
var sessionActive = window.otaSessionActive();
var sessionFailed = window.otaSessionState === window.OtaSessionState.FAILED;
if (pageVisible && window.consecutivePollFailures >= 5 && !sessionActive && !window.isEsp32Rebooting && !window.currentOtaTarget && !lastDeviceBusy && !sessionFailed) {
var sessionActive = sessionActive0;
var sessionFailed = sessionFailed0;
// ★ OTA 期间(含暂停轮询、会话活跃、设备忙、正在重启、FAILED、当前 OTA 目标存在)
// 一律不触发诊断日志/UI 覆盖。这些 W 级日志会刷在日志区,与"上传 xx/xx" "重启中 Xs"
// "中继 N/M" 等进度行混在一起,用户反馈干扰 OTA 进度显示。
// 之前条件漏了 pollingPausedForUpload,且 sessionActive 可能在 finalize/reboot 短窗口
// 翻 false,导致 5 次失败后误触发诊断。这里把所有 OTA 相关态统一兜底。
var otaOngoing = window.pollingPausedForUpload ||
sessionActive || sessionFailed ||
window.isEsp32Rebooting || !!window.currentOtaTarget || lastDeviceBusy;
if (pageVisible && window.consecutivePollFailures >= 5 && !otaOngoing) {
addLog('W', '诊断', '设备连续 5 次无响应,正在诊断网络...');
var diag = await diagnoseNetwork();
addLog('W', '诊断', diag);
updateConnectionUi(false, '未连接');
updateConnectionUi(false, '诊断中');
window.applyDisconnectedRuntimeState();
var cst = document.getElementById('connStatusTxt');
if (cst) cst.textContent = '诊断中: ' + diag;
addLog('W', '诊断', '诊断结果: ' + diag);
}
// ★ 状态机优先:会话已 FAILED → 保持失败 UI,仅标连接胶囊为"设备忙/未连接"
if (sessionFailed) {
+111 -71
View File
@@ -94,33 +94,41 @@ window.sharedFirmwareFiles = window.sharedFirmwareFiles || {
window.flashTarget = target === 'gd32' ? 'gd32' : 'esp32';
persistFlashTarget(window.flashTarget);
applySharedModeUi();
syncBleFileFromSelectedTarget();
/* 与 WiFi 一致:两路文件独立;切换只换「当前目标」面板 + 绑定该路文件到会话 */
if (window.connectionMode === 'ble') {
window.switchStageFlow('ble');
if (typeof setActivePhase === 'function') {
setActivePhase(target === 'gd32' ? 'gd32Ota' : 'esp32Ota');
setActivePhase(window.flashTarget === 'gd32' ? 'gd32Ota' : 'esp32Ota');
}
if (window.bleOta && typeof window.bleOta.refreshTargetUi === 'function') {
window.bleOta.refreshTargetUi();
}
/* 把当前目标对应的 esp32/gd32 固件绑到 BLE 会话(无文件则清空,避免误刷另一芯片) */
syncBleFileFromSelectedTarget();
} else {
window.switchStageFlow(window.flashTarget);
}
if (window.bleOta && typeof window.bleOta.refreshTargetUi === 'function') {
window.bleOta.refreshTargetUi();
}
};
window.handleSharedFileSelect = function (event, chip) {
var file = event && event.target && event.target.files && event.target.files[0] ? event.target.files[0] : null;
if (file) {
/* 传统 input 无持久 handle,刷新后无法自动恢复该次选择 */
window.sharedFirmwareFiles[chip] = { file: file, handle: null };
}
if (typeof window.handleFileSelect === 'function') {
window.handleFileSelect(event, chip);
}
window.flashTarget = chip === 'gd32' ? 'gd32' : 'esp32';
persistFlashTarget(window.flashTarget);
applySharedModeUi();
setTimeout(syncBleFileFromSelectedTarget, 0);
/* 等 processFile(含 HEX→BIN)完成后再写入 shared 并 sync */
var done = (typeof window.handleFileSelect === 'function')
? Promise.resolve(window.handleFileSelect(event, chip))
: Promise.resolve();
done.then(function () {
var prepared = chip === 'gd32' ? window.gd32File : window.esp32File;
if (prepared) {
window.sharedFirmwareFiles[chip] = { file: prepared, handle: null };
}
syncBleFileFromSelectedTarget();
}).catch(function (err) {
if (typeof window.addLog === 'function') {
window.addLog('W', '文件', chip.toUpperCase() + ' 选择失败: ' + (err && err.message ? err.message : err));
}
});
};
window.pickSharedFirmware = function (chip) {
@@ -137,22 +145,25 @@ window.sharedFirmwareFiles = window.sharedFirmwareFiles || {
});
};
Promise.resolve(window.pickFileViaFSAPI(chip)).then(function () {
window.showOpenFilePicker = nativePicker;
if (!capturedHandle || typeof capturedHandle.getFile !== 'function') {
syncBleFileFromSelectedTarget();
return;
/* 用 processFile 后的结果(GD32 HEX→BIN 后的 prepared),不要再塞回原始 .hex */
var prepared = chip === 'gd32' ? window.gd32File : window.esp32File;
var handle = (chip === 'gd32' ? window.gd32FileHandle : window.esp32FileHandle) || capturedHandle;
if (prepared) {
window.sharedFirmwareFiles[chip] = { file: prepared, handle: handle || null };
} else if (capturedHandle) {
window.sharedFirmwareFiles[chip] = {
file: window.sharedFirmwareFiles[chip] && window.sharedFirmwareFiles[chip].file,
handle: capturedHandle
};
}
capturedHandle.getFile().then(function (file) {
window.sharedFirmwareFiles[chip] = { file: file, handle: capturedHandle };
syncBleFileFromSelectedTarget();
}).catch(function () {
syncBleFileFromSelectedTarget();
});
syncBleFileFromSelectedTarget();
}).catch(function (err) {
window.showOpenFilePicker = nativePicker;
if (err && err.name !== 'AbortError' && typeof window.addLog === 'function') {
window.addLog('W', '文件', 'FSAPI 选择失败: ' + err.message);
window.addLog('W', '文件', 'FSAPI 选择失败: ' + (err.message || err));
}
}).then(function () {
/* 无论成败都还原原生 picker,避免二次选择失效 */
window.showOpenFilePicker = nativePicker;
});
} else {
var input = document.getElementById(chip === 'gd32' ? 'fileInputGd32' : 'fileInputEsp32');
@@ -184,60 +195,83 @@ window.sharedFirmwareFiles = window.sharedFirmwareFiles || {
}
};
/**
* 按 flashTarget 把「该芯片一路」固件绑到 BLE 会话(复刻 WiFi:两路独立、切换不串文件)。
* 优先 window.esp32File/gd32File(含 HEX→BIN 结果),其次 sharedFirmwareFiles / input。
* 当前目标无文件时清空 BLE 会话,禁止继续显示/上传另一芯片固件。
*/
window.syncBleFileFromSelectedTarget = function () {
if (window.connectionMode !== 'ble' || !window.bleOta || typeof window.bleOta.notifyFileChange !== 'function') {
if (window.connectionMode !== 'ble' || !window.bleOta) {
return;
}
var isGd32 = window.flashTarget === 'gd32';
var chip = isGd32 ? 'gd32' : 'esp32';
var suffix = isGd32 ? 'Gd32' : 'Esp32';
var stored = window.sharedFirmwareFiles[chip];
if (stored && stored.handle && typeof stored.handle.getFile === 'function') {
stored.handle.getFile().then(function (file) {
stored.file = file;
setBleFirmwareInputFile(file);
window.bleOta.notifyFileChange(file, stored.handle);
}).catch(function () {
if (stored.file) {
setBleFirmwareInputFile(stored.file);
window.bleOta.notifyFileChange(stored.file, stored.handle);
if (window.bleOta.isUploading && window.bleOta.isUploading()) {
return;
}
var chip = window.flashTarget === 'gd32' ? 'gd32' : 'esp32';
var file = chip === 'gd32' ? window.gd32File : window.esp32File;
var handle = chip === 'gd32' ? window.gd32FileHandle : window.esp32FileHandle;
var stored = window.sharedFirmwareFiles && window.sharedFirmwareFiles[chip]
? window.sharedFirmwareFiles[chip]
: null;
if (!handle && stored && stored.handle) handle = stored.handle;
if (!file && stored && stored.file) file = stored.file;
function bindBle(f, h) {
setBleFirmwareInputFile(f);
if (typeof window.bleOta.notifyFileChange === 'function') {
window.bleOta.notifyFileChange(f, h || null);
}
/* 会话 SHA 与对应芯片槽对齐(不跨芯片) */
var slot = chip === 'gd32' ? window._gd32FileSha256 : window._esp32FileSha256;
if (window.bleOta.state) {
if (slot) window.bleOta.state.fileSha256 = slot;
}
}
function clearBleSession() {
if (typeof window.bleOta.clearFile === 'function') {
window.bleOta.clearFile();
} else if (typeof window.bleOta.notifyFileChange === 'function') {
window.bleOta.notifyFileChange(null, null);
}
}
if (file) {
bindBle(file, handle);
return;
}
if (handle && typeof handle.getFile === 'function') {
handle.getFile().then(function (fresh) {
if (chip === 'gd32' && fresh.name.toLowerCase().endsWith('.hex') &&
typeof window.processFile === 'function') {
return Promise.resolve(window.processFile(fresh, 'gd32', {
silent: true,
skipHandlePersist: true
})).then(function () {
if (window.gd32File) bindBle(window.gd32File, handle);
else bindBle(fresh, handle);
});
}
if (chip === 'esp32') window.esp32File = fresh;
else window.gd32File = fresh;
if (stored) stored.file = chip === 'gd32' ? window.gd32File : window.esp32File;
bindBle(chip === 'gd32' ? window.gd32File : window.esp32File, handle);
}).catch(function () {
if (stored && stored.file) bindBle(stored.file, handle);
else clearBleSession();
});
return;
}
if (stored && stored.file) {
setBleFirmwareInputFile(stored.file);
window.bleOta.notifyFileChange(stored.file, stored.handle || null);
return;
}
var suffix = chip === 'gd32' ? 'Gd32' : 'Esp32';
var input = document.getElementById('fileInput' + suffix);
var info = document.getElementById('fileInfo' + suffix);
var hasInfo = info && info.classList.contains('show');
var file = input && input.files && input.files[0] ? input.files[0] : null;
if (file) {
window.sharedFirmwareFiles[chip] = { file: file, handle: null };
setBleFirmwareInputFile(file);
window.bleOta.notifyFileChange(file, null);
var inputFile = input && input.files && input.files[0] ? input.files[0] : null;
if (inputFile) {
if (window.sharedFirmwareFiles) {
window.sharedFirmwareFiles[chip] = { file: inputFile, handle: null };
}
bindBle(inputFile, null);
return;
}
if (!hasInfo) {
return;
}
var nameEl = document.getElementById('fileName' + suffix);
var typeEl = document.getElementById('fileType' + suffix);
var sizeEl = document.getElementById('fileSize' + suffix);
var bleInfo = document.getElementById('fileInfo');
var bleName = document.getElementById('fileName');
var bleType = document.getElementById('fileType');
var bleSize = document.getElementById('fileSize');
if (bleInfo) bleInfo.classList.add('show');
if (bleName && nameEl) bleName.textContent = nameEl.textContent;
if (bleType && typeEl) bleType.textContent = typeEl.textContent;
if (bleSize && sizeEl) bleSize.textContent = sizeEl.textContent;
var packetCount = document.getElementById('packetCount');
var sentCount = document.getElementById('sentCount');
if (packetCount) packetCount.textContent = '-';
if (sentCount) sentCount.textContent = '0';
clearBleSession();
};
window.applySharedModeUi = function () {
@@ -260,9 +294,15 @@ window.sharedFirmwareFiles = window.sharedFirmwareFiles || {
if (el) el.style.display = ids[id] ? '' : 'none';
});
var hint = document.getElementById('readyHint');
var autoBtn = document.getElementById('autoFlashBtn');
if (autoBtn && isBle) {
autoBtn.textContent = isGd32 ? '一键刷写 GD32' : '一键刷写 ESP32';
}
if (hint) {
if (isBle) {
hint.innerHTML = 'BLE 模式:连接设备 → 选择固件 → 一键刷写。';
hint.innerHTML = isGd32
? 'BLE 模式 · 目标 GD32:连接设备 → 选择 GD32 固件 → 一键刷写。'
: 'BLE 模式 · 目标 ESP32:连接设备 → 选择 ESP32 固件 → 一键刷写。';
} else if (isGd32) {
hint.innerHTML = 'WiFi 模式:上传 GD32 固件后自动执行 SPI OTA。';
} else {
+111 -103
View File
@@ -20,6 +20,9 @@ window.esp32File = null;
window.gd32File = null;
window.esp32FileHandle = null; // FileSystemFileHandle for hot reload
window.gd32FileHandle = null; // FileSystemFileHandle for hot reload
/** 两路各自一份 SHA:选文件时写入,上传时读取;互不切换、不共享 */
window._esp32FileSha256 = null;
window._gd32FileSha256 = null;
/** localStorage:升级目标等轻量偏好(刷新后恢复) */
var LS_FLASH_TARGET = 'dcu_ota_flash_target';
@@ -196,7 +199,7 @@ export async function resumePersistedFirmwareWithGesture(chip) {
return false;
}
var file = await handle.getFile();
applyRestoredFile(chip, handle, file, { silent: true });
await applyRestoredFile(chip, handle, file, { silent: true });
window._pendingFwHandles[chip] = null;
addLog('I', '文件', chip.toUpperCase() + ' 已重新授权并恢复: ' + file.name);
showToast(chip.toUpperCase() + ' 已恢复上次固件', 'success');
@@ -218,11 +221,14 @@ function applyRestoredFile(chip, handle, file, options) {
if (window.sharedFirmwareFiles) {
window.sharedFirmwareFiles[chip] = { file: file, handle: handle };
}
processFile(file, chip, Object.assign({ skipHandlePersist: true }, options || {}));
if (typeof window.syncBleFileFromSelectedTarget === 'function') {
window.syncBleFileFromSelectedTarget();
}
safeUpdateUploadButtonLabel();
/* 等 SHA 写入对应槽后再 sync BLE,避免二次重算 */
return Promise.resolve(processFile(file, chip, Object.assign({ skipHandlePersist: true }, options || {})))
.then(function () {
if (typeof window.syncBleFileFromSelectedTarget === 'function') {
window.syncBleFileFromSelectedTarget();
}
safeUpdateUploadButtonLabel();
});
}
/** 无权限时仍展示上次文件名,提示点上传区一键恢复 */
@@ -269,7 +275,7 @@ export async function restorePersistedFirmwareSelection() {
var perm = await queryHandlePermission(handle);
if (perm === 'granted') {
var file = await handle.getFile();
applyRestoredFile(chip, handle, file, { silent: true, skipHandlePersist: true });
await applyRestoredFile(chip, handle, file, { silent: true, skipHandlePersist: true });
window._pendingFwHandles[chip] = null;
addLog('I', '文件', chip.toUpperCase() + ' 已恢复刷新前的固件: ' + file.name);
continue;
@@ -296,13 +302,13 @@ export async function restorePersistedFirmwareSelection() {
export function handleFileSelect(event, chip) {
var file = event.target.files[0];
if (!file) return;
if (!file) return Promise.resolve();
/* 传统 input 无 handle:清掉旧 FSAPI 句柄,避免热重载读到过期路径 */
if (chip === 'esp32') window.esp32FileHandle = null;
else window.gd32FileHandle = null;
window._pendingFwHandles[chip] = null;
persistFileHandle(chip, null);
processFile(file, chip);
return processFile(file, chip);
}
// 通过 File System Access API 选择文件(支持热重载)
@@ -325,9 +331,14 @@ export async function pickFileViaFSAPI(chip) {
return false;
}
try {
/* GD32 常为 .hex:同时声明 octet-stream / text,避免对话框里灰掉选不了 */
var acceptMap = chip === 'esp32'
? { 'application/octet-stream': ['.bin'] }
: { 'application/octet-stream': ['.bin', '.hex'] };
: {
'application/octet-stream': ['.bin', '.hex'],
'text/plain': ['.hex'],
'application/x-intel-hex': ['.hex']
};
var prevHandle = chip === 'esp32' ? window.esp32FileHandle : window.gd32FileHandle;
if (!prevHandle) {
prevHandle = window._pendingFwHandles[chip] || null;
@@ -341,7 +352,18 @@ export async function pickFileViaFSAPI(chip) {
if (prevHandle) {
opts.startIn = prevHandle;
}
var handles = await window.showOpenFilePicker(opts);
var handles;
try {
handles = await window.showOpenFilePicker(opts);
} catch (pickErr) {
/* 失效 handle 作 startIn 时部分浏览器直接抛错,去掉后重试 */
if (opts.startIn && pickErr && pickErr.name !== 'AbortError') {
delete opts.startIn;
handles = await window.showOpenFilePicker(opts);
} else {
throw pickErr;
}
}
if (handles && handles.length > 0) {
var handle = handles[0];
var file = await handle.getFile();
@@ -356,8 +378,8 @@ export async function pickFileViaFSAPI(chip) {
return true;
}
} catch (e) {
if (e.name !== 'AbortError') {
addLog('W', '文件', 'FSAPI 选择失败: ' + e.message);
if (e && e.name !== 'AbortError') {
addLog('W', '文件', 'FSAPI 选择失败: ' + (e.message || e));
}
}
return false;
@@ -484,81 +506,58 @@ export async function convertHexFileToBin(file) {
return { file: binFile, converted: true, baseAddr: result.baseAddr, endAddr: result.endAddr };
}
// ==================== SHA-256 缓存 ====================
// 选文件时就把 SHA-256 算好并缓存(同时记录 file meta),上传前
// 若 handle.getFile() 拿回的 fresh file meta 未变就直接复用,
// 避免每次点上传都重算 30–80ms 的 crypto.subtle.digest。
// 缓存 key:size + lastModified + name 三项均一致(lastModified 在
// 文件 rebuild 后必变,是浏览器给的最可靠 rebuild 信号)。
// ==================== SHA-256:ESP32 / GD32 各一份,互不共用、不互切 ====================
function paintShaUi(chip, hashHex, statusText) {
var t = statusText != null ? statusText : (hashHex || '-');
if (chip === 'esp32') {
var e = document.getElementById('fileSha256Esp32');
if (e) e.textContent = t;
} else if (chip === 'gd32') {
var g = document.getElementById('fileSha256Gd32');
if (g) g.textContent = t;
}
/* BLE 面板只显示当前目标槽,避免算 ESP32 时盖掉 GD32 的展示 */
var cur = (window.flashTarget === 'gd32') ? 'gd32' : 'esp32';
if (chip === cur) {
var ble = document.getElementById('fileSha256');
if (ble) ble.textContent = t;
}
}
/**
* @description 计算 file 的 SHA-256 并写入缓存(sha + meta)。
* GD32 UI 无 SHA 显示行;ESP32 同步更新 #fileSha256Esp32。
* @param {File} file 目标文件
* @param {'esp32'|'gd32'} chip 通道
* @param {ArrayBuffer=} optArrayBuffer 已读的 arrayBuffer(避免重复读取)
* @returns {Promise<string|null>} hex 大写 SHA,失败返回 null
* 选文件时算一次,写入对应芯片槽;上传时直接读槽。
* 不做跨芯片复用、不与另一路互切。
*/
function _computeAndCacheFileSha(file, chip, optArrayBuffer) {
var suffix = chip === 'esp32' ? 'Esp32' : 'Gd32';
var shaElem = document.getElementById('fileSha256' + suffix); // GD32 可能为 null
if (shaElem) shaElem.textContent = '计算中...';
export function computeFileSha(file, chip, optArrayBuffer) {
if (!file || (chip !== 'esp32' && chip !== 'gd32')) {
return Promise.resolve(null);
}
paintShaUi(chip, null, '计算中...');
var bufPromise = optArrayBuffer ? Promise.resolve(optArrayBuffer) : file.arrayBuffer();
return bufPromise.then(function(buf) {
return bufPromise.then(function (buf) {
return crypto.subtle.digest('SHA-256', buf);
}).then(function(hashBuffer) {
}).then(function (hashBuffer) {
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') {
window._esp32FileSha256 = hashHex;
window._esp32FileShaMeta = meta;
} else {
window._gd32FileSha256 = hashHex;
window._gd32FileShaMeta = meta;
}
if (chip === 'esp32') window._esp32FileSha256 = hashHex;
else window._gd32FileSha256 = hashHex;
paintShaUi(chip, hashHex);
addLog('I', '文件', chip.toUpperCase() + ' 固件 SHA-256: ' + hashHex);
return hashHex;
}).catch(function(err) {
if (shaElem) shaElem.textContent = '计算失败: ' + err.message;
}).catch(function (err) {
paintShaUi(chip, null, '计算失败: ' + err.message);
if (chip === 'esp32') window._esp32FileSha256 = null;
else window._gd32FileSha256 = null;
addLog('W', '文件', chip.toUpperCase() + ' SHA-256 计算失败: ' + err.message);
if (chip === 'esp32') { window._esp32FileSha256 = null; window._esp32FileShaMeta = null; }
else { window._gd32FileSha256 = null; window._gd32FileShaMeta = null; }
return null;
});
}
/**
* @description 确保 file 的 SHA-256 已在缓存中:命中直接返回,不命中则重算并缓存。
* @param {File} file 目标文件
* @param {'esp32'|'gd32'} chip 通道
* @param {ArrayBuffer=} optArrayBuffer 命中时不用,未命中时传入可避免重复读文件
* @returns {Promise<string|null>} hex 大写 SHA
*/
export function ensureFileShaCached(file, chip, optArrayBuffer) {
var cachedMeta = chip === 'esp32' ? window._esp32FileShaMeta : window._gd32FileShaMeta;
var cachedSha = chip === 'esp32' ? window._esp32FileSha256 : window._gd32FileSha256;
if (cachedSha && cachedMeta &&
cachedMeta.size === file.size &&
cachedMeta.lastModified === file.lastModified &&
cachedMeta.name === file.name) {
// 命中:把 UI 上的 SHA 同步刷一下(幂等),避免其它代码误清
var shaElem = document.getElementById(chip === 'esp32' ? 'fileSha256Esp32' : 'fileSha256Gd32');
if (shaElem) shaElem.textContent = cachedSha;
addLog('I', '文件', chip.toUpperCase() + ' 复用已缓存 SHA-256(文件未变)');
return Promise.resolve(cachedSha);
}
return _computeAndCacheFileSha(file, chip, optArrayBuffer);
}
// ==================== 文件处理 ====================
/**
* @description 处理用户选择的固件文件(HEX→BIN 转换、SHA-256 计算、文件校验)。
* 对 GD32 .hex 走异步转换;返回 Promise 以便调用方 await
* (避免上游用 setTimeout 硬等,见 upload-esp32.js)。
* @description 处理用户选择的固件文件(HEX→BIN、算 SHA 写入对应芯片槽)。
* @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) {
@@ -569,52 +568,61 @@ export function processFile(file, chip, options) {
if (!opts.skipHandlePersist) {
persistFlashTarget(chip);
}
/* 元数据始终更新,便于刷新后展示「上次文件名」 */
if (file && file.name) {
persistFileMeta(chip, file);
}
function paintFileInfo(name, type, sizeText) {
var info = document.getElementById('fileInfo' + suffix);
var nameEl = document.getElementById('fileName' + suffix);
var typeEl = document.getElementById('fileType' + suffix);
var sizeEl = document.getElementById('fileSize' + suffix);
if (info) info.classList.add('show');
if (nameEl) nameEl.textContent = name;
if (typeEl) typeEl.textContent = type;
if (sizeEl) sizeEl.textContent = sizeText;
}
if (chip === 'gd32' && isHexByName) {
// GD32 固件: .hex 文件自动转换为 .bin,然后替换文件对象
return convertHexFileToBin(file).then(function(result) {
return convertHexFileToBin(file).then(function (result) {
if (!result.converted) {
// 文件名 .hex 但内容不像,直接用原始文件
addLog('W', '文件', file.name + ' 扩展名为 .hex 但内容不像 HEX 格式,按原始文件处理');
window.gd32File = file;
document.getElementById('fileInfo' + suffix).classList.add('show');
document.getElementById('fileName' + suffix).textContent = file.name;
document.getElementById('fileType' + suffix).textContent = 'Binary(?)';
document.getElementById('fileSize' + suffix).textContent = formatBytes(file.size);
paintFileInfo(file.name, 'Binary(?)', formatBytes(file.size));
safeUpdateUploadButtonLabel();
return ensureFileShaCached(file, 'gd32');
return computeFileSha(file, 'gd32');
}
window.gd32File = result.file;
var baseHex = '0x' + result.baseAddr.toString(16).toUpperCase().padStart(8, '0');
addLog('I', '文件', 'HEX → BIN 转换完成: ' + file.name + ' → ' + result.file.name);
addLog('I', '文件', '基地址: ' + baseHex + ', 数据大小: ' + formatBytes(result.file.size));
if (!silent) showToast('HEX 已自动转换为 BIN', 'success');
document.getElementById('fileInfo' + suffix).classList.add('show');
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);
paintFileInfo(
result.file.name + ' (由 ' + file.name + ' 转换)',
'Binary (HEX转换)',
formatBytes(result.file.size)
);
safeUpdateUploadButtonLabel();
return ensureFileShaCached(result.file, 'gd32');
}).catch(function(err) {
addLog('E', '文件', 'HEX 解析失败: ' + err.message);
if (!silent) showToast('HEX 文件解析失败: ' + err.message, 'error');
return computeFileSha(result.file, 'gd32');
}).catch(function (err) {
addLog('E', '文件', 'HEX 解析失败: ' + (err && err.message ? err.message : err));
if (!silent) showToast('HEX 文件解析失败: ' + (err && err.message ? err.message : err), 'error');
window.gd32File = null;
document.getElementById('fileInfo' + suffix).classList.remove('show');
window._gd32FileSha256 = null;
var info = document.getElementById('fileInfo' + suffix);
if (info) info.classList.remove('show');
safeUpdateUploadButtonLabel();
throw err; // 让上游 await 感知失败
throw err;
});
}
// .bin 文件或 ESP32 文件: 直接使用
if (chip === 'esp32') window.esp32File = file;
else window.gd32File = file;
document.getElementById('fileInfo' + suffix).classList.add('show');
document.getElementById('fileName' + suffix).textContent = file.name;
document.getElementById('fileType' + suffix).textContent =
isHexByName ? 'Intel HEX' : 'Binary';
document.getElementById('fileSize' + suffix).textContent = formatBytes(file.size);
paintFileInfo(
file.name,
isHexByName ? 'Intel HEX' : 'Binary',
formatBytes(file.size)
);
safeUpdateUploadButtonLabel();
if (silent) {
addLog('I', '文件', chip.toUpperCase() + ' 已恢复: ' + file.name + ' (' + formatBytes(file.size) + ')');
@@ -622,20 +630,20 @@ export function processFile(file, chip, options) {
addLog('I', '文件', chip.toUpperCase() + ' 已选择: ' + file.name + ' (' + formatBytes(file.size) + ')');
showToast(chip.toUpperCase() + ' 文件已选择', 'success');
}
// 选文件时就把 SHA-256 算好并缓存(ESP32/GD32 都算),
// 上传时若文件未变 ensureFileShaCached 命中直接复用。
return Promise.resolve(ensureFileShaCached(file, chip));
return Promise.resolve(computeFileSha(file, chip));
}
/** @description 清除已选固件文件并重置对应 UI 状态。 */
export function clearFile(chip) {
var suffix = chip === 'esp32' ? 'Esp32' : 'Gd32';
if (chip === 'esp32') {
window.esp32File = null; window.esp32FileHandle = null;
window._esp32FileSha256 = null; window._esp32FileShaMeta = null;
window.esp32File = null;
window.esp32FileHandle = null;
window._esp32FileSha256 = null;
} else {
window.gd32File = null; window.gd32FileHandle = null;
window._gd32FileSha256 = null; window._gd32FileShaMeta = null;
window.gd32File = null;
window.gd32FileHandle = null;
window._gd32FileSha256 = null;
}
window._pendingFwHandles[chip] = null;
persistFileHandle(chip, null);
+1 -2
View File
@@ -156,8 +156,7 @@ export function cleanupAfterOtaFailure(reason) {
window.clearOtaSessionResiduals({
keepErrorStages: true,
statusText: reason || '失败',
errorStyle: true,
clearCacheDisplay: true
errorStyle: true
});
// 2. 阶段指示器标错
+1 -12
View File
@@ -228,16 +228,6 @@ export function clearOtaSessionResiduals(opts) {
: '待机';
}
// 设备侧缓存展示:失败后不要继续显示“已满缓存像成功”
var mOtaCache = document.getElementById('mOtaCache');
if (mOtaCache && opts.clearCacheDisplay) {
mOtaCache.textContent = '0 / 216 KB';
mOtaCache.className = 'v';
}
var sOtaStored = document.getElementById('sOtaStored');
if (sOtaStored && opts.clearCacheDisplay) {
sOtaStored.textContent = '--';
}
}
/** @description 将会话从 FAILED/DONE 复位到 IDLE(用户发起新上传或设备空闲时调用)。 */
@@ -245,8 +235,7 @@ export function otaSessionReset() {
clearOtaSessionResiduals({
keepErrorStages: false,
statusText: '待机',
errorStyle: false,
clearCacheDisplay: false
errorStyle: false
});
if (otaSessionState !== OtaSessionState.IDLE) {
otaSessionState = OtaSessionState.IDLE;
+85 -29
View File
@@ -19,7 +19,7 @@
* window.markGd32OtaUiComplete / window.cleanupAfterOtaFailure / window.otaSessionReset
* window.stopRelayProgressWatch / window.pollLogs / window.emitRelayProgress
*/
import { formatBytes, formatUptime } from '../common/utils.js';
import { formatUptime } from '../common/utils.js';
import { updateConnectionUi, updateHealthUi, updateHeapAndTaskStatusUi } from '../common/connection.js';
import { setActivePhase, setProgress } from './progress.js';
import { otaSessionReset, OtaSessionState } from './session.js';
@@ -107,7 +107,7 @@ function setDisplayedRelayState(rs) {
}
/**
* 常驻仪表盘:堆、连接、健康、版本、温湿度、IP/RSSI、OTA cache。
* 常驻仪表盘:堆、连接、健康、版本、温度、IP/RSSI。
* @param {object} data /system/status JSON
* @param {object} ota data.ota
* @param {object} wifi data.wifi
@@ -150,8 +150,87 @@ function applyDashboardUi(data, ota, wifi) {
var mTempEl = document.getElementById('mTemp');
if (mTempEl) {
mTempEl.textContent =
Number.isFinite(data.temperature) ? data.temperature.toFixed(1) + '\u00B0C' : '--';
/* 与固件 app_config:TEMP_WARNING=80 / TEMP_CRITICAL=100 对齐 */
if (Number.isFinite(data.temperature)) {
var tC = data.temperature;
mTempEl.textContent = tC.toFixed(1) + '\u00B0C';
if (tC >= 100) {
mTempEl.className = 'v err';
} else if (tC >= 80) {
mTempEl.className = 'v warn';
} else {
mTempEl.className = 'v ok';
}
mTempEl.title = 'ESP32 片内温度传感器(警告≥80°C,危险≥100°C)';
} else {
mTempEl.textContent = '--';
mTempEl.className = 'v';
mTempEl.title = '温度无效或未采样';
}
}
/* GD32 ADC:enabled 来自 HELLO_ACK 功能位(GD32 编译开关),ESP32 无本地开关 */
var ga = data.gd32_adc || null;
var feat = data.gd32_features || null;
var mGd32TempEl = document.getElementById('mGd32Temp');
var mVccEl = document.getElementById('mVcc');
var mVddaEl = document.getElementById('mVdda');
function setMetricUi(el, text, cls, title) {
if (!el) return;
el.textContent = text;
el.className = cls || 'v';
if (title) el.title = title;
}
if (mGd32TempEl || mVccEl || mVddaEl) {
if (ga && ga.enabled === false) {
var offTitle = 'GD32 CONFIG_SPI_ADC_FEEDBACK_ENABLE=0(HELLO/PING 身份)';
setMetricUi(mGd32TempEl, '关闭', 'v', offTitle);
setMetricUi(mVccEl, '关闭', 'v', offTitle);
setMetricUi(mVddaEl, '关闭', 'v', offTitle);
} else if (ga && Number.isFinite(ga.temperature) && ga.fresh) {
setMetricUi(mGd32TempEl, ga.temperature.toFixed(1) + '\u00B0C',
ga.valid ? 'v ok' : 'v warn',
'片内温度 · seq=' + (ga.seq || 0) + ' age=' + (ga.age_ms || 0) + 'ms'
+ (ga.valid ? '' : ' (valid=0)'));
/* VCC:status 已带 vcc;旧固件仅 vin_adc 时本地 *11(47k/4.7k) */
var vcc = Number.isFinite(ga.vcc) ? ga.vcc
: (Number.isFinite(ga.vin_adc) ? ga.vin_adc * 11.0 : NaN);
if (Number.isFinite(vcc) && vcc > 0.5) {
setMetricUi(mVccEl, vcc.toFixed(1) + 'V', ga.valid ? 'v ok' : 'v warn',
'输入母线 VCC(PA6 分压反推)· vin_adc='
+ (Number.isFinite(ga.vin_adc) ? ga.vin_adc.toFixed(3) + 'V' : '--'));
} else {
setMetricUi(mVccEl, '--', 'v', '无 VIN 采样');
}
if (Number.isFinite(ga.vdda) && ga.vdda > 0.5) {
setMetricUi(mVddaEl, ga.vdda.toFixed(2) + 'V', ga.valid ? 'v ok' : 'v warn',
'MCU 模拟供电 VDDA(片内 VREFINT 反推)');
} else {
setMetricUi(mVddaEl, '--', 'v', '无 VDDA 采样');
}
} else if (ga && Number.isFinite(ga.temperature)) {
setMetricUi(mGd32TempEl, ga.temperature.toFixed(1) + '\u00B0C (旧)', 'v warn', '超过 5s 未更新');
var vccOld = Number.isFinite(ga.vcc) ? ga.vcc
: (Number.isFinite(ga.vin_adc) ? ga.vin_adc * 11.0 : NaN);
setMetricUi(mVccEl, Number.isFinite(vccOld) ? (vccOld.toFixed(1) + 'V (旧)') : '--',
'v warn', '超过 5s 未更新');
setMetricUi(mVddaEl,
Number.isFinite(ga.vdda) ? (ga.vdda.toFixed(2) + 'V (旧)') : '--',
'v warn', '超过 5s 未更新');
} else if (ga && ga.enabled === true) {
var waitTitle = 'GD32 已开 0xC4,等待首帧';
setMetricUi(mGd32TempEl, '等待…', 'v', waitTitle);
setMetricUi(mVccEl, '等待…', 'v', waitTitle);
setMetricUi(mVddaEl, '等待…', 'v', waitTitle);
} else {
var idleTitle = feat && feat.known === false
? '尚未 HELLO 身份或旧 GD32(无 feat_map)'
: '等待 HELLO / 0xC4';
setMetricUi(mGd32TempEl, '--', 'v', idleTitle);
setMetricUi(mVccEl, '--', 'v', idleTitle);
setMetricUi(mVddaEl, '--', 'v', idleTitle);
}
}
var uptimeEl = document.getElementById('pillUptime');
if (uptimeEl) uptimeEl.textContent = '\u23F1 ' + formatUptime(data.uptime_sec);
@@ -160,18 +239,6 @@ function applyDashboardUi(data, ota, wifi) {
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) {
if (otaStored > 0) {
mOtaCache.textContent = formatBytes(otaStored) + ' / 216 KB';
mOtaCache.className = 'v ok';
} else {
mOtaCache.textContent = '0 / 216 KB';
mOtaCache.className = 'v';
}
}
}
/**
@@ -183,14 +250,11 @@ 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;
var isGd32Storing = sysState === 'GD32_STORING' || sysStateCode === 1;
var isGd32Relaying = sysState === 'GD32_RELAYING' || sysStateCode === 3;
var isGd32Complete = sysState === 'GD32_COMPLETE' || sysStateCode === 4;
var isGd32Error = sysState === 'ERROR' || sysStateCode === 8 || rs === 255;
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;
/* 进度以 relay_seq/relay_total 为准;不再用 stored_size 做「OTA 缓存」展示 */
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;
@@ -321,15 +385,7 @@ function applyGd32OtaSessionUi(ota) {
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));
}
}
/* 不再用 stored_size 做「OTA 缓存」展示;上传走 XHR,中继走 relay_seq。 */
}
/**
+17 -23
View File
@@ -30,7 +30,7 @@ import {
startUploadWaitHeartbeat
} from './session.js';
import { updateEsp32StagesFromPhase, switchStageFlow, setActivePhase, setProgress, markPhaseDone, cleanupAfterOtaFailure } from './progress.js';
import { ensureFileShaCached } from './file-handle.js';
import { computeFileSha } from './file-handle.js';
function isOtaBusyError(status, detail) {
var text = (detail || '').toLowerCase();
@@ -151,40 +151,30 @@ export async function startUpload(chip) {
break;
}
}
// 每次上传前尝试重新从本地读取文件
// 热重载:有 FSAPI handle 则重新读盘(本地编译会改同路径固件)
var handle = chip === 'esp32' ? window.esp32FileHandle : window.gd32FileHandle;
if (handle) {
// 有 FileSystemFileHandle,调用 getFile() 获取最新内容
try {
var freshFile = await handle.getFile();
if (chip === 'esp32') {
window.esp32File = freshFile;
file = freshFile;
if (chip === 'gd32' && freshFile.name.toLowerCase().endsWith('.hex')) {
/* HEX 必须按最新源文件再转 BIN,不能用选文件时的旧 prepared */
await window.processFile(freshFile, 'gd32', { silent: true, skipHandlePersist: true });
file = window.gd32File;
} else {
if (freshFile.name.toLowerCase().endsWith('.hex')) {
// processFile 现已返回 Promise,await 即可拿到转换后的 gd32File,
// 不再用 setTimeout 硬等(避免 HEX 稍大时拿到 null)。
await window.processFile(freshFile, 'gd32');
file = window.gd32File;
} else {
window.gd32File = freshFile;
file = freshFile;
}
if (chip === 'esp32') window.esp32File = freshFile;
else window.gd32File = freshFile;
file = freshFile;
}
addLog('I', '文件', '已重新读取本地文件: ' + file.name + ' (' + formatBytes(file.size) + ')');
} catch (e) {
addLog('W', '文件', '重新读取文件失败: ' + e.message + ',将使用缓存版本');
}
} else {
// 没有 handle,尝试直接读取当前 File 对象
try {
await file.arrayBuffer();
} catch (e) {
// File 引用已失效,提示用户重新选择
addLog('E', '文件', '本地文件已变更或失效,请重新选择文件');
showToast('文件已变更,请重新选择', 'error');
window.clearFile(chip);
// ★ 状态机:文件失效属于会话失败
cleanupAfterOtaFailure('本地文件失效');
return;
}
@@ -209,10 +199,14 @@ export async function startUpload(chip) {
showToast('开始上传固件到 ' + chip.toUpperCase(), 'success');
try {
var arrayBuffer = await file.arrayBuffer();
// SHA-256:processFile 阶段已算好并缓存;这里若 handle.getFile() 拿回的
// fresh file meta 未变就直接复用(命中 30–100ms/MB 的计算);
// 用户 rebuild 使 lastModified 变化时 ensureFileShaCached 自动 fallback 到重算。
await ensureFileShaCached(file, chip, arrayBuffer);
/* 上传前始终按本次内容重算 SHA(本地固件会改,不能沿用选文件时的旧值) */
var savedSha = await computeFileSha(file, chip, arrayBuffer);
if (!savedSha) {
addLog('E', '文件', chip.toUpperCase() + ' SHA-256 计算失败');
showToast('SHA-256 计算失败', 'error');
cleanupAfterOtaFailure('SHA-256 计算失败');
return;
}
var fileSize = file.size;
var body, url = window.getBaseUrl() + '/ota/upload';
if (chip === 'esp32') {
+1 -1
View File
@@ -105,7 +105,7 @@ export function markGd32OtaUiComplete(msg) {
if (typeof window.updateUploadButtonLabel === 'function') {
window.updateUploadButtonLabel();
}
addLog('I', 'OTA', msg || 'GD32 刷写完成');
/* 完成文案以设备 [I][OTA] GD32 OTA完成 为准,前端只 toast/进度,避免日志双份 */
showToast(msg || 'GD32 刷写完成', 'success');
}