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:
@@ -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.*,无需再二次赋值 */
|
||||
@@ -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() + ' 文件已清除');
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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('触发异常');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user