feat(motor-ota): 改进电机OTA中继的握手确认和重试机制

- 添加握手确认逻辑,避免在握手阶段就标记TRANSFER状态
- 实现GD32 auto-retry检测机制,当连续WAIT超限时查询真实状态
- 修复预填充缓冲区数据重复问题,移除旧的offset重置逻辑
- 优化SPI响应等待机制,允许在WAIT后重发DATA以恢复丢失的ACK
- 添加连续SPI失败和WAIT计数监控,超过阈值时主动查询GD32状态

perf(can): 提升CAN批量发送性能和可靠性

- 将CAN帧间延迟从5ms减少到1ms以提升传输速度
- 将单批最大帧数从32增加到64,并将批次间隔从200ms减少到100ms
- 添加手动超时控制器防止fetch请求永久挂起
- 实现批量发送完成后的兜底渲染机制确保结果正确显示

refactor(spi): 优化SPI通信的事件驱动机制

- 实现基于信号量的事件驱动等待机制,替换原有的轮询方式
- 将SPI时钟频率从4MHz提升至8MHz以提高传输速度
- 优化OTA发送后的静默期处理,不同类型的命令采用不同的延迟策略
- 添加临时SPI测速端点用于性能测试

chore(version): 更新固件版本至0.3.74

- 版本号从0.3.72提升到0.3.74
- 更新前端资源版本号以确保缓存刷新
This commit is contained in:
小龙虾
2026-08-01 04:14:51 +08:00
parent e9cb7342f6
commit 04a118219f
17 changed files with 572 additions and 90 deletions
+1 -1
View File
@@ -329,6 +329,6 @@
<span id="toastMsg">操作成功</span>
</div>
<script type="module" src="js/can.js?v=20260727a"></script>
<script type="module" src="js/can.js?v=20260801f"></script>
</body>
</html>
+1 -1
View File
@@ -44,7 +44,7 @@ import {
sendManualFrame,
resetManualFields,
loadPreviewToManual
} from './can/send.js';
} from './can/send.js?v=20260801f';
import {
scheduleCanPersist,
loadCanSession,
+26 -12
View File
@@ -14,7 +14,7 @@
*/
import { state, el, getBaseUrl, padHex, bytesToHex, parseNumericId } from './dbc.js';
import { escapeHtml } from '../common/utils.js';
import { showToast } from '../common/toast.js';
import { showToast } from '../common/toast.js?v=20260801f';
import { updateBatchUi } from './messages.js';
import { ensureMessageDefaults, getSignalValueMap, getSelectedMessage, updateEncodedPreview, encodeMessageData } from './signals.js';
import { scheduleCanPersist } from './persist.js';
@@ -124,12 +124,23 @@ async function sendCanBatchRequest(channel, frames) {
var n = frames && frames.length ? frames.length : 0;
/* 单帧设备侧 timeout 2s;多帧按帧数留余量 */
var timeoutMs = (n <= 1) ? 8000 : Math.max(15000, n * 500);
var resp = await fetch(getBaseUrl() + '/can/send_batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ch: channel, frames: frames }),
signal: AbortSignal.timeout(timeoutMs)
});
/* AbortSignal.timeout 在连接被 reset 的边缘情况下偶发不触发,
* 叠加一条手动竞速超时,避免 await 永久挂起导致整批不渲染。 */
var ac = new AbortController();
var timer = setTimeout(function () { ac.abort(); }, timeoutMs);
var resp;
try {
resp = await fetch(getBaseUrl() + '/can/send_batch', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ch: channel, frames: frames }),
signal: ac.signal
});
} catch (err) {
throw new Error('请求失败: ' + (err && err.message ? err.message : err));
} finally {
clearTimeout(timer);
}
var text = await resp.text();
var json = {};
try {
@@ -231,12 +242,12 @@ export function renderHistory() {
// ==================== 批量发送 ====================
/**
* 单批最多帧数。实测一次发送 >9 帧失败率明显上升,故按 9 帧分批;
* 批次之间留 BATCH_INTER_CHUNK_DELAY_MS 间隔,给 CAN/SPI 总线喘息时间。
* 单批最多帧数。按 64 帧分批(一次请求发 64 帧);批次之间留
* BATCH_INTER_CHUNK_DELAY_MS 间隔,给 CAN/SPI 总线喘息时间。
*/
var BATCH_CHUNK = 32;
var BATCH_CHUNK = 64;
/** 批次间延时(ms),仅在还有后续批次时等待 */
var BATCH_INTER_CHUNK_DELAY_MS = 200;
var BATCH_INTER_CHUNK_DELAY_MS = 100;
/**
* @description 批量发送所有勾选的报文(仅 /can/send_batch)。
@@ -330,10 +341,13 @@ export async function sendCheckedMessages() {
renderHistory();
scheduleCanPersist();
var tone = fail === 0 ? 'success' : (ok === 0 ? 'error' : 'warning');
showToast('批量发送完成:成功 ' + ok + ',失败 ' + fail, tone);
showToast('批量发送完成:成功 ' + ok + ',失败 ' + fail, tone, { force: true });
} finally {
state.batchInFlight = false;
updateBatchUi();
/* 兜底渲染:无论 try 内是否抛异常,都保证已写入的 silent 历史条目可见,
* 避免「批量发了但结果不显示」的偶发现象(renderHistory 幂等,多调一次无害)。 */
renderHistory();
}
}
+8 -2
View File
@@ -12,14 +12,20 @@ const TOAST_AUTOHIDE_MS = 3000;
var _toastHideTimer = null;
var _toastLastKey = '';
var _toastLastAt = 0;
export function showToast(message, type) {
/**
* @param {string} message
* @param {string} [type] success/error/warning
* @param {{ force?: boolean }} [opts] force=true 时跳过 5s 去重,
* 用于用户主动操作(如批量发送完成)的反馈,避免 alert 阻塞后时间漂移导致被去重吞掉。
*/
export function showToast(message, type, opts) {
type = type || 'success';
var toast = document.getElementById('toast');
if (!toast) return;
// 相同文案 5s 内不重复弹,避免轮询把 ✓ 刷成「疯狂跳」
var key = type + '|' + message;
var now = Date.now();
if (key === _toastLastKey && (now - _toastLastAt) < TOAST_DEDUPE_MS) {
if (!(opts && opts.force) && key === _toastLastKey && (now - _toastLastAt) < TOAST_DEDUPE_MS) {
return;
}
_toastLastKey = key;
+23 -7
View File
@@ -2172,8 +2172,14 @@ function startOtaPoll() {
/* 状态变化写日志,避免 UI 只停在「开始握手」一行 */
if (st.state && st.state !== lastState) {
const zh = OTA_STATE_ZH[st.state] || st.state;
/* 握手阶段(pre_cancel / handshake_req / handshake_nonce / handshake_confirm / erase)
* 不显示 x/y 进度——
* 握手未成时 packets_acked 是 ESP32 预填充字节数,不是电机真实 ACK,
* 显示 "0/912" 无意义且让用户误以为在传输。transfer 起才显示进度。 */
const showPkt = (st.state === "transfer" || st.state === "wait_app_run" ||
st.state === "finish_ota" || st.state === "finished");
otaLog(`<span class="log-info">[状态]</span> ${zh}` +
(st.total_packets ? ` · ${st.packets_acked || 0}/${st.total_packets}` : ""));
(showPkt && st.total_packets ? ` · ${st.packets_acked || 0}/${st.total_packets}` : ""));
lastState = st.state;
} else if (st.state === "transfer" && st.total_packets > 0) {
const acked = st.packets_acked || 0;
@@ -2253,13 +2259,23 @@ function startOtaPoll() {
setOtaStage(stage);
const acked = st.packets_acked || 0;
const total = st.total_packets || 1;
/* HTML 模板已是「包 <已确认> / <总数>」,这里各填各的;
* 之前往 otaPktNum 塞整串 "acked/total" 会渲染成「包 696/912 / 912」。 */
$("#otaPktNum").textContent = String(acked);
$("#otaPktTotal").textContent = String(total);
$("#otaProgressFill").style.width = (acked / total * 100).toFixed(1) + "%";
/* 握手阶段(pre_cancel / handshake_req / handshake_nonce / handshake_confirm / erase)
* 不更新进度条和包数——
* 此时 packets_acked 是 ESP32 预填充字节数不是电机真实 ACK,
* 显示 0/912 会让人误以为在传输。transfer 起才显示进度。 */
const inTransfer = (st.state === "transfer" || st.state === "wait_app_run" ||
st.state === "finish_ota" || st.state === "finished");
if (inTransfer) {
$("#otaPktNum").textContent = String(acked);
$("#otaPktTotal").textContent = String(total);
$("#otaProgressFill").style.width = (acked / total * 100).toFixed(1) + "%";
} else {
/* 握手阶段进度条归零,避免残留上次进度 */
$("#otaPktNum").textContent = "0";
$("#otaProgressFill").style.width = "0%";
}
const elapsed = (performance.now() - startTime) / 1000;
if (elapsed > 0.5 && acked > 0) {
if (elapsed > 0.5 && acked > 0 && inTransfer) {
$("#otaSpeed").textContent = (acked * 48 / elapsed / 1024).toFixed(1) + " KB/s";
}
$("#otaCrc").textContent = OTA_STATE_ZH[st.state] || st.state;
+1 -1
View File
@@ -345,6 +345,6 @@
</div>
</div>
<script type="module" src="js/motor/main.js?v=20260730g"></script>
<script type="module" src="js/motor/main.js?v=20260801b"></script>
</body>
</html>
+175
View File
@@ -0,0 +1,175 @@
<!DOCTYPE html>
<!--
【临时功能 SPI-SPEEDTEST】SPI 链路测速独立页面
─────────────────────────────────────────────
完全自包含(无外部 css/js 依赖),不被任何其他页面引用。
删除方法:直接删掉本文件即可(固件端点删除方法见
main/monitor/spi_speed_test.cpp 文件头)。
用法:浏览器直接打开本文件,或经 serve.bat 访问
http://localhost:8080/spi_test.html
-->
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>SPI 测速(临时工具)</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: "Segoe UI", "Microsoft YaHei", sans-serif;
background: #14171c; color: #d8dee6;
max-width: 760px; margin: 0 auto; padding: 24px 16px;
}
h1 { font-size: 20px; margin-bottom: 4px; }
.sub { color: #7a8494; font-size: 12px; margin-bottom: 20px; }
.card {
background: #1c2129; border: 1px solid #2a313c; border-radius: 8px;
padding: 16px; margin-bottom: 16px;
}
label { font-size: 13px; color: #9aa5b3; margin-right: 6px; }
input {
background: #12151a; color: #d8dee6; border: 1px solid #2a313c;
border-radius: 4px; padding: 6px 8px; font-size: 14px; width: 130px;
}
input.narrow { width: 70px; }
.row { display: flex; flex-wrap: wrap; gap: 12px; align-items: center; margin-bottom: 12px; }
button {
background: #2563eb; color: #fff; border: 0; border-radius: 6px;
padding: 8px 18px; font-size: 14px; cursor: pointer;
}
button:hover { background: #1d4ed8; }
button:disabled { background: #3a4250; cursor: wait; }
button.alt { background: #374151; }
button.alt:hover { background: #4b5563; }
pre {
background: #12151a; border: 1px solid #2a313c; border-radius: 6px;
padding: 12px; font-size: 14px; line-height: 1.7; white-space: pre-wrap;
min-height: 120px;
}
pre.fail { border-color: #b91c1c; color: #fca5a5; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th, td { text-align: left; padding: 5px 8px; border-bottom: 1px solid #2a313c; }
th { color: #7a8494; font-weight: normal; }
td.bad { color: #f87171; }
.hint { color: #7a8494; font-size: 12px; line-height: 1.7; margin-top: 8px; }
</style>
</head>
<body>
<h1>SPI 链路测速</h1>
<div class="sub">临时诊断工具 [SPI-SPEEDTEST] — 探针 0xA1 APP PING,删除时直接删掉本文件</div>
<div class="card">
<div class="row">
<label>设备 IP</label><input id="ip" value="192.168.31.127">
<label>帧数</label><input id="frames" class="narrow" value="200">
<label>载荷 B</label><input id="payload" class="narrow" value="236">
</div>
<div class="row">
<button id="btnRun">开始测速</button>
<button id="btnRtt" class="alt">空载 RTT(500 帧)</button>
<button id="btnStress" class="alt">压力测试(2000 帧满载)</button>
</div>
<div class="hint">
满帧 236B = 等效电机 OTA 0xD1 数据节奏;载荷 0 = 纯握手往返。<br>
判读:失败必须为 0;「最快」是链路物理极限周期;「平均-最快」差值是软件调度开销。
</div>
</div>
<div class="card">
<pre id="out">等待测试…</pre>
</div>
<div class="card">
<table>
<thead><tr><th>时间</th><th>帧数</th><th>载荷</th><th>失败</th><th>平均</th><th>最快</th><th>吞吐</th></tr></thead>
<tbody id="hist"></tbody>
</table>
<div class="hint">本页历史仅存内存,刷新即清空——方便改参数前后对比。</div>
</div>
<script>
"use strict";
(function () {
var out = document.getElementById("out");
var hist = document.getElementById("hist");
var btns = [
document.getElementById("btnRun"),
document.getElementById("btnRtt"),
document.getElementById("btnStress")
];
var ipInput = document.getElementById("ip");
var savedIp = null;
try { savedIp = window.localStorage.getItem("spi_test_ip"); } catch (e) {}
if (savedIp) { ipInput.value = savedIp; }
function setBusy(busy) {
for (var i = 0; i < btns.length; i++) { btns[i].disabled = busy; }
}
function pad2(n) { return (n < 10 ? "0" : "") + n; }
function addHistory(frames, payload, text) {
/* 从设备中文文本里提取关键数字做历史行 */
var fail = (text.match(/失败:\s*(\d+)/) || [])[1] || "?";
var avg = (text.match(/平均\s*([\d.]+)\s*ms/) || [])[1] || "?";
var min = (text.match(/最快\s*([\d.]+)\s*ms/) || [])[1] || "?";
var tput = (text.match(/等效吞吐:\s*([\d.]+)\s*KB\/s/) || [])[1] || "-";
var d = new Date();
var tr = document.createElement("tr");
var cells = [
pad2(d.getHours()) + ":" + pad2(d.getMinutes()) + ":" + pad2(d.getSeconds()),
String(frames), String(payload) + "B", fail,
avg + "ms", min + "ms", tput === "-" ? "-" : tput + "KB/s"
];
for (var i = 0; i < cells.length; i++) {
var td = document.createElement("td");
td.textContent = cells[i];
if (i === 3 && fail !== "0") { td.className = "bad"; }
tr.appendChild(td);
}
hist.insertBefore(tr, hist.firstChild);
}
function run(frames, payload) {
var ip = ipInput.value.trim();
try { window.localStorage.setItem("spi_test_ip", ip); } catch (e) {}
var url = "http://" + ip + "/spi/speedtest?frames=" + frames + "&payload=" + payload;
setBusy(true);
out.className = "";
out.textContent = "测试中… (" + frames + " 帧 × " + payload + "B)";
fetch(url)
.then(function (r) {
if (!r.ok) { throw new Error("HTTP " + r.status); }
return r.text();
})
.then(function (text) {
var failed = /失败:\s*[1-9]/.test(text);
out.className = failed ? "fail" : "";
out.textContent = text;
addHistory(frames, payload, text);
})
.catch(function (err) {
out.className = "fail";
out.textContent = "请求失败: " + err.message + "\n请检查设备 IP 与网络连通性。";
})
.then(function () { setBusy(false); });
}
function clampInt(v, lo, hi, dflt) {
var n = parseInt(v, 10);
if (isNaN(n)) { return dflt; }
return Math.min(hi, Math.max(lo, n));
}
btns[0].onclick = function () {
run(clampInt(document.getElementById("frames").value, 1, 2000, 200),
clampInt(document.getElementById("payload").value, 0, 236, 236));
};
btns[1].onclick = function () { run(500, 0); };
btns[2].onclick = function () { run(2000, 236); };
})();
</script>
</body>
</html>