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:
小龙虾
2026-07-16 23:00:15 +08:00
parent 51c89b3065
commit 5db3f66b8e
57 changed files with 4728 additions and 2098 deletions
+20 -11
View File
@@ -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
View File
@@ -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);
+21 -9
View File
@@ -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
View File
@@ -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
View File
@@ -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), {});
}
}
+4 -1
View File
@@ -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');
}