Files
DCU_ECAT_Master/ecat.html
T
cbb f09decc127 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 兼容。
2026-09-03 21:42:07 +08:00

599 lines
28 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<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=20260903b">
<style>
/* ecat 页面专属补充(基于 motor.css 设计体系) */
.ecat-grid { grid-template-columns: minmax(240px, 290px) minmax(0, 1fr) minmax(0, 1.1fr); }
.center-col { display: flex; flex-direction: column; min-height: 0; }
.center-col .global-log { flex: 1; min-height: 90px; max-height: none; }
.mode-op { background: var(--ok-dim); color: var(--ok); }
.mode-safeop { background: var(--warn-dim); color: var(--warn); }
.mode-preop { background: color-mix(in srgb, var(--primary) 16%, transparent); color: var(--primary); }
.eep { margin-top: var(--sp-3); padding: var(--sp-3); background: var(--bg-root);
border: 1px solid var(--border); border-radius: var(--radius-sm);
font-family: var(--font-mono); font-size: 0.74rem; line-height: 1.7;
color: var(--fg-muted); white-space: pre; overflow: auto; max-height: 170px; }
.btn-row { display: flex; gap: var(--sp-2); flex-wrap: wrap; margin-top: var(--sp-3); align-items: center; }
.br-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--sp-2); margin-top: var(--sp-3); }
.br-row .big-reading { cursor: default; padding: var(--sp-2) var(--sp-2); }
.br-row .br-val { font-size: 0.95rem; }
.stat-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: var(--sp-2);
margin-top: var(--sp-3); }
.stat-cell { padding: var(--sp-2) var(--sp-3); background: var(--bg-root);
border: 1px solid var(--border); border-radius: var(--radius-sm);
font-family: var(--font-mono); font-size: 0.7rem; color: var(--fg-dim); }
.stat-cell b { display: block; font-size: 1rem; color: var(--fg); font-weight: 600; }
.stat-cell b.err { color: var(--danger); }
.statusbar .btn { padding: 2px 12px; font-size: .68rem; }
</style>
</head>
<body>
<!-- ═══════════ TOOLBAR ═══════════ -->
<header class="toolbar">
<div class="toolbar-brand">
<svg width="26" height="26" viewBox="0 0 26 26" fill="none" aria-hidden="true">
<rect x="1" y="1" width="24" height="24" rx="6" stroke="var(--primary)" stroke-width="1.5" opacity=".6"/>
<path d="M8 7l-4.2 6L8 19M18 7l4.2 6L18 19" stroke="var(--primary)" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="13" cy="13" r="1.6" fill="var(--primary)"/>
</svg>
<span>EtherCAT 控制台</span>
<span class="sub">DCU LAN9252</span>
</div>
<div class="toolbar-sep"></div>
<div class="conn-group">
<span class="led" id="connLed"></span>
<input type="text" id="substr" value="I226" style="width:96px" aria-label="适配器匹配子串">
<button class="btn btn-primary" id="btnConn">连接</button>
<button class="btn" id="btnDisc" disabled>断开</button>
</div>
<div class="toolbar-sep"></div>
<div class="conn-group">
<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>
<div class="net-banner" id="netBanner">
<span class="led led-err led-pulse" style="width:7px;height:7px"></span>
<span id="netBannerText">未连接 — 输入适配器匹配串(默认 I226)后点击连接</span>
</div>
<!-- ═══════════ MAIN GRID ═══════════ -->
<main class="main-grid ecat-grid">
<!-- LEFT: 从站身份 -->
<aside class="panel">
<div class="panel-head">
<span class="panel-title">从站身份</span>
<span style="margin-left:auto;font-size:.68rem;color:var(--fg-dim);font-family:var(--font-mono)" id="connDesc">—</span>
</div>
<div class="panel-body" style="padding:var(--sp-3)">
<div class="node-card" id="slaveCard">
<div class="nc-top">
<span class="nc-id">S0</span>
<span class="nc-mode mode-off" id="alMode">—</span>
</div>
<div class="nc-status-text" id="slaveName">未连接</div>
<div class="nc-readings">
<div class="nc-reading"><span class="lbl">VendorID</span><span class="val" id="rVid">—</span></div>
<div class="nc-reading"><span class="lbl">ProductCode</span><span class="val" id="rPid">—</span></div>
<div class="nc-reading"><span class="lbl">Revision</span><span class="val" id="rRev">—</span></div>
<div class="nc-reading"><span class="lbl">AL Status Code</span><span class="val" id="rCode">—</span></div>
</div>
</div>
<div class="mode-desc" style="margin-top:var(--sp-3)">
身份字来自 SII(ESC EEPROM 接口直读, 无需断电重载)。<br>
全 F = EEPROM 无效 → 先在 EEPROM 页烧录。<br>
AL 进 PREOP 后 SDO 可用; OP 需 SII 的 SM/FMMU 配置有效。
</div>
</div>
</aside>
<!-- CENTER: EEPROM + 全局日志 -->
<section class="panel center-col">
<div class="panel-head">
<span class="panel-title">EEPROM · SII / AT24C32</span>
<span style="margin-left:auto;font-size:.68rem;color:var(--fg-dim);font-family:var(--font-mono)">4096 B · word 寻址</span>
</div>
<div class="panel-body" style="padding:var(--sp-4);display:flex;flex-direction:column;min-height:0">
<div class="cmd-grid">
<div class="field">
<span class="field-label">start <span class="field-unit">word 地址</span></span>
<input type="text" id="eeStart" value="0x0000">
</div>
<div class="field">
<span class="field-label">count <span class="field-unit">word 数</span></span>
<input type="number" id="eeCount" value="32" min="1" max="2048">
</div>
<div class="field">
<span class="field-label">&nbsp;</span>
<button class="btn" id="btnEeRead" disabled>读取</button>
</div>
</div>
<div class="eep" id="eeOut">—</div>
<div class="btn-row">
<button class="btn btn-danger" id="btnFlashGen" disabled>烧录 gen_eeprom.py 镜像</button>
<button class="btn btn-danger" id="btnFlashFile" disabled>烧录 bin 文件…</button>
<button class="btn" id="btnVerifyFile" disabled>校验 bin 文件…</button>
<input type="file" id="eeFile" style="display:none">
</div>
<div class="mode-desc" style="margin-top:var(--sp-2)">
烧录逐字校验约数秒; 完成后板子断电重启, ESC 才重载 PDI/SYNC 配置。
</div>
<div style="flex:1;min-height:0;display:flex;flex-direction:column;margin-top:var(--sp-4)">
<div class="panel-title" style="margin-bottom:var(--sp-2)">全局日志</div>
<div class="ota-log global-log" id="log"></div>
<div class="ota-log-toolbar">
<button id="btnClearLog">清空</button>
</div>
</div>
</div>
</section>
<!-- RIGHT: PDO / SDO -->
<section class="panel">
<div class="panel-head">
<span class="panel-title">过程数据 · 4×64bit</span>
<span style="margin-left:auto;font-size:.68rem;color:var(--fg-dim);font-family:var(--font-mono)" id="pdoSize">输出 — / 输入 —</span>
</div>
<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 页 -->
<div class="tab-page active" id="tabPdo">
<div class="cmd-grid">
<div class="field"><span class="field-label">输出 u64[0]</span><input type="text" id="o0" value="0x0"></div>
<div class="field"><span class="field-label">输出 u64[1]</span><input type="text" id="o1" value="0x0"></div>
<div class="field"><span class="field-label">输出 u64[2]</span><input type="text" id="o2" value="0x0"></div>
<div class="field"><span class="field-label">输出 u64[3]</span><input type="text" id="o3" value="0x0"></div>
</div>
<div class="btn-row">
<div class="field" style="min-width:110px">
<span class="field-label">周期 <span class="field-unit">ms</span></span>
<select id="rate"><option value="10">10</option><option value="50">50</option>
<option value="100" selected>100</option><option value="200">200</option></select>
</div>
<div class="field" style="min-width:150px">
<span class="field-label">DC SYNC0 <span class="field-unit">us</span></span>
<select id="dcCycle">
<option value="0" selected>关 (free-run)</option>
<option value="500">500</option><option value="1000">1000</option>
<option value="2000">2000</option><option value="4000">4000</option>
</select>
</div>
<div class="field" style="min-width:150px;justify-content:flex-end">
<span class="field-label">&nbsp;</span>
<button class="btn btn-primary" id="btnOp" disabled>进入 OP 并循环</button>
</div>
</div>
<div class="br-row">
<div class="big-reading"><div class="br-val" id="in0">—</div><div class="br-lbl">IN u64[0]</div></div>
<div class="big-reading"><div class="br-val" id="in1">—</div><div class="br-lbl">IN u64[1]</div></div>
<div class="big-reading"><div class="br-val" id="in2">—</div><div class="br-lbl">IN u64[2]</div></div>
<div class="big-reading"><div class="br-val" id="in3">—</div><div class="br-lbl">IN u64[3]</div></div>
</div>
<div class="stat-row">
<div class="stat-cell">周期<b id="kCyc">0</b></div>
<div class="stat-cell">WKC 错误<b id="kErr">0</b></div>
<div class="stat-cell">输出区<b id="kOut">—</b></div>
<div class="stat-cell">输入区<b id="kIn">—</b></div>
<div class="stat-cell">SYNC0<b id="kSync0">—</b></div>
<div class="stat-cell">SYNC0/s<b id="kSyncRate">—</b></div>
</div>
<div class="eep" id="inHex" style="margin-top:var(--sp-3)">—</div>
<div class="mode-desc" style="margin-top:var(--sp-2)">
循环期间每周期都把上方 4 个输出值写入输出 PDO, 修改即时生效。
</div>
</div>
<!-- SDO 页 -->
<div class="tab-page" id="tabSdo">
<div class="cmd-grid">
<div class="field"><span class="field-label">index</span><input type="text" id="sdoIdx" value="0x1000"></div>
<div class="field"><span class="field-label">sub</span><input type="text" id="sdoSub" value="0"></div>
<div class="field"><span class="field-label">size <span class="field-unit">读</span></span><input type="number" id="sdoSize" value="8" min="1"></div>
<div class="field"><span class="field-label">&nbsp;</span><button class="btn" id="btnSdoRead" disabled>SDO 读</button></div>
</div>
<div class="cmd-grid" style="margin-top:var(--sp-3)">
<div class="field"><span class="field-label">写 hex <span class="field-unit">字节流</span></span><input type="text" id="sdoHex" placeholder="AABBCC.."></div>
<div class="field"><span class="field-label">&nbsp;</span><button class="btn" id="btnSdoWrite" disabled>SDO 写</button></div>
</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>
</main>
<!-- ═══════════ STATUSBAR ═══════════ -->
<footer class="statusbar">
<span class="led" id="sbLed"></span>
<span id="sbState">未连接</span>
<span>·</span>
<span id="sbIdent">identity —</span>
<span style="margin-left:auto" id="sbLast"></span>
<button class="btn" id="btnQuit" title="结束后台 Python 服务进程(隐藏窗口模式下的退出入口)">退出服务</button>
</footer>
<script>
"use strict";
const $=id=>document.getElementById(id);
let connected=false, running=false, timer=null, cyc=0, wkcErr=0;
/* ---- 基础 ---- */
function log(msg, cls){
const d=new Date(), t=[d.getHours(),d.getMinutes(),d.getSeconds()]
.map(v=>String(v).padStart(2,"0")).join(":");
const el=document.createElement("div");
if(cls) el.className=cls;
el.innerHTML=`<span style="color:var(--fg-dim)">${t}</span> ${msg}`;
const box=$("log"); box.appendChild(el);
box.scrollTop=box.scrollHeight;
while(box.children.length>400) box.removeChild(box.firstChild);
$("sbLast").textContent=msg.replace(/<[^>]*>/g,"").slice(0,90);
}
function parseNum(v){v=String(v).trim();return /^0x/i.test(v)?parseInt(v,16):parseInt(v,10);}
async function api(path, body){
const r=await fetch(path,{method:"POST",headers:{"Content-Type":"application/json"},
body:JSON.stringify(body||{})});
return await r.json();
}
function showLog(res){
(res.log||[]).forEach(l=>{
const err=/失败|FAIL|错误|不符|异常|无效|超/.test(l);
log(l, err?"log-err":(/OK|已进|已回|成功|全部一致/.test(l)?"log-ok":"log-info"));
});
}
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","btnCanSend","btnCanRepeat"].forEach(id=>$(id).disabled=!on);
$("btnOp").disabled=!on;
$("btnConn").disabled=on;
$("connLed").className="led "+(on?"led-on":"");
$("sbLed").className="led "+(on?"led-on":"led-err");
$("netBanner").classList.toggle("show",!on);
$("netBannerText").textContent=on?"":"未连接 — 输入适配器匹配串(默认 I226)后点击连接";
if(on&&st) updateSlave(st);
else if(!on){
$("slaveName").textContent="未连接"; $("alMode").textContent="—";
$("alMode").className="nc-mode mode-off"; $("alBadge").textContent="AL —";
["rVid","rPid","rRev","rCode"].forEach(id=>$(id).textContent="—");
$("connDesc").textContent="—"; $("sbState").textContent="未连接"; $("sbIdent").textContent="identity —";
}
}
function alInfo(al){
const name={0:"NONE",1:"INIT",2:"PREOP",3:"BOOT",4:"SAFEOP",8:"OP"}[al]||("0x"+al.toString(16));
const cls=al===8?"mode-op":(al===4?"mode-safeop":(al===2?"mode-preop":"mode-off"));
return {name, cls};
}
function updateSlave(st){
$("connDesc").textContent=st.desc||"—";
$("slaveName").textContent=st.name||"—";
const info=alInfo(st.state);
$("alMode").textContent=info.name; $("alMode").className="nc-mode "+info.cls;
$("alBadge").textContent="AL "+info.name;
$("rVid").textContent=hex8(st.man);
$("rPid").textContent=hex8(st.id);
$("rRev").textContent=(st.rev>>>0).toString(10);
$("rCode").textContent="0x"+(st.al_status>>>0).toString(16).toUpperCase().padStart(4,"0");
$("slaveCard").classList.toggle("fault", st.man===0xFFFFFFFF);
$("sbState").textContent="已连接";
$("sbIdent").textContent=`identity ${hex8(st.man)} / ${hex8(st.id)}`;
}
async function pollStatus(){
if(!connected||running) return;
try{const res=await api("/api/status"); if(res.ok) updateSlave(res.slave);}catch(e){}
}
setInterval(pollStatus,2000);
/* ---- 连接 ---- */
$("btnConn").onclick=async()=>{
const res=await api("/api/open",{substr:$("substr").value});
showLog(res);
if(res.ok){setConnected(true,res.slave); pollStatus();}
};
$("btnDisc").onclick=async()=>{
stopCycle(true);
const res=await api("/api/close");
showLog(res); setConnected(false);
};
let quitArmed=false, quitTimer=null;
$("btnQuit").onclick=async()=>{
if(!quitArmed){
quitArmed=true;
$("btnQuit").textContent="再点一次确认退出";
$("btnQuit").classList.add("btn-danger");
quitTimer=setTimeout(()=>{quitArmed=false;
$("btnQuit").textContent="退出服务";
$("btnQuit").classList.remove("btn-danger");},4000);
return;
}
clearTimeout(quitTimer); quitArmed=false;
$("btnQuit").textContent="退出服务";
$("btnQuit").classList.remove("btn-danger");
stopCycle(true);
const wv=(window.pywebview&&window.pywebview.api)?window.pywebview.api:null;
if(wv&&wv.quit_app){ log("正在退出…","log-info"); wv.quit_app(); return; }
try{await api("/api/shutdown");}catch(e){}
setConnected(false);
$("netBannerText").textContent="后台服务已退出 — 双击 ecat_web.vbs 重新启动";
log("后台服务已退出, 双击 ecat_web.vbs 重新启动","log-info");
};
/* ---- EEPROM ---- */
$("btnEeRead").onclick=async()=>{
const res=await api("/api/eeprom/read",
{start:parseNum($("eeStart").value),count:parseNum($("eeCount").value)});
showLog(res);
if(res.ok) $("eeOut").textContent=res.rows
.map(r=>`0x${r.addr.toString(16).padStart(4,"0")}: ${r.hex}`).join("\n");
};
function readBin(file,cb){
const r=new FileReader();
r.onload=()=>{const u=new Uint8Array(r.result);
cb(Array.from(u).map(b=>b.toString(16).padStart(2,"0")).join(""));};
r.readAsArrayBuffer(file);
}
$("eeFile").onchange=e=>{
const f=e.target.files[0]; if(!f) return;
readBin(f,async hex=>{
const isFlash=e.target.dataset.mode==="flash";
log(isFlash?"烧录上传的 bin…":"校验上传的 bin…");
const res=await api(isFlash?"/api/flash":"/api/eeprom/verify",{data:hex});
showLog(res);
if(res.ok&&isFlash) log(`烧录成功 ${res.written} 字, 记得板子断电重启`,"log-ok");
});
e.target.value="";
};
$("btnFlashGen").onclick=async()=>{
log("烧录 gen_eeprom.py 镜像…");
const res=await api("/api/flash",{});
showLog(res);
if(res.ok) log(`烧录成功 ${res.written} 字, 记得板子断电重启`,"log-ok");
};
$("btnFlashFile").onclick=()=>{$("eeFile").dataset.mode="flash";$("eeFile").click();};
$("btnVerifyFile").onclick=()=>{$("eeFile").dataset.mode="verify";$("eeFile").click();};
$("btnClearLog").onclick=()=>{$("log").innerHTML="";};
/* ---- SDO ---- */
$("btnSdoRead").onclick=async()=>{
const res=await api("/api/sdo",{op:"read",index:parseNum($("sdoIdx").value),
sub:parseNum($("sdoSub").value),size:parseNum($("sdoSize").value)});
showLog(res); $("sdoOut").textContent=res.data?res.data.toUpperCase():"—";
};
$("btnSdoWrite").onclick=async()=>{
const res=await api("/api/sdo",{op:"write",index:parseNum($("sdoIdx").value),
sub:parseNum($("sdoSub").value),hex:$("sdoHex").value.trim()});
showLog(res);
};
/* ---- 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");
$(TAB_PAGE[b.dataset.tab]).classList.add("active");
};
});
/* ---- PDO ---- */
function outHex(){
const parts=[];
for(let i=0;i<4;i++){
let v; try{v=BigInt($("o"+i).value.trim()||"0");}catch(e){v=0n;}
if(v<0n) v&=0xFFFFFFFFFFFFFFFFn;
const h=v.toString(16).padStart(16,"0");
parts.push(h.match(/../g).reverse().join(""));
}
return parts.join("");
}
function renderInput(hex){
if(!hex||hex.length<64){["in0","in1","in2","in3"].forEach(id=>$(id).textContent="—");
$("inHex").textContent=hex||"—";return;}
for(let i=0;i<4;i++){
const bytes=hex.substr(i*16,16).match(/../g).reverse().join("");
$("in"+i).textContent="0x"+BigInt("0x"+bytes).toString(16).toUpperCase();
}
$("inHex").textContent="raw: "+hex.toUpperCase();
}
$("btnOp").onclick=async()=>{
if(!running){
const dc=parseNum($("dcCycle").value);
log(dc?`进入 OP(DC SYNC0 ${dc}us)…`:"进入 OP(free-run)…");
const res=await api("/api/op/start",{dc_cycle_us:dc});
showLog(res); if(!res.ok) return;
running=true; cyc=0; wkcErr=0;
$("btnOp").textContent="停止并回 INIT"; $("btnOp").className="btn btn-danger";
$("dcCycle").disabled=true;
$("kOut").textContent=$("kIn").textContent="…";
timer=setInterval(doCycle,parseNum($("rate").value));
doCycle();
syncTimer=setInterval(pollSync,2000); pollSync();
}else stopCycle();
};
function stopCycle(silent){
if(timer){clearInterval(timer);timer=null;}
if(syncTimer){clearInterval(syncTimer);syncTimer=null;}
if(running){
running=false;
api("/api/op/stop").then(res=>{if(!silent)showLog(res);});
$("btnOp").textContent="进入 OP 并循环"; $("btnOp").className="btn btn-primary";
$("dcCycle").disabled=false;
$("kSync0").textContent=$("kSyncRate").textContent="—";
}
}
/* DC 通路观测: 运行期每 2s 经 SDO 读从站 0x2000 SYNC Diag(固件≥1.5.31)。
DC 模式下 SYNC0/s 应≈1e6/周期us(1000us→1000/s); free-run 恒 0。 */
let syncTimer=null;
async function pollSync(){
if(!running) return;
try{
const res=await api("/api/sync_diag");
if(!res.ok){
$("kSync0").textContent="—"; $("kSyncRate").textContent="—";
if(!pollSync.warned){pollSync.warned=true;log(res.log.join(" | "),"log-err");}
return;
}
pollSync.warned=false;
$("kSync0").textContent=res.sync0.toLocaleString();
$("kSyncRate").textContent=res.sync0_rate==null?"…":res.sync0_rate.toLocaleString();
}catch(e){/* 网络瞬断, 下轮重试 */}
}
async function doCycle(){
if(!running) return;
try{
const res=await api("/api/op/cycle",{outputs:outHex()});
if(!res.ok){showLog(res);stopCycle(true);log("循环已停止","log-err");return;}
cyc++;
if(res.wkc<res.expected_wkc) wkcErr++;
$("kCyc").textContent=cyc;
$("kErr").textContent=wkcErr;
$("kErr").className=wkcErr>0?"err":"";
$("kOut").textContent=res.out_len+"B"; $("kIn").textContent=res.in_len+"B";
renderInput(res.input);
}catch(e){/* 网络瞬断, 下个周期重试 */}
}
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 / 收发过程数据。");
</script>
</body>
</html>