feat(server): CANFD 批量收发区 + 电机控制台后端 API

PDO 布局扩展(与 GD32 1.5.56 对齐):
  段2 [48..383]  CANFD 批量发送区 —— 多帧共用一个 seq 沿, 从站在同一次
                 processOutput 里先发单帧再发批量, 9 帧 DLC32 由原来 9 个
                 周期压缩到 1 个周期。
  段4 [360..639] CANFD 批量接收区 —— 一周期回收多帧, 支持 DLC64 满长 CANFD,
                 带 lost/pending 计数。
  单帧区 [0..47] 布局不动, 批量区 count=0 时逐字节兼容 1.5.55, 老主站与
  CAN 调试页行为不变。

新增 API: /api/can/{send,state} 与 /api/motor/{start,stop,state,send,rx};
电机 PDO 后台循环靠滚动窗口扫 27 个槽, 命令走队列按 seq 沿下发。
ecat.html 加「电机控制台」入口与「CAN 转发」标签页。

test_motor_backend.py 8 项全通过(不接硬件, 假从站模拟 GD32 buildInput/
processOutput): DLC32 不截断、滚动窗口扫全槽、STATUS 32B 完整回传、
队列不互相覆盖、循环可干净停止、批量同步下发、DLC64 回收、1.5.55 兼容。
This commit is contained in:
cbb
2026-09-03 21:42:07 +08:00
parent 71c4ee6a14
commit f09decc127
2 changed files with 864 additions and 23 deletions
+124 -3
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>EtherCAT 控制台 — DCU LAN9252</title>
<link rel="icon" href="data:,">
<link rel="stylesheet" href="ecat.css?v=20260831a">
<link rel="stylesheet" href="ecat.css?v=20260903b">
<style>
/* ecat 页面专属补充(基于 motor.css 设计体系) */
.ecat-grid { grid-template-columns: minmax(240px, 290px) minmax(0, 1fr) minmax(0, 1.1fr); }
@@ -57,6 +57,7 @@
<span class="refresh-hint">ESC EEPROM 直读 · 烧录后断电重启生效</span>
</div>
<div class="toolbar-right">
<a class="btn btn-sm" href="/motor.html" title="电机控制台(经 EtherCAT 过程数据,不走 WiFi/HTTP)">电机控制台</a>
<span class="node-badge" id="alBadge">AL —</span>
</div>
</header>
@@ -147,6 +148,7 @@
<div class="tabs-bar">
<button class="tab-btn active" data-tab="pdo">过程数据</button>
<button class="tab-btn" data-tab="sdo">SDO (CoE)</button>
<button class="tab-btn" data-tab="can">CAN 转发</button>
</div>
<div class="tab-content">
<!-- PDO 页 -->
@@ -209,6 +211,52 @@
</div>
<div class="eep" id="sdoOut">—</div>
</div>
<!-- CAN 转发页 -->
<div class="tab-page" id="tabCan">
<div class="cmd-grid">
<div class="field">
<span class="field-label">通道</span>
<select id="canCh">
<option value="0">CH0</option><option value="1">CH1</option><option value="2">CH2</option>
</select>
</div>
<div class="field"><span class="field-label">CAN ID <span class="field-unit">29bit</span></span><input type="text" id="canId" value="0x18DA00F1"></div>
<div class="field"><span class="field-label">DLC <span class="field-unit">≤24</span></span><input type="number" id="canDlc" value="8" min="0" max="24"></div>
</div>
<div class="cmd-grid" style="margin-top:var(--sp-3)">
<div class="field" style="grid-column:1/-1">
<span class="field-label">数据 <span class="field-unit">hex</span></span>
<input type="text" id="canData" value="0102030405060708" placeholder="0102030405060708">
</div>
</div>
<div style="display:flex;gap:var(--sp-3);align-items:flex-end;margin-top:var(--sp-3);flex-wrap:wrap">
<button class="btn btn-primary" id="btnCanSend" disabled>发送一帧</button>
<div class="field" style="width:110px">
<span class="field-label">周期 <span class="field-unit">ms</span></span>
<input type="number" id="canRepeatMs" value="200" min="50" max="10000" step="10">
</div>
<button class="btn" id="btnCanRepeat" disabled>开始周期</button>
</div>
<div class="br-row" style="margin-top:var(--sp-3)">
<div class="big-reading"><div class="br-val" id="canRxId">—</div><div class="br-lbl">最近接收 ID</div></div>
<div class="big-reading"><div class="br-val" id="canRxData">—</div><div class="br-lbl">接收数据</div></div>
</div>
<div class="stat-row">
<div class="stat-cell">接收<b id="canRxCnt">0</b></div>
<div class="stat-cell">发送<b id="canTxCnt">0</b></div>
<div class="stat-cell">来源<b id="canRxCh">—</b></div>
<div class="stat-cell">发送结果<b id="canTxRes">—</b></div>
<div class="stat-cell">队列积压<b id="canBacklog">—</b></div>
<div class="stat-cell">丢帧<b id="canDrop">0</b></div>
</div>
<div class="mode-desc" style="margin-top:var(--sp-2)">
CAN 帧承载在过程数据里(固件 ≥1.5.56 为 384B 出 / 640B 入):输出 PDO = 待发帧, 输入 PDO = 最近收到的帧 + 计数。
发送用<b>序号沿触发</b>, 同一帧不会被每周期重发; 从站回显 ack 即证明整条 输出→应用→输入 链路通。
本调试页只用兼容区(输出前 48B / 输入前 32B, 与 1.5.53 逐字节一致), 接收仍只显示前 8 字节, 超长帧标记截断。
要一周期合并多条 CANFD 帧、或看完整 32B/64B payload, 用<a href="/motor.html">电机控制台</a>(走批量帧区)。
未进 OP 时点发送会自动先进 OP。
</div>
</div>
</div>
</section>
@@ -257,7 +305,7 @@ function hex8(v){return "0x"+(v>>>0).toString(16).toUpperCase().padStart(8,"0");
function setConnected(on, st){
connected=on;
["btnDisc","btnEeRead","btnFlashGen","btnFlashFile","btnVerifyFile",
"btnSdoRead","btnSdoWrite"].forEach(id=>$(id).disabled=!on);
"btnSdoRead","btnSdoWrite","btnCanSend","btnCanRepeat"].forEach(id=>$(id).disabled=!on);
$("btnOp").disabled=!on;
$("btnConn").disabled=on;
$("connLed").className="led "+(on?"led-on":"");
@@ -379,12 +427,13 @@ $("btnSdoWrite").onclick=async()=>{
};
/* ---- tabs ---- */
const TAB_PAGE={pdo:"tabPdo", sdo:"tabSdo", can:"tabCan"};
document.querySelectorAll(".tab-btn").forEach(b=>{
b.onclick=()=>{
document.querySelectorAll(".tab-btn").forEach(x=>x.classList.remove("active"));
document.querySelectorAll(".tab-page").forEach(x=>x.classList.remove("active"));
b.classList.add("active");
$(b.dataset.tab==="pdo"?"tabPdo":"tabSdo").classList.add("active");
$(TAB_PAGE[b.dataset.tab]).classList.add("active");
};
});
@@ -469,6 +518,78 @@ window.addEventListener("beforeunload",()=>{
if(running) navigator.sendBeacon("/api/op/stop");
});
/* ---- CAN 转发 ---- */
let canRepeatTimer=null;
function renderCanState(r){
if(!r || r.valid===false) return;
$("canRxCnt").textContent=r.rx_count;
$("canTxCnt").textContent=r.tx_count;
$("canDrop").textContent=r.drop;
$("canDrop").className=r.drop>0?"err":"";
$("canBacklog").textContent=r.backlog;
$("canTxRes").textContent=r.tx_result_text||"—";
$("canTxRes").className=(r.tx_result&&r.tx_result!==0)?"err":"";
if(r.rx_valid){
$("canRxId").textContent="0x"+r.id.toString(16).toUpperCase();
$("canRxData").textContent=(r.data||"—")+(r.truncated?" ⋯":"");
$("canRxCh").textContent="CH"+r.ch+(r.truncated?" (截断)":"");
}else{
$("canRxId").textContent="—"; $("canRxData").textContent="—";
$("canRxCh").textContent="尚无接收";
}
}
async function doCanSend(quiet){
const body={ch:parseNum($("canCh").value), id:$("canId").value.trim(),
dlc:parseNum($("canDlc").value), data:$("canData").value.replace(/\s+/g,""),
dc_cycle_us:parseNum($("dcCycle").value)};
const res=await api("/api/can/send",body);
if(!quiet||!res.ok) showLog(res);
if(res.ok) renderCanState(res);
return res.ok;
}
$("btnCanSend").onclick=async()=>{
$("btnCanSend").disabled=true;
try{ await doCanSend(false); }
catch(e){ log("发送异常: "+e,"log-err"); }
finally{ $("btnCanSend").disabled=!connected; }
};
$("btnCanRepeat").onclick=()=>{
if(canRepeatTimer){
clearInterval(canRepeatTimer); canRepeatTimer=null;
$("btnCanRepeat").textContent="开始周期";
$("btnCanRepeat").classList.remove("btn-primary");
log("周期发送已停止");
return;
}
const period=Math.max(50,parseNum($("canRepeatMs").value)||200);
canRepeatTimer=setInterval(async()=>{
try{
// 一帧没发成功就停下来,避免掉线后无声空转刷屏
if(!await doCanSend(true)){
clearInterval(canRepeatTimer); canRepeatTimer=null;
$("btnCanRepeat").textContent="开始周期";
$("btnCanRepeat").classList.remove("btn-primary");
log("周期发送因错误停止","log-err");
}
}catch(e){/* 网络瞬断, 下个周期重试 */}
},period);
$("btnCanRepeat").textContent="停止周期";
$("btnCanRepeat").classList.add("btn-primary");
log(`周期发送已启动: 每 ${period}ms 一帧`);
};
// CAN 页可见且未在周期发送时, 1s 轮询一次桥状态(读接收帧/计数)
setInterval(async()=>{
if(!connected || canRepeatTimer) return;
if(!$("tabCan").classList.contains("active")) return;
try{
const r=await api("/api/can/state",{});
if(r.ok) renderCanState(r);
}catch(e){/* 网络瞬断, 下轮重试 */}
},1000);
window.addEventListener("beforeunload",()=>{
if(canRepeatTimer) clearInterval(canRepeatTimer);
});
/* ---- 初始状态 ---- */
setConnected(false);
log("页面就绪: 连接后可扫描 / 烧录 EEPROM / 收发过程数据。");