Files
DCU_DesktopController/js/ota/session.js
T
cbb d2c86c425a 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 换行原样)
2026-09-03 21:30:32 +08:00

250 lines
9.7 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;
}