- 将日志环形缓冲区从静态数组改为动态堆分配
- 实现启动时大容量缓冲区和运行时小容量缓冲区的切换机制
- 添加日志环满时优先保留错误和警告信息的清理策略
- 优化内存使用,在日志为空时避免不必要的内存分配
- 移除调试日志中的冗余字段显示
feat(api): 更新CAN接口为批量发送模式
- 将 `/can/send` 接口替换为 `/can/send_batch`,支持1到N帧的批量发送
- 更新API文档中的接口描述和使用示例
- 修改请求参数格式为 `{"ch":0,"frames":[{...}]}` 返回格式为 `{"status":"ok","results":[...]}`
- 单帧发送也需要通过send_batch接口实现
feat(monitor): 新增GD32 ADC调试反馈处理功能
- 添加gd32_adc_feedback组件用于解析0xC4类型的SPI反馈
- 实现ADC数据解析包括温度、电压等参数的计算和转换
- 在can_feedback_task中集成ADC反馈分发逻辑
- 添加线程安全的数据获取和年龄检查接口
refactor(config): 优化任务栈大小配置并调整OTA块大小
- 重新评估各任务栈使用情况,优化HTTP、WiFi、SPI等工作任务的栈大小
- 将HTTP服务器栈从8KB调整至7KB,WiFi OTA任务栈从4KB调整至3KB
- OTA块大小从2KB调整回4KB以提升传输吞吐性能
- 重新整理配置文件结构,移动WiFi模式配置位置
250 lines
9.7 KiB
JavaScript
250 lines
9.7 KiB
JavaScript
/**
|
||
* 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;
|
||
} |