/** * 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; }