Files
DCU_ECAT_Master/ecat.html
T
cbb d594b8fd94 init: DCU LAN9252 EtherCAT 主站工具集
现有工具纳入版本管理:
- ecat_tool.py: 一体化命令行主站(status/flash/eeprom/sdo/send)
- ecat_server.py + ecat.html/css: 网页/桌面控制台(端口 8090)
- ecat_scan.py / ecat_esc_regs.py / ecat_sniff.py: bring-up 扫描、
  网络侧 ESC 寄存器诊断、裸帧抓包对照
- ecat_flash_eeprom.py: 经网线烧写 SII 镜像 + 逐字校验
- *.bat / *.vbs 启动入口

新增 .gitignore(排除 venv/ 与 __pycache__)、.gitattributes(不改写换行符)、
requirements.txt(锁定 pysoem 1.1.13 等依赖)、README.md。
2026-09-03 01:56:29 +08:00

478 lines
22 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=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>