feat(protocol): 更新SPI Link协议至v2版本并优化CAN通信

- 将SPI OTA帧格式升级为SPI Link v2,采用新的帧格式:
  | SOF0 0xA5 | SOF1 0x5A | VER 0x02 | FLAGS | SESSION 4B LE |
  TX_SEQ | ACK_SEQ | CREDIT | TYPE | LEN 2B LE | RSV 2B | PAYLOAD |
  CRC32 4B LE |
- 新增V2_OVERHEAD=20字节开销,APP最大payload为236字节,BOOT为108字节
- 修改帧类型定义:ENTER_BOOT从0xB5合并至0xB0,新增CAN业务类型0xC0~C3
- 更新CAN协议文档,支持SPI Link v2-only模式,调整最大业务payload计算
- 扩展0xC1帧功能,支持双向通信和1Hz主动推送ID快照
- 添加0xC3完整ID快照帧类型,包含seq/rx_count等完整字段
- 更新协议文档中的错误码定义和反馈payload格式

refactor(main): 重构主程序入口并添加CAN反馈任务

- 在main.cpp中添加CAN反馈任务,更新启动流程注释
- 创建createCriticalTask辅助函数,统一关键任务创建逻辑
- 调整SPI初始化顺序,确保CAN反馈任务依赖正确
- 优化错误处理机制,关键任务失败时立即重启
- 将GD32版本探测移至ota_relay任务处理
- 更新版本号至0.3.48,调整编译时间和Git哈希

config: 更新SPI和BLE配置参数

- 添加SPI主机相关配置参数,包括CS GPIO、READY GPIO等引脚定义
- 配置SPI缓冲区大小:APP为256字节,BOOT为128字节
- 更新BLE滑动窗口参数,ACK窗口设为32,数据队列深度为72
- 调整SPI工作队列大小至5120字节,增加任务堆栈监控说明
This commit is contained in:
小龙虾
2026-07-16 23:00:15 +08:00
parent 51c89b3065
commit 5db3f66b8e
57 changed files with 4728 additions and 2098 deletions
+200 -40
View File
@@ -242,21 +242,10 @@ export async function startUpload(chip) {
});
// 1MB+ 固件:服务端写 Flash 可能较久;禁止无限挂起无反馈
xhr.timeout = 300000;
xhr.addEventListener('timeout', function () {
stopUploadWaitHeartbeat();
if (window.activeXhr) {
cleanupAfterOtaFailure('上传超时(设备未在 5 分钟内响应)');
showToast('上传超时:设备可能忙或连接被踢,请重试', 'error');
}
});
xhr.addEventListener('load', function () {
stopUploadWaitHeartbeat();
window.isUploading = false;
window.activeXhr = null;
window.resumePollingAfterUpload();
uploadBtn.disabled = false;
uploadBtn.textContent = window.uploadBtnOrigText;
window.updateUploadButtonLabel();
/* 成功路径摘掉 activeXhr;失败路径留给 cleanupAfterOtaFailure 统一摘掉并 abort 幂等 */
if (window.activeXhr === xhr) window.activeXhr = null;
if (xhr.status === 200) {
/* 校验 body:避免空响应/网关误 200 被当成上传成功 */
var bodyTxt = (xhr.responseText || '').trim();
@@ -273,6 +262,11 @@ export async function startUpload(chip) {
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 校验…');
@@ -308,16 +302,25 @@ export async function startUpload(chip) {
});
xhr.addEventListener('error', function () {
stopUploadWaitHeartbeat();
if (window.activeXhr) {
cleanupAfterOtaFailure('网络错误');
// cleanup 会先摘 activeXhr 再 abort;仅当本 xhr 仍是活动请求或会话仍在 UPLOADING 时清理
if (window.activeXhr === xhr || window.otaSessionState === OtaSessionState.UPLOADING) {
showToast('上传过程发生网络错误', 'error');
cleanupAfterOtaFailure('网络错误');
}
});
xhr.addEventListener('abort', function () {
stopUploadWaitHeartbeat();
if (window.activeXhr) {
cleanupAfterOtaFailure('已中止');
// 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);
@@ -331,6 +334,126 @@ export async function startUpload(chip) {
// ==================== 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); // 校验中
@@ -356,9 +479,29 @@ export async function verifyEsp32Firmware() {
signal: AbortSignal.timeout(120000)
});
var data = await resp.json();
// 禁止裸 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;
}
}
if (data.status === 'ok') {
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');
@@ -374,32 +517,49 @@ export async function verifyEsp32Firmware() {
resultEl.className = 'svi-val ok';
}
}
// 服务器已设置启动分区并自动重启,前端直接进入重启等待状态
addLog('I', 'OTA', 'SHA-256 校验通过,设备自动重启中...');
showToast('校验通过,设备重启中...', 'success');
updateEsp32StagesFromPhase(4); // 应用固件
window.rebootObservedDown = false; // 重置:尚未观察到设备实际下线
updateEsp32StagesFromPhase(5); // 重启中
window.rebootStartTime = Date.now();
// ★ 状态机:校验通过 → 进入 REBOOTING(内部同步 isEsp32Rebooting = true)
otaSessionTransition(OtaSessionState.REBOOTING);
} else {
var detail = data.message || (data.device ? '不匹配' : '未知错误');
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');
var resultEl = document.getElementById('sviResult');
if (resultEl) { resultEl.textContent = '✗ ' + detail; resultEl.className = 'svi-val err'; }
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(SHA 计算耗时)、可能已崩溃、可能网络抖动。
// 如实报告失败,由 pollStatus 探测设备真实状态。
addLog('E', 'OTA', '应用请求失败: ' + e.message + '(设备状态未知,等待轮询探测)');
showToast('应用请求失败: ' + e.message, 'error');
var resultEl = document.getElementById('sviResult');
if (resultEl) { resultEl.textContent = '✗ 请求失败: ' + e.message; resultEl.className = 'svi-val err'; }
cleanupAfterOtaFailure('应用请求失败');
// fetch 异常 ≠ 一定失败:finalize 成功后 esp_restart 会掐断 TCP
await resolveEsp32VerifyAmbiguous(e && e.message ? e.message : String(e), {});
}
}