Files
DCU_DesktopController/lab/spi_test.html
T

1780 lines
104 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>
<!--
【临时功能 SPEEDTEST】SPI / EtherCAT 链路测速台(独立页面)
─────────────────────────────────────────────
完全自包含(无外部 css/js 依赖),不被任何其他页面引用。设计令牌从
css/base.css 抄一份子集进来,就是为了保住"删掉本文件即可整体下线"的性质。
删除方法:直接删掉本文件(固件端点删除方法见
main/monitor/spi_speed_test.cpp 文件头)。
用法:经 serve.bat 访问 http://localhost:2625/lab/spi_test.html
(EtherCAT 面板必须经页面服务器打开 —— 它靠 esp32_server.py 的
/ecat/api/* 反向代理同源访问 ecat_native;file:// 直开拿不到代理。)
EtherCAT 侧的**硬约束**(改动前务必读):
1. 只调只读路由:/api/motor/state · /api/app/load · /api/motor/rxraw ·
/api/status · /api/native/health · /api/motor/dbg · /api/motor/period(热改)。
2. **绝不调 /api/motor/rx** —— 它是"取走即移除"的消费语义,会把电机页
赖以攒 CONFIG/版本的批量接收区抢干净。本页要帧就从未消费的
/api/motor/state.rx_batch 与 /api/motor/rxraw 里拿。
3. **绝不调 /api/motor/start · /api/op/* · /api/open · /api/ecat/diffstats ·
/api/dc/bench** —— 前三条会先 loop_stop:停手超过从站 1000ms 过程数据
看门狗就掉 OP(AL 0x001B),重进还可能把 SSC 邮箱楔死(实测弄死过两块板)。
后两条自己 loop_stop 去走 SDO。本页因此**不负责连接总线**:没在交换时
只提示"去电机页 / EtherCAT 页连一次",不替用户点连接。
4. 计数口径不能想当然:cycles 是 long long 可直接相减;bridge.drop /
rx_count 是 **u16**(4000/s 下 16s 绕一圈)必须回绕修正,单拍增量 >32768
视为采样率不足、该点作废并计数;head.global_seq 是 u32,λ 以它为准;
rx_torn / pdo_reentry 是**饱和**计数(到 65535 停),封顶只能报"下界"。
5. 从站 load_perm 在从站停报时会保留残值,必须用 age_ms<1000 闸一下,
否则会把楔死前的旧值当实测画进曲线。
6. 刷新率一律以**实测**(Δcycles/Δt)为准,名义 1000/period_ms 只作对照。
-->
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>链路测速台 · SPI / EtherCAT</title>
<style>
/* ═══════ 设计令牌(css/base.css 子集,本页自带以保持自包含) ═══════ */
:root {
--bg-root:#0a0f1a; --bg-panel:#0e1624; --bg-card:#131d2e; --bg-elevated:#1a2740;
--bg-input:#0b1220; --bg-hover:#20304c;
--border:#21304a; --border-strong:#2e4262; --border-subtle:#1a2740;
--text:#e9f0fa; --text-dim:#93a8c4; --text-label:#617a99;
--esp32:#2fd8c1; --esp32-hi:#6ff2e0; --esp32-dim:rgba(47,216,193,.14);
--gd32:#fcbf3f; --gd32-hi:#ffd97a; --gd32-dim:rgba(252,191,63,.14);
--can:#43d97b; --can-dim:rgba(67,217,123,.14);
--motor:#22d3ee; --motor-hi:#67e8f9; --motor-dim:rgba(34,211,238,.14);
--mem:#a88bfa; --mem-hi:#c7b3ff; --mem-dim:rgba(168,139,250,.14);
--green:#3ddc8e; --amber:#fcbf3f; --red:#ff6b6b; --red-dim:rgba(255,107,107,.14);
--spi:var(--esp32); --ecat:var(--mem); --ecat-hi:var(--mem-hi); --ecat-dim:var(--mem-dim);
--radius-sm:8px; --radius:12px;
--font:"PingFang SC","Microsoft YaHei","Segoe UI",system-ui,sans-serif;
--mono:"SF Mono","Fira Code","Cascadia Code","Consolas",monospace;
--shadow-card:0 10px 30px rgba(3,7,16,.45), 0 1px 0 rgba(255,255,255,.03) inset;
}
* { box-sizing:border-box; margin:0; padding:0; }
html,body { height:100%; }
body {
background:var(--bg-root); color:var(--text); font-family:var(--font);
font-size:14px; line-height:1.55; -webkit-font-smoothing:antialiased;
}
body::before {
content:""; position:fixed; inset:0; z-index:0; pointer-events:none;
background-image:
linear-gradient(rgba(148,180,226,.035) 1px, transparent 1px),
linear-gradient(90deg, rgba(148,180,226,.035) 1px, transparent 1px);
background-size:44px 44px;
mask-image:radial-gradient(ellipse 120% 90% at 50% -8%, black 30%, transparent 78%);
}
body::after {
content:""; position:fixed; inset:0; z-index:0; pointer-events:none;
background:
radial-gradient(900px 520px at 8% -10%, rgba(168,139,250,.10), transparent 60%),
radial-gradient(820px 560px at 100% 112%, rgba(47,216,193,.08), transparent 62%);
}
::-webkit-scrollbar { width:9px; height:9px; }
::-webkit-scrollbar-track { background:transparent; }
::-webkit-scrollbar-thumb { background:var(--border-strong); border-radius:99px; border:2px solid var(--bg-root); }
::selection { background:rgba(168,139,250,.3); color:#f4efff; }
:focus-visible { outline:2px solid var(--ecat); outline-offset:2px; border-radius:4px; }
.wrap { position:relative; z-index:1; max-width:1480px; margin:0 auto; padding:22px 26px 48px; }
/* ═══════ 头部 + 面板切换 ═══════ */
header { display:flex; flex-wrap:wrap; gap:14px; align-items:flex-end; margin-bottom:14px; }
h1 { font-size:21px; letter-spacing:.3px; }
h1 .mark { color:var(--ecat-hi); }
.sub { color:var(--text-label); font-size:12px; margin-top:2px; }
.tabs { display:flex; gap:6px; margin-left:auto; }
.tab {
background:var(--bg-card); color:var(--text-dim); border:1px solid var(--border);
border-radius:99px; padding:7px 18px; font:inherit; font-size:13px; cursor:pointer;
display:flex; align-items:center; gap:7px; transition:background .16s,color .16s,border-color .16s;
}
.tab:hover { background:var(--bg-hover); color:var(--text); }
.tab .dot { width:7px; height:7px; border-radius:99px; background:var(--text-label); }
.tab[data-pane="spi"].on { color:var(--esp32-hi); border-color:rgba(47,216,193,.45); background:var(--esp32-dim); }
.tab[data-pane="spi"].on .dot { background:var(--esp32); box-shadow:0 0 8px var(--esp32); }
.tab[data-pane="ecat"].on { color:var(--ecat-hi); border-color:rgba(168,139,250,.5); background:var(--ecat-dim); }
.tab[data-pane="ecat"].on .dot { background:var(--ecat); box-shadow:0 0 8px var(--ecat); }
/* ═══════ 卡片 ═══════ */
.card {
background:var(--bg-panel); border:1px solid var(--border); border-radius:var(--radius);
padding:14px 16px; margin-bottom:14px; box-shadow:var(--shadow-card);
}
.card > h2 {
font-size:12px; font-weight:600; letter-spacing:.9px; text-transform:uppercase;
color:var(--text-label); margin-bottom:11px; display:flex; align-items:center; gap:9px; flex-wrap:wrap;
}
.card > h2 .hint2 { font-size:11px; font-weight:400; letter-spacing:0; text-transform:none; color:var(--text-label); }
.card > h2::before { content:""; width:3px; height:12px; border-radius:2px; background:var(--ecat); }
#pane-spi .card > h2::before { background:var(--spi); }
.row { display:flex; flex-wrap:wrap; gap:10px 14px; align-items:center; }
.row + .row { margin-top:10px; }
label { font-size:12px; color:var(--text-dim); display:flex; align-items:center; gap:6px; }
input, select {
background:var(--bg-input); color:var(--text); border:1px solid var(--border);
border-radius:var(--radius-sm); padding:6px 9px; font:inherit; font-size:13px; width:132px;
}
input.narrow { width:76px; }
select { width:auto; cursor:pointer; }
input:focus, select:focus { border-color:var(--border-strong); outline:none; }
button {
background:var(--bg-elevated); color:var(--text); border:1px solid var(--border-strong);
border-radius:var(--radius-sm); padding:7px 15px; font:inherit; font-size:13px; cursor:pointer;
transition:background .16s,border-color .16s,transform .06s;
}
button:hover:not(:disabled) { background:var(--bg-hover); border-color:#3a5378; }
button:active:not(:disabled) { transform:translateY(1px); }
button:disabled { opacity:.45; cursor:not-allowed; }
button.primary { background:linear-gradient(180deg,#8b6cf0,#6d4dd8); border-color:#9d84f5; color:#fff; font-weight:600; }
button.primary:hover:not(:disabled) { background:linear-gradient(180deg,#9d84f5,#7a58e0); }
button.spi { background:linear-gradient(180deg,#33d6bd,#1fae99); border-color:#3fe0c8; color:#04211c; font-weight:600; }
button.spi:hover:not(:disabled) { background:linear-gradient(180deg,#4ee7d0,#28c0a9); }
button.stop { background:var(--red-dim); border-color:rgba(255,107,107,.5); color:#ffc9c9; }
button.ghost { background:transparent; }
button.sm { padding:4px 10px; font-size:12px; }
.pill {
display:inline-flex; align-items:center; gap:6px; padding:3px 10px; border-radius:99px;
font-size:11.5px; border:1px solid var(--border); background:var(--bg-card); color:var(--text-dim);
}
.pill.ok { color:var(--green); border-color:rgba(61,220,142,.4); background:rgba(61,220,142,.1); }
.pill.warn { color:var(--amber); border-color:rgba(252,191,63,.4); background:var(--gd32-dim); }
.pill.bad { color:var(--red); border-color:rgba(255,107,107,.45); background:var(--red-dim); }
.pill .led { width:6px; height:6px; border-radius:99px; background:currentColor; box-shadow:0 0 6px currentColor; }
/* ═══════ 指标卡栅格 ═══════ */
.metrics { display:grid; grid-template-columns:repeat(auto-fit,minmax(172px,1fr)); gap:10px; }
.metric {
background:var(--bg-card); border:1px solid var(--border); border-radius:var(--radius-sm);
padding:10px 12px; position:relative; overflow:hidden;
}
.metric::after { content:""; position:absolute; left:0; top:0; bottom:0; width:2px; background:var(--border-strong); }
.metric.good::after { background:var(--green); }
.metric.warn::after { background:var(--amber); }
.metric.bad::after { background:var(--red); }
.metric .k { font-size:11px; color:var(--text-label); letter-spacing:.3px; }
.metric .v { font-size:25px; font-weight:600; letter-spacing:-.4px; margin-top:3px; font-variant-numeric:tabular-nums; }
.metric .v small { font-size:12px; font-weight:400; color:var(--text-dim); margin-left:3px; letter-spacing:0; }
.metric .f { font-size:11px; color:var(--text-dim); margin-top:2px; font-family:var(--mono); }
.metric.hero { background:linear-gradient(180deg,rgba(168,139,250,.12),rgba(168,139,250,.02)); border-color:rgba(168,139,250,.32); }
.metric.hero .v { font-size:38px; color:var(--ecat-hi); }
canvas { display:block; width:100%; height:150px; }
/* ═══════ 表格 ═══════ */
table { width:100%; border-collapse:collapse; font-size:12px; }
th, td { text-align:left; padding:4px 7px; border-bottom:1px solid var(--border-subtle); white-space:nowrap; }
th { color:var(--text-label); font-weight:500; font-size:11px; letter-spacing:.4px; text-transform:uppercase; position:sticky; top:0; background:var(--bg-panel); z-index:1; }
td.num, th.num { text-align:right; font-family:var(--mono); font-variant-numeric:tabular-nums; }
td.hex { font-family:var(--mono); color:var(--text-dim); font-size:11.5px; }
td.bad { color:var(--red); } td.good { color:var(--green); } td.warn { color:var(--amber); } td.dim { color:var(--text-label); }
.scroll { height:330px; overflow:auto; border:1px solid var(--border-subtle); border-radius:var(--radius-sm); }
.scroll.sm { height:auto; max-height:200px; }
pre {
background:var(--bg-input); border:1px solid var(--border); border-radius:var(--radius-sm);
padding:9px 11px; font-family:var(--mono); font-size:12px; line-height:1.65;
white-space:pre-wrap; word-break:break-all; max-height:300px; overflow:auto;
}
/* 多列 hex 网格:每行定宽,auto-fill 按可用宽度自动排 2~4 列。
单列 pre 时每行 54 字符只占面板四成宽度,剩下全是空白。 */
.hexgrid {
display:grid; grid-template-columns:repeat(auto-fill,minmax(56ch,1fr)); gap:0 18px;
background:var(--bg-input); border:1px solid var(--border); border-radius:var(--radius-sm);
padding:7px 10px; height:var(--hex-h,190px); overflow:auto;
font-family:var(--mono); font-size:11px; line-height:1.5; color:var(--text-dim);
}
.hexgrid i { font-style:normal; white-space:pre; }
.hexgrid i.hdr { color:var(--text-label); }
.hexgrid i.slot { color:var(--mem-hi); }
pre.fail { border-color:rgba(255,107,107,.5); color:#fca5a5; }
.kv { display:grid; grid-template-columns:repeat(auto-fit,minmax(170px,1fr)); gap:4px 14px; }
.kv div { font-size:12px; color:var(--text-dim); display:flex; gap:9px; justify-content:space-between; border-bottom:1px dotted var(--border-subtle); padding:2.5px 0; min-width:0; }
.kv div > span { flex:0 0 auto; }
.kv b { color:var(--text); font-weight:500; font-family:var(--mono); font-size:12px;
flex:0 1 auto; min-width:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.kv b.bad { color:var(--red); } .kv b.good { color:var(--green); } .kv b.warn { color:var(--amber); } .kv b.dim { color:var(--text-label); }
.kv b.trunc, td.hex.trunc { cursor:help; text-decoration:underline dotted var(--border-strong); text-underline-offset:3px; }
/* 定高容器:数据行数变化时卡片不再上下跳。pane=整块滚动,paneFlex=标题固定+内容撑满 */
.pane { height:var(--pane-h, 300px); overflow:auto; }
.paneFlex { height:var(--pane-h, 300px); display:flex; flex-direction:column; overflow:hidden; min-width:0; }
.paneFlex > .grow { flex:1 1 auto; min-height:0; max-height:none; height:auto; }
.paneFlex > .cap { flex:0 0 auto; height:2.9em; overflow:auto; }
.hint { color:var(--text-label); font-size:11.5px; line-height:1.75; margin-top:9px; }
.lbl { font-size:11px; color:var(--text-label); letter-spacing:.3px; margin-bottom:4px; }
.hint code { font-family:var(--mono); color:var(--text-dim); background:var(--bg-input); padding:1px 4px; border-radius:4px; }
.cols2 { display:grid; grid-template-columns:1fr 1fr; gap:14px; }
@media (max-width:1080px){ .cols2 { grid-template-columns:1fr; } }
details > summary { cursor:pointer; font-size:11.5px; color:var(--text-label); padding:3px 0; }
details[open] > summary { color:var(--text-dim); }
[hidden] { display:none !important; }
</style>
</head>
<body>
<div class="wrap">
<header>
<div>
<h1><span class="mark">链路测速台</span> · DCU 总线吞吐取证</h1>
<div class="sub">临时诊断工具 [SPEEDTEST] — SPI 探针 0xA1 APP PING · EtherCAT 只读观测 ecat_native 过程数据</div>
</div>
<nav class="tabs">
<button class="tab" data-pane="spi"><span class="dot"></span>SPI 链路</button>
<button class="tab" data-pane="ecat"><span class="dot"></span>EtherCAT 链路</button>
</nav>
</header>
<!-- ══════════════════════ SPI 面板 ══════════════════════ -->
<section id="pane-spi" hidden>
<div class="card">
<h2>测试参数 <span class="hint2">SPI 探针 0xA1 · 请求-应答往返</span></h2>
<div class="row">
<label>设备 IP <input id="ip" value="192.168.31.127"></label>
<label>帧数 <input id="frames" class="narrow" value="200"></label>
<label>载荷 B <input id="payload" class="narrow" value="236"></label>
<label>超时 ms <input id="spiTimeout" class="narrow" value="8000"></label>
</div>
<div class="row">
<button id="btnRun" class="spi">开始测速</button>
<button id="btnRtt">空载 RTT(500 帧)</button>
<button id="btnStress">压力测试(2000 帧满载)</button>
<button id="btnAbort" class="stop" hidden>中止</button>
<span id="spiState" class="pill"><span class="led"></span>空闲</span>
</div>
<div class="hint">
满帧 236B = 等效电机 OTA 0xD1 数据节奏;载荷 0 = 纯握手往返。<br>
判读:<b>失败必须为 0</b>;「往返 最快」是链路物理极限周期;「调度开销 平均」是固件自己量的事件驱动唤醒损耗,
「SPI 事务 平均」才是纯总线时间;下面五段拆解告诉你时间花在提交/发送/等应答/读响应/唤醒哪一段。<br>
设备无应答时最多等「超时」就自行判失败,按钮不会永久卡在等待态。
</div>
</div>
<div class="card">
<h2>本次结果 <span class="hint2">三段计时均取自固件原始行,不做二次推断</span></h2>
<div class="metrics">
<div class="metric hero" id="sAvgCard"><div class="k">往返延迟 平均</div><div class="v" id="mAvg">—</div><div class="f" id="mAvgF">µs / 帧</div></div>
<div class="metric"><div class="k">往返 最快</div><div class="v" id="mMin">—</div><div class="f">µs · 链路物理极限</div></div>
<div class="metric"><div class="k">往返 最慢</div><div class="v" id="mMax">—</div><div class="f">µs · 尾部有多差</div></div>
<div class="metric"><div class="k">SPI 事务 平均</div><div class="v" id="mSpi">—</div><div class="f">µs · 纯总线时间</div></div>
<div class="metric"><div class="k">调度开销 平均</div><div class="v" id="mSched">—</div><div class="f">µs · 固件自报</div></div>
<div class="metric"><div class="k">等效吞吐</div><div class="v" id="mTput">—</div><div class="f">KB/s</div></div>
<div class="metric"><div class="k">失败帧</div><div class="v" id="mFail">—</div><div class="f">必须为 0</div></div>
</div>
<div id="segWrap" style="margin-top:12px">
<div class="k" style="font-size:11px; color:var(--text-label); margin-bottom:5px">单帧分段平均(固件五段拆解,占总往返比例)</div>
<div id="segBar" style="display:flex; height:20px; border-radius:6px; overflow:hidden; background:var(--bg-input)"></div>
<div id="segLegend" class="hint" style="margin-top:6px">跑一次测速后出现</div>
<div class="k" style="font-size:11px; color:var(--text-label); margin:12px 0 5px">延迟分布(固件 7 档计数,条长按最大档归一)</div>
<div id="distBar" style="display:flex; height:20px; border-radius:6px; overflow:hidden; background:var(--bg-input)"></div>
<div id="distLegend" class="hint" style="margin-top:6px">跑一次测速后出现</div>
</div>
<details style="margin-top:10px">
<summary>设备原始返回</summary>
<pre id="out" style="margin-top:7px">等待测试…</pre>
</details>
</div>
<div class="card">
<h2>本页历史 <span class="hint2">仅存内存,刷新即清空 —— 改参数前后对比用</span></h2>
<div class="scroll">
<table>
<thead><tr><th>时间</th><th class="num">帧数</th><th class="num">载荷</th><th class="num">失败</th><th class="num">往返平均</th><th class="num">往返最快</th><th class="num">调度开销</th><th class="num">SPI事务</th><th class="num">吞吐</th></tr></thead>
<tbody id="hist"></tbody>
</table>
</div>
</div>
</section>
<!-- ══════════════════════ EtherCAT 面板 ══════════════════════ -->
<section id="pane-ecat" hidden>
<div class="card">
<h2>观测控制 <span class="hint2">本页只读过程数据缓存,不连接、不重启、不停 PDO 循环</span></h2>
<div class="row">
<button id="btnLive" class="primary">开始实时观测</button>
<button id="btnRing" class="ghost">逐拍环:关</button>
<label>采样率
<select id="hz">
<option value="2">2 Hz</option>
<option value="5">5 Hz</option>
<option value="10" selected>10 Hz</option>
<option value="20">20 Hz</option>
</select>
</label>
<label>PDO 周期档位
<select id="periodPick">
<option value="">(不自选)</option>
<option value="4">4 ms · 250 Hz</option>
<option value="2">2 ms · 500 Hz</option>
<option value="1">1 ms · 1 kHz</option>
<option value="0.5">0.5 ms · 2 kHz</option>
<option value="0.25">0.25 ms · 4 kHz</option>
<option value="0.125">0.125 ms · 8 kHz</option>
</select>
</label>
<button id="btnPeriod">热改周期</button>
<span id="ecatState" class="pill"><span class="led"></span>未开始</span>
<span id="ecatLink" class="pill">链路 —</span>
<span id="ecatAl" class="pill">AL —</span>
</div>
<div class="hint">
热改周期走 <code>/api/motor/period</code>(循环每拍重读的变量,全程喂狗不断流,随时可调、可反复调)。
<b>低于 1ms 档后端自动用自旋节拍,会占满一个 CPU 核</b>;从站固件修好前别低于 1ms —— 实测
0.25 / 0.125ms 档 GD32 顶到 99.5%,CAN 到帧率 λ 从 4000/s 塌到 149/s(瓶颈在从站不在主站,
只看主站达成率会整条看漏)。
</div>
</div>
<div class="card">
<h2>刷新率 <span class="hint2">实测 = Δcycles/Δt;名义 = 1000/period_ms 仅作对照,两者差值就是软件调度开销</span></h2>
<div class="metrics">
<div class="metric hero" id="cHz"><div class="k">实测 PDO 刷新率</div><div class="v" id="eHz">—<small>Hz</small></div><div class="f" id="eHzF">cycles 差分</div></div>
<div class="metric" id="cAchieve"><div class="k">达成率</div><div class="v" id="eAchieve">—</div><div class="f" id="eAchieveF">名义 — Hz</div></div>
<div class="metric" id="cBus"><div class="k">单拍耗时</div><div class="v" id="eBus">—<small>µs</small></div><div class="f" id="eBusF">p50 / p90 / max</div></div>
<div class="metric" id="cDuty"><div class="k">主站占空比</div><div class="v" id="eDuty">—</div><div class="f">单拍 p50 ÷ 周期</div></div>
<div class="metric" id="cSlot"><div class="k">单电机状态刷新</div><div class="v" id="eSlot">—<small>Hz</small></div><div class="f" id="eSlotF">slot_period_ms —</div></div>
<div class="metric" id="cLambda"><div class="k">CAN 到帧率 λ</div><div class="v" id="eLambda">—<small>/s</small></div><div class="f">Δglobal_seq (u32)</div></div>
<div class="metric" id="cDrop"><div class="k">真丢帧</div><div class="v" id="eDrop">—<small>帧/拍</small></div><div class="f" id="eDropF">主站 seq 空洞 · 从站 OVF(抽样单拍值)</div></div>
<div class="metric" id="cSkip"><div class="k">跳拍</div><div class="v" id="eSkip">—<small>/s</small></div><div class="f" id="eSkipF">累计 —</div></div>
<div class="metric" id="cLoad"><div class="k">从站 CPU 负载</div><div class="v" id="eLoad">—</div><div class="f" id="eLoadF">load_perm · age —</div></div>
<div class="metric" id="cWkc"><div class="k">WKC</div><div class="v" id="eWkc">—</div><div class="f" id="eWkcF">期望 —</div></div>
<div class="metric" id="cTorn"><div class="k">撕裂 / 重入</div><div class="v" id="eTorn">—</div><div class="f" id="eTornF">rx_torn / pdo_reentry</div></div>
<div class="metric" id="cRtt"><div class="k">本页请求耗时</div><div class="v" id="eRtt">—<small>ms</small></div><div class="f">本页→代理→native 最慢一个请求的往返(≠节拍耗时)</div></div>
</div>
<div class="hint">
判读:<b>达成率 &lt; 100%</b> = 节拍被软件拖住;<b>λ ÷ 实测Hz</b> = 每拍到达几帧 CAN(当前 1ms 档实测 6 帧/拍)。<br>
<b><code>drop</code> 不判好坏</b>:它是段 1/段 3 那个"最近一帧"<u>单格</u>被未取先覆的次数,
而单格每拍只能发布 1 帧;且自增点在 STATUS 过滤<u>之前</u>(<code>ethercat_can_bridge.cpp:398</code>
对 <code>:435</code>),所以每拍到的 6 帧全算进来 → 恒等于 λ − 实测Hz,零独立信息量。
<u>这些帧并没有丢</u>:STATUS 由段 2 按槽常驻上报,其余进段 4 覆盖窗口(实测 lost/OVF 均 0)。
<code>drop</code> 哪天才有权报警:让只走单格的帧(版本/节点应答)承担可靠投递时。<br>
<b>真丢帧逐层看</b>:① CAN 控制器 err/TEC/REC;② CAN 软件 RX 队列满(深度 16)的
<code>rx_drop_count</code> —— 只在 ESP32 <code>/can/feedback</code> 有,过程数据里没有;
③ 段 4 覆盖窗口 <u>主站侧</u>按 seq 空洞算的 <code>rx_batch.lost</code>(<code>step/2−1</code>,
发布值恒为偶数,按 step−1 会把每帧虚报成丢 1 个);④ 段 4 <u>从站侧</u>未取先覆
<code>rx_batch.pending</code>(= OVF,与 ③ 是两个独立仪表,正常应当同步增长);
⑤ CAN 取帧撕裂 <code>rx_torn</code>。③④ 是本页 10Hz 抽样看到的<u>单拍值</u>,只能当下界。
撕裂 / 重入是<u>饱和</u>计数(到 65535 停),封顶时只能报下界。
</div>
</div>
<div class="card">
<h2>实时曲线 <span class="hint2">滚动 90s · 紫 = 实测 PDO Hz(左轴) · 青 = 单拍 p50 µs(右轴)</span></h2>
<canvas id="chart" height="150"></canvas>
</div>
<div class="cols2">
<div class="card">
<h2>总线与从站 <span class="hint2">/api/status · /api/native/health · state.head</span></h2>
<div class="kv" id="busKv"></div>
<div class="hint">窗口自适应:<code>win_bases</code> 是当前收缩出的接收窗口基址数,
单槽刷新 = <code>period_ms × win_bases.length</code>;开机首圈先扫全表(6 拍)。</div>
</div>
<div class="card">
<h2>桥接计数 <span class="hint2">state.bridge · 桥全局,无按通道拆分 / 无 TEC·REC</span></h2>
<div class="kv" id="bridgeKv"></div>
<div class="hint">过程数据里没有逐通道 TEC/REC —— 没有的就显示 <code>—</code>,
不编 0 让"总线正常"和"没这个数据"长得一模一样。</div>
</div>
</div>
<div class="card">
<h2>收到的东西 ① 电机状态槽 <span class="hint2">state.motors[] · 48B STATUS 解码(DBC Motor_CAN_FD v0.74)</span></h2>
<div class="scroll sm">
<table id="tblMotors">
<thead><tr>
<th class="num">槽</th><th class="num">节点</th><th class="num">CH</th><th>在线</th>
<th class="num">seq</th><th class="num">seq变化/s</th>
<th class="num">MCU温</th><th class="num">电机温</th><th class="num">U/V/W mA</th>
<th class="num">母线V</th><th class="num">角度°</th><th class="num">速度参考°/s</th><th class="num">电流mA</th>
<th class="num">从站%</th><th>状态位</th><th>InitStatus</th><th class="num">DLC</th>
</tr></thead>
<tbody></tbody>
</table>
</div>
<details><summary>逐槽原始 hex(48B 全长,16B 一行、多列排)</summary>
<div id="motorHex" class="hexgrid" style="--hex-h:210px; margin-top:6px"><i class="hdr">—</i></div>
</details>
<div class="hint">「速度参考」= <code>RT_Velocity</code>,是内部斜坡<b>参考值</b>而非实测转速,
不能拿去回答"三轴实际是否同步"。<code>InitStatus</code> 显示 <code>—</code> 表示这条链路没送
B32-35 —— 补 0 会把"无数据"显示成"初始化正常"。</div>
</div>
<div class="card">
<h2>收到的东西 ② 批量接收区 <span class="hint2">rxraw.parsed(与下面槽表同一拍快照)· 段 4 覆盖窗口去重后的事件流</span></h2>
<div class="row" style="margin-bottom:8px">
<span id="rxbPill" class="pill">本拍新帧 / seq 空洞 / 从站 OVF —</span>
<span id="rxbSeen" class="pill">本页帧样本 —</span>
<button id="btnRxbClear" class="sm ghost">清零计数</button>
</div>
<div class="scroll" style="height:230px">
<table id="tblFrames">
<thead><tr><th class="num">时刻</th><th class="num">CH</th><th class="num">CAN ID</th><th>节点</th><th>子码语义</th><th class="num">DLC</th><th>数据 hex</th><th class="num">样本数</th></tr></thead>
<tbody></tbody>
</table>
</div>
<div style="font-size:11px; color:var(--text-label); margin:11px 0 5px">
覆盖窗口 5 槽原样(从站每拍整块拷出,写游标绕圈覆盖 —— <b>槽下标 ≠ 到达顺序</b>,seq 才是顺序)。
末列「本拍新上报」:<b>是</b> = 这一拍第一次见到这帧,主站把它交给上层;<b>否</b> = 它是上一拍就交过的旧帧
(窗口只留"最近 5 帧",一帧会连续几拍都躺在里面,但不会被重复上报)。</div>
<div class="scroll" style="height:170px">
<table id="tblWinSlots">
<thead><tr><th class="num">槽</th><th>状态</th><th class="num">seq</th><th class="num">Δseq</th><th class="num">CH</th><th class="num">DLC</th><th class="num">CAN ID</th><th>节点/子码</th><th>载荷前 8B</th><th>本拍新上报</th></tr></thead>
<tbody></tbody>
</table>
</div>
<details style="margin-top:8px"><summary>按子码汇总(每 sub 一行的采样次数 / 最近载荷)</summary>
<div class="scroll sm" style="margin-top:6px">
<table id="tblSubs"><thead><tr><th>子码</th><th>语义</th><th class="num">采样</th><th class="num">最近 DLC</th><th>最近载荷</th></tr></thead><tbody></tbody></table>
</div>
</details>
<div class="hint">
<b>同一 ID + 同一载荷出现两次不是重复显示</b>:主站已按每槽 seq 去重,同一帧不会报第二次。
实测 120 个窗口快照里「同 ID 且 seq 相同」0 次、「同 ID 但 seq 不同」240 次 —— 那是<b>两帧真的各到达一次</b>,
只是电机空闲时 CONFIG 回报的 24B 逐字节相同,肉眼看一模一样。看下面槽表的 seq 列即可分辨。
<br><b>"前面像少了一帧"也不是丢帧</b>:① 主站每拍只把 seq 比上次大的槽交给上层,上一拍已经交过的帧
仍躺在窗口里(就是末列的"否"),但不会再进上面的表;② 页面 10Hz 采样、PDO 1kHz,两次采样之间约
100 拍的窗口内容已被覆盖,看不见(所以"样本数"只占全量约 0.8%,全量看 λ 那张卡)。
本表能看到的丢帧判据只有两个:<code>seq 空洞</code>(主站侧)与 <code>OVF</code>(从站侧),
上面 pill 里实时显示,实测同为 0;<u>本表看不到的还有三层</u> —— CAN 控制器 err/TEC/REC、
GD32 软件 RX 队列满丢(深度 16,只在 ESP32 <code>/can/feedback</code> 有)、CAN 取帧撕裂
<code>rx_torn</code>。逐层对账跑 <code>scripts/can_bridge_reconcile.py</code>。
<br>长帧(CONFIG 24B / 版本应答)在 8B 短快照里会被截断,所以后端另开 <code>state.xrx</code> 长帧槽,
逐字节的真凭实据在下面第 ③ 区的原始窗口里。</div>
</div>
<div class="card">
<h2>收到的东西 ③ 原始接收窗口 + 长帧 + 逐拍环 <span class="hint2">/api/motor/rxraw · state.xrx · /api/motor/dbg(全为只读缓存拷贝)</span></h2>
<div class="cols2" style="--pane-h:250px">
<div class="paneFlex">
<div class="kv" id="rawKv"></div>
<div class="hint cap" style="height:2.6em">
<code>layout</code>:win=1.6.15 定长覆盖窗口 / slim=变长 / old=旧布局,解析器必须按 layout 选,
否则拿变长游标解定长窗口会整片错位。</div>
<div class="grow" style="margin-top:6px; min-height:0; display:flex; flex-direction:column">
<div class="lbl">xrx 长帧完整载荷(kv 里那行是截断的,这里不截)</div>
<pre id="xrxFull" class="grow" style="margin-top:4px">—</pre>
</div>
</div>
<div class="paneFlex">
<div class="scroll grow" style="border-top:0">
<table id="tblBeats"><thead><tr><th class="num">相对时刻 ms</th><th class="num">拍间隔 µs</th><th class="num">发送 µs</th><th class="num">接收 µs</th><th class="num">整拍 µs</th><th class="num">跳拍 k</th></tr></thead><tbody></tbody></table>
</div>
<div class="hint" id="beatStat" style="flex:0 0 auto; height:2.9em; overflow:auto; margin-top:8px">逐拍环未开启。开启后实测每次约 160KB(后端 RING=2048 拍),
只在需要看抖动分布时开,别常年挂着 —— 仪表本身也是负载。</div>
</div>
</div>
<div style="margin-top:10px">
<div class="lbl">覆盖窗口原始字节 —— 按 44B/槽分组:头 12B(ch dlc flags pad ‖ id LE ‖ seq LE)+ 数据 32B(两行)</div>
<div id="rawHex" class="hexgrid" style="--hex-h:158px"><i class="hdr">—</i></div>
</div>
</div>
<div class="card">
<h2>测速 <span class="hint2">与 SPI 面板同一套习惯:跑一轮 → 出统计行 → 落历史表对比</span></h2>
<div class="row">
<label>定点时长 s <input id="runSec" class="narrow" value="10"></label>
<label>采样率 Hz <input id="runHz" class="narrow" value="10"></label>
<button id="btnEcatRun" class="primary">定点测速</button>
<span style="width:14px"></span>
<label>扫描档位 ms <input id="sweepList" style="width:150px" value="4,2,1,0.5"></label>
<label>每档驻留 s <input id="sweepDwell" class="narrow" value="10"></label>
<button id="btnSweep">档位扫描</button>
<button id="btnEcatAbort" class="stop" hidden>中止并恢复周期</button>
</div>
<div class="row"><span id="runPill" class="pill"><span class="led"></span>空闲</span><span id="runProg" class="hint" style="margin:0">—</span></div>
<div class="hint">
扫描逐档下发 <code>/api/motor/period</code>(热改,不停循环),每档采完<b>回读 period_ms 确认生效</b>
—— 不一致就是被别处的 localStorage 或另一个标签页劫持了,该档标红而不是照单收下。
结束(含中止/异常)必把周期恢复成扫描前的原值并回读校验。
低档位有把从站打满的风险(λ 塌陷、从站停报),页内不做"帮你自动试到崩"。<br>
<b>每档驻留要大于节拍环容量 2048×周期</b>(4ms 档 ≈8.2s、1ms 档 ≈2.05s),否则环里还是上一档的旧节拍,
页内会直接标注"本档抖动不可信"而不是给你个看着像样的 p50。
</div>
<div class="scroll" style="margin-top:10px">
<table id="tblRuns">
<thead><tr><th>时刻</th><th>类型</th><th class="num">周期 ms</th><th class="num">实测 Hz</th><th class="num">达成 %</th><th class="num">λ /s</th><th class="num">真丢帧 帧/拍</th><th class="num">skip /s</th><th class="num">单拍 p50/p90/max µs</th><th class="num">从站 %</th><th class="num">撕裂</th><th class="num">在线槽</th><th class="num">WKC</th><th class="num">采样</th><th>备注</th></tr></thead>
<tbody></tbody>
</table>
</div>
</div>
</section>
</div>
<script>
"use strict";
(function () {
/* ═══════════════════ 通用工具 ═══════════════════ */
const $ = (s) => document.querySelector(s);
const $$ = (s) => Array.from(document.querySelectorAll(s));
const el = (tag, cls, text) => {
const n = document.createElement(tag);
if (cls) n.className = cls;
if (text !== undefined && text !== null) n.textContent = String(text);
return n;
};
const setTxt = (id, v) => { const n = document.getElementById(id); if (n) n.textContent = (v === null || v === undefined || v === "") ? "—" : v; };
/** 指标卡:value 里可含 <small> 单位,只在这里允许内嵌(内容全部由本页自己拼,不含外部数据) */
function metric(cardId, valueHtml, foot, cls) {
const c = document.getElementById(cardId); if (!c) return;
c.classList.remove("good", "warn", "bad");
if (cls) c.classList.add(cls);
const v = c.querySelector(".v"); if (v) v.innerHTML = valueHtml;
const f = c.querySelector(".f"); if (f && foot !== undefined) f.textContent = foot;
}
/** 字段表:rows = [[label, value, cls]],值一律 textContent 落 DOM(后端串不进 HTML) */
function kv(boxId, rows) {
const box = document.getElementById(boxId); if (!box) return;
box.textContent = "";
for (const [k, v, cls] of rows) {
const d = el("div");
d.appendChild(el("span", null, k));
let val = (v === null || v === undefined || v === "") ? "—" : String(v);
const b = el("b", cls || (val === "—" ? "dim" : null), val);
/* 长 hex(xrx data 96 字符)一旦换行/溢出就会盖住下面的说明文字:CSS 已做单行省略,
* 这里再截一道,完整值进 title,被截断的加 .trunc 标记 */
if (val.length > 40) {
b.textContent = val.slice(0, 34) + "…";
b.title = val;
b.classList.add("trunc");
}
d.appendChild(b);
box.appendChild(d);
}
}
const isNum = (v) => typeof v === "number" && isFinite(v);
const f1 = (v) => (isNum(v) ? v.toFixed(1) : "—");
const f2 = (v) => (isNum(v) ? v.toFixed(2) : "—");
const f0 = (v) => (isNum(v) ? String(Math.round(v)) : "—");
const pad2 = (n) => (n < 10 ? "0" : "") + n;
const clock = (d) => pad2(d.getHours()) + ":" + pad2(d.getMinutes()) + ":" + pad2(d.getSeconds());
function clampInt(v, lo, hi, dflt) { const n = parseInt(v, 10); return isNaN(n) ? dflt : Math.min(hi, Math.max(lo, n)); }
function clampNum(v, lo, hi, dflt) { const n = parseFloat(v); return isNaN(n) ? dflt : Math.min(hi, Math.max(lo, n)); }
/** u16/u32 计数器的回绕差分(cycles 是 long long,别用这个 —— 见文件头第 4 条) */
function wrapDelta(prev, cur, bits) {
const m = Math.pow(2, bits);
let d = (cur - prev) % m;
if (d < 0) d += m;
return d;
}
/** 饱和计数器(到 65535 停):返回 [增量, 是否封顶]。倒退说明后端重启过,按 0 收下并标注。 */
function satDelta(prev, cur) {
if (prev === null || cur === null || cur === undefined) return [0, false];
if (cur >= prev) return [cur - prev, cur >= 65535];
return [0, true];
}
/** 长 hex 短显示:CONFIG 24B=48 字符、xrx 32B=64 字符,nowrap 表格里会把列撑出屏 */
function hexShort(s, n) {
const v = String(s || "");
return v.length > (n || 32) ? v.slice(0, n || 32) + "…" : v;
}
/** 字节间单空格、每 4 字节再空一格 —— 行宽固定,多列网格的列数才可预期 */
function hexGrp(hex) {
const by = String(hex || "").match(/../g) || [];
let out = "";
for (let i = 0; i < by.length; i++) out += (i && i % 4 ? " " : " ") + by[i].toUpperCase();
return out.trim();
}
function quantile(arr, q) {
if (!arr || !arr.length) return null;
const s = arr.slice().sort((a, b) => a - b);
return s[Math.min(s.length - 1, Math.floor(q * (s.length - 1)))];
}
function ls(key, val) { // localStorage 读写包装(隐私模式会抛)
try { if (val === undefined) return window.localStorage.getItem(key); window.localStorage.setItem(key, val); }
catch (_) { return null; }
}
/* ═══════════════════ 面板切换 ═══════════════════ */
const PANES = { spi: $("#pane-spi"), ecat: $("#pane-ecat") };
function showPane(name) {
for (const k in PANES) PANES[k].hidden = (k !== name);
for (const b of $$(".tab")) b.classList.toggle("on", b.dataset.pane === name);
ls("speedtest_pane", name);
if (name === "ecat") {
layoutChart();
/* 首次进来自动开;之后只在"被切走时本来是开着"的场合恢复 */
if (!E.everStarted || E.wantLive) startEcatIfIdle();
} else if (E.live) {
/* 切到 SPI 就把看板轮询停掉:SPI 测的是 µs 级帧周期,这边 10Hz×3 条代理请求
* 会把自己的测量抬高 —— 先排除自己造的假象。 */
const was = E.live; stopEcat(); E.wantLive = was;
ecatPill("#ecatState", "", "已暂停(在 SPI 页)");
}
}
for (const b of $$(".tab")) b.onclick = () => showPane(b.dataset.pane);
/* ═══════════════════ SPI 测速面板 ═══════════════════
* 设备端点无应答时原实现会把按钮永久卡在 disabled(fetch 没有超时),这就是"卡死"。
* 这里每次请求都带 AbortController + 超时,并额外给一个手动中止口。 */
const spiOut = $("#out"), spiHist = $("#hist"), spiPill = $("#spiState"), btnAbort = $("#btnAbort");
const spiBtns = [$("#btnRun"), $("#btnRtt"), $("#btnStress")];
let spiCtl = null;
try { const saved = ls("spi_test_ip"); if (saved) $("#ip").value = saved; } catch (_) {}
/* 设备 IP 与控制台其它页共用 dcu_esp_ip:其它页改过 IP,这里不必再手敲一遍 */
if (!ls("spi_test_ip")) { const shared = ls("dcu_esp_ip"); if (shared) $("#ip").value = shared; }
function spiBusy(busy, cls, text) {
for (const b of spiBtns) b.disabled = busy;
btnAbort.hidden = !busy;
spiPill.className = "pill" + (cls ? " " + cls : "");
spiPill.innerHTML = '<span class="led"></span>';
spiPill.appendChild(el("span", null, text));
}
/** 固件返回是分行的中文纯文本;逐行取数,缺行就留 null(不能补 0 —— 会把"没这项数据"显示成"0µs") */
function spiParse(text) {
const line = (re) => (text.match(re) || [""])[0];
const tri = (s) => {
const out = []; const re = /([\d.]+)\s*µs/g; let m;
while ((m = re.exec(s)) && out.length < 3) out.push(parseFloat(m[1]));
return out;
};
const rt = tri(line(/^往返延迟:.*$/m));
const tr = tri(line(/^SPI 事务:.*$/m));
const sc = tri(line(/^调度开销:.*$/m));
/* 固件只在最后一段尾上写 µs:"提交/调度 41 | 发送帧 96 | ... | 唤醒 0 µs"
* —— 逐段要求 µs 会只剩一段,所以标签+整数即可,µs 是可选尾巴 */
const segLine = line(/^单帧分段 平均:.*$/m).replace(/^单帧分段 平均:\s*/, "");
const segs = [];
for (const p of segLine.split("|")) {
const m = p.match(/^\s*([^\d|]+?)\s+(\d+)\s*µ?\s*s?\s*$/);
if (m) segs.push([m[1].trim(), parseFloat(m[2])]);
}
/* 延迟分布: <0.5ms:180 0.5-1ms:15 ... >=5ms:0 —— 标签里没有空格,冒号后紧跟计数 */
const distLine = line(/^延迟分布:.*$/m).replace(/^延迟分布:\s*/, "");
const dist = [];
let dm; const dre = /(\S+?):(\d+)/g;
while ((dm = dre.exec(distLine))) dist.push([dm[1], parseInt(dm[2], 10)]);
return {
rtAvg: rt[0], rtMin: rt[1], rtMax: rt[2], spiAvg: tr[0], schedAvg: sc[0], segs, dist,
fail: (line(/失败:\s*(\d+)/).match(/失败:\s*(\d+)/) || [])[1],
tput: (line(/等效吞吐:\s*([\d.]+)\s*KB\/s/).match(/等效吞吐:\s*([\d.]+)/) || [])[1],
total: (line(/总耗时:\s*(\d+)\s*ms/).match(/总耗时:\s*(\d+)/) || [])[1],
};
}
const SEG_COLORS = ["var(--esp32)", "var(--gd32)", "#5fa8ff", "var(--can)", "var(--mem)"];
function spiRender(text) {
const p = spiParse(text);
setTxt("mAvg", f0(p.rtAvg)); setTxt("mMin", f0(p.rtMin)); setTxt("mMax", f0(p.rtMax));
setTxt("mSpi", f0(p.spiAvg)); setTxt("mSched", f0(p.schedAvg));
setTxt("mTput", p.tput ? parseFloat(p.tput).toFixed(1) : "—");
setTxt("mFail", p.fail === undefined ? "—" : p.fail);
setTxt("mAvgF", "µs / 帧" + (isNum(p.rtAvg) && isNum(p.rtMin) ? " · 比最快慢 " + f0(p.rtAvg - p.rtMin) + "µs" : ""));
const failN = p.fail === undefined ? null : parseInt(p.fail, 10);
document.getElementById("mFail").parentNode.className = "metric" + (failN === null ? "" : (failN === 0 ? " good" : " bad"));
document.getElementById("sAvgCard").className = "metric hero" +
(isNum(p.rtMin) && isNum(p.rtAvg) ? (p.rtAvg <= p.rtMin * 1.5 ? " good" : " warn") : "");
const bar = $("#segBar"), legend = $("#segLegend");
bar.textContent = "";
if (!p.segs.length) { legend.textContent = "这一版固件没有「单帧分段」行(需含分段计时的固件)"; return p; }
const sum = p.segs.reduce((a, s) => a + s[1], 0) || 1;
p.segs.forEach(([label, v], i) => {
const cell = el("div");
cell.style.cssText = "flex:" + (v / sum) + " 0 0; background:" + SEG_COLORS[i % SEG_COLORS.length] +
"; min-width:2px";
cell.title = label + " " + f0(v) + "µs · " + (v / sum * 100).toFixed(1) + "%";
bar.appendChild(cell);
});
legend.textContent = p.segs.map(([label, v]) =>
label + " " + f0(v) + "µs(" + (v / sum * 100).toFixed(0) + "%)").join(" | ");
/* 每档等宽槽 + 槽内按 count/max 填充:直接按 count 分宽会让 0 档整列消失,
* 看的人就不知道本来有 7 档 */
const dbar = $("#distBar"), dleg = $("#distLegend");
dbar.textContent = "";
if (!p.dist.length) { dleg.textContent = "这一版固件没有「延迟分布」行"; return p; }
const dmax = Math.max.apply(null, p.dist.map((d) => d[1])) || 1;
const DIST_COLORS = ["#3ddc8e", "#2fd8c1", "#5fa8ff", "#fcbf3f", "#ff9d3f", "#ff6b6b", "#c0392b"];
p.dist.forEach(([label, cnt], i) => {
const slot = el("div");
slot.style.cssText = "flex:1 0 0; position:relative; border-right:1px solid var(--bg-root)";
const fill = el("div");
fill.style.cssText = "position:absolute; bottom:0; left:0; right:0; height:" +
Math.max(cnt ? 6 : 0, cnt / dmax * 100) + "%; background:" + DIST_COLORS[i % DIST_COLORS.length] + "; opacity:.85";
slot.appendChild(fill);
slot.title = label + ": " + cnt;
dbar.appendChild(slot);
});
dleg.textContent = p.dist.map(([label, cnt]) => label + ":" + cnt).join(" ") +
" · 共 " + p.dist.reduce((a, d) => a + d[1], 0) + " 帧";
return p;
}
function spiHistory(frames, payload, p) {
const fail = p.fail === undefined ? "?" : p.fail;
const tr = el("tr");
const cells = [clock(new Date()), String(frames), payload + "B", fail,
f0(p.rtAvg) + "µs", f0(p.rtMin) + "µs", f0(p.schedAvg) + "µs", f0(p.spiAvg) + "µs",
p.tput ? p.tput + "KB/s" : "-"];
cells.forEach((c, i) => {
const td = el("td", i >= 1 ? "num" : null, c);
if (i === 3 && fail !== "0") td.className = "num bad";
tr.appendChild(td);
});
spiHist.insertBefore(tr, spiHist.firstChild);
while (spiHist.children.length > 200) spiHist.removeChild(spiHist.lastChild);
}
async function spiRun(frames, payload) {
const ip = ($("#ip").value || "").trim();
ls("spi_test_ip", ip);
const timeout = clampInt($("#spiTimeout").value, 1000, 60000, 8000);
spiCtl = new AbortController();
/* 超时交给网络栈的 AbortSignal.timeout,别用 JS setTimeout —— 后台标签页会把它
* 节流到 1s 粒度(实测 2500ms 的守卫拖到 3999ms 才判失败)。没有 AbortSignal.any
* 的老内核退回 JS 定时器兜底。 */
const nativeSig = (typeof AbortSignal === "function" && AbortSignal.timeout && AbortSignal.any);
const sig = nativeSig ? AbortSignal.any([spiCtl.signal, AbortSignal.timeout(timeout)]) : spiCtl.signal;
const timer = nativeSig ? null : setTimeout(() => spiCtl.abort("timeout"), timeout + 500);
spiBusy(true, "warn", "测试中…");
spiOut.className = "";
spiOut.textContent = "测试中… (" + frames + " 帧 × " + payload + "B) · 超时 " + timeout + "ms";
const t0 = performance.now();
try {
const resp = await fetch("http://" + ip + "/spi/speedtest?frames=" + frames + "&payload=" + payload,
{ signal: sig, cache: "no-store" });
if (!resp.ok) throw new Error("HTTP " + resp.status);
const text = await resp.text();
const failed = /失败:\s*[1-9]/.test(text);
spiOut.className = failed ? "fail" : "";
spiOut.textContent = text;
const p = spiRender(text);
spiHistory(frames, payload, p);
spiBusy(false, failed ? "bad" : "ok", failed ? "有失败帧" : "完成 · " + f0(performance.now() - t0) + "ms" +
(isNum(p.total) ? " · 设备侧总耗时 " + p.total + "ms" : ""));
} catch (e) {
/* 手动中止优先判 controller,超时判 TimeoutError —— 两者都会以 AbortError 族抛出 */
const why = spiCtl.signal.aborted ? "已手动中止"
: ((e === "timeout") || (e && e.name === "TimeoutError")) ? ("超时:等满 " + timeout + "ms 设备无应答")
: ((e && e.name === "AbortError") ? "已中止" : ((e && e.message) || String(e)));
spiOut.className = "fail";
spiOut.textContent = "请求失败: " + why + "\n检查设备 IP、网络连通性,以及固件是否带 [SPI-SPEEDTEST] 端点。";
for (const id of ["mAvg", "mMin", "mMax", "mSpi", "mSched", "mTput", "mFail"]) setTxt(id, "—");
$("#segBar").textContent = ""; setTxt("segLegend", "跑一次测速后出现");
spiBusy(false, "bad", "失败 · " + f0(performance.now() - t0) + "ms");
} finally {
if (timer) clearTimeout(timer);
spiCtl = null;
}
}
btnAbort.onclick = () => { if (spiCtl) spiCtl.abort("user"); };
spiBtns[0].onclick = () => spiRun(clampInt($("#frames").value, 1, 2000, 200), clampInt($("#payload").value, 0, 236, 236));
spiBtns[1].onclick = () => spiRun(500, 0);
spiBtns[2].onclick = () => spiRun(2000, 236);
/* ═══════════════════ EtherCAT 只读客户端 ═══════════════════
* 经 esp32_server.py 的 /ecat/api/* 反向代理同源访问 ecat_native(它只绑
* 127.0.0.1 且无 CORS,浏览器直连跨端口会卡在预检上)。
* 每条路径都带超时:代理连不上后端时会给 503,读缓存路由本身是微秒级。 */
const EAPI = "/ecat/api/";
async function eapi(path, body, timeoutMs) {
const ctl = new AbortController();
const timer = setTimeout(() => ctl.abort(), timeoutMs || 4000);
const t0 = performance.now();
let resp, text;
try {
resp = await fetch(EAPI + path, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify(body || {}), signal: ctl.signal, cache: "no-store",
});
text = await resp.text();
} finally {
clearTimeout(timer);
}
const rtt = performance.now() - t0;
let j = {};
try { j = text ? JSON.parse(text) : {}; }
catch (_) { throw Object.assign(new Error("/api/" + path + " 响应非合法 JSON (HTTP " + resp.status + ")"), { rtt }); }
if (!resp.ok && j.ok === undefined) j.ok = false;
if (!j.__rtt) j.__rtt = rtt;
return j;
}
/* ── 速率窗口:把"计数 + 时刻"成对存下来,再逐对累加 ──
* 计数与时刻必须同一瞬间取(沿用 rate_probe 记过一次实踩教训:分子取末次 cycles、
* 分母在循环退出后才算,dt 多一次 sleep 把 99.2% 误报成 96%)。 */
class RateWindow {
constructor(winMs) { this.win = winMs || 3000; this.s = []; }
reset() { this.s = []; }
push(sample) {
this.s.push(sample);
while (this.s.length > 2 && sample.t - this.s[0].t > this.win) this.s.shift();
}
/** 返回窗口内的各类速率;无法判定的一律给 null,不猜 */
calc() {
const s = this.s, out = {
span: 0, pairs: 0, hz: null, lambda: null, drop: null, rx: null, skip: null,
untrusted: 0, motorStep: {}, motorChanges: {},
rxbLostMax: 0, rxbPendingMax: 0, samples: s.length,
};
if (s.length < 2) return out;
let dCyc = 0, dLam = 0, dDrop = 0, dRx = 0, dSkip = null, dt = 0;
const steps = {}, changes = {};
for (let i = 1; i < s.length; i++) {
const a = s[i - 1], b = s[i];
const pair = (b.t - a.t) / 1000;
if (!(pair > 0)) continue;
dt += pair; out.pairs++;
if (isNum(a.cycles) && isNum(b.cycles)) dCyc += (b.cycles - a.cycles);
if (isNum(a.gseq) && isNum(b.gseq)) dLam += wrapDelta(a.gseq, b.gseq, 32);
if (isNum(a.rx) && isNum(b.rx)) dRx += wrapDelta(a.rx, b.rx, 32);
if (isNum(a.drop) && isNum(b.drop)) {
const d = wrapDelta(a.drop, b.drop, 16);
/* 单拍增量超过 u16 半程 = 采样率不足以分辨回绕,该点对 drop 作废并计数 */
if (d > 32768) out.untrusted++; else dDrop += d;
}
if (isNum(a.skip) && isNum(b.skip)) dSkip = (dSkip || 0) + (b.skip - a.skip);
/* 槽 seq = 该槽被刷新那一刻的 global_seq,所以"两次变化之间的增量"就是
* 两次刷新之间经过的 CAN 帧数 → 真实刷新率 = λ / step,与采样率无关。
* (把 step 累加再除 dt 得到的是 λ 本身,那是个会冒充刷新率的假数。) */
for (const k in b.seq) {
if (a.seq[k] === undefined) continue;
const d = wrapDelta(a.seq[k], b.seq[k], 32);
if (d === 0) continue;
(steps[k] = steps[k] || []).push(d);
changes[k] = (changes[k] || 0) + 1;
}
out.rxbLostMax = Math.max(out.rxbLostMax, b.rxbLost || 0);
out.rxbPendingMax = Math.max(out.rxbPendingMax, b.rxbPending || 0);
}
out.span = dt;
if (dt > 0) {
out.hz = dCyc / dt; out.lambda = dLam / dt; out.rx = dRx / dt; out.drop = dDrop / dt;
if (dSkip !== null) out.skip = dSkip / dt;
for (const k in steps) {
out.motorStep[k] = quantile(steps[k], 0.5);
out.motorChanges[k] = changes[k] / dt; // 受采样率限制,只是下界
}
}
return out;
}
}
/* ═══════════════════ EtherCAT:解码表 ═══════════════════ */
/* CAN ID 后 16 位(子码)语义,与 js/motor/main.js 的 canId() 一套约定 */
const SUB_NAME = {
0x0100: "STATUS 电机状态", 0x0101: "CONFIG 目标值回报", 0x00FF: "版本应答(同 ID)",
0x0200: "目标下发 Host→Motor", 0x0400: "SET_NODE_ID", 0x0401: "节点 ID 应答",
};
/** 48B STATUS → 物理量(位序照抄 main.js decodeStatus / DBC v0.74,含 B11/B13 非对齐坑) */
function decodeStatus(hex) {
if (!hex || hex.length < 64) return null; // 32B 起解,<32B 视为这条链路没送
const u8 = new Uint8Array(hex.length / 2);
for (let i = 0; i < u8.length; i++) u8[i] = parseInt(hex.substr(i * 2, 2), 16);
const copy = new Uint8Array(36); copy.set(u8.subarray(0, Math.min(36, u8.length)));
const dv = new DataView(copy.buffer), b10 = copy[10];
const f32 = (off) => { const v = dv.getFloat32(off, true); return Number.isFinite(v) ? v : 0; };
return {
mcuTemp: dv.getInt16(0, true) * 0.01, motTemp: dv.getInt16(2, true) * 0.01,
uCurrent: dv.getInt16(4, true), vCurrent: dv.getInt16(6, true), wCurrent: dv.getInt16(8, true),
motStatus: (b10 & 1) ? 1 : 0, magStatus: (b10 & 2) ? 1 : 0, thermal: (b10 & 4) ? 1 : 0,
eepromStatus: (b10 >> 3) & 7,
powerVoltage: dv.getUint16(11, true) * 0.001, // bit88 → B11,非对齐
rtAngle: f32(13), rtVelocity: f32(17), rtCurrent: f32(21), testFloat: f32(25),
testU16: dv.getUint16(29, true), cpuUsage: copy[31],
/* 载荷不足 36B 说明这条链路根本没送 InitStatus —— 返回 null 让 UI 显示"—",
* 补 0 会把"无数据"显示成"初始化正常" */
initStatus: u8.length >= 36 ? dv.getUint32(32, true) : null,
};
}
const AL_NAME = { 0: "NONE", 1: "INIT", 2: "PREOP", 3: "BOOT", 4: "SAFEOP", 8: "OP" };
const pcores = (mask) => {
const out = []; let m = mask >>> 0;
for (let b = 0; b < 32 && m; b++, m >>>= 1) if (m & 1) out.push(b);
let hi = Math.floor(mask / 4294967296);
for (let b = 0; b < 32 && hi; b++, hi >>>= 1) if (m & 1) out.push(b + 32);
return out;
};
const fmtPeriod = (ms) => (!ms ? "—" : (ms < 1 ? Math.round(ms * 1000) + "µs" : ms + "ms"));
/* ═══════════════════ EtherCAT:实时观测状态 ═══════════════════ */
const E = {
live: false, ring: false, wantLive: false, everStarted: false,
win: new RateWindow(3000),
pollDts: [], pollHz: null, intervalMs: null, lastRates: null,
lastState: null, status: null, health: null, load: null, raw: null, dbg: null,
tornPrev: null, reentryPrev: null, tornCap: false, reentryCap: false,
lastRawCycle: null, seenFrames: 0, seenT0: 0, subs: new Map(),
lastSeq: {}, seqAt: {},
chart: { t: [], hz: [], us: [] }, errors: 0, errText: "",
};
/** 槽 seq 停更多久算不在交换(要大于"扫完全表 + 抖动":period 4ms × 6 拍 = 24ms,2s 有充足余量) */
const STALE_OFFLINE_MS = 2000;
const slotAge = (key) => (isNum(E.seqAt[key]) ? performance.now() - E.seqAt[key] : null);
const tblMotorsBody = $("#tblMotors tbody"), tblFramesBody = $("#tblFrames tbody");
const tblSubsBody = $("#tblSubs tbody"), tblBeatsBody = $("#tblBeats tbody");
const tblWinBody = $("#tblWinSlots tbody");
function ecatPill(id, cls, text) {
const n = $(id); n.className = "pill" + (cls ? " " + cls : "");
n.innerHTML = '<span class="led"></span>'; n.appendChild(el("span", null, text));
}
/** 节拍源:隐藏标签页会把主线程 setTimeout 节流到 ≥1s(实测被压到 1Hz,
* 采样密度一掉,所有"按相邻两拍差分"的判据就跟着失真)。电机页同款问题
* 用 js/motor/tick.worker.js 解决,Worker 的定时器不受此限;本页要保住
* "删掉文件即整体下线",所以 Worker 源码用 Blob 内联而不引外部文件。 */
let tickInFlight = false, tickPrev = null, tid = null;
const TICK_SRC = "let t=null;self.onmessage=function(e){var m=e.data;if(!m||!m.type)return;" +
"if(m.type==='start'||m.type==='update'){if(t)clearInterval(t);t=setInterval(function(){self.postMessage('tick')},m.interval||250);}" +
"else if(m.type==='stop'){if(t){clearInterval(t);t=null;}}};";
const ticker = (function () {
let w = null;
try {
w = new Worker(URL.createObjectURL(new Blob([TICK_SRC], { type: "text/javascript" })));
w.onmessage = () => requestTick();
w.onerror = () => { w = null; };
} catch (_) { w = null; }
return {
kind: w ? "worker" : "setTimeout",
start(iv) { E.intervalMs = iv; if (w) w.postMessage({ type: "start", interval: iv }); else arm(); },
update(iv) { E.intervalMs = iv; if (w) w.postMessage({ type: "update", interval: iv }); },
stop() { if (w) w.postMessage({ type: "stop" }); clearTimeout(tid); },
};
})();
function arm() { // setTimeout 兜底:每拍重新挂一次
if (!E.live || ticker.kind === "worker") return;
clearTimeout(tid);
tid = setTimeout(() => requestTick(), E.intervalMs || 100);
}
function requestTick() {
if (!E.live || tickInFlight) return;
tickInFlight = true;
const now = performance.now();
if (tickPrev) {
const dt = now - tickPrev;
if (dt > 0 && dt < 30000) { E.pollDts.push(dt); while (E.pollDts.length > 30) E.pollDts.shift(); }
}
tickPrev = now;
E.pollHz = quantile(E.pollDts, 0.5) ? 1000 / quantile(E.pollDts, 0.5) : null;
eapi("motor/state", {}, 4000).then((st) => handleTick(st)).catch((e) => {
E.errors++; E.errText = (e && e.message) || String(e);
}).finally(() => { tickInFlight = false; arm(); });
}
/** 主线程 setTimeout 在隐藏标签页会被节流到 ≥1s(实测 5s 只采到 7 拍 = 1.4Hz),
* 测速采样密度被悄悄拖低,u16 计数的回绕风险也跟着上来 —— 等待改用 Worker 定时。 */
const SLEEP_SRC = "self.onmessage=function(e){var m=e.data;if(!m||m.type!=='sleep')return;" +
"var t=setTimeout(function(){clearTimeout(t);self.postMessage(m.id)},m.ms);};";
const sleeper = (function () {
let w = null, seq = 0;
const pending = new Map();
try {
w = new Worker(URL.createObjectURL(new Blob([SLEEP_SRC], { type: "text/javascript" })));
w.onmessage = (e) => { const res = pending.get(e.data); if (res) { pending.delete(e.data); res(); } };
w.onerror = () => { w = null; };
} catch (_) { w = null; }
return {
kind: w ? "worker" : "setTimeout",
ms(ms) {
if (!w) return new Promise((res) => setTimeout(res, ms));
const id = ++seq;
return new Promise((res) => { pending.set(id, res); w.postMessage({ type: "sleep", ms, id }); });
},
};
})();
const wsleep = (ms) => sleeper.ms(Math.max(0, ms));
/** 一拍:state 之外再读 app/load(可选 dbg) 与 rxraw,计数与时刻成对喂给速率窗口 */
async function handleTick(st) {
let load = null, dbg = null, raw = null, rtt = st.__rtt || 0;
try {
const jobs = [eapi("app/load", {}, 4000), E.ring ? eapi("motor/dbg", {}, 6000) : null];
const done = await Promise.all(jobs);
load = done[0]; dbg = done[1];
rtt = Math.max(rtt, (load && load.__rtt) || 0, (dbg && dbg.__rtt) || 0);
} catch (e) { E.errors++; E.errText = (e && e.message) || String(e); }
/* rxraw 顺带读:它带 cycle,能挡住"同一拍被重复读两次"的重复计数。
* 它的响应体最大(原始字节转 hex + 解析结果),所以必须计入 rtt,漏了这卡就系统性偏低。 */
try { raw = await eapi("motor/rxraw", {}, 4000); if (raw) rtt = Math.max(rtt, raw.__rtt || 0); }
catch (_) { raw = null; }
const t = performance.now(); // 与计数同一瞬间,不能事后再取
const b = st.bridge || {}, head = st.head || {};
const seq = {};
for (const m of (st.motors || [])) if (isNum(m.seq)) seq[m.node + ":" + m.ch] = m.seq;
/* 快照里的 online 是从站说过的历史标志,循环停了也不会消失 —— 判活只能看 seq 还动不动 */
for (const k in seq) { if (E.lastSeq[k] !== seq[k]) { E.lastSeq[k] = seq[k]; E.seqAt[k] = t; } }
for (const k in E.seqAt) if (!(k in seq)) delete E.seqAt[k];
E.win.push({
t, cycles: isNum(st.cycles) ? st.cycles : null, gseq: isNum(head.global_seq) ? head.global_seq : null,
drop: b.valid && isNum(b.drop) ? b.drop : null, rx: b.valid && isNum(b.rx_count) ? b.rx_count : null,
skip: dbg && isNum(dbg.skip_total) ? dbg.skip_total : null, seq,
rxbLost: (st.rx_batch || {}).lost, rxbPending: (st.rx_batch || {}).pending,
});
E.lastState = st; E.load = load; E.dbg = dbg; E.raw = raw;
const rates = E.win.calc();
E.lastRates = rates;
renderMetrics(st, load, dbg, rates, rtt);
renderBus(st);
renderBridge(st);
renderMotors(st, rates);
if (raw) renderFrames(st, raw);
renderRaw(st, raw);
if (dbg) renderBeats(dbg);
pushChart(rates, st);
drawChart();
}
function pollHz() { return clampNum($("#hz").value, 0.5, 20, 10); }
function startEcatIfIdle() {
if (E.live) return;
E.live = true; E.everStarted = true; E.wantLive = true; tickPrev = null; E.pollDts = [];
E.seenFrames = 0; E.seenT0 = performance.now(); E.lastRawCycle = null;
$("#btnLive").textContent = "暂停观测";
ecatPill("#ecatState", "warn", "观测中");
eapi("status", {}, 6000).then((s) => { E.status = s; renderBus(E.lastState); })
.catch((e) => { E.status = null; E.errText = e.message; });
eapi("native/health", {}, 6000).then((h) => { E.health = h; renderBus(E.lastState); }).catch(() => { E.health = null; });
ticker.start(1000 / pollHz());
requestTick();
}
function stopEcat() {
E.live = false; E.wantLive = false;
ticker.stop();
$("#btnLive").textContent = "开始实时观测";
ecatPill("#ecatState", "", "已暂停");
}
$("#btnLive").onclick = () => { E.live ? stopEcat() : startEcatIfIdle(); };
$("#btnRing").onclick = () => {
E.ring = !E.ring;
$("#btnRing").textContent = "逐拍环:" + (E.ring ? "开" : "关");
if (!E.ring) { tblBeatsBody.textContent = ""; $("#beatStat").textContent = "逐拍环未开启。"; }
};
$("#hz").onchange = () => { E.win.reset(); ticker.update(1000 / pollHz()); };
/* ═══════════════════ 渲染:指标卡 ═══════════════════ */
function renderMetrics(st, load, dbg, r, rtt) {
const period = st && isNum(st.period_ms) ? st.period_ms : null;
const nominal = period ? 1000 / period : null;
const cyc = (st && st.cyc_us) || {};
const tot = cyc.total || {};
const linkOk = !!(st && st.cycles && r.hz > 0);
metric("cHz", (isNum(r.hz) ? Math.round(r.hz).toLocaleString("en-US") : "—") + "<small>Hz</small>",
"cycles " + (st && isNum(st.cycles) ? st.cycles.toLocaleString("en-US") : "—") + " · 窗口 " + f2(r.span) + "s",
linkOk ? "good" : "bad");
const achieve = (isNum(r.hz) && nominal) ? r.hz / nominal * 100 : null;
metric("cAchieve", f1(achieve) + "<small>%</small>", "名义 " + f0(nominal) + " Hz · 档位 " + fmtPeriod(period),
achieve === null ? "" : (achieve > 97 ? "good" : achieve > 90 ? "warn" : "bad"));
metric("cBus", f1(tot.p50) + "<small>µs</small>",
"p50 " + f1(tot.p50) + " · p90 " + f1(tot.p90) + " · max " + f1(tot.max) + " (n=" + (cyc.n || 0) + ")",
tot.p50 === undefined ? "" : "good");
const duty = (isNum(tot.p50) && period) ? tot.p50 / (period * 1000) * 100 : null;
metric("cDuty", f1(duty) + "<small>%</small>", "单拍 p50 ÷ 周期",
duty === null ? "" : (duty < 60 ? "good" : duty < 85 ? "warn" : "bad"));
/* 单电机状态刷新:用后端自己的窗口算术 1000/slot_period_ms(= period × win_bases 数)。
* 故意不用"相邻 seq 步长"反推 —— 采样一旦被节流(隐藏标签页 setTimeout 会掉到
* 1Hz),步长就变成"这一秒里的全部帧数",λ/步长会产出 1Hz 这种看着自洽的假数。
* 实测 seq 变化次数只作为下界显示,并标清它受采样率限制。 */
const exchanging = !!(st && isNum(st.cycles) && st.cycles > 0 && isNum(r.hz) && r.hz > 0);
const mv = ((st && st.motors) || []).filter((m) => {
const a = slotAge(m.node + ":" + m.ch);
return m.online && a !== null && a <= STALE_OFFLINE_MS;
});
const slotFromPeriod = (exchanging && isNum(st && st.slot_period_ms) && st.slot_period_ms > 0)
? 1000 / st.slot_period_ms : null;
const chVals = Object.values(r.motorChanges).filter(isNum);
metric("cSlot", (slotFromPeriod === null ? "—" : f0(slotFromPeriod)) + "<small>Hz</small>",
slotFromPeriod === null ? "没在交换 —— slot_period_ms 是缓存旧值,不拿来当实测"
: "1000÷slot_period " + f2(st && st.slot_period_ms) + "ms · 实测 seq 变化 ≥ " +
f1(quantile(chVals, 0.5)) + "/s · 本页采样 " + f1(E.pollHz) + "Hz",
slotFromPeriod === null ? "bad" : "good");
metric("cLambda", f0(r.lambda) + "<small>/s</small>", "λ ÷ 实测Hz = " + (isNum(r.lambda) && isNum(r.hz) && r.hz ? f2(r.lambda / r.hz) : "—") + " 帧/拍",
isNum(r.lambda) ? (r.lambda > 0 ? "good" : "bad") : "");
/* drop 是个会自证的数:段1/段3 只有一个"最近一帧"格子,每拍最多发布 1 帧,
* 于是它恒等于 λ − 实测Hz,拿它判好坏没有灵敏度(连"视野百分比"这种换算都不必做,
* 那是同一个数的另一种写法)。卡面只报真丢帧:主站 seq 空洞(rxbLostMax) 与从站
* OVF(rxbPendingMax) 取抽样单拍最大值(两者独立、正常应同步增长),颜色叠上撕裂累计。 */
const holes = Math.max(r.rxbLostMax || 0, r.rxbPendingMax || 0);
const tornN = E.tornAccum || 0;
metric("cDrop", holes + "<small>帧/拍</small>",
"主站空洞 " + (r.rxbLostMax || 0) + " · 从站OVF " + (r.rxbPendingMax || 0) +
" · 撕裂 " + tornN + " · 另有段1/段3 单格 drop " + f0(r.drop) + "/s(≡λ−Hz,不算丢帧" +
(r.untrusted ? " · " + r.untrusted + " 点作废(采样率不足)" : "") + ")",
(r.drop === null && !holes && !tornN) ? "" : (holes > 0 || tornN > 0 ? "bad" : "good"));
metric("cSkip", f1(r.skip) + "<small>/s</small>",
isNum(r.skip) ? "累计 " + ((dbg && dbg.skip_total) ?? "—") : "需开「逐拍环」才有 skip_total",
r.skip === null ? "" : (r.skip < 0.5 ? "good" : "bad"));
const loadFresh = load && load.valid && isNum(load.age_ms) && load.age_ms < 1000;
const loadPct = loadFresh ? load.load_perm / 10 : null;
metric("cLoad", f1(loadPct) + "<small>%</small>",
"load_perm " + ((load && isNum(load.load_perm)) ? load.load_perm : "—") + " · age " + ((load && isNum(load.age_ms)) ? load.age_ms : "—") + "ms",
loadPct === null ? "bad" : loadPct < 70 ? "good" : loadPct < 90 ? "warn" : "bad");
const wkc = st && isNum(st.wkc) ? st.wkc : null, exp = st && isNum(st.expected_wkc) ? st.expected_wkc : null;
metric("cWkc", (wkc ?? "—") + " / " + (exp ?? "—"), "期望不符即从站丢帧/掉 OP 前兆",
wkc === null ? "" : (wkc === exp ? "good" : "bad"));
if (load && isNum(load.rx_torn)) {
const [dt] = satDelta(E.tornPrev, load.rx_torn);
const [dr] = satDelta(E.reentryPrev, load.pdo_reentry);
if (E.tornPrev !== null) E.tornAccum = (E.tornAccum || 0) + dt;
if (E.reentryPrev !== null) E.reentryAccum = (E.reentryAccum || 0) + dr;
E.tornCap = E.tornCap || load.rx_torn >= 65535;
E.reentryCap = E.reentryCap || load.pdo_reentry >= 65535;
E.tornPrev = load.rx_torn; E.reentryPrev = load.pdo_reentry;
metric("cTorn", (E.tornAccum || 0) + " / " + (E.reentryAccum || 0),
"累计增量" + (E.tornCap || E.reentryCap ? " · 有计数已封顶(下界)" : ""),
(E.tornAccum || 0) === 0 && (E.reentryAccum || 0) === 0 ? "good" : "warn");
} else {
metric("cTorn", "—", "rx_torn / pdo_reentry 需 /api/app/load valid");
}
metric("cRtt", f1(rtt) + "<small>ms</small>",
"最慢一个请求(state/app-load/dbg/rxraw 四取其一)· errors " + E.errors +
(E.errText ? " · " + E.errText.slice(0, 46) : ""),
rtt === null ? "bad" : rtt < 30 ? "good" : "warn");
if (st && st.ok === false) {
ecatPill("#ecatLink", "bad", "后端未交换:" + ((st.log || []).join(" / ") || "未知"));
} else if (!exchanging) {
ecatPill("#ecatLink", "warn", "PDO 循环没在跑(wkc " + (st ? st.wkc : "—") + "/" + (st ? st.expected_wkc : "—") +
"," + (((st && st.motors) || []).length - mv.length) + " 槽 seq 停更)→ 去电机页/EtherCAT 页连一次");
} else {
ecatPill("#ecatLink", "ok", "交换中 · " + f0(r.hz) + "Hz · " + mv.length + " 槽在刷新");
}
const s = E.status && E.status.slave;
if (s) {
const al = AL_NAME[s.state] || ("0x" + (s.state || 0).toString(16));
ecatPill("#ecatAl", s.state === 8 ? "ok" : "warn", "AL " + al + (s.al_status ? " code 0x" + s.al_status.toString(16).toUpperCase() : ""));
} else {
ecatPill("#ecatAl", "", "AL —");
}
}
/* ═══════════════════ 渲染:总线 / 桥接 ═══════════════════ */
function renderBus(st) {
const head = (st && st.head) || {}, s = (E.status && E.status.slave) || {};
const h = E.health || {};
kv("busKv", [
["从站名", s.name || (h.slaves ? h.slaves + " 个从站" : null)],
["AL 状态", s.state === undefined ? null : (AL_NAME[s.state] || ("0x" + s.state.toString(16)))],
["al_status", isNum(s.al_status) ? "0x" + s.al_status.toString(16).toUpperCase().padStart(4, "0") : null,
s.al_status ? "warn" : "good"],
["bus_open / op / loop", (h.bus_open === undefined ? null : h.bus_open + " / " + h.op + " / " + h.loop)],
["PDO 周期 period_ms", isNum(st && st.period_ms) ? f2(st.period_ms) + " ms" : null],
["名义刷新率", isNum(st && st.period_ms) ? f0(1000 / st.period_ms) + " Hz" : null],
["slot_period_ms", isNum(st && st.slot_period_ms) ? f2(st.slot_period_ms) + " ms" : null],
["win_bases", (st && st.win_bases) ? JSON.stringify(st.win_bases) : null],
["head.win_base / count", head.win_base === undefined ? null : head.win_base + " / " + head.win_count],
["head.active", isNum(head.active) ? head.active : null],
["boot_active", head.boot_active === undefined ? null : String(head.boot_active)],
["global_seq (u32)", isNum(head.global_seq) ? head.global_seq.toLocaleString("en-US") : null],
["cycles (long long)", isNum(st && st.cycles) ? st.cycles.toLocaleString("en-US") : null],
["wkc / expected", st ? st.wkc + " / " + st.expected_wkc : null, (st && st.wkc === st.expected_wkc) ? "good" : "bad"],
["自旋节拍 spin", st ? (st.spin ? "开(占 1 核)" : "关") : null, st && st.spin ? "warn" : null],
["钉核 pcore_mask", st && st.pcore_mask ? pcores(st.pcore_mask).join(",") : "未钉"],
["queue_depth / sent_seq", st ? st.queue_depth + " / " + st.sent_seq : null],
["楔死恢复 wr", isNum(st && st.wr) ? st.wr : null, (st && st.wr) ? "warn" : "good"],
]);
}
function renderBridge(st) {
const b = (st && st.bridge) || {};
const rb = (st && st.rx_batch) || {};
kv("bridgeKv", [
["bridge.valid", b.valid === undefined ? null : String(b.valid), b.valid ? "good" : "bad"],
["ch / dlc / id", b.valid ? b.ch + " / " + b.dlc + " / 0x" + (b.id >>> 0).toString(16).toUpperCase() : null],
["data (8B 短快照)", b.valid ? b.data : null],
["rx_count (u32)", isNum(b.rx_count) ? b.rx_count.toLocaleString("en-US") : null],
["tx_count / drop(段1/段3 单格,≡λ−Hz)", b.valid ? b.tx_count + " / " + b.drop : null],
["ack", isNum(b.ack) ? b.ack : null],
["rx_valid / truncated", b.valid ? b.rx_valid + " / " + b.truncated : null, (b.valid && b.truncated) ? "warn" : null],
["backlog", isNum(b.backlog) ? b.backlog : null, b.backlog ? "warn" : "good"],
["tx_result", b.valid ? (b.tx_result + " " + (b.tx_result_text || "")) : null, (b.valid && b.tx_result === 0) ? "good" : "warn"],
["rx_batch count / lost(主站空洞) / pending(从站OVF)", rb.count === undefined ? null : rb.count + " / " + rb.lost + " / " + rb.pending,
(rb.lost || rb.pending) ? "warn" : "good"],
["xrx 长帧", (st && st.xrx && st.xrx.data) ? "0x" + st.xrx.id.toString(16).toUpperCase() + " · " + st.xrx.dlc + "B" : null],
["TEC / REC", null, "dim"],
]);
}
/* ═══════════════════ 渲染:电机状态槽 ═══════════════════ */
function renderMotors(st, r) {
const body = tblMotorsBody;
const motors = (st && st.motors) || [];
if (!motors.length) {
if (!body.children.length) {
const tr = el("tr");
tr.appendChild(el("td", "dim", "快照里没有槽 —— 循环没跑,或还没收到任何 STATUS"));
body.appendChild(tr);
}
return;
}
body.textContent = "";
const mg = document.getElementById("motorHex");
mg.textContent = "";
const mcell = (t, cls) => mg.appendChild(el("i", cls, t));
for (const m of motors) {
const d = decodeStatus(m.data || "");
const key = m.node + ":" + m.ch;
const chg = r.motorChanges[key], step = r.motorStep[key];
const tr = el("tr");
const add = (txt, cls) => tr.appendChild(el("td", cls, txt));
add(m.slot, "num"); add(m.node, "num"); add(m.ch, "num");
const age = slotAge(key);
const fresh = age !== null && age <= STALE_OFFLINE_MS;
add(!m.online ? "离线" : (fresh ? "在线" : "停更 " + (age / 1000).toFixed(1) + "s"),
(m.online && fresh) ? "good" : "bad");
add(isNum(m.seq) ? m.seq.toLocaleString("en-US") : null, "num");
add(f1(chg) + (isNum(step) ? " ·步" + f0(step) : ""), "num " + (chg > 0 ? "good" : "bad"));
if (d) {
add(f1(d.mcuTemp)); add(f1(d.motTemp));
add(d.uCurrent + "/" + d.vCurrent + "/" + d.wCurrent, "num");
add(f2(d.powerVoltage), "num"); add(f1(d.rtAngle), "num");
add(f1(d.rtVelocity), "num"); add(f0(d.rtCurrent), "num");
add(d.cpuUsage, "num " + (d.cpuUsage > 90 ? "bad" : d.cpuUsage > 70 ? "warn" : ""));
add((d.motStatus || d.magStatus || d.thermal || d.eepromStatus)
? [d.motStatus ? "报错" : null, d.magStatus ? "磁异常" : null, d.thermal ? "过热" : null,
d.eepromStatus ? "EEPROM" + d.eepromStatus : null].filter(Boolean).join(" ")
: "正常",
(d.motStatus || d.magStatus || d.thermal || d.eepromStatus) ? "bad" : "good");
add(d.initStatus === null ? null : (d.initStatus === 0 ? "0 正常" : "0x" + d.initStatus.toString(16).toUpperCase()),
d.initStatus === 0 ? "good" : (d.initStatus ? "bad" : "dim"));
} else {
for (let i = 0; i < 10; i++) add("—", "dim");
}
add(isNum(m.dlc) ? m.dlc + "B" : null, "num");
body.appendChild(tr);
mcell("槽" + m.slot + " node" + m.node + " ch" + m.ch + " dlc" + m.dlc + " seq" + m.seq, "slot");
const hx = m.data || "";
for (let i = 0; i < 96; i += 32) mcell(" " + (hx.substr(i, 32) ? hexGrp(hx.substr(i, 32)) : "—"));
}
if (!motors.length) mcell("无槽", "hdr");
}
/* ═══════════════════ 渲染:批量接收区(按 cycle 去重) ═══════════════════ */
function renderFrames(st, raw) {
/* 事件表用 rxraw.parsed 而不是 state.rx_batch.frames:主站保证 raw 与 parsed 同拍,
* 而 state 是另一次请求的另一拍 —— 混用会让"上表 2 行 / 槽表里只有 1 行是本拍放行"
* 看起来像丢帧,其实是两个快照。 */
const frames = (raw && raw.parsed) || [];
const rb = (st && st.rx_batch) || {};
$("#rxbPill").textContent = "rxraw 本拍新帧 " + frames.length + " · state 侧计数 新帧 " + rb.count +
" / seq 空洞 " + rb.lost + " / 从站 OVF " + rb.pending +
" · layout " + (raw && raw.layout) + " · cycle " + (raw && raw.cycle);
/* 轮询只看得到"这一拍的覆盖窗口",全量帧永远采不完 —— 必须把采样占空比摊开说,
* 否则展示时这串数字会被当成总帧数。 */
const el2 = (performance.now() - (E.seenT0 || 0)) / 1000;
const lam = (E.lastRates && isNum(E.lastRates.lambda)) ? E.lastRates.lambda : null;
const duty = lam && el2 > 5 ? (E.seenFrames / (lam * el2) * 100) : null;
$("#rxbSeen").textContent = "本页轮询采到的帧样本 " + E.seenFrames.toLocaleString("en-US") + " 条" +
(duty === null ? "" : " · 约占全量 " + duty.toFixed(2) + "%(全量速率看 λ)");
/* cycle 每个 PDO 周期都在涨(实测 102 拍对应 +102),所以它只能去掉"同一拍被
* 重复读两次";覆盖窗口本身的重复回显去不掉 —— 这也是上面要标"样本"的原因。 */
if (isNum(raw.cycle) && raw.cycle !== E.lastRawCycle) {
E.lastRawCycle = raw.cycle;
for (const f of (raw.parsed || [])) {
E.seenFrames++;
const sub = (f.id & 0xFFFF);
const rec = E.subs.get(sub) || { n: 0, dlc: f.dlc, data: f.data };
rec.n++; rec.dlc = f.dlc; rec.data = f.data;
E.subs.set(sub, rec);
}
}
const body = tblFramesBody;
body.textContent = "";
/* 同 id+同载荷可能有多个槽(就是用户看到的"重复"),放行的是 seq 最大的那个 */
const slots = decodeWinSlots(raw && raw.layout === "win" ? raw.raw : "");
const best = new Map();
for (const s of slots) {
const k = (s.id >>> 0) + ":" + s.data;
if (!best.has(k) || s.seq > best.get(k).seq) best.set(k, s);
}
if (!frames.length) body.appendChild(el("tr")).appendChild(el("td", "dim", "这一拍没有新到的批量帧(STATUS 走状态窗口,不进这里)"));
for (const f of frames) {
const node = (f.id >> 16) & 0xF, sub = f.id & 0xFFFF;
const src = best.get((f.id >>> 0) + ":" + String(f.data).slice(0, 16).toUpperCase());
const tr = el("tr");
const td = (txt, cls) => tr.appendChild(el("td", cls, txt));
const tdHex = (v) => { const c = el("td", "hex", hexShort(v, 32)); if (String(v).length > 32) { c.title = v; c.classList.add("trunc"); } tr.appendChild(c); };
td(clock(new Date()), "num");
td(src ? src.ch : (isNum(f.ch) ? f.ch : "—"), "num");
td("0x" + (f.id >>> 0).toString(16).toUpperCase().padStart(8, "0"), "num");
td("N" + node); td(SUB_NAME[sub] || ("子码 0x" + sub.toString(16).toUpperCase()));
td(f.dlc, "num"); tdHex(f.data);
td((E.subs.get(sub) || { n: 0 }).n, "num");
body.appendChild(tr);
}
const sb = tblSubsBody;
sb.textContent = "";
const subs = Array.from(E.subs.entries()).sort((a, b) => b[1].n - a[1].n);
if (!subs.length) sb.appendChild(el("tr")).appendChild(el("td", "dim", "尚无去重后的新帧"));
for (const [sub, rec] of subs) {
const tr = el("tr");
const td = (txt, cls) => tr.appendChild(el("td", cls, txt));
td("0x" + sub.toString(16).toUpperCase().padStart(4, "0"), "num");
td(SUB_NAME[sub] || "未定义");
td(rec.n, "num good"); td(rec.dlc, "num");
const c = el("td", "hex", hexShort(rec.data, 32));
if (String(rec.data).length > 32) { c.title = rec.data; c.classList.add("trunc"); }
tr.appendChild(c);
sb.appendChild(tr);
}
renderWinSlots(raw, slots, best);
}
/* 覆盖窗口单槽布局:44B = ch@0 dlc@1 flags@2 (pad@3) id@4..7 seq@8..11 data@12..43 */
const WIN_SLOT_SIZE = 44, WIN_SLOT_COUNT = 5;
function decodeWinSlots(hex) {
if (!hex) return [];
const b = new Uint8Array(hex.length / 2);
for (let i = 0; i < b.length; i++) b[i] = parseInt(hex.substr(i * 2, 2), 16);
const dv = new DataView(b.buffer), out = [];
for (let s = 0; s < WIN_SLOT_COUNT; s++) {
const o = s * WIN_SLOT_SIZE;
if (o + WIN_SLOT_SIZE > b.length) break;
const dlc = b[o + 1];
let data = "";
for (let i = 0; i < Math.min(dlc || 0, 8); i++) data += b[o + 12 + i].toString(16).padStart(2, "0");
out.push({ slot: s, ch: b[o], dlc, flags: b[o + 2], id: dv.getUint32(o + 4, true),
seq: dv.getUint32(o + 8, true), data: data.toUpperCase() });
}
return out;
}
function renderWinSlots(raw, slots, best) {
const body = tblWinBody;
slots = slots || ((raw && raw.layout === "win") ? decodeWinSlots(raw.raw) : []);
body.textContent = "";
if (!slots.length) {
const tr = el("tr");
tr.appendChild(el("td", "dim", raw && raw.layout !== "win"
? "非 1.6.15 覆盖窗口布局(layout=" + (raw && raw.layout) + "),无 5 槽可解"
: "窗口未取到原始字节"));
tr.children[0].colSpan = 10;
body.appendChild(tr);
return;
}
/* 顺序按 seq 排(写游标是环形的,槽下标不代表到达顺序);发布的 seq 是 2,4,6…
* 相邻帧差 2,所以 Δseq=2 就是"紧挨着的下一帧",>2 即中间有被覆盖的帧 */
const bySeq = slots.slice().sort((a, b) => (a.seq || 1e18) - (b.seq || 1e18));
let prevSeq = null;
const delta = new Map();
for (const s of bySeq) {
if (!s.seq) continue;
delta.set(s.slot, prevSeq === null ? null : s.seq - prevSeq);
prevSeq = s.seq;
}
const passed = new Set(((raw && raw.parsed) || []).map((f) => (f.id >>> 0) + ":" + String(f.data).slice(0, 16).toUpperCase()));
for (const s of slots) {
const empty = !s.seq, writing = !empty && (s.seq & 1) === 1, marked = (s.flags & 1) !== 0;
const usable = !empty && !writing && marked;
const node = (s.id >> 16) & 0xF, sub = s.id & 0xFFFF;
const key = (s.id >>> 0) + ":" + s.data;
/* 必须是同键里 seq 最大的那个才算本拍放行:同载荷的旧槽键值相同,只按 id+载荷
* 匹配会把旧槽一起标成"是",看着就像放行了 2 帧而实际只有 1 帧 */
const isPassed = usable && passed.has(key) && best && best.get(key) === s;
const tr = el("tr");
const td = (v, cls) => tr.appendChild(el("td", cls, v));
td(s.slot, "num");
td(empty ? "空槽" : (writing ? "写入中(奇seq)" : (marked ? "有效" : "未标记")),
empty ? "dim" : (writing || !marked ? "warn" : "good"));
td(empty ? "—" : s.seq.toLocaleString("en-US"), "num");
const d = delta.get(s.slot);
td(d === null || d === undefined ? "—" : (d === 2 ? "相邻" : "+" + d), "num " + (d && d > 2 ? "warn" : ""));
td(usable ? s.ch : "—", "num");
td(usable ? s.dlc : "—", "num");
td(usable ? "0x" + (s.id >>> 0).toString(16).toUpperCase().padStart(8, "0") : "—", "num");
td(usable ? "N" + node + " · " + (SUB_NAME[sub] || ("0x" + sub.toString(16).toUpperCase())) : "—");
td(usable ? hexShort(s.data, 16) : "—", "hex");
td(isPassed ? "是(本拍新到,交给上层)"
: (usable ? "否(上一拍已交过,这拍没被新帧覆盖)" : "—"), isPassed ? "good" : "dim");
body.appendChild(tr);
}
}
/* ═══════════════════ 渲染:原始接收窗口 ═══════════════════ */
function renderRaw(st, raw) {
const x = (st && st.xrx) || null;
/* rxraw 的三个头字节是"原样 dump 不翻译":win 布局下它们是
* slot_count / wr_idx(写游标) / ovf(从站侧未取先覆计数),沿用旧名会把写游标读成丢帧数 */
const isWin = raw && raw.layout === "win";
const hLbl = isWin ? ["窗口槽数 slot_count", "写游标 wr_idx(下一格覆盖谁)", "从站 OVF 未取先覆"]
: ["count", "lost", "pending"];
kv("rawKv", [
["rxraw cycle", isNum(raw && raw.cycle) ? raw.cycle.toLocaleString("en-US") : null],
["layout", raw ? raw.layout : null],
[hLbl[0], raw ? raw.count : null],
[hLbl[1], raw ? raw.lost : null],
[hLbl[2], raw ? raw.pending : null, (isWin && raw.pending) ? "warn" : null],
["len (字节)", raw ? raw.len : null],
["parsed 帧数", raw && raw.parsed ? raw.parsed.length : null],
["xrx ch/dlc/id", x ? x.ch + " / " + x.dlc + " / 0x" + (x.id >>> 0).toString(16).toUpperCase() : null],
["xrx data", x ? x.data : null],
["wr(楔死恢复)", isNum(st && st.wr) ? st.wr : null, (st && st.wr) ? "warn" : "good"],
]);
/* xrx 完整载荷:kv 里那行是截断显示的,这里给全量 */
const xf = document.getElementById("xrxFull");
if (x && x.data) {
xf.textContent = "ch=" + x.ch + " dlc=" + x.dlc + " id=0x" + (x.id >>> 0).toString(16).toUpperCase() +
"\n" + x.data.replace(/(.{8})/g, "$1 ").trim();
} else {
xf.textContent = "无长帧(xrx 未 valid)—— CONFIG/版本这类 >8B 的帧才会进这里";
}
const grid = document.getElementById("rawHex");
grid.textContent = "";
const cell = (t, cls) => grid.appendChild(el("i", cls, t));
if (!raw || !raw.raw) { cell(raw ? "原始窗口为空(这一拍没有批量帧)" : "—", "hdr"); return; }
if (raw.layout !== "win") {
for (let i = 0; i < raw.raw.length; i += 32)
cell(String(i / 2).padStart(4, "0") + " " + hexGrp(raw.raw.substr(i, 32)));
return;
}
/* win 布局:5 槽 × 44B(头 12B + 数据 32B)。按槽切行、行宽固定,所以能排成多列网格
* —— 原来单列 pre 每行只用掉面板四成宽度。快照长 232B,5 槽只占 220B,
* 剩下 12B 是段 4 之后的邻接字节:单独一行标"尾",不能当"槽5"(会被读成窗口多一格)。 */
for (let s = 0; s < WIN_SLOT_COUNT && s * 88 < raw.raw.length; s++) {
const hdr = raw.raw.substr(s * 88, 24), dat = raw.raw.substr(s * 88 + 24, 64);
if (!hdr) break;
cell("槽" + s + " " + hexGrp(hdr), "slot");
cell(" " + hexGrp(dat.substr(0, 32)));
if (dat.length > 32) cell(" " + hexGrp(dat.substr(32, 32)));
}
const tail = raw.raw.substr(WIN_SLOT_COUNT * 88);
if (tail) cell("尾 " + hexGrp(tail), "hdr");
}
/* ═══════════════════ 渲染:逐拍节拍环 ═══════════════════ */
function renderBeats(dbg) {
const beats = dbg.cyc || [];
const body = tblBeatsBody;
body.textContent = "";
const show = beats.slice(-260);
const gaps = [];
for (let i = 1; i < show.length; i++) {
const prev = show[i - 1], cur = show[i];
const gap = (cur[0] - prev[0]) * 1e6;
const send = (cur[2] - cur[1]) * 1e6, rx = (cur[3] - cur[2]) * 1e6, work = (cur[4] - cur[0]) * 1e6;
if (i > 1 && gap > 0 && gap < 5e6) gaps.push(gap);
const tr = el("tr");
const td = (txt, cls) => tr.appendChild(el("td", cls, txt));
td(((cur[0] - show[0][0]) * 1000).toFixed(3), "num");
td(gap ? gap.toFixed(0) : "—", "num " + (gap && Math.abs(gap - dbg.period_ms * 1e6) > dbg.period_ms * 1e5 ? "warn" : ""));
td(send.toFixed(1), "num"); td(rx.toFixed(1), "num"); td(work.toFixed(1), "num");
td(cur[5], "num " + (cur[5] ? "bad" : ""));
body.appendChild(tr);
}
const gp50 = quantile(gaps, 0.5), gp90 = quantile(gaps, 0.9), gmax = gaps.length ? Math.max.apply(null, gaps) : null;
setTxt("beatStat", "环内 " + beats.length + " 拍(后端 RING=2048)· 拍间隔 p50 " + f0(gp50) +
" / p90 " + f0(gp90) + " / max " + f0(gmax) + " µs · 抖动(max−p50) " + f0((gmax || 0) - (gp50 || 0)) +
" µs · 跳拍累计 " + (dbg.skip_total ?? "—"));
}
/* ═══════════════════ 实时曲线 ═══════════════════ */
const cvs = $("#chart"), c2 = cvs.getContext("2d");
const CHART_WIN_MS = 90000;
function layoutChart() {
const dpr = window.devicePixelRatio || 1;
const w = cvs.clientWidth || (cvs.parentNode && cvs.parentNode.clientWidth) || 900;
cvs.width = Math.round(w * dpr); cvs.height = Math.round(150 * dpr);
c2.setTransform(dpr, 0, 0, dpr, 0, 0);
drawChart();
}
function pushChart(r, st) {
if (!isNum(r.hz)) return;
const tot = ((st || {}).cyc_us || {}).total || {};
const t = performance.now();
E.chart.t.push(t); E.chart.hz.push(r.hz); E.chart.us.push(isNum(tot.p50) ? tot.p50 : null);
while (E.chart.t.length > 2 && t - E.chart.t[0] > CHART_WIN_MS) {
E.chart.t.shift(); E.chart.hz.shift(); E.chart.us.shift();
}
}
function drawChart() {
const w = cvs.clientWidth, h = 150, pl = 46, pr = 46, pt = 10, pb = 18;
if (!w) return;
c2.clearRect(0, 0, w, h);
const t = E.chart.t;
c2.strokeStyle = "#1a2740"; c2.lineWidth = 1; c2.fillStyle = "#617a99"; c2.font = "10px " + getComputedStyle(document.body).fontFamily;
for (let i = 0; i <= 4; i++) {
const y = pt + (h - pt - pb) * i / 4;
c2.beginPath(); c2.moveTo(pl, y); c2.lineTo(w - pr, y); c2.stroke();
}
if (t.length < 2) { c2.fillText("等待采样…", pl + 6, h / 2); return; }
const t1 = t[t.length - 1], t0 = Math.max(t[0], t1 - CHART_WIN_MS);
const X = (ts) => pl + (w - pl - pr) * (ts - t0) / Math.max(1, t1 - t0);
const hzMax = Math.max.apply(null, E.chart.hz.map((v) => v || 0)) * 1.15 || 10;
const usVals = E.chart.us.filter(isNum);
const usMax = (usVals.length ? Math.max.apply(null, usVals) : 100) * 1.2;
const Y1 = (v) => pt + (h - pt - pb) * (1 - v / hzMax);
const Y2 = (v) => pt + (h - pt - pb) * (1 - v / usMax);
/* 面积 + 折线 */
c2.beginPath();
E.chart.hz.forEach((v, i) => { const x = X(t[i]), y = Y1(v || 0); i ? c2.lineTo(x, y) : c2.moveTo(x, y); });
c2.lineTo(X(t[t.length - 1]), h - pb); c2.lineTo(X(t[0]), h - pb); c2.closePath();
c2.fillStyle = "rgba(168,139,250,.16)"; c2.fill();
c2.beginPath();
E.chart.hz.forEach((v, i) => { const x = X(t[i]), y = Y1(v || 0); i ? c2.lineTo(x, y) : c2.moveTo(x, y); });
c2.strokeStyle = "#a88bfa"; c2.lineWidth = 1.8; c2.stroke();
c2.beginPath(); let started = false;
E.chart.us.forEach((v, i) => {
if (!isNum(v)) { started = false; return; }
const x = X(t[i]), y = Y2(v);
if (!started) { c2.moveTo(x, y); started = true; } else c2.lineTo(x, y);
});
c2.strokeStyle = "#22d3ee"; c2.lineWidth = 1.4; c2.stroke();
c2.fillStyle = "#93a8c4";
c2.fillText(f0(hzMax) + " Hz", 3, pt + 8);
c2.fillText(f0(hzMax / 2) + " Hz", 3, pt + (h - pt - pb) / 2 + 3);
c2.textAlign = "right";
c2.fillText(f0(usMax) + " µs", w - 3, pt + 8);
c2.fillText(f0(usMax / 2) + " µs", w - 3, pt + (h - pt - pb) / 2 + 3);
c2.textAlign = "left";
c2.fillText("← " + f1((t1 - t0) / 1000) + "s 窗口 · 每点为速率窗口内 Δcycles/Δt", pl, h - 5);
}
window.addEventListener("resize", layoutChart);
/* ═══════════════════ 热改 PDO 周期 ═══════════════════ */
const runPill = $("#runPill"), runProg = $("#runProg"), btnEcatAbort = $("#btnEcatAbort");
function runBusy(cls, text) {
runPill.className = "pill" + (cls ? " " + cls : "");
runPill.innerHTML = '<span class="led"></span>'; runPill.appendChild(el("span", null, text));
}
$("#btnPeriod").onclick = async () => {
const v = parseFloat($("#periodPick").value);
if (!(v > 0)) { runBusy("warn", "先在「PDO 周期档位」里选一档"); return; }
runBusy("warn", "下发 " + v + "ms…");
try {
const r = await eapi("motor/period", { period_ms: v }, 8000);
if (r.ok !== true) throw new Error((r.log || []).join(" / ") || "被拒");
/* 热调是异步落到循环线程的,必须回读确认,不能拿下发值当生效值 */
let eff = null;
for (let i = 0; i < 8; i++) {
await wsleep(250);
eff = (await eapi("motor/state", {}, 4000)).period_ms;
if (isNum(eff) && Math.abs(eff - v) < 1e-6) break;
}
const ok = isNum(eff) && Math.abs(eff - v) < 1e-6;
runBusy(ok ? "ok" : "bad", ok ? ("已生效 " + eff + "ms(" + f0(1000 / eff) + "Hz 档)")
: ("下发 " + v + "ms 但回读 " + eff + "ms —— 有他端在设周期(localStorage 劫持)"));
} catch (e) { runBusy("bad", "失败:" + e.message); }
};
/* ═══════════════════ 测速:定点 / 档位扫描 ═══════════════════ */
const RUN_BODY = $("#tblRuns tbody");
let E_ABORT = false;
function addRunRow(rec) {
const tr = el("tr");
const td = (v, cls) => tr.appendChild(el("td", cls, v === null || v === undefined ? "—" : v));
td(clock(new Date()));
td(rec.kind, "dim");
td(f2(rec.period), "num"); td(f0(rec.hz), "num good"); td(f1(rec.achieve), "num " + (rec.achieve > 97 ? "good" : rec.achieve > 90 ? "warn" : "bad"));
td(f0(rec.lambda), "num"); td(isNum(rec.holes) ? rec.holes : "—", "num " + (rec.holes > 0 ? "bad" : "good"));
td(f1(rec.skip), "num " + (rec.skip > 0.5 ? "bad" : "good"));
td(f1(rec.p50) + " / " + f1(rec.p90) + " / " + f1(rec.pmax), "num");
td(f1(rec.load), "num " + (rec.load > 90 ? "bad" : rec.load > 70 ? "warn" : "good"));
td((rec.torn || 0) + (rec.tornCapped ? "≥" : ""), "num " + (rec.torn ? "warn" : "good"));
td(rec.online + "/" + rec.slots, "num");
td(rec.wkc + "/" + rec.expected, "num " + (rec.wkc === rec.expected ? "good" : "bad"));
td(rec.samples, "num dim");
td(rec.note, "dim");
if (rec.hijack) tr.children[14].className = "bad";
RUN_BODY.insertBefore(tr, RUN_BODY.firstChild);
while (RUN_BODY.children.length > 200) RUN_BODY.removeChild(RUN_BODY.lastChild);
}
/**
* 在给定秒数内采一轮。pauseLive 由调用方负责,避免测速时混进看板轮询的负载。
* skip 与抖动取自收尾那一次 /api/motor/dbg:环只有 2048 拍,驻留越久越只代表末段 ——
* 这一点必须写在备注里,不能拿末段 2s 的抖动冒充整段。
*/
async function measureOnce(kind, seconds, hz, extraNote) {
const win = new RateWindow(seconds * 1000 + 60000);
const p50s = [], p90s = [], pmaxs = [], loads = [];
let torn = 0, tornCap = false, tornPrev = null, loadStale = 0, errs = 0, iters = 0;
let online = 0, slots = 0, wkc = null, expected = null, period = null, spin = null;
let dbg0 = null;
const seqPrev = {}, seqAt = {}; // 本函数自己的判活依据(快照 online 是锁存的历史标志)
const tEnd = performance.now() + seconds * 1000;
const interval = 1000 / hz;
try { dbg0 = await eapi("motor/dbg", {}, 8000); } catch (_) { dbg0 = null; }
while (performance.now() < tEnd) {
if (E_ABORT) break;
const tc = performance.now();
let st = null, load = null;
try {
const done = await Promise.all([eapi("motor/state", {}, 4000), eapi("app/load", {}, 4000)]);
st = done[0]; load = done[1];
} catch (e) { errs++; await wsleep(interval); continue; }
const t = performance.now(); // 与计数同一瞬间
iters++;
const b = st.bridge || {}, head = st.head || {}, cyc = st.cyc_us || {};
const seq = {};
for (const m of (st.motors || [])) {
const ky = m.node + ":" + m.ch;
if (!isNum(m.seq)) continue;
seq[ky] = m.seq;
if (seqPrev[ky] !== m.seq) { seqPrev[ky] = m.seq; seqAt[ky] = t; }
}
win.push({
t, cycles: isNum(st.cycles) ? st.cycles : null, gseq: isNum(head.global_seq) ? head.global_seq : null,
drop: b.valid && isNum(b.drop) ? b.drop : null, rx: b.valid && isNum(b.rx_count) ? b.rx_count : null,
seq, rxbLost: (st.rx_batch || {}).lost, rxbPending: (st.rx_batch || {}).pending,
});
if (cyc.total) { p50s.push(cyc.total.p50); p90s.push(cyc.total.p90); pmaxs.push(cyc.total.max); }
/* 从站停报时 load_perm 会留残值,不加 age 闸就是把旧值当实测写进结论 */
if (load && load.valid && isNum(load.load_perm) && (!isNum(load.age_ms) || load.age_ms < 1000)) loads.push(load.load_perm / 10);
else loadStale++;
if (load && isNum(load.rx_torn)) {
const [d, capped] = satDelta(tornPrev, load.rx_torn);
torn += d; tornCap = tornCap || capped; tornPrev = load.rx_torn;
}
period = isNum(st.period_ms) ? st.period_ms : period;
spin = typeof st.spin === "boolean" ? st.spin : spin;
wkc = isNum(st.wkc) ? st.wkc : wkc; expected = isNum(st.expected_wkc) ? st.expected_wkc : expected;
slots = (st.motors || []).length;
online = (st.motors || []).filter((m) => {
const a = seqAt[m.node + ":" + m.ch];
return m.online && isNum(a) && (t - a) <= STALE_OFFLINE_MS;
}).length;
const remain = tEnd - performance.now();
runProg.textContent = kind + " 剩余 " + (remain > 0 ? (remain / 1000).toFixed(1) : "0.0") + "s · 采样 " + iters + " 次";
await wsleep(Math.max(0, Math.min(interval - (performance.now() - tc), remain)));
}
let dbg1 = null;
try { dbg1 = await eapi("motor/dbg", {}, 8000); } catch (_) {}
const r = win.calc();
const nominal = period ? 1000 / period : null;
const gaps = [];
if (dbg1 && dbg1.cyc && dbg1.cyc.length > 2) {
for (let i = 1; i < dbg1.cyc.length; i++) {
const g = (dbg1.cyc[i][0] - dbg1.cyc[i - 1][0]) * 1e6;
if (g > 0 && g < 5e6) gaps.push(g);
}
}
const note = [];
if (extraNote) note.push(extraNote);
const achieved = r.span > 0 ? iters / r.span : null;
note.push("窗口 " + f2(r.span) + "s/" + iters + "拍 · 实测采样 " + f1(achieved) + "Hz(设定 " + f1(hz) + "Hz)");
if (isNum(achieved) && achieved < hz * 0.8) note.push("!! 采样被拖慢(后台标签页节流?),速率型判据精度下降");
if (r.untrusted) note.push("drop 有 " + r.untrusted + " 点因采样率不足作废");
if (loadStale) note.push("负载 " + loadStale + " 次陈旧已排除");
if (spin) note.push("自旋节拍占 1 核");
/* 节拍环容量 2048 拍:换档后驻留不足 2048×新周期时,环里大半还是换档前的旧节拍。
* 拿它算"本档抖动"或反判"档位没跑到"都是仪表自己造的假象(实测 4ms 档 3s 驻留
* 就报出 p50 2000µs —— 那是上一档 2ms 的节拍,而本档回读与 Δcycles/Δt 都对得上)。 */
const ringSpanS = gaps.length && isNum(period) ? (dbg1.cyc.length * period) / 1000 : null;
const ringTrustworthy = ringSpanS !== null && r.span > ringSpanS * 1.05;
if (ringSpanS === null) {
note.push("抖动无数据(逐拍环未取到)");
} else if (ringTrustworthy) {
note.push("抖动(环 " + dbg1.cyc.length + "拍已填满本档): p50 " + f0(quantile(gaps, 0.5)) +
" p90 " + f0(quantile(gaps, 0.9)) + " max " + f0(Math.max.apply(null, gaps)) + "µs");
} else {
note.push("本档抖动不可信:环 " + dbg1.cyc.length + "拍≈" + f1(ringSpanS) + "s > 驻留 " + f2(r.span) +
"s,内含换档前旧节拍(需驻留 >" + f1(ringSpanS) + "s)");
}
if (errs) note.push("请求失败 " + errs + " 次");
/* 档位到底跑没跑到,只认实测 Δcycles/Δt 对名义值的达成率 —— 不用环的拍间隔反推 */
const achievePct = (isNum(r.hz) && nominal) ? r.hz / nominal * 100 : null;
const hijack = isNum(achievePct) && achievePct < 90;
if (hijack) note.push("!! 实测 " + f0(r.hz) + "Hz 只对名义 " + f0(nominal) + "Hz 的 " + f1(achievePct) + "%,档位没真跑到");
return {
kind, period, hz: r.hz, achieve: (isNum(r.hz) && nominal) ? r.hz / nominal * 100 : null,
lambda: r.lambda, drop: r.drop,
holes: Math.max(r.rxbLostMax || 0, r.rxbPendingMax || 0),
skip: (dbg0 && dbg1 && isNum(dbg1.skip_total) && isNum(dbg0.skip_total))
? (dbg1.skip_total - dbg0.skip_total) / Math.max(1e-6, r.span) : null,
p50: quantile(p50s, 0.5), p90: quantile(p90s, 0.5), pmax: pmaxs.length ? Math.max.apply(null, pmaxs) : null,
load: quantile(loads, 0.5), torn, tornCapped: tornCap, online, slots, wkc, expected,
samples: iters, note: note.join(" · "), hijack,
};
}
async function withLivePaused(fn) {
const wasLive = E.live;
if (wasLive) { E.live = false; ticker.stop(); }
btnEcatAbort.hidden = false; E_ABORT = false;
for (const b of [$("#btnEcatRun"), $("#btnSweep"), $("#btnPeriod")]) b.disabled = true;
try { return await fn(); }
finally {
for (const b of [$("#btnEcatRun"), $("#btnSweep"), $("#btnPeriod")]) b.disabled = false;
btnEcatAbort.hidden = true;
if (wasLive) { E.live = true; tickPrev = null; ecatPill("#ecatState", "warn", "观测中"); ticker.start(1000 / pollHz()); requestTick(); }
else runBusy("", "空闲");
}
}
$("#btnEcatAbort").onclick = () => { E_ABORT = true; runBusy("warn", "中止中…"); };
$("#btnEcatRun").onclick = () => withLivePaused(async () => {
const sec = clampNum($("#runSec").value, 1, 600, 10);
const hz = clampNum($("#runHz").value, 1, 20, 10);
runBusy("warn", "定点测速中…");
try {
const rec = await measureOnce("定点", sec, hz, E.live ? "" : "已暂停看板轮询");
if (E_ABORT) { runBusy("bad", "已中止"); return; }
addRunRow(rec);
runBusy(rec.hijack ? "bad" : "ok", "完成 " + f0(rec.hz) + "Hz / 达成 " + f1(rec.achieve) + "% / λ " + f0(rec.lambda));
} catch (e) { runBusy("bad", "失败:" + e.message); }
finally { runProg.textContent = "—"; }
});
$("#btnSweep").onclick = () => withLivePaused(async () => {
const periods = String($("#sweepList").value || "").split(",").map((x) => parseFloat(x.trim())).filter((x) => x > 0);
const dwell = clampNum($("#sweepDwell").value, 2, 600, 10);
const hz = clampNum($("#runHz").value, 1, 20, 10);
if (!periods.length) { runBusy("warn", "没有有效档位"); return; }
let orig = null;
try {
const st0 = await eapi("motor/state", {}, 4000);
orig = isNum(st0.period_ms) ? st0.period_ms : null;
} catch (e) { runBusy("bad", "读不到快照:" + e.message); return; }
if (orig === null) { runBusy("bad", "PDO 循环没在跑,无法安全扫描(避免用 motor/start 重启总线)"); return; }
try {
for (const ms of periods) {
if (E_ABORT) { runBusy("bad", "已中止,正在恢复周期…"); break; }
const set = await eapi("motor/period", { period_ms: ms }, 8000);
if (set.ok !== true) { addRunRow({ kind: "扫描", period: ms, note: "档位被拒:" + (set.log || []).join(" / "), hijack: true, online: 0, slots: 0, torn: 0 }); continue; }
/* 生效确认:热调异步落到循环线程,且可能被别处拽回 */
let eff = null;
for (let i = 0; i < 12; i++) {
await wsleep(250);
eff = (await eapi("motor/state", {}, 4000)).period_ms;
if (isNum(eff) && Math.abs(eff - ms) < 1e-6) break;
}
const hijacked = !(isNum(eff) && Math.abs(eff - ms) < 1e-6);
runBusy("warn", "档位 " + ms + "ms · 驻留 " + dwell + "s…");
const rec = await measureOnce("扫描 " + ms + "ms", dwell, hz,
hijacked ? ("下发 " + ms + "ms 但生效 " + eff + "ms(疑 localStorage 劫持或有他端在设)") : "");
rec.period = eff; rec.hijack = rec.hijack || hijacked;
if (E_ABORT) { addRunRow(Object.assign(rec, { note: "中止时部分样本 · " + rec.note })); break; }
addRunRow(rec);
}
} catch (e) {
runBusy("bad", "扫描异常:" + e.message);
} finally {
/* 无论正常跑完还是中止,都必须把周期放回原值并回读校验 —— 总线是共享的 */
try {
await eapi("motor/period", { period_ms: orig }, 8000);
let back = null;
for (let i = 0; i < 12; i++) {
await wsleep(250);
back = (await eapi("motor/state", {}, 4000)).period_ms;
if (isNum(back) && Math.abs(back - orig) < 1e-6) break;
}
runBusy(isNum(back) && Math.abs(back - orig) < 1e-6 ? "ok" : "bad",
isNum(back) && Math.abs(back - orig) < 1e-6 ? ("扫描结束,已恢复 " + orig + "ms")
: ("!! 恢复失败:设定 " + orig + "ms 回读 " + back + "ms,请手动确认"));
} catch (e) { runBusy("bad", "!! 恢复周期请求失败:" + e.message); }
runProg.textContent = "—";
}
});
$("#btnRxbClear").onclick = () => {
E.seenFrames = 0; E.subs.clear(); E.lastRawCycle = null; E.tornPrev = null; E.reentryPrev = null;
E.tornAccum = 0; E.reentryAccum = 0; E.tornCap = false; E.reentryCap = false;
E.seenT0 = performance.now();
/* 暂停观测时不会再渲染,标签必须就地归零,否则点了像没生效 */
$("#rxbSeen").textContent = "本页轮询采到的帧样本 0 条";
tblFramesBody.textContent = ""; tblSubsBody.textContent = ""; tblWinBody.textContent = "";
};
/* ═══════════════════ 初始化 ═══════════════════ */
showPane(ls("speedtest_pane") === "spi" ? "spi" : "ecat");
layoutChart();
window.addEventListener("beforeunload", () => { E.live = false; ticker.stop(); });
/* 诊断入口:控制台里 __speedtest.E 能直接看当前累加的原始响应 */
window.__speedtest = { E, eapi, RateWindow, decodeStatus, measureOnce };
})();
</script>
</body>
</html>