Files
DCU_ECAT_Master/ecat.html
T

478 lines
22 KiB
HTML
Raw Normal View History

2026-09-03 01:56:29 +08:00
<!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=20260831a">
<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">
<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>
</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>
</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"].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 ---- */
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");
};
});
/* ---- 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");
});
/* ---- 初始状态 ---- */
setConnected(false);
log("页面就绪: 连接后可扫描 / 烧录 EEPROM / 收发过程数据。");
</script>
</body>
</html>