移除固定的包间延迟配置 `OTA_PACKET_DELAY_MS`,采用基于SPI Link v2 CREDIT背压的动态节奏控制,在SpiMaster中通过remoteCredit==0时wait/拒发 实现流量控制,提高OTA传输稳定性。 refactor(stack): 统一任务栈大小配置并添加CPU统计功能 - 在app_config.h中集中定义所有任务栈大小常量,基于实测水位优化分配 - 新增monitor/cpu_stats.cpp组件,提供1Hz FreeRTOS CPU占用采样 - 更新main.cpp中的任务创建使用新的栈大小常量 - 调整health_monitor和ota_relay等任务的栈分配 perf(ota): 改进ESP32和GD32 OTA分区预擦策略 - ESP32 OTA从边写边擦改为整分区预擦模式,避免写入时的扇区擦除阻塞 - GD32 OTA在prepare阶段进行整分区预擦,提升后续写入吞吐量 - 优化OTA协调器状态管理,修复FINALIZING状态残留问题 fix(log): 改进UDP日志发送的堆内存管理 - 当堆内存不足时记录UDP发送跳过的告警信息到ring buffer - 降低UDP发送跳过的内存阈值从10KB到6KB - 降低日志排水最小内存阈值从8KB到4KB docs(readme): 更新OTA配置参数文档说明 移除已废弃的OTA_PACKET_DELAY_MS参数,更新包间节奏控制说明为 基于CREDIT背压机制的描述。
320 lines
14 KiB
JavaScript
320 lines
14 KiB
JavaScript
/**
|
||
* file-handle.js - 文件处理 + Intel HEX → BIN 转换
|
||
*
|
||
* 包含:HEX 解析、文件选择、FSAPI、SHA-256 计算、文件信息 UI、清除。
|
||
*
|
||
* 共享状态(通过 window.* 引用):
|
||
* window.esp32File / window.gd32File / window.esp32FileHandle / window.gd32FileHandle
|
||
* window._esp32FileSha256 / window._gd32FileSha256
|
||
*
|
||
* 复用的函数(通过 window.* 引用):
|
||
* window.updateUploadButtonLabel / window.showToast / window.addLog
|
||
*/
|
||
import { formatBytes } from '../common/utils.js';
|
||
import { showToast } from '../common/toast.js';
|
||
import { addLog } from '../common/log.js';
|
||
|
||
// ==================== 文件处理 ====================
|
||
// 文件状态(模块级,但被 upload-esp32/upload-gd32 通过 window.* 读写)
|
||
window.esp32File = null;
|
||
window.gd32File = null;
|
||
window.esp32FileHandle = null; // FileSystemFileHandle for hot reload
|
||
window.gd32FileHandle = null; // FileSystemFileHandle for hot reload
|
||
|
||
export function handleFileSelect(event, chip) {
|
||
var file = event.target.files[0];
|
||
if (file) processFile(file, chip);
|
||
}
|
||
|
||
// 通过 File System Access API 选择文件(支持热重载)
|
||
export async function pickFileViaFSAPI(chip) {
|
||
if (!window.showOpenFilePicker) {
|
||
addLog('W', '文件', '当前浏览器不支持 File System Access API,请手动重新选择文件');
|
||
return false;
|
||
}
|
||
try {
|
||
var acceptMap = chip === 'esp32'
|
||
? { 'application/octet-stream': ['.bin'] }
|
||
: { 'application/octet-stream': ['.bin', '.hex'] };
|
||
var handles = await window.showOpenFilePicker({
|
||
types: [{ description: '固件文件', accept: acceptMap }],
|
||
multiple: false
|
||
});
|
||
if (handles && handles.length > 0) {
|
||
var handle = handles[0];
|
||
var file = await handle.getFile();
|
||
if (chip === 'esp32') window.esp32FileHandle = handle;
|
||
else window.gd32FileHandle = handle;
|
||
processFile(file, chip);
|
||
addLog('I', '文件', '已通过 FSAPI 选择文件(支持热重载): ' + file.name);
|
||
return true;
|
||
}
|
||
} catch (e) {
|
||
if (e.name !== 'AbortError') {
|
||
addLog('W', '文件', 'FSAPI 选择失败: ' + e.message);
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
export function setupDropZone(zoneId, chip) {
|
||
var zone = document.getElementById(zoneId);
|
||
if (!zone) return;
|
||
zone.addEventListener('dragover', function (e) {
|
||
e.preventDefault(); e.currentTarget.classList.add('dragover');
|
||
});
|
||
zone.addEventListener('dragleave', function (e) {
|
||
e.currentTarget.classList.remove('dragover');
|
||
});
|
||
zone.addEventListener('drop', function (e) {
|
||
e.preventDefault(); e.currentTarget.classList.remove('dragover');
|
||
var file = e.dataTransfer.files[0];
|
||
if (file) processFile(file, chip);
|
||
});
|
||
}
|
||
|
||
// ==================== Intel HEX → BIN 转换 ====================
|
||
/**
|
||
* 将 Intel HEX 字符串解析为原始 BIN 字节数组。
|
||
*
|
||
* 支持记录类型: 00(数据), 01(EOF), 02(扩展段地址), 04(扩展线性地址)。
|
||
* 地址间隙填充 0xFF(Flash 擦除态)。
|
||
*
|
||
* @param {string} hexText Intel HEX 文件的完整文本内容。
|
||
* @returns {{ data: Uint8Array, baseAddr: number, endAddr: number }}
|
||
*/
|
||
export function parseIntelHex(hexText) {
|
||
var baseAddr = 0; // 当前扩展地址
|
||
var segments = []; // { offset, data } 列表
|
||
var minAddr = 0xFFFFFFFF;
|
||
var maxAddr = 0;
|
||
var lines = hexText.split(/\r?\n/);
|
||
for (var i = 0; i < lines.length; i++) {
|
||
var line = lines[i].trim();
|
||
if (!line || line[0] !== ':') continue;
|
||
// 解析行: :LLAAAATT[DD...]CC
|
||
var byteCount = parseInt(line.substr(1, 2), 16);
|
||
var addr16 = parseInt(line.substr(3, 4), 16);
|
||
var recType = parseInt(line.substr(7, 2), 16);
|
||
// 校验和验证
|
||
var sum = 0;
|
||
for (var j = 1; j < line.length; j += 2) {
|
||
sum += parseInt(line.substr(j, 2), 16);
|
||
}
|
||
if ((sum & 0xFF) !== 0) {
|
||
throw new Error('HEX 行 ' + (i + 1) + ' 校验和错误: ' + line);
|
||
}
|
||
if (recType === 0x01) {
|
||
// EOF
|
||
break;
|
||
} else if (recType === 0x04) {
|
||
// 扩展线性地址 (upper 16 bits)
|
||
baseAddr = parseInt(line.substr(9, 4), 16) << 16;
|
||
} else if (recType === 0x02) {
|
||
// 扩展段地址
|
||
baseAddr = (parseInt(line.substr(9, 4), 16) << 4) & 0xFFFFF;
|
||
} else if (recType === 0x00) {
|
||
// 数据记录
|
||
var absAddr = baseAddr + addr16;
|
||
var data = new Uint8Array(byteCount);
|
||
for (var k = 0; k < byteCount; k++) {
|
||
data[k] = parseInt(line.substr(9 + k * 2, 2), 16);
|
||
}
|
||
segments.push({ offset: absAddr, data: data });
|
||
if (absAddr < minAddr) minAddr = absAddr;
|
||
var endAddr = absAddr + byteCount;
|
||
if (endAddr > maxAddr) maxAddr = endAddr;
|
||
}
|
||
// 其他记录类型 (03, 05 等) 忽略
|
||
}
|
||
if (segments.length === 0) {
|
||
throw new Error('HEX 文件中没有有效数据记录');
|
||
}
|
||
// 合并为连续 bin,间隙填 0xFF
|
||
var totalSize = maxAddr - minAddr;
|
||
var bin = new Uint8Array(totalSize);
|
||
// 先填充 0xFF
|
||
for (var m = 0; m < totalSize; m++) bin[m] = 0xFF;
|
||
for (var s = 0; s < segments.length; s++) {
|
||
var seg = segments[s];
|
||
var off = seg.offset - minAddr;
|
||
bin.set(seg.data, off);
|
||
}
|
||
return { data: bin, baseAddr: minAddr, endAddr: maxAddr };
|
||
}
|
||
|
||
/**
|
||
* 检测文件内容是否为 Intel HEX 格式。
|
||
* 看前几个非空行是否以 ':' 开头。
|
||
*/
|
||
export function isIntelHexText(text) {
|
||
var lines = text.split(/\r?\n/);
|
||
for (var i = 0; i < Math.min(lines.length, 5); i++) {
|
||
var line = lines[i].trim();
|
||
if (!line) continue;
|
||
return line[0] === ':';
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* 公共 HEX→BIN 转换:如果文件是 .hex 格式,自动解析为 bin。
|
||
* BLE / WiFi 共用此函数,避免转换逻辑重复。
|
||
* @param {File} file 原始文件
|
||
* @returns {Promise<{file: File, converted: boolean, baseAddr: number|null}>}
|
||
*/
|
||
export async function convertHexFileToBin(file) {
|
||
if (!file || !file.name.toLowerCase().endsWith('.hex')) {
|
||
return { file: file, converted: false, baseAddr: null };
|
||
}
|
||
var text = await file.text();
|
||
if (!isIntelHexText(text)) {
|
||
return { file: file, converted: false, baseAddr: null };
|
||
}
|
||
var result = parseIntelHex(text);
|
||
var binName = file.name.replace(/\.hex$/i, '.bin');
|
||
var binBlob = new Blob([result.data], { type: 'application/octet-stream' });
|
||
var binFile = new File([binBlob], binName, { type: 'application/octet-stream', lastModified: file.lastModified });
|
||
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 信号)。
|
||
/**
|
||
* @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 = '计算中...';
|
||
var bufPromise = optArrayBuffer ? Promise.resolve(optArrayBuffer) : file.arrayBuffer();
|
||
return bufPromise.then(function(buf) {
|
||
return crypto.subtle.digest('SHA-256', buf);
|
||
}).then(function(hashBuffer) {
|
||
var hashArray = Array.from(new Uint8Array(hashBuffer));
|
||
var hashHex = hashArray.map(function(b) { return b.toString(16).padStart(2, '0'); }).join('').toUpperCase();
|
||
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;
|
||
}
|
||
addLog('I', '文件', chip.toUpperCase() + ' 固件 SHA-256: ' + hashHex);
|
||
return hashHex;
|
||
}).catch(function(err) {
|
||
if (shaElem) shaElem.textContent = '计算失败: ' + err.message;
|
||
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 计算、文件校验)。 */
|
||
export function processFile(file, chip) {
|
||
var suffix = chip === 'esp32' ? 'Esp32' : 'Gd32';
|
||
var isHexByName = file.name.toLowerCase().endsWith('.hex');
|
||
if (chip === 'gd32' && isHexByName) {
|
||
// GD32 固件: .hex 文件自动转换为 .bin,然后替换文件对象
|
||
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);
|
||
window.updateUploadButtonLabel();
|
||
ensureFileShaCached(file, 'gd32');
|
||
return;
|
||
}
|
||
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));
|
||
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);
|
||
window.updateUploadButtonLabel();
|
||
ensureFileShaCached(result.file, 'gd32');
|
||
}).catch(function(err) {
|
||
addLog('E', '文件', 'HEX 解析失败: ' + err.message);
|
||
showToast('HEX 文件解析失败: ' + err.message, 'error');
|
||
window.gd32File = null;
|
||
document.getElementById('fileInfo' + suffix).classList.remove('show');
|
||
window.updateUploadButtonLabel();
|
||
});
|
||
return; // 异步,回调里设置 gd32File
|
||
}
|
||
// .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);
|
||
window.updateUploadButtonLabel();
|
||
addLog('I', '文件', chip.toUpperCase() + ' 已选择: ' + file.name + ' (' + formatBytes(file.size) + ')');
|
||
showToast(chip.toUpperCase() + ' 文件已选择', 'success');
|
||
// 选文件时就把 SHA-256 算好并缓存(ESP32/GD32 都算),
|
||
// 上传时若文件未变 ensureFileShaCached 命中直接复用。
|
||
ensureFileShaCached(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;
|
||
} else {
|
||
window.gd32File = null; window.gd32FileHandle = null;
|
||
window._gd32FileSha256 = null; window._gd32FileShaMeta = null;
|
||
}
|
||
document.getElementById('fileInput' + suffix).value = '';
|
||
document.getElementById('fileInfo' + suffix).classList.remove('show');
|
||
var shaElem = document.getElementById('fileSha256' + suffix);
|
||
if (shaElem) shaElem.textContent = '-';
|
||
window.updateUploadButtonLabel();
|
||
addLog('I', '文件', chip.toUpperCase() + ' 文件已清除');
|
||
} |