feat(protocol): 更新SPI Link协议至v2版本并优化CAN通信
- 将SPI OTA帧格式升级为SPI Link v2,采用新的帧格式: | SOF0 0xA5 | SOF1 0x5A | VER 0x02 | FLAGS | SESSION 4B LE | TX_SEQ | ACK_SEQ | CREDIT | TYPE | LEN 2B LE | RSV 2B | PAYLOAD | CRC32 4B LE | - 新增V2_OVERHEAD=20字节开销,APP最大payload为236字节,BOOT为108字节 - 修改帧类型定义:ENTER_BOOT从0xB5合并至0xB0,新增CAN业务类型0xC0~C3 - 更新CAN协议文档,支持SPI Link v2-only模式,调整最大业务payload计算 - 扩展0xC1帧功能,支持双向通信和1Hz主动推送ID快照 - 添加0xC3完整ID快照帧类型,包含seq/rx_count等完整字段 - 更新协议文档中的错误码定义和反馈payload格式 refactor(main): 重构主程序入口并添加CAN反馈任务 - 在main.cpp中添加CAN反馈任务,更新启动流程注释 - 创建createCriticalTask辅助函数,统一关键任务创建逻辑 - 调整SPI初始化顺序,确保CAN反馈任务依赖正确 - 优化错误处理机制,关键任务失败时立即重启 - 将GD32版本探测移至ota_relay任务处理 - 更新版本号至0.3.48,调整编译时间和Git哈希 config: 更新SPI和BLE配置参数 - 添加SPI主机相关配置参数,包括CS GPIO、READY GPIO等引脚定义 - 配置SPI缓冲区大小:APP为256字节,BOOT为128字节 - 更新BLE滑动窗口参数,ACK窗口设为32,数据队列深度为72 - 调整SPI工作队列大小至5120字节,增加任务堆栈监控说明
This commit is contained in:
@@ -5,12 +5,20 @@
|
||||
* - 覆盖 app.js 中的 switchStageFlow,支持 BLE 阶段流程
|
||||
* - 提供 setConsoleMode / selectFlashTarget 等供 HTML onclick 调用
|
||||
* - 维护共享固件文件状态 (sharedFirmwareFiles) 并在 WiFi/BLE 间同步
|
||||
* - 页面刷新后恢复上次升级目标与固件选择
|
||||
*
|
||||
* 依赖:app.js (通过 window.* 或 bare 引用) 提供 switchStageFlow、
|
||||
* handleFileSelect、clearFile、pickFileViaFSAPI、addLog、showToast、setActivePhase 等。
|
||||
*/
|
||||
import {
|
||||
restorePersistedFirmwareSelection,
|
||||
persistFlashTarget,
|
||||
loadPersistedFlashTarget
|
||||
} from './file-handle.js';
|
||||
|
||||
window.connectionMode = window.connectionMode || 'wifi';
|
||||
window.flashTarget = window.flashTarget || 'esp32';
|
||||
/* 刷新后优先用上次目标,避免默认回到 ESP32 丢掉 GD32 选择上下文 */
|
||||
window.flashTarget = loadPersistedFlashTarget() || window.flashTarget || 'esp32';
|
||||
window.sharedFirmwareFiles = window.sharedFirmwareFiles || {
|
||||
esp32: { file: null, handle: null },
|
||||
gd32: { file: null, handle: null }
|
||||
@@ -84,6 +92,7 @@ window.sharedFirmwareFiles = window.sharedFirmwareFiles || {
|
||||
return;
|
||||
}
|
||||
window.flashTarget = target === 'gd32' ? 'gd32' : 'esp32';
|
||||
persistFlashTarget(window.flashTarget);
|
||||
applySharedModeUi();
|
||||
syncBleFileFromSelectedTarget();
|
||||
if (window.connectionMode === 'ble') {
|
||||
@@ -102,18 +111,21 @@ window.sharedFirmwareFiles = window.sharedFirmwareFiles || {
|
||||
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);
|
||||
};
|
||||
|
||||
window.pickSharedFirmware = function (chip) {
|
||||
window.flashTarget = chip === 'gd32' ? 'gd32' : 'esp32';
|
||||
persistFlashTarget(window.flashTarget);
|
||||
applySharedModeUi();
|
||||
if (window.showOpenFilePicker && typeof window.pickFileViaFSAPI === 'function') {
|
||||
var nativePicker = window.showOpenFilePicker;
|
||||
@@ -260,14 +272,11 @@ window.sharedFirmwareFiles = window.sharedFirmwareFiles || {
|
||||
};
|
||||
|
||||
applySharedModeUi();
|
||||
/* 刷新后恢复上次固件与目标(FSAPI handle + localStorage 目标) */
|
||||
restorePersistedFirmwareSelection().then(function () {
|
||||
if (typeof window.switchStageFlow === 'function' && window.connectionMode !== 'ble') {
|
||||
window.switchStageFlow(window.flashTarget || 'esp32');
|
||||
}
|
||||
}).catch(function () {});
|
||||
})();
|
||||
|
||||
// 暴露给 HTML onclick 调用的函数(ES Module 迁移:模块作用域不再自动成为全局变量)
|
||||
window.setConsoleMode = setConsoleMode;
|
||||
window.selectFlashTarget = selectFlashTarget;
|
||||
window.handleSharedFileSelect = handleSharedFileSelect;
|
||||
window.pickSharedFirmware = pickSharedFirmware;
|
||||
window.clearSharedFirmware = clearSharedFirmware;
|
||||
window.setBleFirmwareInputFile = setBleFirmwareInputFile;
|
||||
window.syncBleFileFromSelectedTarget = syncBleFileFromSelectedTarget;
|
||||
window.applySharedModeUi = applySharedModeUi;
|
||||
/* HTML onclick 用的 API 已在 IIFE 内挂到 window.*,无需再二次赋值 */
|
||||
|
||||
+329
-9
@@ -21,13 +21,297 @@ window.gd32File = null;
|
||||
window.esp32FileHandle = null; // FileSystemFileHandle for hot reload
|
||||
window.gd32FileHandle = null; // FileSystemFileHandle for hot reload
|
||||
|
||||
/** localStorage:升级目标等轻量偏好(刷新后恢复) */
|
||||
var LS_FLASH_TARGET = 'dcu_ota_flash_target';
|
||||
/** localStorage:上次文件名/大小(无权限时仍可展示,提示一点恢复) */
|
||||
var LS_FILE_META = 'dcu_ota_fw_meta';
|
||||
/** IndexedDB:FileSystemFileHandle 可结构化克隆,刷新后可恢复同一文件 */
|
||||
var IDB_NAME = 'dcu_ota_fw_v1';
|
||||
var IDB_STORE = 'file_handles';
|
||||
|
||||
/**
|
||||
* 刷新后权限常为 prompt,requestPermission 必须在用户手势里才成功。
|
||||
* 此处暂存待授权 handle,点击上传区时再 requestPermission 并直接 getFile。
|
||||
*/
|
||||
window._pendingFwHandles = window._pendingFwHandles || { esp32: null, gd32: null };
|
||||
|
||||
function openHandleDb() {
|
||||
return new Promise(function (resolve, reject) {
|
||||
if (!window.indexedDB) {
|
||||
reject(new Error('indexedDB unavailable'));
|
||||
return;
|
||||
}
|
||||
var req = indexedDB.open(IDB_NAME, 1);
|
||||
req.onupgradeneeded = function () {
|
||||
var db = req.result;
|
||||
if (!db.objectStoreNames.contains(IDB_STORE)) {
|
||||
db.createObjectStore(IDB_STORE);
|
||||
}
|
||||
};
|
||||
req.onsuccess = function () { resolve(req.result); };
|
||||
req.onerror = function () { reject(req.error || new Error('idb open failed')); };
|
||||
});
|
||||
}
|
||||
|
||||
function idbPutHandle(chip, handle) {
|
||||
return openHandleDb().then(function (db) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
var tx = db.transaction(IDB_STORE, 'readwrite');
|
||||
if (handle) {
|
||||
tx.objectStore(IDB_STORE).put(handle, chip);
|
||||
} else {
|
||||
tx.objectStore(IDB_STORE).delete(chip);
|
||||
}
|
||||
tx.oncomplete = function () { resolve(); };
|
||||
tx.onerror = function () { reject(tx.error); };
|
||||
});
|
||||
}).catch(function () { /* 无痕/禁用存储时静默 */ });
|
||||
}
|
||||
|
||||
function idbGetHandle(chip) {
|
||||
return openHandleDb().then(function (db) {
|
||||
return new Promise(function (resolve, reject) {
|
||||
var tx = db.transaction(IDB_STORE, 'readonly');
|
||||
var req = tx.objectStore(IDB_STORE).get(chip);
|
||||
req.onsuccess = function () { resolve(req.result || null); };
|
||||
req.onerror = function () { reject(req.error); };
|
||||
});
|
||||
}).catch(function () { return null; });
|
||||
}
|
||||
|
||||
/** 持久化升级目标(esp32/gd32) */
|
||||
export function persistFlashTarget(chip) {
|
||||
try {
|
||||
if (chip === 'esp32' || chip === 'gd32') {
|
||||
localStorage.setItem(LS_FLASH_TARGET, chip);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
export function loadPersistedFlashTarget() {
|
||||
try {
|
||||
var t = localStorage.getItem(LS_FLASH_TARGET);
|
||||
return (t === 'gd32' || t === 'esp32') ? t : null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 保存 FSAPI handle,刷新后可默认恢复该文件 */
|
||||
export function persistFileHandle(chip, handle) {
|
||||
if (chip !== 'esp32' && chip !== 'gd32') return Promise.resolve();
|
||||
return idbPutHandle(chip, handle || null);
|
||||
}
|
||||
|
||||
function readFileMetaMap() {
|
||||
try {
|
||||
var raw = localStorage.getItem(LS_FILE_META);
|
||||
if (!raw) return {};
|
||||
var o = JSON.parse(raw);
|
||||
return o && typeof o === 'object' ? o : {};
|
||||
} catch (_) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function persistFileMeta(chip, file) {
|
||||
if (chip !== 'esp32' && chip !== 'gd32') return;
|
||||
try {
|
||||
var map = readFileMetaMap();
|
||||
if (file && file.name) {
|
||||
map[chip] = {
|
||||
name: file.name,
|
||||
size: file.size || 0,
|
||||
lastModified: file.lastModified || 0
|
||||
};
|
||||
} else {
|
||||
delete map[chip];
|
||||
}
|
||||
localStorage.setItem(LS_FILE_META, JSON.stringify(map));
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
function loadFileMeta(chip) {
|
||||
var map = readFileMetaMap();
|
||||
return map[chip] || null;
|
||||
}
|
||||
|
||||
/** 仅 query,不 request(刷新无用户手势时 request 会失败) */
|
||||
async function queryHandlePermission(handle) {
|
||||
if (!handle || typeof handle.getFile !== 'function') return 'denied';
|
||||
try {
|
||||
if (typeof handle.queryPermission === 'function') {
|
||||
return await handle.queryPermission({ mode: 'read' });
|
||||
}
|
||||
/* 无 query API 时尝试直接 getFile */
|
||||
await handle.getFile();
|
||||
return 'granted';
|
||||
} catch (_) {
|
||||
return 'denied';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 在用户点击手势中:对上次 handle 重新授权并恢复文件(无需再走选文件对话框)。
|
||||
* @returns {Promise<boolean>} 是否已成功恢复
|
||||
*/
|
||||
export async function resumePersistedFirmwareWithGesture(chip) {
|
||||
if (chip !== 'esp32' && chip !== 'gd32') return false;
|
||||
var handle = (window._pendingFwHandles && window._pendingFwHandles[chip]) ||
|
||||
(chip === 'esp32' ? window.esp32FileHandle : window.gd32FileHandle) ||
|
||||
null;
|
||||
if (!handle) {
|
||||
try {
|
||||
handle = await idbGetHandle(chip);
|
||||
} catch (_) {
|
||||
handle = null;
|
||||
}
|
||||
}
|
||||
if (!handle || typeof handle.getFile !== 'function') return false;
|
||||
|
||||
try {
|
||||
var perm = 'granted';
|
||||
if (typeof handle.queryPermission === 'function') {
|
||||
perm = await handle.queryPermission({ mode: 'read' });
|
||||
}
|
||||
if (perm !== 'granted' && typeof handle.requestPermission === 'function') {
|
||||
perm = await handle.requestPermission({ mode: 'read' });
|
||||
}
|
||||
if (perm !== 'granted') {
|
||||
addLog('W', '文件', chip.toUpperCase() + ' 未授予读取权限,请在弹窗中允许,或重新选择文件');
|
||||
return false;
|
||||
}
|
||||
var file = await handle.getFile();
|
||||
applyRestoredFile(chip, handle, file, { silent: true });
|
||||
if (window._pendingFwHandles) window._pendingFwHandles[chip] = null;
|
||||
addLog('I', '文件', chip.toUpperCase() + ' 已重新授权并恢复: ' + file.name);
|
||||
showToast(chip.toUpperCase() + ' 已恢复上次固件', 'success');
|
||||
return true;
|
||||
} catch (e) {
|
||||
addLog('W', '文件', chip.toUpperCase() + ' 恢复失败: ' + (e && e.message ? e.message : e));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function applyRestoredFile(chip, handle, file, options) {
|
||||
if (chip === 'esp32') {
|
||||
window.esp32FileHandle = handle;
|
||||
window.esp32File = file;
|
||||
} else {
|
||||
window.gd32FileHandle = handle;
|
||||
window.gd32File = file;
|
||||
}
|
||||
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();
|
||||
}
|
||||
if (typeof window.updateUploadButtonLabel === 'function') {
|
||||
window.updateUploadButtonLabel();
|
||||
}
|
||||
}
|
||||
|
||||
/** 无权限时仍展示上次文件名,提示点上传区一键恢复 */
|
||||
function showPendingReauthUi(chip, handle, meta) {
|
||||
if (chip === 'esp32') window.esp32FileHandle = handle;
|
||||
else window.gd32FileHandle = handle;
|
||||
if (window._pendingFwHandles) window._pendingFwHandles[chip] = handle;
|
||||
if (window.sharedFirmwareFiles) {
|
||||
window.sharedFirmwareFiles[chip] = { file: null, handle: handle };
|
||||
}
|
||||
|
||||
var suffix = chip === 'esp32' ? 'Esp32' : 'Gd32';
|
||||
var info = document.getElementById('fileInfo' + suffix);
|
||||
var nameEl = document.getElementById('fileName' + suffix);
|
||||
var typeEl = document.getElementById('fileType' + suffix);
|
||||
var sizeEl = document.getElementById('fileSize' + suffix);
|
||||
var name = (meta && meta.name) || (handle && handle.name) || '上次固件';
|
||||
if (info) info.classList.add('show');
|
||||
if (nameEl) nameEl.textContent = name + '(点击上传区重新授权)';
|
||||
if (typeEl) typeEl.textContent = '待授权';
|
||||
if (sizeEl) sizeEl.textContent = meta && meta.size ? formatBytes(meta.size) : '--';
|
||||
var shaEl = document.getElementById('fileSha256' + suffix);
|
||||
if (shaEl) shaEl.textContent = '刷新后需点击授权';
|
||||
if (typeof window.updateUploadButtonLabel === 'function') {
|
||||
window.updateUploadButtonLabel();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 页面刷新后:恢复升级目标 + 上次 FSAPI 固件。
|
||||
* 权限仍 granted → 静默恢复;否则保留 handle,等用户点击上传区再授权。
|
||||
*/
|
||||
export async function restorePersistedFirmwareSelection() {
|
||||
var target = loadPersistedFlashTarget();
|
||||
if (target) {
|
||||
window.flashTarget = target;
|
||||
}
|
||||
|
||||
var chips = ['esp32', 'gd32'];
|
||||
for (var i = 0; i < chips.length; i++) {
|
||||
var chip = chips[i];
|
||||
try {
|
||||
var handle = await idbGetHandle(chip);
|
||||
if (!handle) continue;
|
||||
var meta = loadFileMeta(chip);
|
||||
var perm = await queryHandlePermission(handle);
|
||||
if (perm === 'granted') {
|
||||
var file = await handle.getFile();
|
||||
applyRestoredFile(chip, handle, file, { silent: true, skipHandlePersist: true });
|
||||
if (window._pendingFwHandles) window._pendingFwHandles[chip] = null;
|
||||
addLog('I', '文件', chip.toUpperCase() + ' 已恢复刷新前的固件: ' + file.name);
|
||||
continue;
|
||||
}
|
||||
/* prompt/denied:不在此处 requestPermission(无用户手势会失败) */
|
||||
showPendingReauthUi(chip, handle, meta);
|
||||
addLog('I', '文件',
|
||||
chip.toUpperCase() + ' 上次固件「' +
|
||||
((meta && meta.name) || handle.name || '未知') +
|
||||
'」需点击上传区重新授权(浏览器安全限制)');
|
||||
} catch (e) {
|
||||
addLog('W', '文件', chip.toUpperCase() + ' 恢复上次固件失败: ' + (e && e.message ? e.message : e));
|
||||
}
|
||||
}
|
||||
|
||||
if (typeof window.applySharedModeUi === 'function') {
|
||||
window.applySharedModeUi();
|
||||
}
|
||||
if (typeof window.syncBleFileFromSelectedTarget === 'function') {
|
||||
window.syncBleFileFromSelectedTarget();
|
||||
}
|
||||
if (typeof window.updateUploadButtonLabel === 'function') {
|
||||
window.updateUploadButtonLabel();
|
||||
}
|
||||
}
|
||||
|
||||
export function handleFileSelect(event, chip) {
|
||||
var file = event.target.files[0];
|
||||
if (file) processFile(file, chip);
|
||||
if (!file) return;
|
||||
/* 传统 input 无 handle:清掉旧 FSAPI 句柄,避免热重载读到过期路径 */
|
||||
if (chip === 'esp32') window.esp32FileHandle = null;
|
||||
else window.gd32FileHandle = null;
|
||||
if (window._pendingFwHandles) window._pendingFwHandles[chip] = null;
|
||||
persistFileHandle(chip, null);
|
||||
processFile(file, chip);
|
||||
}
|
||||
|
||||
// 通过 File System Access API 选择文件(支持热重载)
|
||||
export async function pickFileViaFSAPI(chip) {
|
||||
/*
|
||||
* 仅在「刷新后待授权」或「尚无已加载 File」时尝试一键恢复。
|
||||
* 已有可用文件时再点上传区 → 打开选文件对话框(可换文件)。
|
||||
*/
|
||||
var pending = window._pendingFwHandles && window._pendingFwHandles[chip];
|
||||
var hasLoadedFile = !!(chip === 'esp32' ? window.esp32File : window.gd32File);
|
||||
if (pending || !hasLoadedFile) {
|
||||
var resumed = await resumePersistedFirmwareWithGesture(chip);
|
||||
if (resumed) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!window.showOpenFilePicker) {
|
||||
addLog('W', '文件', '当前浏览器不支持 File System Access API,请手动重新选择文件');
|
||||
return false;
|
||||
@@ -36,16 +320,30 @@ export async function pickFileViaFSAPI(chip) {
|
||||
var acceptMap = chip === 'esp32'
|
||||
? { 'application/octet-stream': ['.bin'] }
|
||||
: { 'application/octet-stream': ['.bin', '.hex'] };
|
||||
var handles = await window.showOpenFilePicker({
|
||||
var prevHandle = chip === 'esp32' ? window.esp32FileHandle : window.gd32FileHandle;
|
||||
if (!prevHandle && window._pendingFwHandles) {
|
||||
prevHandle = window._pendingFwHandles[chip] || null;
|
||||
}
|
||||
var opts = {
|
||||
/* 记住该 id 上次目录;再配合 startIn 定位到刷新前的文件 */
|
||||
id: chip === 'esp32' ? 'dcu-fw-esp32' : 'dcu-fw-gd32',
|
||||
types: [{ description: '固件文件', accept: acceptMap }],
|
||||
multiple: false
|
||||
});
|
||||
};
|
||||
if (prevHandle) {
|
||||
opts.startIn = prevHandle;
|
||||
}
|
||||
var handles = await window.showOpenFilePicker(opts);
|
||||
if (handles && handles.length > 0) {
|
||||
var handle = handles[0];
|
||||
var file = await handle.getFile();
|
||||
if (chip === 'esp32') window.esp32FileHandle = handle;
|
||||
else window.gd32FileHandle = handle;
|
||||
if (window._pendingFwHandles) window._pendingFwHandles[chip] = null;
|
||||
processFile(file, chip);
|
||||
persistFileHandle(chip, handle);
|
||||
persistFileMeta(chip, file);
|
||||
persistFlashTarget(chip);
|
||||
addLog('I', '文件', '已通过 FSAPI 选择文件(支持热重载): ' + file.name);
|
||||
return true;
|
||||
}
|
||||
@@ -246,10 +544,25 @@ export function ensureFileShaCached(file, chip, optArrayBuffer) {
|
||||
}
|
||||
|
||||
// ==================== 文件处理 ====================
|
||||
/** @description 处理用户选择的固件文件(HEX→BIN 转换、SHA-256 计算、文件校验)。 */
|
||||
export function processFile(file, chip) {
|
||||
/**
|
||||
* @description 处理用户选择的固件文件(HEX→BIN 转换、SHA-256 计算、文件校验)。
|
||||
* @param {File} file
|
||||
* @param {'esp32'|'gd32'} chip
|
||||
* @param {{silent?: boolean, skipHandlePersist?: boolean}=} options
|
||||
* silent: 恢复场景不弹 toast;skipHandlePersist: 已从 IDB 读出时不再写回
|
||||
*/
|
||||
export function processFile(file, chip, options) {
|
||||
var opts = options || {};
|
||||
var silent = !!opts.silent;
|
||||
var suffix = chip === 'esp32' ? 'Esp32' : 'Gd32';
|
||||
var isHexByName = file.name.toLowerCase().endsWith('.hex');
|
||||
if (!opts.skipHandlePersist) {
|
||||
persistFlashTarget(chip);
|
||||
}
|
||||
/* 元数据始终更新,便于刷新后展示「上次文件名」 */
|
||||
if (file && file.name) {
|
||||
persistFileMeta(chip, file);
|
||||
}
|
||||
if (chip === 'gd32' && isHexByName) {
|
||||
// GD32 固件: .hex 文件自动转换为 .bin,然后替换文件对象
|
||||
convertHexFileToBin(file).then(function(result) {
|
||||
@@ -269,7 +582,7 @@ export function processFile(file, chip) {
|
||||
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');
|
||||
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转换)';
|
||||
@@ -278,7 +591,7 @@ export function processFile(file, chip) {
|
||||
ensureFileShaCached(result.file, 'gd32');
|
||||
}).catch(function(err) {
|
||||
addLog('E', '文件', 'HEX 解析失败: ' + err.message);
|
||||
showToast('HEX 文件解析失败: ' + err.message, 'error');
|
||||
if (!silent) showToast('HEX 文件解析失败: ' + err.message, 'error');
|
||||
window.gd32File = null;
|
||||
document.getElementById('fileInfo' + suffix).classList.remove('show');
|
||||
window.updateUploadButtonLabel();
|
||||
@@ -294,8 +607,12 @@ export function processFile(file, chip) {
|
||||
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');
|
||||
if (silent) {
|
||||
addLog('I', '文件', chip.toUpperCase() + ' 已恢复: ' + file.name + ' (' + formatBytes(file.size) + ')');
|
||||
} else {
|
||||
addLog('I', '文件', chip.toUpperCase() + ' 已选择: ' + file.name + ' (' + formatBytes(file.size) + ')');
|
||||
showToast(chip.toUpperCase() + ' 文件已选择', 'success');
|
||||
}
|
||||
// 选文件时就把 SHA-256 算好并缓存(ESP32/GD32 都算),
|
||||
// 上传时若文件未变 ensureFileShaCached 命中直接复用。
|
||||
ensureFileShaCached(file, chip);
|
||||
@@ -311,6 +628,9 @@ export function clearFile(chip) {
|
||||
window.gd32File = null; window.gd32FileHandle = null;
|
||||
window._gd32FileSha256 = null; window._gd32FileShaMeta = null;
|
||||
}
|
||||
if (window._pendingFwHandles) window._pendingFwHandles[chip] = null;
|
||||
persistFileHandle(chip, null);
|
||||
persistFileMeta(chip, null);
|
||||
document.getElementById('fileInput' + suffix).value = '';
|
||||
document.getElementById('fileInfo' + suffix).classList.remove('show');
|
||||
var shaElem = document.getElementById('fileSha256' + suffix);
|
||||
|
||||
@@ -111,6 +111,8 @@ export function updateEsp32StagesError() {
|
||||
* @param {string} reason 失败原因描述
|
||||
*/
|
||||
export function cleanupAfterOtaFailure(reason) {
|
||||
// 幂等:已在 FAILED 且无活跃 XHR 时只刷新文案,避免 abort 事件二次递归清状态
|
||||
var alreadyFailed = window.otaSessionState === window.OtaSessionState.FAILED;
|
||||
var chip = window.activeUploadChip || window.otaSessionTarget || window.currentOtaTarget || window.flashTarget || 'esp32';
|
||||
|
||||
// 0. 状态机:转入 FAILED(先于标志重置)
|
||||
@@ -136,23 +138,33 @@ export function cleanupAfterOtaFailure(reason) {
|
||||
setActivePhase(null);
|
||||
window.currentOtaTarget = null;
|
||||
|
||||
// 4. 恢复上传按钮
|
||||
var btnId = (chip === 'gd32') ? 'uploadGd32Btn' : 'uploadEsp32Btn';
|
||||
var btn = document.getElementById(btnId);
|
||||
if (btn) {
|
||||
// 4. 恢复上传按钮(两枚都解禁,避免 chip 判错时另一侧仍灰)
|
||||
['uploadEsp32Btn', 'uploadGd32Btn'].forEach(function (btnId) {
|
||||
var btn = document.getElementById(btnId);
|
||||
if (!btn) return;
|
||||
btn.disabled = false;
|
||||
if (window.uploadBtnOrigText) btn.textContent = window.uploadBtnOrigText;
|
||||
}
|
||||
if (window.uploadBtnOrigText &&
|
||||
((chip === 'gd32' && btnId === 'uploadGd32Btn') ||
|
||||
(chip !== 'gd32' && btnId === 'uploadEsp32Btn'))) {
|
||||
btn.textContent = window.uploadBtnOrigText;
|
||||
}
|
||||
});
|
||||
window.activeUploadChip = null;
|
||||
window.otaSessionTarget = null;
|
||||
window.uploadBtnOrigText = '';
|
||||
|
||||
window.updateUploadButtonLabel();
|
||||
if (typeof window.updateUploadButtonLabel === 'function') {
|
||||
window.updateUploadButtonLabel();
|
||||
}
|
||||
|
||||
// 5. 恢复轮询(之后 status 不得覆盖 FAILED UI,直至用户新上传或设备 idle 复位)
|
||||
window.resumePollingAfterUpload();
|
||||
if (typeof window.resumePollingAfterUpload === 'function') {
|
||||
window.resumePollingAfterUpload();
|
||||
}
|
||||
|
||||
addLog('E', 'OTA', '刷写流程已中止,已清理残留状态: ' + (reason || '未知原因'));
|
||||
if (!alreadyFailed) {
|
||||
addLog('E', 'OTA', '刷写流程已中止,已清理残留状态: ' + (reason || '未知原因'));
|
||||
}
|
||||
}
|
||||
|
||||
/** @description 设置当前活跃 OTA 阶段并更新 UI 步骤指示器。 */
|
||||
|
||||
+25
-7
@@ -123,14 +123,32 @@ export function otaSessionTransition(newState, reason) {
|
||||
} else if (newState === OtaSessionState.UPLOADING) {
|
||||
otaFailReason = '';
|
||||
}
|
||||
// 同步旧标志(渐进式迁移):UPLOADING 对应 isUploading=true
|
||||
if (newState === OtaSessionState.UPLOADING) window.isUploading = true;
|
||||
else if (newState === OtaSessionState.IDLE ||
|
||||
newState === OtaSessionState.DONE ||
|
||||
newState === OtaSessionState.FAILED) window.isUploading = false;
|
||||
// isUploading 仅表示 XHR 上传中;VERIFYING/RELAYING/REBOOTING 必须清掉,
|
||||
// 否则 updateUploadButtonLabel 会把「上传并升级」一直 disabled。
|
||||
window.isUploading = (newState === OtaSessionState.UPLOADING);
|
||||
// REBOOTING 复用 isEsp32Rebooting(pollStatus 重启检测依赖)
|
||||
if (newState === OtaSessionState.REBOOTING) window.isEsp32Rebooting = true;
|
||||
else if (newState !== OtaSessionState.VERIFYING) window.isEsp32Rebooting = false;
|
||||
// VERIFYING 保持 isEsp32Rebooting=false;FAILED/IDLE/DONE 必须清掉,避免 residual 假「重启中」
|
||||
if (newState === OtaSessionState.REBOOTING) {
|
||||
window.isEsp32Rebooting = true;
|
||||
} else if (newState === OtaSessionState.VERIFYING) {
|
||||
/* 校验阶段不改 reboot 标志 */
|
||||
} else {
|
||||
window.isEsp32Rebooting = false;
|
||||
}
|
||||
if (prev !== newState && typeof addLog === 'function') {
|
||||
addLog('D', 'OTA', '会话 ' + prev + ' → ' + newState +
|
||||
(reason ? ' (' + reason + ')' : ''));
|
||||
}
|
||||
// 终态/离开上传态后刷新按钮(避免卡在 disabled)
|
||||
if (typeof window.updateUploadButtonLabel === 'function' &&
|
||||
(newState === OtaSessionState.IDLE ||
|
||||
newState === OtaSessionState.DONE ||
|
||||
newState === OtaSessionState.FAILED ||
|
||||
newState === OtaSessionState.VERIFYING ||
|
||||
newState === OtaSessionState.RELAYING ||
|
||||
newState === OtaSessionState.REBOOTING)) {
|
||||
window.updateUploadButtonLabel();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+200
-40
@@ -242,21 +242,10 @@ export async function startUpload(chip) {
|
||||
});
|
||||
// 1MB+ 固件:服务端写 Flash 可能较久;禁止无限挂起无反馈
|
||||
xhr.timeout = 300000;
|
||||
xhr.addEventListener('timeout', function () {
|
||||
stopUploadWaitHeartbeat();
|
||||
if (window.activeXhr) {
|
||||
cleanupAfterOtaFailure('上传超时(设备未在 5 分钟内响应)');
|
||||
showToast('上传超时:设备可能忙或连接被踢,请重试', 'error');
|
||||
}
|
||||
});
|
||||
xhr.addEventListener('load', function () {
|
||||
stopUploadWaitHeartbeat();
|
||||
window.isUploading = false;
|
||||
window.activeXhr = null;
|
||||
window.resumePollingAfterUpload();
|
||||
uploadBtn.disabled = false;
|
||||
uploadBtn.textContent = window.uploadBtnOrigText;
|
||||
window.updateUploadButtonLabel();
|
||||
/* 成功路径摘掉 activeXhr;失败路径留给 cleanupAfterOtaFailure 统一摘掉并 abort 幂等 */
|
||||
if (window.activeXhr === xhr) window.activeXhr = null;
|
||||
if (xhr.status === 200) {
|
||||
/* 校验 body:避免空响应/网关误 200 被当成上传成功 */
|
||||
var bodyTxt = (xhr.responseText || '').trim();
|
||||
@@ -273,6 +262,11 @@ export async function startUpload(chip) {
|
||||
cleanupAfterOtaFailure('响应异常');
|
||||
return;
|
||||
}
|
||||
/* 成功:恢复轮询/按钮(会话仍 VERIFYING/RELAYING,isUploading 由 transition 清) */
|
||||
window.resumePollingAfterUpload();
|
||||
uploadBtn.disabled = false;
|
||||
uploadBtn.textContent = window.uploadBtnOrigText;
|
||||
window.updateUploadButtonLabel();
|
||||
// 成功路径只打一条「上传完成」日志,避免 [上传]+[OTA] 语义重复
|
||||
if (chip === 'esp32') {
|
||||
addLog('I', 'OTA', '上传完成,开始 SHA-256 校验…');
|
||||
@@ -308,16 +302,25 @@ export async function startUpload(chip) {
|
||||
});
|
||||
xhr.addEventListener('error', function () {
|
||||
stopUploadWaitHeartbeat();
|
||||
if (window.activeXhr) {
|
||||
cleanupAfterOtaFailure('网络错误');
|
||||
// cleanup 会先摘 activeXhr 再 abort;仅当本 xhr 仍是活动请求或会话仍在 UPLOADING 时清理
|
||||
if (window.activeXhr === xhr || window.otaSessionState === OtaSessionState.UPLOADING) {
|
||||
showToast('上传过程发生网络错误', 'error');
|
||||
cleanupAfterOtaFailure('网络错误');
|
||||
}
|
||||
});
|
||||
xhr.addEventListener('abort', function () {
|
||||
stopUploadWaitHeartbeat();
|
||||
if (window.activeXhr) {
|
||||
cleanupAfterOtaFailure('已中止');
|
||||
// clearOtaSessionResiduals 先置 activeXhr=null 再 abort → 此处勿二次 cleanup
|
||||
if (window.activeXhr === xhr) {
|
||||
showToast('上传已中止', 'error');
|
||||
cleanupAfterOtaFailure('已中止');
|
||||
}
|
||||
});
|
||||
xhr.addEventListener('timeout', function () {
|
||||
stopUploadWaitHeartbeat();
|
||||
if (window.activeXhr === xhr || window.otaSessionState === OtaSessionState.UPLOADING) {
|
||||
showToast('上传超时:设备可能忙或连接被踢,请重试', 'error');
|
||||
cleanupAfterOtaFailure('上传超时(设备未在 5 分钟内响应)');
|
||||
}
|
||||
});
|
||||
xhr.open('POST', url);
|
||||
@@ -331,6 +334,126 @@ export async function startUpload(chip) {
|
||||
|
||||
// ==================== ESP32 OTA 完成升级 ====================
|
||||
// 一次调用完成:校验 SHA-256 → 设置启动分区 → 自动重启(无需确认按钮)
|
||||
//
|
||||
// 竞态说明(易误报“应用失败”):
|
||||
// 1) finalize 成功 → 回 JSON ok → 100ms 后 esp_restart
|
||||
// 2) 响应丢失 / 浏览器只收到半截 / 请求迟到打到已重启的新固件
|
||||
// → 正文可能是明文或 JSON 的 "OTA not started"
|
||||
// 3) 此时 /system/status 已是 IDLE,uptime 往往很小
|
||||
// 旧逻辑:resp.json() 抛错 → 探测到 IDLE → cleanupAfterOtaFailure
|
||||
// 正确:上传已成功的 VERIFYING 会话里,优先按「已应用并重启」推进 REBOOTING。
|
||||
|
||||
/** @description 进入 ESP32 重启等待(校验成功或推断已应用)。 */
|
||||
function enterEsp32RebootingUi(reason, opts) {
|
||||
opts = opts || {};
|
||||
var resultEl = document.getElementById('sviResult');
|
||||
if (resultEl && !opts.keepResult) {
|
||||
resultEl.textContent = opts.resultText || '已应用,等待重启';
|
||||
resultEl.className = 'svi-val ok';
|
||||
}
|
||||
addLog('I', 'OTA', reason || '设备重启中…');
|
||||
showToast(opts.toast || '设备重启中…', 'success');
|
||||
updateEsp32StagesFromPhase(4);
|
||||
updateEsp32StagesFromPhase(5);
|
||||
window.rebootObservedDown = !!opts.observedDown;
|
||||
window.rebootStartTime = Date.now();
|
||||
otaSessionTransition(OtaSessionState.REBOOTING);
|
||||
}
|
||||
|
||||
/**
|
||||
* /ota/update 结果不明确时探测设备真实状态。
|
||||
* @param {string} detail 原始错误摘要
|
||||
* @param {{notStarted?: boolean}} flags
|
||||
*/
|
||||
async function resolveEsp32VerifyAmbiguous(detail, flags) {
|
||||
flags = flags || {};
|
||||
addLog('W', 'OTA', '应用结果不明确: ' + (detail || 'unknown') + ',探测设备状态…');
|
||||
|
||||
var sawOffline = false;
|
||||
var delays = [0, 800, 1500, 2500, 4000];
|
||||
for (var i = 0; i < delays.length; i++) {
|
||||
if (delays[i] > 0) {
|
||||
await new Promise(function (r) { setTimeout(r, delays[i]); });
|
||||
}
|
||||
try {
|
||||
var probe = await fetch(window.getBaseUrl() + '/system/status', {
|
||||
signal: AbortSignal.timeout(4000)
|
||||
});
|
||||
if (!probe.ok) {
|
||||
sawOffline = true;
|
||||
continue;
|
||||
}
|
||||
var st = await probe.json();
|
||||
var o = st.ota || {};
|
||||
var ssc = Number.isFinite(o.sys_state_code) ? o.sys_state_code : -1;
|
||||
var uptime = Number(st.uptime_sec);
|
||||
if (!Number.isFinite(uptime)) uptime = -1;
|
||||
|
||||
// 7=REBOOTING
|
||||
if (ssc === 7) {
|
||||
enterEsp32RebootingUi('探测到设备 REBOOTING,按重启中处理', {
|
||||
toast: '设备正在重启…',
|
||||
observedDown: false
|
||||
});
|
||||
return;
|
||||
}
|
||||
// 5=RECEIVING / 6=FINALIZING:仍在收尾,再等几轮
|
||||
if (ssc === 5 || ssc === 6) {
|
||||
addLog('I', 'OTA', '设备仍在 ESP32 OTA (sys=' + ssc + '),继续等待…');
|
||||
continue;
|
||||
}
|
||||
// IDLE/其它:上传本会话已成功过;短 uptime 或曾离线 → 几乎一定是已 finalize 并重启
|
||||
// 「OTA not started」在 VERIFYING 上下文下同理(新固件无会话)。
|
||||
var freshBoot = uptime >= 0 && uptime < 120;
|
||||
if (flags.notStarted || freshBoot || sawOffline) {
|
||||
enterEsp32RebootingUi(
|
||||
flags.notStarted
|
||||
? ('设备回报 OTA not started(uptime=' + uptime +
|
||||
's)— 上传已完成,按已应用并重启处理')
|
||||
: ('探测到设备已在线 (sys=' + ssc + ', uptime=' + uptime +
|
||||
's) — 按已应用并重启处理'),
|
||||
{
|
||||
toast: '固件可能已应用,等待启动完成…',
|
||||
observedDown: sawOffline,
|
||||
resultText: '已应用(响应未完整返回,按重启完成确认)'
|
||||
}
|
||||
);
|
||||
return;
|
||||
}
|
||||
// 长 uptime + IDLE 且非 notStarted:更像请求失败、设备从未进入 finalize
|
||||
addLog('E', 'OTA', '应用请求失败且设备非重启态 (sys=' + ssc +
|
||||
', uptime=' + uptime + 's): ' + (detail || ''));
|
||||
var resultElFail = document.getElementById('sviResult');
|
||||
if (resultElFail) {
|
||||
resultElFail.textContent = '✗ 请求失败: ' + (detail || 'unknown');
|
||||
resultElFail.className = 'svi-val err';
|
||||
}
|
||||
showToast('应用请求失败: ' + (detail || 'unknown'), 'error');
|
||||
cleanupAfterOtaFailure('应用请求失败');
|
||||
return;
|
||||
} catch (_probeErr) {
|
||||
sawOffline = true;
|
||||
}
|
||||
}
|
||||
|
||||
// 多轮无响应,或一直停在 FINALIZING
|
||||
if (sawOffline) {
|
||||
enterEsp32RebootingUi('设备无响应,按重启中观察(可能 TCP 被 esp_restart 掐断)', {
|
||||
toast: '设备无响应,等待重启恢复…',
|
||||
observedDown: true,
|
||||
resultText: '等待设备重启…'
|
||||
});
|
||||
return;
|
||||
}
|
||||
addLog('E', 'OTA', '应用阶段设备长时间仍忙或状态不明: ' + (detail || ''));
|
||||
var resultElBusy = document.getElementById('sviResult');
|
||||
if (resultElBusy) {
|
||||
resultElBusy.textContent = '✗ 请求中断,设备仍忙';
|
||||
resultElBusy.className = 'svi-val err';
|
||||
}
|
||||
cleanupAfterOtaFailure('应用请求中断(设备仍忙)');
|
||||
}
|
||||
|
||||
/** @description 验证 ESP32 固件 SHA-256 是否与设备端计算值一致。 */
|
||||
export async function verifyEsp32Firmware() {
|
||||
updateEsp32StagesFromPhase(3); // 校验中
|
||||
@@ -356,9 +479,29 @@ export async function verifyEsp32Firmware() {
|
||||
signal: AbortSignal.timeout(120000)
|
||||
});
|
||||
|
||||
var data = await resp.json();
|
||||
// 禁止裸 resp.json():失败体可能是 httpd 明文或半截响应
|
||||
var bodyTxt = '';
|
||||
try {
|
||||
bodyTxt = await resp.text();
|
||||
} catch (te) {
|
||||
await resolveEsp32VerifyAmbiguous('读取响应失败: ' + te.message, {});
|
||||
return;
|
||||
}
|
||||
var data = null;
|
||||
if (bodyTxt && bodyTxt.trim()) {
|
||||
try {
|
||||
data = JSON.parse(bodyTxt);
|
||||
} catch (_pe) {
|
||||
data = null;
|
||||
}
|
||||
}
|
||||
|
||||
if (data.status === 'ok') {
|
||||
var notStarted =
|
||||
(data && (data.code === 'ota_not_started' ||
|
||||
/OTA not started/i.test(String(data.message || '')))) ||
|
||||
/OTA not started/i.test(bodyTxt || '');
|
||||
|
||||
if (resp.ok && data && data.status === 'ok') {
|
||||
addLog('I', 'OTA', '设备端 SHA-256: ' + (data.sha256 || '-'));
|
||||
if (data.sha256) {
|
||||
var deviceSha = document.getElementById('sviDeviceSha');
|
||||
@@ -374,32 +517,49 @@ export async function verifyEsp32Firmware() {
|
||||
resultEl.className = 'svi-val ok';
|
||||
}
|
||||
}
|
||||
// 服务器已设置启动分区并自动重启,前端直接进入重启等待状态
|
||||
addLog('I', 'OTA', 'SHA-256 校验通过,设备自动重启中...');
|
||||
showToast('校验通过,设备重启中...', 'success');
|
||||
updateEsp32StagesFromPhase(4); // 应用固件
|
||||
window.rebootObservedDown = false; // 重置:尚未观察到设备实际下线
|
||||
updateEsp32StagesFromPhase(5); // 重启中
|
||||
window.rebootStartTime = Date.now();
|
||||
// ★ 状态机:校验通过 → 进入 REBOOTING(内部同步 isEsp32Rebooting = true)
|
||||
otaSessionTransition(OtaSessionState.REBOOTING);
|
||||
} else {
|
||||
var detail = data.message || (data.device ? '不匹配' : '未知错误');
|
||||
enterEsp32RebootingUi('SHA-256 校验通过,设备自动重启中...', {
|
||||
toast: '校验通过,设备重启中...',
|
||||
keepResult: true,
|
||||
observedDown: false
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// SHA 明确不匹配
|
||||
if (data && (data.status === 'error' || !resp.ok) &&
|
||||
/mismatch|不匹配/i.test(String(data.message || bodyTxt || ''))) {
|
||||
var detail = data.message || 'SHA-256 不匹配';
|
||||
addLog('E', 'OTA', '校验失败: ' + detail);
|
||||
showToast('校验失败: ' + detail, 'error');
|
||||
var resultEl = document.getElementById('sviResult');
|
||||
if (resultEl) { resultEl.textContent = '✗ ' + detail; resultEl.className = 'svi-val err'; }
|
||||
if (data.device) {
|
||||
var dSha = document.getElementById('sviDeviceSha');
|
||||
if (dSha) dSha.textContent = data.device;
|
||||
}
|
||||
var resultElMm = document.getElementById('sviResult');
|
||||
if (resultElMm) {
|
||||
resultElMm.textContent = '✗ ' + detail;
|
||||
resultElMm.className = 'svi-val err';
|
||||
}
|
||||
cleanupAfterOtaFailure('校验失败');
|
||||
return;
|
||||
}
|
||||
|
||||
// 迟到请求打到已重启固件 / 非 JSON 响应:勿当硬失败
|
||||
if (notStarted) {
|
||||
await resolveEsp32VerifyAmbiguous(
|
||||
bodyTxt.slice(0, 120) || 'OTA not started',
|
||||
{ notStarted: true }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
var failDetail = (data && data.message) ||
|
||||
formatHttpErrorDetail(bodyTxt) ||
|
||||
('HTTP ' + resp.status);
|
||||
await resolveEsp32VerifyAmbiguous(failDetail, {});
|
||||
} catch (e) {
|
||||
// fetch 异常仅代表请求未完成,不能推断设备已重启:
|
||||
// 设备可能仍在 finalize(SHA 计算耗时)、可能已崩溃、可能网络抖动。
|
||||
// 如实报告失败,由 pollStatus 探测设备真实状态。
|
||||
addLog('E', 'OTA', '应用请求失败: ' + e.message + '(设备状态未知,等待轮询探测)');
|
||||
showToast('应用请求失败: ' + e.message, 'error');
|
||||
var resultEl = document.getElementById('sviResult');
|
||||
if (resultEl) { resultEl.textContent = '✗ 请求失败: ' + e.message; resultEl.className = 'svi-val err'; }
|
||||
cleanupAfterOtaFailure('应用请求失败');
|
||||
// fetch 异常 ≠ 一定失败:finalize 成功后 esp_restart 会掐断 TCP
|
||||
await resolveEsp32VerifyAmbiguous(e && e.message ? e.message : String(e), {});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -56,9 +56,9 @@ export function emitRelayProgress(rseq, rtot, opts) {
|
||||
if (rseq === window.relayWatchLastSeq) return false;
|
||||
window.relayWatchLastSeq = rseq;
|
||||
var pct = Math.min(100, Math.round(rseq * 100 / rtot));
|
||||
/* 进度只走进度条/阶段文案,不写调试日志(避免 ring/网页刷屏) */
|
||||
setProgress(60 + Math.round(pct * 0.30),
|
||||
'已转发 ' + rseq + ' / ' + rtot + ' 包 (' + pct + '%)');
|
||||
addLog('I', 'OTA', '转发 ' + rseq + '/' + rtot + ' (' + pct + '%)');
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -104,6 +104,9 @@ export function markGd32OtaUiComplete(msg) {
|
||||
window.otaSessionState === OtaSessionState.FAILED) {
|
||||
otaSessionTransition(OtaSessionState.DONE);
|
||||
}
|
||||
if (typeof window.updateUploadButtonLabel === 'function') {
|
||||
window.updateUploadButtonLabel();
|
||||
}
|
||||
addLog('I', 'OTA', msg || 'GD32 刷写完成');
|
||||
showToast(msg || 'GD32 刷写完成', 'success');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user