init: DCU ESP32 调试控制台前端与本地页面服务

纯前端上位机界面 + esp32_server.py 静态服务/桌面窗口外壳, 不含固件、
不直接访问硬件; 业务 API 由 ESP32_COMM 固件 80 端口跨域提供。

- tabs.html 单窗口多标签外壳, iframe 承载各功能页
- index.html OTA 中继(ESP32 WiFi / GD32 relay / BLE), js/ota + js/ble
- can.html / can_monitor.html CAN 通信与接收监控, js/can
- motor.html 电机 CAN FD 控制台, js/motor
- continuity_test.html LAN9252 通断测试, spi_test.html SPI 测速(临时, 自包含)
- 三入口: esp32_app.vbs 桌面窗口 / esp32_web.vbs 浏览器 / serve.bat 控制台调试
- 补 README(页面与目录约定、?v= 缓存版本等改动须知)、.gitignore、
  .gitattributes(* -text 保持 Windows 换行原样)
This commit is contained in:
cbb
2026-09-03 21:30:32 +08:00
commit d2c86c425a
54 changed files with 20643 additions and 0 deletions
+320
View File
@@ -0,0 +1,320 @@
/**
* console-mode.js - 共享模式/目标切换胶水代码(从 index.html 内联脚本抽取)
*
* 职责:
* - 覆盖 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';
/* 刷新后优先用上次目标,避免默认回到 ESP32 丢掉 GD32 选择上下文 */
window.flashTarget = loadPersistedFlashTarget() || window.flashTarget || 'esp32';
window.sharedFirmwareFiles = window.sharedFirmwareFiles || {
esp32: { file: null, handle: null },
gd32: { file: null, handle: null }
};
(function () {
var originalSwitchStageFlow = window.switchStageFlow;
window.switchStageFlow = function (target) {
if (target === 'ble') {
window.currentOtaTarget = 'ble';
var esp32El = document.getElementById('esp32Stages');
var gd32El = document.getElementById('gd32Stages');
var bleEl = document.getElementById('bleStages');
if (esp32El) esp32El.style.display = 'none';
if (gd32El) gd32El.style.display = 'none';
if (bleEl) bleEl.style.display = '';
return;
}
if (typeof originalSwitchStageFlow === 'function') {
originalSwitchStageFlow(target);
}
var bleEl = document.getElementById('bleStages');
if (bleEl) bleEl.style.display = 'none';
};
window.setConsoleMode = function (mode) {
// ★ 刷写进行中禁止切换连接模式:setConsoleMode → syncBleFileFromSelectedTarget →
// notifyFileChange → updateFileInfo → finalizeFileInfo → setProgress(0) 会覆盖
// 进行中的 WiFi/BLE 上传进度(与 selectFlashTarget 同样的守卫策略)。
var bleBusy = window.bleOta && window.bleOta.isUploading && window.bleOta.isUploading();
if (window.isUploading || bleBusy) {
var active = bleBusy ? 'BLE' : ((window.activeUploadChip || 'OTA').toUpperCase());
if (typeof addLog === 'function') addLog('W', 'OTA', active + ' 刷写进行中,无法切换连接模式');
if (typeof showToast === 'function') showToast('当前有 ' + active + ' 任务在运行,请等待结束后再切换', 'error');
return;
}
var isBle = mode === 'ble';
window.connectionMode = isBle ? 'ble' : 'wifi';
var wifiCards = document.getElementById('wifiModeCards');
var bleCards = document.getElementById('bleModeCards');
var wifiBtn = document.getElementById('modeWifiBtn');
var bleBtn = document.getElementById('modeBleBtn');
if (wifiCards) wifiCards.style.display = isBle ? 'none' : '';
if (bleCards) bleCards.style.display = isBle ? '' : 'none';
if (wifiBtn) wifiBtn.className = isBle ? 'btn btn-outline' : 'btn btn-primary';
if (bleBtn) bleBtn.className = isBle ? 'btn btn-primary' : 'btn btn-outline';
applySharedModeUi();
syncBleFileFromSelectedTarget();
if (isBle) {
window.switchStageFlow('ble');
if (typeof setActivePhase === 'function') {
setActivePhase(window.flashTarget === 'gd32' ? 'gd32Ota' : 'esp32Ota');
}
if (window.bleOta && typeof window.bleOta.refreshButtons === 'function') {
window.bleOta.refreshButtons();
}
} else {
window.switchStageFlow(window.flashTarget || (window.activePhase === 'esp32Ota' ? 'esp32' : 'gd32'));
}
};
window.selectFlashTarget = function (target) {
// OTA 上传进行中禁止切换目标:避免前端状态与后端 relay/upload 不同步导致卡死
// ★ 兼容 BLE 模式:BLE 上传用 bleOta.state.uploading,window.isUploading 仅 WiFi 通道使用。
var bleBusy = window.bleOta && window.bleOta.isUploading && window.bleOta.isUploading();
if (window.isUploading || bleBusy) {
var active = bleBusy ? 'BLE' : ((window.activeUploadChip || 'OTA').toUpperCase());
if (typeof addLog === 'function') addLog('W', 'OTA', active + ' 刷写进行中,无法切换目标');
if (typeof showToast === 'function') showToast('当前有 ' + active + ' 任务在运行,请等待结束后再切换', 'error');
return;
}
window.flashTarget = target === 'gd32' ? 'gd32' : 'esp32';
persistFlashTarget(window.flashTarget);
applySharedModeUi();
/* 与 WiFi 一致:两路文件独立;切换只换「当前目标」面板 + 绑定该路文件到会话 */
if (window.connectionMode === 'ble') {
window.switchStageFlow('ble');
if (typeof setActivePhase === 'function') {
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);
}
};
window.handleSharedFileSelect = function (event, chip) {
window.flashTarget = chip === 'gd32' ? 'gd32' : 'esp32';
persistFlashTarget(window.flashTarget);
applySharedModeUi();
/* 等 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) {
window.flashTarget = chip === 'gd32' ? 'gd32' : 'esp32';
persistFlashTarget(window.flashTarget);
applySharedModeUi();
if (window.showOpenFilePicker && typeof window.pickFileViaFSAPI === 'function') {
var nativePicker = window.showOpenFilePicker;
var capturedHandle = null;
window.showOpenFilePicker = function () {
return nativePicker.apply(this, arguments).then(function (handles) {
capturedHandle = handles && handles.length ? handles[0] : null;
return handles;
});
};
Promise.resolve(window.pickFileViaFSAPI(chip)).then(function () {
/* 用 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
};
}
syncBleFileFromSelectedTarget();
}).catch(function (err) {
if (err && err.name !== 'AbortError' && typeof window.addLog === 'function') {
window.addLog('W', '文件', 'FSAPI 选择失败: ' + (err.message || err));
}
}).then(function () {
/* 无论成败都还原原生 picker,避免二次选择失效 */
window.showOpenFilePicker = nativePicker;
});
} else {
var input = document.getElementById(chip === 'gd32' ? 'fileInputGd32' : 'fileInputEsp32');
if (input) input.click();
}
};
window.clearSharedFirmware = function (chip) {
window.sharedFirmwareFiles[chip] = { file: null, handle: null };
if (typeof window.clearFile === 'function') {
window.clearFile(chip);
}
if (window.connectionMode === 'ble' && window.flashTarget === chip && window.bleOta && typeof window.bleOta.clearFile === 'function') {
window.bleOta.clearFile();
}
};
window.setBleFirmwareInputFile = function (file) {
var input = document.getElementById('firmwareFile');
if (!input || !file || typeof window.DataTransfer !== 'function') {
return;
}
try {
var dt = new DataTransfer();
dt.items.add(file);
input.files = dt.files;
} catch (err) {
// Some browsers restrict assignment to file inputs; BLE state is still synced via notifyFileChange.
}
};
/**
* 按 flashTarget 把「该芯片一路」固件绑到 BLE 会话(复刻 WiFi:两路独立、切换不串文件)。
* 优先 window.esp32File/gd32File(含 HEX→BIN 结果),其次 sharedFirmwareFiles / input。
* 当前目标无文件时清空 BLE 会话,禁止继续显示/上传另一芯片固件。
*/
window.syncBleFileFromSelectedTarget = function () {
if (window.connectionMode !== 'ble' || !window.bleOta) {
return;
}
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;
}
var suffix = chip === 'gd32' ? 'Gd32' : 'Esp32';
var input = document.getElementById('fileInput' + suffix);
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;
}
clearBleSession();
};
window.applySharedModeUi = function () {
var isBle = window.connectionMode === 'ble';
var isGd32 = window.flashTarget === 'gd32';
var targetEsp32 = document.getElementById('targetEsp32Tab');
var targetGd32 = document.getElementById('targetGd32Tab');
if (targetEsp32) targetEsp32.className = isGd32 ? 'btn btn-outline' : 'btn btn-primary';
if (targetGd32) targetGd32.className = isGd32 ? 'btn btn-primary' : 'btn btn-outline';
var ids = {
wifiEsp32UploadPane: !isGd32,
wifiGd32UploadPane: isGd32,
bleUploadPane: false,
wifiEsp32ActionPane: !isBle && !isGd32,
wifiGd32ActionPane: !isBle && isGd32,
bleActionPane: isBle
};
Object.keys(ids).forEach(function (id) {
var el = document.getElementById(id);
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 = isGd32
? 'BLE 模式 · 目标 GD32:连接设备 → 选择 GD32 固件 → 一键刷写。'
: 'BLE 模式 · 目标 ESP32:连接设备 → 选择 ESP32 固件 → 一键刷写。';
} else if (isGd32) {
hint.innerHTML = 'WiFi 模式:上传 GD32 固件后自动执行 SPI OTA。';
} else {
hint.innerHTML = 'WiFi 模式:上传 ESP32 固件后自动执行 OTA 并重启。';
}
}
};
applySharedModeUi();
/* 刷新后恢复上次固件与目标(FSAPI handle + localStorage 目标) */
restorePersistedFirmwareSelection().then(function () {
if (typeof window.switchStageFlow === 'function' && window.connectionMode !== 'ble') {
window.switchStageFlow(window.flashTarget || 'esp32');
}
}).catch(function () {});
})();
/* HTML onclick 用的 API 已在 IIFE 内挂到 window.*,无需再二次赋值 */
+734
View File
@@ -0,0 +1,734 @@
/**
* 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, bytesToHex } 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
/** 两路各自一份 SHA:选文件时写入,上传时读取;互不切换、不共享 */
window._esp32FileSha256 = null;
window._gd32FileSha256 = null;
/** 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 = { esp32: null, gd32: null };
/** 模块级 IndexedDB 连接缓存,避免每次 put/get 都重开 */
var _idbDbPromise = null;
function openHandleDb() {
if (!_idbDbPromise) {
_idbDbPromise = 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')); };
});
// 失败时清空缓存,下次允许重试
_idbDbPromise.catch(function () { _idbDbPromise = null; });
}
return _idbDbPromise;
}
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,刷新后可默认恢复该文件(仅模块内使用,不导出) */
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;
}
/** 安全调用 window.updateUploadButtonLabel(恢复路径可能在 app.js 初始化前触发) */
function safeUpdateUploadButtonLabel() {
if (typeof window.updateUploadButtonLabel === 'function') {
window.updateUploadButtonLabel();
}
}
/** 仅 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[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();
await applyRestoredFile(chip, handle, file, { silent: true });
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 };
}
/* 等 SHA 写入对应槽后再 sync BLE,避免二次重算 */
return Promise.resolve(processFile(file, chip, Object.assign({ skipHandlePersist: true }, options || {})))
.then(function () {
if (typeof window.syncBleFileFromSelectedTarget === 'function') {
window.syncBleFileFromSelectedTarget();
}
safeUpdateUploadButtonLabel();
});
}
/** 无权限时仍展示上次文件名,提示点上传区一键恢复 */
function showPendingReauthUi(chip, handle, meta) {
if (chip === 'esp32') window.esp32FileHandle = handle;
else window.gd32FileHandle = handle;
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 = '刷新后需点击授权';
safeUpdateUploadButtonLabel();
}
/**
* 页面刷新后:恢复升级目标 + 上次 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();
await applyRestoredFile(chip, handle, file, { silent: true, skipHandlePersist: true });
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();
}
safeUpdateUploadButtonLabel();
}
export function handleFileSelect(event, chip) {
var file = event.target.files[0];
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);
return processFile(file, chip);
}
// 通过 File System Access API 选择文件(支持热重载)
export async function pickFileViaFSAPI(chip) {
/*
* 仅在「刷新后待授权」或「尚无已加载 File」时尝试一键恢复。
* 已有可用文件时再点上传区 → 打开选文件对话框(可换文件)。
*/
var pending = 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;
}
try {
/* GD32 常为 .hex:同时声明 octet-stream / text,避免对话框里灰掉选不了 */
var acceptMap = chip === 'esp32'
? { 'application/octet-stream': ['.bin'] }
: {
'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;
}
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;
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();
if (chip === 'esp32') window.esp32FileHandle = handle;
else window.gd32FileHandle = handle;
window._pendingFwHandles[chip] = null;
await processFile(file, chip);
persistFileHandle(chip, handle);
persistFileMeta(chip, file);
persistFlashTarget(chip);
addLog('I', '文件', '已通过 FSAPI 选择文件(支持热重载): ' + file.name);
return true;
}
} catch (e) {
if (e && e.name !== 'AbortError') {
addLog('W', '文件', 'FSAPI 选择失败: ' + (e.message || e));
}
}
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) return;
/* 拖拽拿不到 FSAPI 句柄:必须清掉旧句柄,否则热重载会把上一个文件读回来
覆盖本次拖入的文件(表现为"刷错固件"),并同步删除 IDB 持久化句柄 */
if (chip === 'esp32') window.esp32FileHandle = null;
else window.gd32FileHandle = null;
window._pendingFwHandles[chip] = null;
persistFileHandle(chip, null);
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; // 当前扩展地址(uint32)
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.slice(1, 3), 16);
var addr16 = parseInt(line.slice(3, 7), 16);
var recType = parseInt(line.slice(7, 9), 16);
// 校验和验证
var sum = 0;
for (var j = 1; j < line.length; j += 2) {
sum += parseInt(line.slice(j, 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),>>>0 归整为 uint32
baseAddr = (parseInt(line.slice(9, 13), 16) << 16) >>> 0;
} else if (recType === 0x02) {
// 扩展段地址:seg << 4,不加掩码(避免截断 0x10FFEF 等合法地址)
baseAddr = (parseInt(line.slice(9, 13), 16) << 4) >>> 0;
} else if (recType === 0x00) {
// 数据记录
var absAddr = (baseAddr + addr16) >>> 0;
var data = new Uint8Array(byteCount);
for (var k = 0; k < byteCount; k++) {
data[k] = parseInt(line.slice(9 + k * 2, 11 + k * 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, endAddr: number|null}>}
*/
export async function convertHexFileToBin(file) {
if (!file || !file.name.toLowerCase().endsWith('.hex')) {
return { file: file, converted: false, baseAddr: null, endAddr: null };
}
var text = await file.text();
if (!isIntelHexText(text)) {
return { file: file, converted: false, baseAddr: null, endAddr: 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 };
}
// ==================== GD32 包身份判定(前端早期防呆) ====================
/**
* 判定 GD32 镜像字节是否为合法 APP 包。
* 与设备端 Gd32FlashStore::validateStoredImageIdentity 同判据:
* 向量表初始 MSP 须落在 SRAM(0x20000000~0x2001FFFF),
* 复位向量须落在 APP 区(0x08048000~0x0807DFFB);
* 落在 BOOT 区(0x08000000~0x08011FFF) 即 Bootloader 包。
* @param {Uint8Array} bytes 至少 8 字节
* @returns {{ok: boolean, kind: 'app'|'bootloader'|'invalid'}}
*/
export function classifyGd32ImageBytes(bytes) {
if (!bytes || bytes.length < 8) return { ok: false, kind: 'invalid' };
var dv = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
var msp = dv.getUint32(0, true);
var reset = (dv.getUint32(4, true) & 0xFFFFFFFE) >>> 0;
var mspOk = msp >= 0x20000000 && msp < 0x20020000;
var inApp = reset >= 0x08048000 && reset < 0x0807DFFC;
var inBoot = reset >= 0x08000000 && reset < 0x08012000;
if (mspOk && inApp) return { ok: true, kind: 'app' };
if (inBoot) return { ok: false, kind: 'bootloader' };
return { ok: false, kind: 'invalid' };
}
/**
* 读取文件前 8 字节并判定 GD32 包身份。
* 无文件返回 kind='none'("没有选文件"≠"固件非法",调用方不应据此报身份错误)。
*/
export function checkGd32ImageFile(file) {
if (!file) return Promise.resolve({ ok: false, kind: 'none' });
return file.slice(0, 8).arrayBuffer().then(function (buf) {
return classifyGd32ImageBytes(new Uint8Array(buf));
}).catch(function () {
return { ok: false, kind: 'invalid' };
});
}
/** 身份不符:清空已选文件 + 明确报错(不抛异常,避免各调用点未捕获 rejection)。 */
function rejectGd32Identity(fileName, kind) {
var msg = kind === 'bootloader'
? '检测到 Bootloader 固件,不能刷入 APP 区,请选择 APP 固件'
: kind === 'none'
? '未选择固件文件'
: '文件不是合法的 GD32 APP 固件(向量表非法),已拒绝';
addLog('E', '文件', fileName + ': ' + msg);
showToast(msg, 'error');
window.gd32File = null;
window._gd32FileSha256 = null;
var info = document.getElementById('fileInfoGd32');
if (info) info.classList.remove('show');
safeUpdateUploadButtonLabel();
}
// ==================== 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;
}
}
/**
* 选文件时算一次,写入对应芯片槽;上传时直接读槽。
* 不做跨芯片复用、不与另一路互切。
*/
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 crypto.subtle.digest('SHA-256', buf);
}).then(function (hashBuffer) {
var hashHex = bytesToHex(new Uint8Array(hashBuffer)).toUpperCase();
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) {
paintShaUi(chip, null, '计算失败: ' + err.message);
if (chip === 'esp32') window._esp32FileSha256 = null;
else window._gd32FileSha256 = null;
addLog('W', '文件', chip.toUpperCase() + ' SHA-256 计算失败: ' + err.message);
return null;
});
}
// ==================== 文件处理 ====================
/**
* @description 处理用户选择的固件文件(HEX→BIN、算 SHA 写入对应芯片槽)。
* @param {File} file
* @param {'esp32'|'gd32'} chip
* @param {{silent?: boolean, skipHandlePersist?: boolean}=} options
* @returns {Promise<void>}
*/
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);
}
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) {
return convertHexFileToBin(file).then(function (result) {
if (!result.converted) {
addLog('W', '文件', file.name + ' 扩展名为 .hex 但内容不像 HEX 格式,按原始文件处理');
return checkGd32ImageFile(file).then(function (id) {
if (!id.ok) { rejectGd32Identity(file.name, id.kind); return; }
window.gd32File = file;
paintFileInfo(file.name, 'Binary(?)', formatBytes(file.size));
safeUpdateUploadButtonLabel();
return computeFileSha(file, 'gd32');
});
}
return checkGd32ImageFile(result.file).then(function (id) {
if (!id.ok) { rejectGd32Identity(file.name, id.kind); 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));
if (!silent) showToast('HEX 已自动转换为 BIN', 'success');
paintFileInfo(
result.file.name + ' (由 ' + file.name + ' 转换)',
'Binary (HEX转换)',
formatBytes(result.file.size)
);
safeUpdateUploadButtonLabel();
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;
window._gd32FileSha256 = null;
var info = document.getElementById('fileInfo' + suffix);
if (info) info.classList.remove('show');
safeUpdateUploadButtonLabel();
throw err;
});
}
function finalizeSelect() {
if (chip === 'esp32') window.esp32File = file;
else window.gd32File = file;
paintFileInfo(
file.name,
isHexByName ? 'Intel HEX' : 'Binary',
formatBytes(file.size)
);
safeUpdateUploadButtonLabel();
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');
}
return computeFileSha(file, chip);
}
/* GD32 原始 BIN:选文件时即判定包身份,BOOT 包不进上传队列 */
if (chip === 'gd32') {
return checkGd32ImageFile(file).then(function (id) {
if (!id.ok) { rejectGd32Identity(file.name, id.kind); return null; }
return finalizeSelect();
});
}
return Promise.resolve(finalizeSelect());
}
/** @description 清除已选固件文件并重置对应 UI 状态。 */
export function clearFile(chip) {
var suffix = chip === 'esp32' ? 'Esp32' : 'Gd32';
if (chip === 'esp32') {
window.esp32File = null;
window.esp32FileHandle = null;
window._esp32FileSha256 = null;
} else {
window.gd32File = null;
window.gd32FileHandle = null;
window._gd32FileSha256 = null;
}
window._pendingFwHandles[chip] = null;
persistFileHandle(chip, null);
persistFileMeta(chip, null);
document.getElementById('fileInput' + suffix).value = '';
document.getElementById('fileInfo' + suffix).classList.remove('show');
var shaEl = document.getElementById('fileSha256' + suffix);
if (shaEl) shaEl.textContent = '-';
safeUpdateUploadButtonLabel();
addLog('I', '文件', chip.toUpperCase() + ' 文件已清除');
}
+257
View File
@@ -0,0 +1,257 @@
/**
* progress.js - 折叠面板 + 刷写进度 + 阶段节点 UI
*
* 操作 DOM: #esp32Stages / #gd32Stages / #stageStatusBar / #progressFill / #progressTxt / #progressPct
*
* 共享状态(通过 window.* 引用):
* window.activePhase / window.currentOtaTarget / window.esp32OtaPhase
*
* 复用的函数(通过 window.* 引用):
* window.updateStagesFromRelayState / window.cleanupAfterOtaFailure
* window.resumePollingAfterUpload / window.addLog
*/
import { addLog } from '../common/log.js';
// ==================== 常量 ====================
// ESP32 OTA 阶段 DOM 节点 id(es1..es6 + esd1..esd6)
var ESP32_STAGE_IDS = ['es1', 'es2', 'es3', 'es4', 'es5', 'es6'];
var ESP32_DETAIL_IDS = ['esd1', 'esd2', 'esd3', 'esd4', 'esd5', 'esd6'];
// 顶部步骤 pill → phase 名映射
var STEP_MAP = { esp32Ota: 'ppEsp32', gd32Ota: 'ppGd32Ota' };
// 进度条 DOM 元素懒缓存(首次访问后复用,DOM 未就绪时回退到 getElementById)
var progressFillEl = null;
var progressTxtEl = null;
var progressPctEl = null;
// ==================== 折叠面板 ====================
export function toggleCollapse(headEl) {
headEl.closest('.card').classList.toggle('collapsed');
}
// ==================== 刷写进度 ====================
window.activePhase = null; // 'esp32Ota' | 'gd32Ota' | null
window.currentOtaTarget = null; // 'esp32' | 'gd32' | null — 当前 OTA 目标,决定显示哪套阶段
// 切换 OTA 阶段面板显示(原始版本,会被 console-mode.js 覆盖以支持 BLE)
export function switchStageFlow(target) {
window.currentOtaTarget = target;
var esp32El = document.getElementById('esp32Stages');
var gd32El = document.getElementById('gd32Stages');
if (target === 'esp32') {
if (esp32El) esp32El.style.display = '';
if (gd32El) gd32El.style.display = 'none';
} else if (target === 'gd32') {
if (esp32El) esp32El.style.display = 'none';
if (gd32El) gd32El.style.display = '';
} else {
// null / 'ble' / 未知:隐藏两套面板(reset 语义,避免残留显示)
if (esp32El) esp32El.style.display = 'none';
if (gd32El) gd32El.style.display = 'none';
}
resetEsp32StageNodes();
resetGd32StageNodes();
}
/** @description 把 ESP32 阶段节点(es1..es6 / esd1..esd6)复位到初始态。 */
function resetEsp32StageNodes() {
ESP32_STAGE_IDS.forEach(function (id) {
var el = document.getElementById(id);
if (el) el.className = 'stage-node';
});
ESP32_DETAIL_IDS.forEach(function (id) {
var el = document.getElementById(id);
if (el) el.textContent = '';
});
}
/** @description 把 GD32 阶段节点(s1..s6 / sd1..sd6)复位到初始态。 */
function resetGd32StageNodes() {
['s1', 's2', 's3', 's4', 's5', 's6'].forEach(function (id) {
var el = document.getElementById(id);
if (el) el.className = 'stage-node';
});
['sd1', 'sd2', 'sd3', 'sd4', 'sd5', 'sd6'].forEach(function (id) {
var el = document.getElementById(id);
if (el) el.textContent = '';
});
}
// ESP32 OTA 阶段状态: 0=待机, 1=上传中, 2=OTA写入, 3=SHA-256校验, 4=应用固件, 5=重启中, 6=启动完成
window.esp32OtaPhase = 0;
export const esp32PhaseLabels = ['', '上传中', '写入中', '校验中', '应用中', '重启中', '已完成'];
export function updateEsp32StagesFromPhase(phase) {
window.esp32OtaPhase = phase;
var all = ESP32_STAGE_IDS.map(function (id) { return document.getElementById(id); });
var details = ESP32_DETAIL_IDS.map(function (id) { return document.getElementById(id); });
all.forEach(function (s) { if (s) s.className = 'stage-node'; });
details.forEach(function (d) { if (d) d.textContent = ''; });
var statusBar = document.getElementById('stageStatusBar');
var statusText = document.getElementById('stageStatusText');
if (phase <= 0) {
if (statusBar) statusBar.className = 'stage-status-bar';
if (statusText) statusText.textContent = '待机';
return;
}
/* phase=N:前 N-1 步 done;第 N 步进行中 live。
* phase=6(启动完成/已完成)是终态:全部 done(绿),最后一步不要 live(蓝)。 */
for (var i = 0; i < 6; i++) {
if (!all[i]) continue;
if (i < phase - 1) {
all[i].className = 'stage-node done';
} else if (i === phase - 1) {
all[i].className = (phase >= 6) ? 'stage-node done' : 'stage-node live';
if (details[i]) details[i].textContent = esp32PhaseLabels[phase] || '';
}
}
if (statusBar && statusText) {
if (phase >= 6) {
statusBar.className = 'stage-status-bar ok';
statusText.textContent = 'ESP32 刷写完成';
} else {
statusBar.className = 'stage-status-bar active';
statusText.textContent = (esp32PhaseLabels[phase] || '进行中') + ' (' + phase + '/6)';
}
}
}
export function updateEsp32StagesError() {
var all = ESP32_STAGE_IDS.map(function (id) { return document.getElementById(id); });
var details = ESP32_DETAIL_IDS.map(function (id) { return document.getElementById(id); });
all.forEach(function (s) { if (s) s.className = 'stage-node'; });
// ★ 失败态:阶段下方文案也要清空,避免残留"上传中/校验中"与红色样式并存
details.forEach(function (d) { if (d) d.textContent = ''; });
var statusBar = document.getElementById('stageStatusBar');
var statusText = document.getElementById('stageStatusText');
if (statusBar) statusBar.className = 'stage-status-bar err';
if (statusText) statusText.textContent = '校验/应用失败';
}
/**
* @description OTA 失败后的统一清理函数。
* 所有上传失败、校验失败、中继触发失败等异常路径都应调用此函数,
* 确保 UI 状态完整重置,不会残留"上传中"或中间态。
*
* ★ 状态机改造:转入 FAILED 态,置位 otaFailReason。
* 之后轮询(pollStatus/updateSystemStatusUi)不得覆盖此失败态,
* 直到用户发起新上传(otaSessionReset)或检测到设备已回 IDLE。
* @param {string} reason 失败原因描述
*/
export function cleanupAfterOtaFailure(reason) {
// 幂等:已在 FAILED 且无活跃 XHR 时只刷新文案,避免 abort 事件二次递归清状态
var alreadyFailed = window.otaSessionState === window.OtaSessionState.FAILED;
/* chip 判定优先级(权威来源顺序):
* activeUploadChip — XHR 上传开始时由 startUpload 写入,WiFi 路径权威
* otaSessionTarget — OTA 会话状态机目标(仅 WiFi 走 OtaSessionState,BLE 不走)
* currentOtaTarget — switchStageFlow 设置的当前面板目标
* flashTarget — 用户选择的目标(BLE 路径未设上述两者时兜底)
* 'esp32' — 最终默认值 */
var chip = window.activeUploadChip || window.otaSessionTarget ||
window.currentOtaTarget || window.flashTarget || 'esp32';
// 0. 状态机:转入 FAILED(先于标志重置)
window.otaSessionTransition(window.OtaSessionState.FAILED, reason);
// 1. 清掉本会话全部残留标志 / 定时器 / 校验面板 / 旧 progress 语义
window.clearOtaSessionResiduals({
keepErrorStages: true,
statusText: reason || '失败',
errorStyle: true
});
// 2. 阶段指示器标错
if (chip === 'esp32') {
updateEsp32StagesError();
} else {
window.updateStagesFromRelayState(255);
}
// 3. 进度条失败文案(clear 已把 fill 的 done 去掉)
setProgress(0, reason || '刷写失败');
setActivePhase(null);
window.currentOtaTarget = null;
// 4. 恢复上传按钮(两枚都解禁并恢复各自原文案,避免 chip 判错或一侧残留"上传中")
// ★ 按钮文案是固定的,绝不能用共享的 uploadBtnOrigText 回写两枚,否则最后
// 刷 GD32 后 ESP32 按钮会被错写成 "GD32 OTA"(反之亦然)。
var uploadBtnLabels = { uploadEsp32Btn: '上传并升级 ESP32', uploadGd32Btn: 'GD32 OTA' };
Object.keys(uploadBtnLabels).forEach(function (btnId) {
var btn = document.getElementById(btnId);
if (!btn) return;
btn.disabled = false;
btn.textContent = uploadBtnLabels[btnId];
});
window.activeUploadChip = null;
window.otaSessionTarget = null;
window.uploadBtnOrigText = '';
if (typeof window.updateUploadButtonLabel === 'function') {
window.updateUploadButtonLabel();
}
// 5. 恢复轮询(之后 status 不得覆盖 FAILED UI,直至用户新上传或设备 idle 复位)
if (typeof window.resumePollingAfterUpload === 'function') {
window.resumePollingAfterUpload();
}
if (!alreadyFailed) {
addLog('E', 'OTA', '刷写流程已中止,已清理残留状态: ' + (reason || '未知原因'));
}
}
/** @description 设置当前活跃 OTA 阶段并更新 UI 步骤指示器。 */
export function setActivePhase(phase) {
if (phase === 'gd32Upload' || phase === 'gd32Relay') phase = 'gd32Ota';
window.activePhase = phase;
Object.keys(STEP_MAP).forEach(function (p) {
var el = document.getElementById(STEP_MAP[p]);
if (!el) return;
el.classList.remove('active', 'done');
if (p === phase) el.classList.add('active');
});
// Reset progress fill color
var fill = document.getElementById('progressFill');
if (fill) fill.classList.remove('done');
}
/** @description 标记指定阶段为完成,更新进度条颜色。 */
export function markPhaseDone(phase) {
if (phase === 'gd32Upload' || phase === 'gd32Relay') phase = 'gd32Ota';
var el = document.getElementById(STEP_MAP[phase]);
if (el) { el.classList.remove('active'); el.classList.add('done'); }
// All done? turn progress bar green
if (phase === 'gd32Ota' || phase === 'esp32Ota') {
var fill = document.getElementById('progressFill');
if (fill) fill.classList.add('done');
}
}
/** @description 更新进度条百分比和文本显示。对 pct 做边界校验,过滤 NaN/undefined。 */
export function setProgress(pct, text) {
// 数值校验:BLE ACK 高频调用路径偶尔传入 undefined/NaN,需兜底避免出现 "undefined%"
var n = Number(pct);
if (!Number.isFinite(n)) n = 0;
if (n < 0) n = 0;
else if (n > 100) n = 100;
// 懒缓存 DOM 元素引用;DOM 未就绪时回退到 getElementById(兼容早期调用)
if (!progressFillEl || !progressFillEl.isConnected) progressFillEl = document.getElementById('progressFill');
if (!progressTxtEl || !progressTxtEl.isConnected) progressTxtEl = document.getElementById('progressTxt');
if (!progressPctEl || !progressPctEl.isConnected) progressPctEl = document.getElementById('progressPct');
if (progressFillEl) progressFillEl.style.width = n + '%';
if (progressTxtEl) progressTxtEl.textContent = text;
if (progressPctEl) progressPctEl.textContent = n + '%';
}
/** @description 重置进度条和阶段状态到初始值。 */
export function resetProgress() {
setProgress(0, '等待操作...');
window.activePhase = null;
window.esp32OtaPhase = 0;
switchStageFlow(null);
['ppEsp32', 'ppGd32Ota'].forEach(function (id) {
var el = document.getElementById(id);
if (el) el.classList.remove('active', 'done');
});
var fill = document.getElementById('progressFill');
if (fill) fill.classList.remove('done');
}
+250
View File
@@ -0,0 +1,250 @@
/**
* session.js - 前端 OTA 会话状态机
*
* 统一管理一次 OTA 会话的完整生命周期,避免散布的布尔标志
* (isUploading / currentOtaTarget / activeUploadChip / isEsp32Rebooting)
* 在网络错误后被轮询错误覆盖、状态混乱的问题。
*
* 状态流转:
* IDLE → UPLOADING → (ESP32: VERIFYING → REBOOTING) → DONE
* IDLE → UPLOADING → (GD32: RELAYING) → DONE
* 任意活跃态 ──(failure/abort)──→ FAILED
* FAILED/DONE ──(startUpload)──→ UPLOADING
* FAILED/DONE ──(device idle detected by poll)──→ IDLE
*
* 共享状态(通过 window.* 引用,入口模块统一挂载):
* window.isUploading / window.isEsp32Rebooting / window.rebootStartTime
* window.rebootObservedDown / window.activeUploadChip / window.currentOtaTarget
* window.esp32OtaPhase / window.activePhase
* window.gd32CompleteAnnounced / window.relayWatchLastSeq / window.gd32RelaySessionLive
* window.lastSysStateCode / window.lastRelayState
*
* 复用的函数(通过 window.* 引用):
* window.stopRelayProgressWatch / window.setProgress / window.addLog
*/
import { addLog } from '../common/log.js';
// ==================== 全局状态 ====================
// 文件句柄与上传进度等状态保留为模块级,被 file-handle / upload-esp32 等模块通过 window.* 读写
window.isUploading = false;
window.uploadProgress = 0;
window.isEsp32Rebooting = false;
window.rebootStartTime = 0;
window.rebootObservedDown = false;
window.pollingPausedForUpload = false;
window.uploadPollChip = null;
window.activeXhr = null;
window.uploadBtnOrigText = '';
window.activeUploadChip = null;
// 心跳定时器(模块级,仅本模块使用)
let uploadWaitHeartbeatId = null;
let uploadWaitStartMs = 0;
/** @description 停止「等待设备 HTTP 响应」心跳,避免残留定时器改进度条。 */
export function stopUploadWaitHeartbeat() {
if (uploadWaitHeartbeatId) {
clearInterval(uploadWaitHeartbeatId);
uploadWaitHeartbeatId = null;
}
uploadWaitStartMs = 0;
}
/**
* @description 浏览器 body 已发完但尚未收到 HTTP 响应时启动心跳。
* 上传期轮询已暂停,若无此反馈会像“卡在 100%”。
*/
export function startUploadWaitHeartbeat() {
stopUploadWaitHeartbeat();
uploadWaitStartMs = Date.now();
uploadWaitHeartbeatId = setInterval(function () {
if (!window.activeXhr) {
stopUploadWaitHeartbeat();
return;
}
var sec = Math.round((Date.now() - uploadWaitStartMs) / 1000);
window.setProgress(100, '数据已发完,设备处理中… 已等待 ' + sec + 's');
// 每 10s 打一条日志,便于区分“仍在处理”与“真死锁”
if (sec > 0 && sec % 10 === 0) {
addLog('I', '上传', '仍在等待设备 HTTP 响应(已 ' + sec +
's;设备可能仍在写 Flash / 算 SHA,或连接异常)');
}
}, 5000);
}
// ==================== 前端 OTA 会话状态机 ====================
export const OtaSessionState = {
IDLE: 'idle', // 无会话
UPLOADING: 'uploading', // 固件上传中(XHR 上传阶段)
VERIFYING: 'verifying', // ESP32 SHA-256 校验 + 应用中
RELAYING: 'relaying', // GD32 中继转发中(triggerGd32Relay 发起后)
REBOOTING: 'rebooting', // ESP32 重启等待中
DONE: 'done', // 会话成功完成
FAILED: 'failed' // 会话失败/中止
};
window.OtaSessionState = OtaSessionState;
let otaSessionState = OtaSessionState.IDLE;
let otaSessionTarget = null; // 'esp32' | 'gd32' | null — 本会话的目标芯片
let otaFailReason = ''; // FAILED 时的失败原因(用于 UI 显示)
// 同步到 window 供 polling.js / stages.js / upload-gd32.js 等通过 window.* 读取
window.otaSessionState = otaSessionState;
window.otaSessionTarget = otaSessionTarget;
export { otaSessionState, otaSessionTarget, otaFailReason };
/** @description 查询当前 OTA 会话是否处于活跃态(非 IDLE/DONE/FAILED)。 */
export function otaSessionActive() {
return otaSessionState === OtaSessionState.UPLOADING ||
otaSessionState === OtaSessionState.VERIFYING ||
otaSessionState === OtaSessionState.RELAYING ||
otaSessionState === OtaSessionState.REBOOTING;
}
/** @description 查询当前 OTA 会话是否已终结(DONE 或 FAILED)。 */
export function otaSessionTerminated() {
return otaSessionState === OtaSessionState.DONE ||
otaSessionState === OtaSessionState.FAILED;
}
/**
* @description 转换 OTA 会话状态。
* 统一在此处维护状态机一致性,同时同步旧标志位
* (isUploading / currentOtaTarget),保证渐进式迁移期间两套标志不冲突。
* @param {string} newState 目标状态。
* @param {string} [reason] 失败原因(仅转入 FAILED 时使用)。
*/
export function otaSessionTransition(newState, reason) {
var prev = otaSessionState;
otaSessionState = newState;
window.otaSessionState = newState;
if (newState === OtaSessionState.FAILED) {
otaFailReason = reason || '未知原因';
} else if (newState === OtaSessionState.UPLOADING) {
otaFailReason = '';
}
// isUploading 仅表示 XHR 上传中;VERIFYING/RELAYING/REBOOTING 必须清掉,
// 否则 updateUploadButtonLabel 会把「上传并升级」一直 disabled。
window.isUploading = (newState === OtaSessionState.UPLOADING);
// REBOOTING 复用 isEsp32Rebooting(pollStatus 重启检测依赖)
// 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();
}
}
/**
* @description 清掉本会话残留 UI/标志(失败、超时、中止、新会话开始前都要调)。
* 不改变 otaSessionState(由调用方负责 FAILED/IDLE 转换)。
* @param {object} [opts]
* @param {boolean} [opts.keepErrorStages] true=保留错误阶段样式;false=节点复位到待机
* @param {string} [opts.statusText] 状态栏文案;null 表示「待机」
* @param {boolean} [opts.errorStyle] 状态栏是否红色
*/
export function clearOtaSessionResiduals(opts) {
opts = opts || {};
window.stopRelayProgressWatch();
stopUploadWaitHeartbeat();
window.gd32CompleteAnnounced = false;
window.relayWatchLastSeq = -1;
window.gd32RelaySessionLive = false;
window.lastSysStateCode = -1;
window.lastRelayState = 0;
window.isEsp32Rebooting = false;
window.rebootObservedDown = false;
window.rebootStartTime = 0;
window.isUploading = false;
/* 先摘掉引用再 abort,避免 abort 事件回调再次进入 cleanup 递归 */
var xh = window.activeXhr;
window.activeXhr = null;
if (xh) {
try { xh.abort(); } catch (_) {}
}
// 校验面板
var verifyInfo = document.getElementById('stageVerifyInfo');
if (verifyInfo) verifyInfo.style.display = 'none';
var sviFront = document.getElementById('sviFrontSha');
var sviDevice = document.getElementById('sviDeviceSha');
var sviResult = document.getElementById('sviResult');
if (sviFront) sviFront.textContent = '-';
if (sviDevice) sviDevice.textContent = '-';
if (sviResult) {
sviResult.textContent = '-';
sviResult.className = 'svi-val';
}
// 阶段节点
if (!opts.keepErrorStages) {
['es1','es2','es3','es4','es5','es6','s1','s2','s3','s4','s5','s6'].forEach(function(id) {
var el = document.getElementById(id);
if (el) el.className = 'stage-node';
});
['esd1','esd2','esd3','esd4','esd5','esd6','sd1','sd2','sd3','sd4','sd5','sd6'].forEach(function(id) {
var el = document.getElementById(id);
if (el) el.textContent = '';
});
window.esp32OtaPhase = 0;
}
// 顶部步骤 pill
['ppEsp32', 'ppGd32Ota'].forEach(function(id) {
var el = document.getElementById(id);
if (el) el.classList.remove('active', 'done');
});
var fill = document.getElementById('progressFill');
if (fill) fill.classList.remove('done');
window.activePhase = null;
// 状态栏
var sb = document.getElementById('stageStatusBar');
var st = document.getElementById('stageStatusText');
if (sb) {
sb.className = opts.errorStyle ? 'stage-status-bar err' : 'stage-status-bar';
}
if (st) {
st.textContent = (opts.statusText !== undefined && opts.statusText !== null)
? opts.statusText
: '待机';
}
}
/** @description 将会话从 FAILED/DONE 复位到 IDLE(用户发起新上传或设备空闲时调用)。 */
export function otaSessionReset() {
clearOtaSessionResiduals({
keepErrorStages: false,
statusText: '待机',
errorStyle: false
});
if (otaSessionState !== OtaSessionState.IDLE) {
otaSessionState = OtaSessionState.IDLE;
otaSessionTarget = null;
otaFailReason = '';
window.otaSessionState = OtaSessionState.IDLE;
window.otaSessionTarget = null;
window.currentOtaTarget = null;
window.isUploading = false;
}
window.activeUploadChip = null;
}
+512
View File
@@ -0,0 +1,512 @@
/**
* stages.js - OTA 阶段驱动 (真实状态)
*
* 将设备端 relay_state / sys_state 映射到 GD32 阶段节点 UI,
* 并驱动 updateSystemStatusUi 综合 UI 更新。
*
* 结构:
* applyDashboardUi — 堆/连接/版本/温湿度等常驻面板
* applyGd32OtaSessionUi — GD32 会话进度/完成/失败(唯一 UI 应用路径)
* updateSystemStatusUi — 入口:仪表盘 →(非 BLE/非 ESP32-OTA)会话
*
* 共享状态(通过 window.* 引用):
* window.latestSystemStatus / window.currentOtaTarget / window.esp32OtaPhase
* window.otaSessionState / window.otaSessionTarget / window.isUploading
* window.gd32CompleteAnnounced / window.gd32RelaySessionLive
*
* 复用的函数(通过 window.* 引用):
* window.setActivePhase / window.setProgress
* window.markGd32OtaUiComplete / window.cleanupAfterOtaFailure / window.otaSessionReset
* window.stopRelayProgressWatch / window.pollLogs / window.emitRelayProgress
*/
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';
// Relay state comes from relay_state_t in main/relay/ota_relay.h:
// 0=IDLE, 1=HANDSHAKING, 2=SIZE_NEG, 3=TRANSFERRING,
// 4=FINISHING, 5=DONE, 255=ERROR.
// 与 index.html #gd32Stages s1..s6 一一对应(rs→节点序号):
// 1→s1, 2→s2, 3→s3, 4→s4, 5→s1..s6 全 done。
const relayStateDetailMap = {
0: '待机',
1: '认证握手',
2: '传输协商',
3: '数据转发',
4: '收尾验证',
5: 'OTA完成',
255: '错误'
};
/** 上次刷到阶段条的 displayRelayState,未变则跳过 DOM 重建 */
var lastDisplayedRelayState = undefined;
/**
* 将 relay_state 映射到 GD32 阶段节点 (s1..s6)。
* @param {number|null|undefined} rs
*/
export function updateStagesFromRelayState(rs) {
var ids = ['s1', 's2', 's3', 's4', 's5', 's6'];
var detailIds = ['sd1', 'sd2', 'sd3', 'sd4', 'sd5', 'sd6'];
var all = ids.map(function(id) { return document.getElementById(id); });
var details = detailIds.map(function(id) { return document.getElementById(id); });
all.forEach(function (s) { if (s) s.className = 'stage-node'; });
details.forEach(function (d) { if (d) d.textContent = ''; });
var statusBar = document.getElementById('stageStatusBar');
var statusText = document.getElementById('stageStatusText');
if (rs === undefined || rs === null) {
if (statusBar) statusBar.className = 'stage-status-bar';
if (statusText) statusText.textContent = '待机';
lastDisplayedRelayState = rs;
return;
}
if (rs === 255) {
all.forEach(function (s) { if (s) s.className = 'stage-node err'; });
if (statusBar) statusBar.className = 'stage-status-bar err';
if (statusText) statusText.textContent = '错误';
lastDisplayedRelayState = 255;
return;
}
/* rs → 节点:1..4 直接对应 s1..s4;rs=5 DONE → 点亮完成链(s1..s6 全 done) */
var activeStage = 0;
if (rs === 1) activeStage = 1;
else if (rs === 2) activeStage = 2;
else if (rs === 3) activeStage = 3;
else if (rs === 4) activeStage = 4;
else if (rs === 5) activeStage = 6;
for (var i = 0; i < activeStage - 1; i++) {
if (all[i]) all[i].className = 'stage-node done';
}
if (rs === 5) {
/* DONE:s1..s5 已在上方标 done,s5/s6 一并完成 */
if (all[4]) all[4].className = 'stage-node done';
if (all[5]) all[5].className = 'stage-node done';
if (details[5]) details[5].textContent = relayStateDetailMap[5];
} else if (activeStage >= 1 && activeStage <= 6) {
if (all[activeStage - 1]) all[activeStage - 1].className = 'stage-node live';
if (details[activeStage - 1]) {
details[activeStage - 1].textContent = relayStateDetailMap[rs] || '';
}
}
if (statusBar && statusText) {
if (rs === 0) {
statusBar.className = 'stage-status-bar';
statusText.textContent = '待机';
} else {
statusBar.className = 'stage-status-bar active';
statusText.textContent = (relayStateDetailMap[rs] || '未知') + ' (' + rs + ')';
}
}
lastDisplayedRelayState = rs;
}
function setDisplayedRelayState(rs) {
if (lastDisplayedRelayState === rs) return;
updateStagesFromRelayState(rs);
}
/* 写前比较小工具:每秒轮询里大部分仪表盘字段不变,
* 无条件写 textContent/className/title 会白白触发样式失效。 */
function setNodeText(el, txt) {
if (el && el.textContent !== txt) el.textContent = txt;
}
function setNodeClass(el, cls) {
if (el && el.className !== cls) el.className = cls;
}
function setNodeTitle(el, title) {
if (el && el.title !== title) el.title = title;
}
/**
* 常驻仪表盘:堆、连接、健康、版本、温度、IP/RSSI。
* @param {object} data /system/status JSON
* @param {object} ota data.ota
* @param {object} wifi data.wifi
*/
function applyDashboardUi(data, ota, wifi) {
updateHeapAndTaskStatusUi(data);
var connected = !!wifi.connected;
var connText = connected ? (wifi.mode || 'STA') + ' 已连接' : (wifi.mode || 'STA') + ' 未连接';
updateConnectionUi(connected, connText);
updateHealthUi(data.health, data.free_heap);
if (data.version) {
var pillVerEl = document.getElementById('pillVer');
if (pillVerEl) {
setNodeText(pillVerEl, 'v' + data.version);
var verTip = data.build_time ? ('编译: ' + data.build_time) : '';
if (pillVerEl.dataset.tooltip !== verTip) pillVerEl.dataset.tooltip = verTip;
}
var mGd32App = document.getElementById('mGd32App');
var mGd32Boot = document.getElementById('mGd32Boot');
if (mGd32App) {
if (ota.gd32_app && ota.gd32_app !== 'v0.0.0.0') {
setNodeText(mGd32App, ota.gd32_app);
setNodeClass(mGd32App, 'v ok');
} else {
setNodeText(mGd32App, ota.gd32_app_alive ? '未知' : '离线');
setNodeClass(mGd32App, 'v');
}
}
if (mGd32Boot) {
if (ota.gd32_boot && ota.gd32_boot !== 'v0.0.0.0') {
setNodeText(mGd32Boot, ota.gd32_boot);
setNodeClass(mGd32Boot, 'v ok');
} else {
/* BOOT 版本与 APP alive 无直接关系,无版本时统一「未知」 */
setNodeText(mGd32Boot, '未知');
setNodeClass(mGd32Boot, 'v');
}
}
}
/* GD32 APP 上电自检(GD32 1.5.34+ /system/status gd32_init):
* state 0=正常 1=降级(非关键模块失败) 2=关键模块失败;详情经 SPI 0xA3 惰性查询。
* 1.5.36+ fault_codes[] 给出每模块具体原因,直接显示无需悬停。 */
var mGd32InitEl = document.getElementById('mGd32Init');
if (mGd32InitEl) {
var gi = data.gd32_init || null;
/* 与 GD32 sys/initial.h AppInitializer::ModuleBit 位序一致 */
var kModNames = ['nvic','systick','esp32_hold','usart0','adc','canfd',
'ota_boot_req','app_spi','task_manager','scheduler',
'ethercat','ethercat_sync','watchdog'];
var kRstNames = ['上电','外部引脚','独立看门狗','窗口看门狗','软件','低功耗'];
/* 故障码 → 中文原因(码值定义见 GD32 sys/initial.h 与各模块头文件) */
var kFaultText = {
nvic: { 1: '优先级分组回读不一致' },
systick: { 1: 'SysTick 配置失败', 2: '1ms 节拍未前进' },
esp32_hold: { 1: 'PC9 被外部钳低' },
usart0: { 1: 'USART 使能未生效' },
adc: { 1: '校准复位超时(ADC 无响应)', 2: '校准超时', 3: 'VDDA 采样无效(供电/DMA)' },
canfd: { 1: '控制器初始化失败', 2: '卡在初始化模式', 3: '收发器 STB 配置未生效' },
ota_boot_req: {},
app_spi: {},
task_manager: {},
scheduler: { 1: '定时器未使能', 2: 'PSC 回读不一致', 3: 'CAR 回读不一致', 4: '100us 节拍未前进(中断异常)' },
ethercat: { 1: 'EXMC 总线未就绪', 2: 'SSC 协议栈初始化失败' },
ethercat_sync: {},
watchdog: { 1: '预分频写入未确认', 2: '重载值写入未确认', 3: '配置更新挂起', 4: 'LSI(IRC40K) 未就绪' }
};
/* canfd 故障码 = 原因码 | (通道号<<4),返回 'CHn 原因' */
function decodeFault(modName, code) {
code = Number(code) & 0xFF;
if (code === 0) return null;
if (modName === 'canfd') {
var ch = (code >> 4) & 0x7;
var reason = kFaultText.canfd[code & 0xF] || ('未知原因 0x' + (code & 0xF).toString(16));
return 'canfd CH' + ch + ' ' + reason;
}
var tbl = kFaultText[modName] || {};
return modName + ' ' + (tbl[code] || ('故障码 0x' + code.toString(16)));
}
if (!gi || !gi.state_known) {
setNodeText(mGd32InitEl, '--');
setNodeClass(mGd32InitEl, 'v');
setNodeTitle(mGd32InitEl, 'GD32 APP 上电初始化状态(未收到身份块或固件 < 1.5.34)');
} else {
/* 失败明细:优先用 fault_codes(含具体原因),否则退到位图模块名 */
var critMask = Number(gi.critical_fail_mask) >>> 0;
var modMask = Number(gi.module_mask) >>> 0;
var failList = [];
var i;
if (gi.detail_known && gi.fault_codes && gi.fault_codes.length >= kModNames.length) {
for (i = 0; i < kModNames.length; i++) {
var txt = decodeFault(kModNames[i], gi.fault_codes[i]);
if (txt) failList.push(txt + ((critMask & (1 << i)) !== 0 ? '(关键)' : ''));
}
} else if (gi.detail_known) {
for (i = 0; i < kModNames.length; i++) {
if ((modMask & (1 << i)) === 0) {
failList.push(kModNames[i] + ((critMask & (1 << i)) !== 0 ? '(关键)' : ''));
}
}
}
var tip = '';
if (gi.detail_known) {
tip = failList.length > 0 ? ('失败模块: ' + failList.join(',')) : '全部模块初始化成功';
var rstList = [];
var rc = Number(gi.reset_cause) & 0xFF;
for (var rb = 0; rb < kRstNames.length; rb++) {
if ((rc & (1 << rb)) !== 0) rstList.push(kRstNames[rb]);
}
if (rstList.length > 0) tip += '\n本次复位源: ' + rstList.join('+');
} else {
tip = '模块级详情未查询(SPI 0xA3 惰性获取中)';
}
if (gi.state === 0) {
setNodeText(mGd32InitEl, '正常');
setNodeClass(mGd32InitEl, 'v ok');
} else {
/* 失败/降级:值直接带首要原因,不用悬停 */
var head = (gi.state === 1) ? '降级' : '失败';
setNodeText(mGd32InitEl, failList.length > 0 ? (head + ': ' + failList[0]) : head);
setNodeClass(mGd32InitEl, (gi.state === 1) ? 'v warn' : 'v err');
}
setNodeTitle(mGd32InitEl, tip);
}
}
var mTempEl = document.getElementById('mTemp');
if (mTempEl) {
/* 与固件 app_config:TEMP_WARNING=80 / TEMP_CRITICAL=100 对齐 */
if (Number.isFinite(data.temperature)) {
var tC = data.temperature;
setNodeText(mTempEl, tC.toFixed(1) + '\u00B0C');
if (tC >= 100) {
setNodeClass(mTempEl, 'v err');
} else if (tC >= 80) {
setNodeClass(mTempEl, 'v warn');
} else {
setNodeClass(mTempEl, 'v ok');
}
setNodeTitle(mTempEl, 'ESP32 片内温度传感器(警告≥80°C,危险≥100°C)');
} else {
setNodeText(mTempEl, '--');
setNodeClass(mTempEl, 'v');
setNodeTitle(mTempEl, '温度无效或未采样');
}
}
/* 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;
setNodeText(el, text);
setNodeClass(el, cls || 'v');
if (title) setNodeTitle(el, 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) setNodeText(uptimeEl, '\u23F1 ' + formatUptime(data.uptime_sec));
var pillIPEl = document.getElementById('pillIP');
if (pillIPEl) setNodeText(pillIPEl, wifi.ip || '--');
var rssi = (wifi.rssi !== undefined && wifi.rssi !== -127) ? wifi.rssi + ' dBm' : '--';
var pillRSSIEl = document.getElementById('pillRSSI');
if (pillRSSIEl) setNodeText(pillRSSIEl, rssi);
}
/**
* GD32 OTA 会话 UI:阶段节点 + 进度条 + 完成/失败。
* 门控逻辑保持与历史实现一致,勿随意放宽完成条件。
* @param {object} ota data.ota
*/
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 isGd32Relaying = sysState === 'GD32_RELAYING' || sysStateCode === 3;
var isGd32Complete = sysState === 'GD32_COMPLETE' || sysStateCode === 4;
var isGd32Error = sysState === 'ERROR' || sysStateCode === 8 || rs === 255;
/* 进度以 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;
var gd32AppAlive = ota.gd32_app_alive;
/* ★ FAILED 守卫:禁止后端状态覆盖失败 UI;设备回 IDLE 才自动复位 */
if (window.otaSessionState === OtaSessionState.FAILED) {
var deviceIdle = (sysStateCode === 0 || sysStateCode === 2 || sysStateCode === 4) &&
(rs === 0 || rs === 5);
if (!deviceIdle) {
if ((rs === 3 || isGd32Relaying) && relayTotal > 0) {
setActivePhase('gd32Ota');
var failRelayOverall = 60 + Math.round(relayProgress * 0.30);
setProgress(failRelayOverall,
'已转发 ' + relaySeq + ' / ' + relayTotal + ' 包(设备仍在跑)');
setDisplayedRelayState(3);
} else {
setDisplayedRelayState(255);
}
return;
}
otaSessionReset();
window.stopRelayProgressWatch();
/* 落到下方正常分支刷新 UI */
}
var displayRelayState = isGd32Complete ? 5 : (isGd32Relaying && rs === 0 ? 1 : rs);
if (window.currentOtaTarget !== 'esp32') {
setDisplayedRelayState(displayRelayState);
}
var gd32Session =
window.otaSessionState === OtaSessionState.RELAYING ||
window.currentOtaTarget === 'gd32' ||
window.otaSessionTarget === 'gd32';
if (isGd32Error && gd32Session) {
var failDetail = 'GD32 OTA 失败';
if (relayTotal > 0) {
failDetail += '(已转发 ' + relaySeq + '/' + relayTotal +
',relay_state=' + rs + ',sys=' + (sysState || sysStateCode) + ')';
} else {
failDetail += '(relay_state=' + rs + ',sys=' + (sysState || sysStateCode) + ')';
}
window.cleanupAfterOtaFailure(failDetail);
window.pollLogs();
return;
}
if (isGd32Error) {
setDisplayedRelayState(255);
var sbErr = document.getElementById('stageStatusBar');
var stErr = document.getElementById('stageStatusText');
if (sbErr) sbErr.className = 'stage-status-bar err';
if (stErr) stErr.textContent = 'GD32 错误(待机)';
return;
}
if (window.otaSessionState === OtaSessionState.UPLOADING) {
/* 上传进行中:不要用旧缓存/旧完成态覆盖进度条 */
return;
}
if (window.otaSessionState === OtaSessionState.RELAYING) {
/* 必须先看到本会话真实中继,才允许 DONE */
if (isGd32Relaying || (rs >= 1 && rs <= 4)) {
window.gd32RelaySessionLive = true;
}
var deviceIdleAfterRelay =
(sysStateCode === 0 || sysStateCode === 2) &&
(rs === 0 || rs === 5) &&
window.gd32RelaySessionLive &&
!window.gd32CompleteAnnounced;
if (deviceIdleAfterRelay) {
window.cleanupAfterOtaFailure(
'GD32 中继已结束但未成功(设备已回 IDLE' +
(relayTotal > 0 ? ',已转发 ' + relaySeq + '/' + relayTotal : '') + ')'
);
window.pollLogs();
return;
}
/*
* 包进度/完成:统一走 emitRelayProgress + markGd32OtaUiComplete。
* FINISHING 时 force100 后只改文案,禁止 setProgress(95) 回跳。
*/
if (window.gd32RelaySessionLive && relayTotal > 0 &&
(relaySeq >= relayTotal || rs >= 4 || isGd32Complete)) {
if (typeof window.emitRelayProgress === 'function') {
window.emitRelayProgress(relayTotal, relayTotal, { force100: true });
}
} else if (window.gd32RelaySessionLive && relayTotal > 0 && relaySeq > 0 &&
typeof window.emitRelayProgress === 'function') {
window.emitRelayProgress(relaySeq, relayTotal);
}
if (window.gd32RelaySessionLive &&
(isGd32Complete || rs === 5 ||
(gd32AppAlive && relayTotal > 0 && relaySeq >= relayTotal && rs >= 3))) {
if (!window.gd32CompleteAnnounced) {
window.markGd32OtaUiComplete('GD32 刷写完成');
}
return;
}
if (rs === 1) {
setActivePhase('gd32Ota');
setProgress(50, '握手与认证中...');
} else if (rs === 4) {
/* 收尾:保持 100%,只更新文案(避免 100→95 回跳) */
setActivePhase('gd32Ota');
if (!window.gd32CompleteAnnounced) {
setProgress(100, gd32AppAlive ? '收尾中…' : '收尾中,等待 APP 启动...');
}
} else if (rs === 3 && relaySeq <= 0) {
setActivePhase('gd32Ota');
setProgress(60, '数据包传输中...');
} else if (rs === 2 || (isGd32Relaying && relaySeq === 0)) {
setActivePhase('gd32Ota');
setProgress(55, relayTotal > 0
? ('大小协商中(共 ' + relayTotal + ' 包)')
: '固件大小协商中');
} else if (!window.gd32RelaySessionLive) {
setActivePhase('gd32Ota');
setProgress(48, '已触发中继,等待设备进入握手…');
}
return;
}
/* 不再用 stored_size 做「OTA 缓存」展示;上传走 XHR,中继走 relay_seq。 */
}
/**
* /system/status → UI 总入口。
* @param {object} data
*/
export function updateSystemStatusUi(data) {
if (!data) return;
window.latestSystemStatus = data;
var wifi = data.wifi || {};
var ota = data.ota || {};
applyDashboardUi(data, ota, wifi);
/* BLE:仪表盘可更新,进度/阶段由 ble 路径独占 */
if (window.connectionMode === 'ble') {
return;
}
/* ESP32 OTA 进行中:勿用 GD32 relay 覆盖阶段条 */
if (window.currentOtaTarget === 'esp32' && window.esp32OtaPhase > 0) {
return;
}
applyGd32OtaSessionUi(ota);
}
+614
View File
@@ -0,0 +1,614 @@
/**
* upload-esp32.js - ESP32 本机 OTA 上传流程 + 完成升级
*
* 包含:startUpload 入口(处理 esp32 + gd32 共用互斥/重读逻辑)、
* XHR 上传、SHA 校验、verifyEsp32Firmware、restartEsp32。
*
* 共享状态(通过 window.* 引用):
* window.esp32File / window.gd32File / window.esp32FileHandle / window.gd32FileHandle
* window._esp32FileSha256 / window._gd32FileSha256 / window.activeXhr
* window.isUploading / window.uploadBtnOrigText / window.activeUploadChip
*
* 复用的函数(通过 window.* 引用):
* window.otaSessionActive / window.otaSessionTerminated / window.otaSessionReset
* window.otaSessionTransition / window.OtaSessionState / window.otaSessionTarget
* window.cleanupAfterOtaFailure / window.pausePollingForUpload
* window.resumePollingAfterUpload / window.updateEsp32StagesFromPhase
* window.switchStageFlow / window.setActivePhase / window.setProgress
* window.markPhaseDone / window.startUploadWaitHeartbeat / window.stopUploadWaitHeartbeat
* window.updateUploadButtonLabel / window.processFile / window.clearFile
* window.triggerGd32Relay / window.rebootObservedDown / window.rebootStartTime
* window.isEsp32Rebooting / window.getBaseUrl / window.formatBytes
* window.showToast / window.addLog
*/
import { formatBytes } from '../common/utils.js';
import { showToast } from '../common/toast.js';
import { addLog } from '../common/log.js';
import {
otaSessionActive, otaSessionTerminated, otaSessionReset,
otaSessionTransition, OtaSessionState, stopUploadWaitHeartbeat,
startUploadWaitHeartbeat
} from './session.js';
import { updateEsp32StagesFromPhase, switchStageFlow, setActivePhase, setProgress, markPhaseDone, cleanupAfterOtaFailure } from './progress.js';
import { computeFileSha } from './file-handle.js';
function isOtaBusyError(status, detail) {
var text = (detail || '').toLowerCase();
return status === 403 ||
status === 409 ||
status === 423 ||
text.indexOf('busy') >= 0 ||
text.indexOf('occupied') >= 0 ||
text.indexOf('another ota') >= 0 ||
text.indexOf('invalid state') >= 0 ||
text.indexOf('占用') >= 0 ||
text.indexOf('互斥') >= 0 ||
text.indexOf('系统忙') >= 0;
}
function formatHttpErrorDetail(text) {
var detail = (text || '').replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim();
if (detail.length > 180) detail = detail.slice(0, 177) + '...';
return detail;
}
/** @description 启动 ESP32 本机 OTA 上传流程(互斥获取→分块上传→SHA验证→重启)。 */
export async function startUpload(chip) {
var file = chip === 'esp32' ? window.esp32File : window.gd32File;
if (!file) return;
// ★ 状态机守卫:用统一的会话活跃判断替代裸 isUploading。
// FAILED/DONE 会话需要先复位才能发起新上传(下方 otaSessionReset)。
// RELAYING 可能因 ERROR 被设备软清为 IDLE 而前端未收尾,先探测再决定。
if (otaSessionActive()) {
var busyState = window.otaSessionState;
if (busyState === OtaSessionState.RELAYING || busyState === OtaSessionState.VERIFYING) {
try {
var staleProbe = await fetch(window.getBaseUrl() + '/system/status', {
signal: AbortSignal.timeout(2000)
});
if (staleProbe.ok) {
var staleSt = await staleProbe.json();
var so = staleSt.ota || {};
var ssc0 = Number.isFinite(so.sys_state_code) ? so.sys_state_code : -1;
var rs0 = Number.isFinite(so.relay_state) ? so.relay_state : -1;
/* 设备已空闲/完成/错误残留:前端会话是陈旧活跃态,强制清掉 */
var deviceFree = (ssc0 === 0 || ssc0 === 2 || ssc0 === 4 || ssc0 === 8) &&
(rs0 === 0 || rs0 === 5 || rs0 === 255 || rs0 < 0);
if (deviceFree) {
addLog('W', '上传', '检测到陈旧会话 ' + busyState +
'(设备已 ' + (so.sys_state || ssc0) + '),自动清理后继续');
otaSessionReset();
}
}
} catch (_) { /* 探测失败则仍按占用拒绝 */ }
}
}
if (otaSessionActive()) {
var busyChip = (window.otaSessionTarget || window.activeUploadChip || window.flashTarget || chip || 'ota').toUpperCase();
addLog('W', '上传', '已有 ' + busyChip + ' OTA 会话进行中 (' + window.otaSessionState + '),拒绝新的 ' + chip.toUpperCase() + ' 上传');
showToast('OTA 通道正在占用,请等待当前刷写结束', 'error');
return;
}
// 上一次会话已终结(DONE/FAILED):发起新会话前先复位状态机
if (otaSessionTerminated()) otaSessionReset();
// 记录本会话目标
window.otaSessionTarget = chip;
window.pausePollingForUpload();
/* 先进入 UPLOADING(同步 isUploading),再 await 读文件,防止重复提交 */
otaSessionTransition(OtaSessionState.UPLOADING);
window.activeUploadChip = chip;
/* 新上传:清掉上次完成标志,避免立刻被 status 残留判成“又完成了” */
window.gd32CompleteAnnounced = false;
window.relayWatchLastSeq = -1;
window.gd32RelaySessionLive = false;
if (chip === 'esp32') {
updateEsp32StagesFromPhase(1);
}
// 跨通道互斥前置检查(含 BLE 取消后的瞬态重试)
var _probeReady = false;
for (var _pi = 0; _pi < 4; _pi++) {
if (_pi > 0) {
var _pd = [1500, 3000, 3000][_pi - 1];
addLog('I', '上传', '等待设备清理 OTA 状态 (' + _pd + 'ms)...');
await new Promise(function(r) { setTimeout(r, _pd); });
}
try {
var probe = await fetch(window.getBaseUrl() + '/system/status', {
signal: AbortSignal.timeout(2000)
});
if (probe.ok) {
var st = await probe.json();
var ota = st.ota || {};
var ssc = Number.isFinite(ota.sys_state_code) ? ota.sys_state_code : -1;
var otaChannel = Number.isFinite(ota.channel) ? ota.channel : -1;
var otaChannelName = ota.channel_name || (otaChannel === 1 ? 'BLE' : 'WIFI');
// ESP32 RECEIVING(5) 可由同一 WiFi 通道的新上传接管;后台/休眠可能让 /ota/update 没发出,
// 若这里提前拦截,后端 beginEsp32OtaWithRetry 的同通道清理分支永远没有机会执行。
// ERROR(8) 放行给后端清理;REBOOTING(7) 不可逆。
// FINALIZING(6):真收尾时后端会 403;SHA 失败后可能假占用(core 已空),
// status 自愈 / begin 残留清理需要请求触达设备,前端同通道不硬拦。
if ((ssc === 5 || ssc === 6) && otaChannel === 1 && _pi < 3) continue;
var esp32Busy = (ssc === 7 ||
(ssc === 6 && otaChannel !== 0) ||
(ssc === 5 && otaChannel !== 0));
// GD32 STORING(1) 可由同一 WiFi 通道的新上传接管;RELAYING(3) 不可中断。
var gd32Busy = (ssc === 3 || (ssc === 1 && otaChannel !== 0));
if ((esp32Busy || gd32Busy)) {
addLog('E', '上传', '设备已有 OTA 进行中 (sys_state=' + (ota.sys_state || ssc) + ', channel=' + otaChannelName + '),拒绝本次 ' + chip.toUpperCase() + ' 上传');
showToast('设备正在 OTA 中,请等待结束后再试', 'error');
// ★ 状态机:预检失败也需完整清理(转入 FAILED)
cleanupAfterOtaFailure('设备 OTA 占用中');
return;
}
}
_probeReady = true;
break;
} catch (e) {
// 探测失败:可能是设备瞬时忙或网络问题。保守继续走,由后端 403 兜底。
addLog('W', '上传', '跨通道互斥状态探测失败: ' + e.message + '(继续尝试,由设备端兜底)');
_probeReady = true;
break;
}
}
// 热重载:有 FSAPI handle 则重新读盘(本地编译会改同路径固件)
var handle = chip === 'esp32' ? window.esp32FileHandle : window.gd32FileHandle;
if (handle) {
try {
var freshFile = await handle.getFile();
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 (chip === 'esp32') window.esp32File = freshFile;
else window.gd32File = freshFile;
file = freshFile;
/* .bin 直读分支此前无任何日志,热重载是否生效不可观察,补一条 */
addLog('I', '文件', '热重载: 已重新读取 ' + freshFile.name +
' (' + formatBytes(freshFile.size) + ')');
}
} catch (e) {
addLog('W', '文件', '重新读取文件失败: ' + e.message + ',将使用缓存版本');
}
} else {
/* 无 FSAPI 句柄(拖拽/传统方式选择):无法读盘,只能用缓存。
GD32 缓存是内存中转换出的 Blob,arrayBuffer 校验永远通过,
若不显式告知,本地重新编译后点上传会静默刷入旧固件 */
var cachedAt = file.lastModified
? new Date(file.lastModified).toLocaleTimeString('zh-CN', { hour12: false })
: '未知时间';
addLog('W', '文件', '热重载不可用(无 FSAPI 句柄):本次上传缓存文件 ' + file.name +
'(磁盘修改于 ' + cachedAt + ')。如需自动读取最新编译产物,请点击上传区重新选择');
showToast('热重载不可用,上传的是缓存文件', 'warning');
try {
await file.arrayBuffer();
} catch (e) {
addLog('E', '文件', '本地文件已变更或失效,请重新选择文件');
showToast('文件已变更,请重新选择', 'error');
window.clearFile(chip);
cleanupAfterOtaFailure('本地文件失效');
return;
}
}
var uploadBtn = document.getElementById(chip === 'esp32' ? 'uploadEsp32Btn' : 'uploadGd32Btn');
window.uploadBtnOrigText = uploadBtn.textContent;
uploadBtn.disabled = true;
uploadBtn.textContent = '上传中...';
switchStageFlow(chip);
setActivePhase(chip === 'esp32' ? 'esp32Ota' : 'gd32Ota');
if (chip === 'esp32') updateEsp32StagesFromPhase(1);
// Reset verification panel
var verifyInfo = document.getElementById('stageVerifyInfo');
if (verifyInfo) verifyInfo.style.display = 'none';
var sviFront = document.getElementById('sviFrontSha');
var sviDevice = document.getElementById('sviDeviceSha');
var sviResult = document.getElementById('sviResult');
if (sviFront) sviFront.textContent = '-';
if (sviDevice) sviDevice.textContent = '-';
if (sviResult) { sviResult.textContent = '-'; sviResult.className = 'svi-val'; }
addLog('I', '上传', '开始上传: ' + file.name + ' \u2192 ' + chip.toUpperCase());
showToast('开始上传固件到 ' + chip.toUpperCase(), 'success');
try {
var arrayBuffer = await file.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') {
var headerObj = { type: 'esp32', total_size: fileSize };
var payload = arrayBuffer;
addLog('I', '上传', '直传: ' + formatBytes(fileSize) +
'(设备边收边写 Flash;进度 100% 后仍需等 HTTP 响应)');
setProgress(1, '连接设备并开始写入…');
var headerJson = JSON.stringify(headerObj);
var encoder = new TextEncoder();
var headerBytes = encoder.encode(headerJson);
var combined = new Uint8Array(headerBytes.length + payload.byteLength);
combined.set(headerBytes, 0);
combined.set(new Uint8Array(payload), headerBytes.length);
body = combined.buffer;
} else {
var headerObj = { total_size: fileSize };
var payload = arrayBuffer;
addLog('I', '上传', '直传: ' + formatBytes(fileSize));
var gd32Header = JSON.stringify(headerObj);
var encoder = new TextEncoder();
var headerBytes = encoder.encode(gd32Header);
var combined = new Uint8Array(headerBytes.length + payload.byteLength);
combined.set(headerBytes, 0);
combined.set(new Uint8Array(payload), headerBytes.length);
body = combined.buffer;
}
window.activeXhr = new XMLHttpRequest();
var xhr = window.activeXhr;
xhr.upload.addEventListener('progress', function (e) {
if (e.lengthComputable) {
var pct = Math.round((e.loaded / e.total) * 100);
window.uploadProgress = pct;
if (chip === 'gd32') {
var overallPct = Math.min(45, Math.round(pct * 0.45));
setProgress(overallPct, '已上传 ' + formatBytes(e.loaded) + ' / ' + formatBytes(e.total));
} else if (pct >= 100) {
setProgress(100, '数据已发完,等待设备写 Flash 并响应…');
} else {
setProgress(pct, '已上传 ' + formatBytes(e.loaded) + ' / ' + formatBytes(e.total));
}
}
});
xhr.upload.addEventListener('load', function () {
// 请求体发送完毕 ≠ 服务端处理完毕
setProgress(100, '数据已发完,等待设备写 Flash 并响应…');
addLog('I', '上传', '浏览器已发完全部数据,等待 ESP32 HTTP 响应…');
startUploadWaitHeartbeat();
});
// 1MB+ 固件:服务端写 Flash 可能较久;禁止无限挂起无反馈
xhr.timeout = 300000;
xhr.addEventListener('load', function () {
stopUploadWaitHeartbeat();
/* 成功路径摘掉 activeXhr;失败路径留给 cleanupAfterOtaFailure 统一摘掉并 abort 幂等 */
if (window.activeXhr === xhr) window.activeXhr = null;
if (xhr.status === 200) {
/* 校验 body:避免空响应/网关误 200 被当成上传成功 */
var bodyTxt = (xhr.responseText || '').trim();
var bodyOk = false;
try {
var bj = bodyTxt ? JSON.parse(bodyTxt) : null;
bodyOk = !!(bj && (bj.status === 'ok' || bj.target === chip));
} catch (_pe) {
bodyOk = bodyTxt.indexOf('"status"') >= 0 && bodyTxt.indexOf('ok') >= 0;
}
if (!bodyOk) {
addLog('E', '上传', 'HTTP 200 但响应异常: ' + (bodyTxt.slice(0, 120) || '(空)'));
showToast('上传响应异常,请重试', 'error');
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 校验…');
showToast('固件上传成功', 'success');
setProgress(100, '上传完成');
markPhaseDone('esp32Ota');
updateEsp32StagesFromPhase(2); // OTA 写入阶段
// ★ 状态机:ESP32 上传完成 → 进入 VERIFYING(verifyEsp32Firmware 内部继续推进)
otaSessionTransition(OtaSessionState.VERIFYING);
verifyEsp32Firmware();
} else {
addLog('I', 'OTA', '上传完成,校验 SHA 并触发 GD32 中继…');
showToast('上传完成,GD32 OTA 已自动开始', 'success');
switchStageFlow('gd32');
setActivePhase('gd32Ota');
setProgress(45, '上传完成,校验并触发 GD32 OTA…');
// ★ 状态机:GD32 上传完成 → 进入 RELAYING(triggerGd32Relay 内部驱动中继)
otaSessionTransition(OtaSessionState.RELAYING);
window.triggerGd32Relay();
}
} else if (isOtaBusyError(xhr.status, formatHttpErrorDetail(xhr.responseText))) {
var busyDetail = formatHttpErrorDetail(xhr.responseText);
addLog('E', '上传', chip.toUpperCase() + ' OTA 被其他通道占用(BLE/WiFi 互斥)' + (busyDetail ? ': ' + busyDetail : ''));
showToast('OTA 通道被占用,请稍后重试', 'error');
cleanupAfterOtaFailure('通道被占用');
} else {
var detail = formatHttpErrorDetail(xhr.responseText);
var message = '上传失败: HTTP ' + xhr.status + (detail ? ' - ' + detail : '');
addLog('E', '上传', message);
showToast(detail ? ('上传失败: ' + detail) : '上传失败', 'error');
cleanupAfterOtaFailure('HTTP ' + xhr.status);
}
});
xhr.addEventListener('error', function () {
stopUploadWaitHeartbeat();
// cleanup 会先摘 activeXhr 再 abort;仅当本 xhr 仍是活动请求或会话仍在 UPLOADING 时清理
if (window.activeXhr === xhr || window.otaSessionState === OtaSessionState.UPLOADING) {
showToast('上传过程发生网络错误', 'error');
cleanupAfterOtaFailure('网络错误');
}
});
xhr.addEventListener('abort', function () {
stopUploadWaitHeartbeat();
// 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);
xhr.send(body);
} catch (e) {
addLog('E', '上传', '上传失败: ' + e.message);
showToast('上传失败: ' + e.message, 'error');
cleanupAfterOtaFailure('上传异常');
}
}
// ==================== 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); // 校验中
var sha256 = window._esp32FileSha256;
if (sha256) {
addLog('I', 'OTA', '前端文件 SHA-256: ' + sha256);
var verifyInfo = document.getElementById('stageVerifyInfo');
if (verifyInfo) verifyInfo.style.display = 'block';
var frontSha = document.getElementById('sviFrontSha');
if (frontSha) frontSha.textContent = sha256;
}
showToast('ESP32 正在校验固件并应用...', 'success');
try {
// 带 sha256 时设备端校验、set_boot 并自动重启;无需二次“应用”请求
var requestBody = {};
if (sha256) requestBody.sha256 = sha256;
var resp = await fetch(window.getBaseUrl() + '/ota/update', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(requestBody),
signal: AbortSignal.timeout(120000)
});
// 禁止裸 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;
}
}
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');
if (deviceSha) deviceSha.textContent = data.sha256;
}
var resultEl = document.getElementById('sviResult');
if (resultEl) {
if (sha256 && data.sha256 && sha256.toLowerCase() === data.sha256.toLowerCase()) {
resultEl.textContent = '验证通过';
resultEl.className = 'svi-val ok';
} else {
resultEl.textContent = '已应用(未比对 SHA)';
resultEl.className = 'svi-val ok';
}
}
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');
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 成功后 esp_restart 会掐断 TCP
await resolveEsp32VerifyAmbiguous(e && e.message ? e.message : String(e), {});
}
}
export async function restartEsp32() {
if (!confirm('确定要重启 ESP32 吗?')) return;
addLog('I', '系统', '=== 请求重启 ESP32 ===');
showToast('ESP32 正在重启...', 'success');
try {
var resp = await fetch(window.getBaseUrl() + '/restart', {
method: 'POST', signal: AbortSignal.timeout(5000)
});
if (resp.ok) {
var data = await resp.json();
addLog('I', '系统', '重启已触发: ' + data.message);
showToast('ESP32 重启中,页面将自动恢复...', 'success');
window.isEsp32Rebooting = true;
window.rebootObservedDown = false; // 重置:尚未观察到设备实际下线
window.rebootStartTime = Date.now();
}
} catch (e) {
addLog('E', '系统', '重启请求失败: ' + e.message);
showToast('重启失败', 'error');
}
}
+218
View File
@@ -0,0 +1,218 @@
/**
* upload-gd32.js - GD32 中继 OTA 触发 + 进度辅助
*
* 进度/完成/失败 UI 的唯一应用路径:pollStatus → updateSystemStatusUi(stages.js)。
* 本文件只负责:
* - 触发 /ota/relay
* - 中继期加速 status 轮询(400ms)
* - emitRelayProgress / markGd32OtaUiComplete 等被 stages 调用的辅助函数
*
* 共享状态(通过 window.* 引用):
* window.gd32CompleteAnnounced / window.relayWatchLastSeq / window.gd32RelaySessionLive
* window.pollingPausedForUpload / window.statusPollIntervalMs
* window.otaSessionState / window.lastSysStateCode / window.lastRelayState
*
* 复用的函数(通过 window.* 引用):
* window.otaSessionTransition / window.OtaSessionState
* window.markPhaseDone / window.setProgress / window.updateSystemStatusUi
* window.cleanupAfterOtaFailure / window.scheduleStatusPoll / window.pollLogs
* window.resumePollingAfterUpload / window.getBaseUrl / window.showToast / window.addLog
*/
import { showToast } from '../common/toast.js';
import { addLog } from '../common/log.js';
import {
otaSessionTransition, OtaSessionState
} from './session.js';
import { markPhaseDone, setProgress } from './progress.js';
/** 中继期 status 轮询间隔(ms);结束后由 stopRelayProgressWatch 恢复 1000 */
const RELAY_STATUS_POLL_MS = 400;
const NORMAL_STATUS_POLL_MS = 1000;
window.gd32CompleteAnnounced = false;
window.relayWatchLastSeq = -1;
/** 本会话是否已观察到真实中继态(1~4),避免旧 relay_state=5 残留导致一点击就“完成” */
window.gd32RelaySessionLive = false;
/**
* 包进度变更时更新进度条(同 seq 不重复)。
* 由 stages.js 在 status 轮询路径调用;勿在本文件再起独立定时器刷进度。
* @param {number} rseq
* @param {number} rtot
* @param {{force100?: boolean}} [opts]
* @returns {boolean} 是否实际输出了进度
*/
export function emitRelayProgress(rseq, rtot, opts) {
opts = opts || {};
rseq = Number(rseq) || 0;
rtot = Number(rtot) || 0;
if (rtot <= 0) return false;
if (opts.force100 || rseq >= rtot) {
rseq = rtot;
}
if (rseq <= 0) return false;
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 + '%)');
return true;
}
/** 完成/收尾前:若尚未打出 100%,用 status 里的包数补一条 */
export function ensureRelayProgressComplete() {
var data = window.latestSystemStatus;
var o = (data && data.ota) || {};
var rtot = Number(o.relay_total_packets) || 0;
var rseq = Number(o.relay_seq) || 0;
if (rtot <= 0) return;
/* FINISHING/DONE 时后端 seq 已到 total;status 滞后时也强制 100% */
if (rseq < rtot && (Number(o.relay_state) >= 4 || Number(o.sys_state_code) === 4)) {
rseq = rtot;
}
if (rseq >= rtot) {
emitRelayProgress(rtot, rtot, { force100: true });
}
}
export function markGd32OtaUiComplete(msg) {
// 已公告过:禁止再改 className / setProgress / toast,否则 status 重触发动效会「疯狂跳 ✓」
if (window.gd32CompleteAnnounced) {
return;
}
/* 仅本会话 RELAYING/FAILED 可宣布完成;IDLE/UPLOADING 时 status 里的旧 relay 残留一律忽略 */
if (window.otaSessionState !== OtaSessionState.RELAYING &&
window.otaSessionState !== OtaSessionState.FAILED) {
return;
}
/* 先 100% 转发进度,再宣布刷写完成 —— 避免日志里「完成」早于「1089/1089」 */
ensureRelayProgressComplete();
window.gd32CompleteAnnounced = true;
stopRelayProgressWatch();
markPhaseDone('gd32Ota');
window.currentOtaTarget = null;
setProgress(100, msg || 'GD32 刷写完成');
var sbDone = document.getElementById('stageStatusBar');
var stDone = document.getElementById('stageStatusText');
if (sbDone) sbDone.className = 'stage-status-bar ok';
if (stDone) stDone.textContent = msg || 'GD32 刷写完成';
if (window.otaSessionState === OtaSessionState.RELAYING ||
window.otaSessionState === OtaSessionState.FAILED) {
otaSessionTransition(OtaSessionState.DONE);
}
if (typeof window.updateUploadButtonLabel === 'function') {
window.updateUploadButtonLabel();
}
/* 完成文案以设备 [I][OTA] GD32 OTA完成 为准,前端只 toast/进度,避免日志双份 */
showToast(msg || 'GD32 刷写完成', 'success');
}
/**
* 结束中继加速轮询,恢复常态 1s status 间隔。
* 不再持有独立 setInterval(进度/完成/失败统一由 stages 在 pollStatus 路径处理)。
*/
export function stopRelayProgressWatch() {
if (typeof window.setStatusPollIntervalMs === 'function') {
window.setStatusPollIntervalMs(NORMAL_STATUS_POLL_MS);
}
}
/**
* 中继进度跟踪:只加速 /system/status 轮询,不再另起 400ms 定时器。
* UI 真相源仍是 pollStatus → updateSystemStatusUi(stages.js)。
*/
export function startRelayProgressWatch() {
if (window.pollingPausedForUpload) {
window.resumePollingAfterUpload();
}
if (typeof window.setStatusPollIntervalMs === 'function') {
window.setStatusPollIntervalMs(RELAY_STATUS_POLL_MS);
}
if (typeof window.scheduleStatusPoll === 'function') {
window.scheduleStatusPoll(0);
}
if (typeof window.pollLogs === 'function') {
window.pollLogs();
}
}
/** @description 触发 GD32 中继 OTA 流程(通过 HTTP 接口)。 */
export async function triggerGd32Relay() {
var sha256 = window._gd32FileSha256;
if (!sha256) {
addLog('E', 'OTA', '缺少 GD32 文件 SHA-256,请先上传文件');
showToast('SHA-256 缺失', 'error');
window.cleanupAfterOtaFailure('SHA-256 缺失');
return;
}
addLog('I', 'OTA', '触发 GD32 中继…');
setProgress(48, '校验 SHA 并触发 GD32 中继…');
try {
// 仅触发中继(设备端异步跑),不阻塞到刷写结束;SHA 读分区可能数秒
var resp = await fetch(window.getBaseUrl() + '/ota/relay', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ sha256: sha256 }),
signal: AbortSignal.timeout(120000)
});
var text = await resp.text();
if (resp.ok) {
addLog('I', 'OTA', 'GD32 OTA 已触发;进度看状态栏包数');
showToast('GD32 OTA 已触发', 'success');
setProgress(50, '中继已启动,同步进度…');
window.gd32CompleteAnnounced = false;
window.relayWatchLastSeq = -1;
startRelayProgressWatch();
} else {
// 尝试解析错误响应中的 device SHA / 身份校验错误
try {
var err = JSON.parse(text);
if (err.code === 'firmware_identity_mismatch') {
var idMsg = err.message || '固件身份校验失败,已拒绝刷写';
addLog('E', 'OTA', 'GD32 包身份校验失败: ' + idMsg);
showToast(idMsg, 'error');
window.cleanupAfterOtaFailure('固件身份不符');
return;
}
if (err.device) {
addLog('E', 'OTA', 'GD32 SHA-256 不匹配: 设备=' + err.device +
' 前端=' + sha256);
showToast('SHA-256 不匹配', 'error');
window.cleanupAfterOtaFailure('SHA-256 不匹配');
return;
}
} catch (_) {}
addLog('E', 'OTA', 'GD32 触发失败: HTTP ' + resp.status + ' ' + text);
showToast('GD32 触发失败', 'error');
window.cleanupAfterOtaFailure('触发失败 HTTP ' + resp.status);
}
} catch (e) {
// 请求超时/断连 ≠ 设备未启动中继:先探测 status 再决定是否标失败
try {
var probe = await fetch(window.getBaseUrl() + '/system/status', {
signal: AbortSignal.timeout(3000)
});
if (probe.ok) {
var st = await probe.json();
var o = st.ota || {};
var code = Number(o.sys_state_code);
var rstate = Number(o.relay_state);
if (code === 3 || (rstate >= 1 && rstate <= 4)) {
addLog('W', 'OTA', '触发请求中断(' + e.message + '),但设备已在中继,继续跟踪进度');
showToast('设备已在刷写,同步进度中', 'success');
if (window.otaSessionState !== OtaSessionState.RELAYING) {
otaSessionTransition(OtaSessionState.RELAYING);
}
try { window.updateSystemStatusUi(st); } catch (_) {}
startRelayProgressWatch();
return;
}
}
} catch (_) { /* probe 失败再走失败清理 */ }
addLog('E', 'OTA', 'GD32 触发失败: ' + e.message);
showToast('GD32 触发失败', 'error');
window.cleanupAfterOtaFailure('触发异常');
}
}