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:
+3
-15
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -24,6 +24,7 @@ window.bleOta = {
|
||||
resetBleStages();
|
||||
bleLog('SYS', `Web Bluetooth 刷写控制台已就绪 (${UI_BUILD})`);
|
||||
},
|
||||
state: state,
|
||||
disconnect: disconnectBle,
|
||||
showToast: showToast,
|
||||
notifyFileChange: notifyFileChange,
|
||||
|
||||
+2
-2
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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="关闭">×</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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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);
|
||||
|
||||
@@ -156,8 +156,7 @@ export function cleanupAfterOtaFailure(reason) {
|
||||
window.clearOtaSessionResiduals({
|
||||
keepErrorStages: true,
|
||||
statusText: reason || '失败',
|
||||
errorStyle: true,
|
||||
clearCacheDisplay: true
|
||||
errorStyle: true
|
||||
});
|
||||
|
||||
// 2. 阶段指示器标错
|
||||
|
||||
+1
-12
@@ -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
@@ -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。 */
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user