Files
DCU_DesktopController/lab/spi_test.html
T

1843 lines
106 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 / reltimeout / overfull / reject 是**饱和**计数(到 65535 停),
封顶只能报"下界"。旧字段 pdo_reentry 在 600B 布局下实为 reltimeout@592。
5. 从站 load_perm 在从站停报时会保留残值,必须用 age_ms<1000 闸一下,
否则会把楔死前的旧值当实测画进曲线。
6. 刷新率一律以**实测**(Δcycles/Δt)为准,名义 1000/period_ms 只作对照。
【实时性】观测仍默认自动开、默认 10Hz。逐拍环 / 原始窗口 / 帧表·hex
「每拍重建」都在各自模块卡片里开关(默认关)。标签页隐藏时自动暂停。
EtherCAT 面板的定点测速/档位扫描模块已下线(热改周期仍保留)。
-->
<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-dim:rgba(252,191,63,.14);
--can:#43d97b;
--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; }
/* 面板内模块用 gap 分隔(对齐全站 .main-layout / .card+.card 的 --gap=18px 习惯) */
#pane-spi, #pane-ecat { display:flex; flex-direction:column; gap:18px; }
/* ═══════ 头部 + 面板切换 ═══════ */
header { display:flex; flex-wrap:wrap; gap:14px; align-items:flex-end; margin-bottom:18px; }
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); }
/* ═══════ 卡片(对齐 layout.css:色带 + 独立卡头 + 内容内边距) ═══════ */
.card {
position:relative;
background:linear-gradient(170deg, var(--bg-card) 0%, rgba(15,23,38,.92) 100%);
border:1px solid var(--border); border-radius:var(--radius);
overflow:hidden; box-shadow:var(--shadow-card);
padding:0 16px 16px;
}
/* 顶部 2px 子系统色带(layout.css .card::before 同款) */
.card::before {
content:""; display:block; height:2px; margin:0 -16px;
background:linear-gradient(90deg, var(--ecat), transparent 85%);
opacity:.7; pointer-events:none;
}
#pane-spi .card::before { background:linear-gradient(90deg, var(--spi), transparent 85%); }
.card > h2 {
margin:0 -16px 14px; padding:13px 16px 12px;
border-bottom:1px solid var(--border-subtle);
background:rgba(255,255,255,.02);
font-size:12px; font-weight:600; letter-spacing:.9px; text-transform:uppercase;
color:var(--text-label); 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); flex:0 0 auto; }
#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:18px; }
@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 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>
<label>采样率
<select id="hz">
<option value="1">1 Hz</option>
<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="runPill" class="pill"><span class="led"></span>空闲</span>
<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(瓶颈在从站不在主站,
只看主站达成率会整条看漏)。<br>
<b>观测默认 10Hz</b>。逐拍环 / 原始窗口 / 每拍重建都在各自模块卡片里开关(默认关)。标签页切到后台会自动暂停。
</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">—<small>Hz</small></div><div class="f">cycles 差分</div></div>
<div class="metric" id="cAchieve"><div class="k">达成率</div><div class="v">—</div><div class="f">名义 — Hz</div></div>
<div class="metric" id="cBus"><div class="k">单拍耗时</div><div class="v">—<small>µs</small></div><div class="f">p50 / p90 / max</div></div>
<div class="metric" id="cDuty"><div class="k">主站占空比</div><div class="v">—</div><div class="f">单拍 p50 ÷ 周期</div></div>
<div class="metric" id="cSlot"><div class="k">单拍分段 p50</div><div class="v">—</div><div class="f">work / send / rx µs</div></div>
<div class="metric" id="cLambda"><div class="k">CAN 到帧率 λ</div><div class="v">—<small>/s</small></div><div class="f">Δglobal_seq (u32)</div></div>
<div class="metric" id="cDrop"><div class="k">真丢帧</div><div class="v">—<small>帧/拍</small></div><div class="f">主站空洞 · GD32 OVF</div></div>
<div class="metric" id="cSkip"><div class="k">跳拍</div><div class="v">—<small>/s</small></div><div class="f">累计 —</div></div>
<div class="metric" id="cLoad"><div class="k">从站 CPU 负载</div><div class="v">—</div><div class="f">load_perm · age —</div></div>
<div class="metric" id="cWkc"><div class="k">WKC</div><div class="v">—</div><div class="f">期望 —</div></div>
<div class="metric" id="cTorn"><div class="k">CAN 病理仪表</div><div class="v">—</div><div class="f">撕裂 · RFD超时 · 满溢 · 拼接</div></div>
<div class="metric" id="cRtt"><div class="k">本页请求耗时</div><div class="v">—<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>GD32 侧</u>未取先覆
<code>rx_batch.pending</code>(= OVF,与 ③ 是两个独立仪表,正常应当同步增长);
⑤ CAN 取帧撕裂 <code>rx_torn</code>;⑥ FIFO 满溢 <code>overfull</code>;⑦ 内容拼接 <code>reject</code>。
③④ 是本页 10Hz 抽样看到的<u>单拍值</u>,只能当下界。密排 STATUS 不走段4,
真丢帧仪表只盖长帧/win 路径。
撕裂 / 重入是<u>饱和</u>计数(到 65535 停),封顶时只能报下界。
</div>
</div>
<div class="card">
<h2>实时曲线 <span class="hint2">滚动 90s · 紫 = 实测 PDO Hz(左轴) · 青 = 单拍 p50 µs(右轴)</span></h2>
<div class="row" style="margin-bottom:8px">
<button id="btnChartMax" class="sm ghost" title="cyc_us.total.max(后端 STATS_N=400 拍窗口最大值)">单拍 max:关</button>
<button id="btnChartSkip" class="sm ghost" title="cyc_us.late / 窗口时长(late=起跑超期拍数)">跳拍:关</button>
<span id="chartHint" class="hint" style="margin:0">开 max / 跳拍 可叠加显示;右轴 µs,跳拍独立缩放</span>
</div>
<canvas id="chart" height="160"></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 · 1.6.21 密排:段2整轮槽 + 段3长帧变更</span></h2>
<div class="row" style="margin-bottom:8px">
<span id="rxbPill" class="pill">本拍新帧 / seq 空洞 / GD32 OVF —</span>
<span id="rxbSeen" class="pill">本页帧样本 —</span>
<button id="btnHeavyFrames" class="sm ghost" title="帧表/密排槽表是否每拍重建 DOM。关=观测时不动这两块">每拍重建:关</button>
<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" id="winSlotCap">
密排整轮槽(GD32 每拍原样拷出 count×56B:node ch online dlc seq + 48B STATUS)。
末列「本拍新数据」:<b>是</b> = seq 比上一拍大(差分命中,状态真变了);<b>否</b> = seq 未变,固件本拍跳过填充。</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>密排整轮(1.6.21)</b>:接 N 个电机本拍就发 N 个状态,空槽不发送。
STATUS 全在上方状态槽解码;本表是同一拍的<b>线级原样</b>(seq / online / 前 8B 载荷),
用来核对"差分是否真的跳过了未变电机"。<br>
<b>长帧(CONFIG 24B / 版本应答)</b>:段 3「最近一帧」,内容变了才进上面的事件表
(主站按 ch/dlc/id/data 逐字节比对,相同不重复上报)。逐字节的真凭实据在下面第 ③ 区。<br>
本表看不到的还有三层 —— CAN 控制器 err/TEC/REC、GD32 软件 RX 队列满丢(深度 16,
只在 ESP32 <code>/can/feedback</code> 有)、CAN 取帧撕裂 <code>rx_torn</code>。
逐层对账跑 <code>scripts/can_bridge_reconcile.py</code>。</div>
</div>
<div class="card">
<h2>收到的东西 ③ 原始接收窗口 + 长帧 + 逐拍环 <span class="hint2">/api/motor/rxraw · state.xrx · /api/motor/dbg(全为只读缓存拷贝)</span></h2>
<div class="row" style="margin-bottom:8px">
<button id="btnRxraw" class="sm ghost" title="每拍多打 /api/motor/rxraw(约 600B hex)。默认关">原始窗口:关</button>
<button id="btnHeavyHex" class="sm ghost" title="原始 hex 网格是否每拍重建 DOM。关=观测时不动这块">hex每拍重建:关</button>
<button id="btnRing" class="sm ghost" title="每拍多打 /api/motor/dbg(约 160KB)。默认关,只在看抖动时开">逐拍环:关</button>
</div>
<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>:dense=1.6.21 密排整轮 / 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">接收区原始字节 —— dense:8B 整轮头 + count×56B 槽;win:44B/槽 覆盖窗口</div>
<div id="rawHex" class="hexgrid" style="--hex-h:158px"><i class="hdr">—</i></div>
</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);
}
}
/** 状态胶囊:SPI / ECAT / 热改周期共用同一套 led + 文案结构 */
function setPill(sel, cls, text) {
const n = $(sel); n.className = "pill" + (cls ? " " + cls : "");
n.innerHTML = '<span class="led"></span>'; n.appendChild(el("span", null, text));
}
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;
}
/**
* 有界回绕差分:真回绕最多一步 m;但 gseq 在「INIT→OP 重拉 / 撕裂读 / 布局误判」
* 时会倒退或跳飞,(cur-prev) mod 2^32 会变成十几亿——看板曾把 1,250,025,703/s
* 当成 λ。超过 maxStep 的点对一律作废(返回 null),调用方计 untrusted。
*/
function wrapDeltaBounded(prev, cur, bits, maxStep) {
const d = wrapDelta(prev, cur, bits);
if (!(d <= maxStep)) return null;
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 级帧周期,这边 N Hz×M 条代理请求
* 会把自己的测量抬高 —— 先排除自己造的假象。 */
const was = E.live; stopEcat(); E.wantLive = was;
setPill("#ecatState", "", "已暂停(在 SPI 页)");
}
}
for (const b of $$(".tab")) b.onclick = () => showPane(b.dataset.pane);
/* ═══════════════════ SPI 测速面板 ═══════════════════
* 设备端点无应答时原实现会把按钮永久卡在 disabled(fetch 没有超时),这就是"卡死"。
* 这里每次请求都带 AbortController + 超时,并额外给一个手动中止口。 */
const spiOut = $("#out"), spiHist = $("#hist"), 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;
setPill("#spiState", cls, 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, hz: null, lambda: null, drop: null, skip: null,
untrusted: 0, motorStep: {}, motorChanges: {},
rxbLostMax: 0, rxbPendingMax: 0,
};
if (s.length < 2) return out;
let dCyc = 0, dLam = 0, dDrop = 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;
if (isNum(a.cycles) && isNum(b.cycles)) dCyc += (b.cycles - a.cycles);
if (isNum(a.gseq) && isNum(b.gseq)) {
/* 采样 ~10Hz,250µs 档名义 λ=4000/s → 一对样本最多几百;给 50 倍余量。
* 真正的 u32 回绕(4e9)与倒退/跳飞一律作废,否则一次重拉就能把
* λ 显示成 1.25e9/s(2026-09-13 看板实测)。 */
const maxStep = Math.max(50000, Math.ceil(pair * 200000));
const dl = wrapDeltaBounded(a.gseq, b.gseq, 32, maxStep);
if (dl === null) out.untrusted++;
else dLam += dl;
}
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 = wrapDeltaBounded(a.seq[k], b.seq[k], 32, Math.max(50000, Math.ceil(pair * 200000)));
if (d === null || 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.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 (hi & 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, rxraw: false,
heavyFrames: false, heavyHex: false,
wantLive: false, everStarted: false, pausedByHidden: 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,
overPrev: null, rejPrev: null, overAccum: 0, rejAccum: 0,
lastRawCycle: null, seenFrames: 0, seenT0: 0, subs: new Map(),
lastSeq: {}, seqAt: {}, densLast: null,
chart: { t: [], hz: [], us: [], usMax: [], lateHz: [] },
chartOpt: { max: false, skip: false },
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");
/** 节拍源:隐藏标签页会把主线程 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 响应体最大(约 600B hex + parsed),默认关。开启时才拉,并计入 rtt。 */
if (E.rxraw) {
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,
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);
/* 帧表 / hex 网格:各自模块的「每拍重建」开关(默认关)。关着时 DOM 冻结。 */
if (E.rxraw && raw) {
if (E.heavyFrames) renderFrames(st, raw);
else countSeenFrames(raw); /* 计数照走,只是不重画表 */
if (E.heavyHex) renderRaw(st, raw);
} else if (E.heavyHex) {
renderRaw(st, null);
}
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; E.pausedByHidden = false;
tickPrev = null; E.pollDts = [];
E.seenFrames = 0; E.seenT0 = performance.now(); E.lastRawCycle = null;
$("#btnLive").textContent = "暂停观测";
setPill("#ecatState", "warn", "观测中 · " + pollHz() + "Hz" + (E.rxraw ? " · rxraw" : ""));
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; E.pausedByHidden = false;
ticker.stop();
$("#btnLive").textContent = "开始实时观测";
setPill("#ecatState", "", "已暂停");
}
/* 后台标签页:Worker 定时器不受节流,live 着=持续打代理。暂停但记住 wantLive。 */
function pauseEcatHidden() {
if (!E.live) return;
E.pausedByHidden = true;
E.live = false;
ticker.stop();
$("#btnLive").textContent = "后台已暂停 · 点此恢复";
setPill("#ecatState", "", "后台已暂停");
}
function resumeEcatShown() {
if (!E.pausedByHidden || !E.wantLive) return;
E.pausedByHidden = false;
startEcatIfIdle();
}
document.addEventListener("visibilitychange", () => {
if (document.hidden) pauseEcatHidden();
else resumeEcatShown();
});
$("#btnLive").onclick = () => { E.live ? stopEcat() : startEcatIfIdle(); };
$("#btnRing").onclick = () => {
E.ring = !E.ring;
$("#btnRing").textContent = "逐拍环:" + (E.ring ? "开" : "关");
if (!E.ring) { tblBeatsBody.textContent = ""; $("#beatStat").textContent = "逐拍环未开启。"; }
};
$("#btnRxraw").onclick = () => {
E.rxraw = !E.rxraw;
$("#btnRxraw").textContent = "原始窗口:" + (E.rxraw ? "开" : "关");
$("#btnRxraw").title = E.rxraw
? "每拍额外读 /api/motor/rxraw(约 600B hex)。对 EtherCAT 有负载,看完建议关掉"
: "每拍多打 /api/motor/rxraw(约 600B hex)。默认关";
if (E.live) setPill("#ecatState", "warn", "观测中 · " + pollHz() + "Hz" + (E.rxraw ? " · rxraw" : ""));
if (!E.rxraw) E.raw = null;
if (E.rxraw && E.lastState && E.raw) {
if (E.heavyFrames) renderFrames(E.lastState, E.raw);
if (E.heavyHex) renderRaw(E.lastState, E.raw);
}
};
$("#btnHeavyFrames").onclick = () => {
E.heavyFrames = !E.heavyFrames;
$("#btnHeavyFrames").textContent = "每拍重建:" + (E.heavyFrames ? "开" : "关");
if (E.heavyFrames && E.lastState && E.raw) renderFrames(E.lastState, E.raw);
};
$("#btnHeavyHex").onclick = () => {
E.heavyHex = !E.heavyHex;
$("#btnHeavyHex").textContent = "hex每拍重建:" + (E.heavyHex ? "开" : "关");
if (E.heavyHex && E.lastState) {
if (E.rxraw && E.raw) renderRaw(E.lastState, E.raw);
else renderRaw(E.lastState, null);
}
};
$("#hz").onchange = () => {
E.win.reset();
if (E.live) 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"));
/* 单拍分段:cyc_us.work/send/rx 的 p50(µs)。
* work=t0→te 应用+组帧+等应答全程;send=ts→tm 发送段;rx=tm→tr 收应答段。
* max 冲到 1~2ms 时对一下哪一段先涨,比再放一个和 PDO 同数的卡有用。 */
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 segW = f0((cyc.work || {}).p50), segS = f0((cyc.send || {}).p50), segR = f0((cyc.rx || {}).p50);
const hasSeg = cyc.work && cyc.send && cyc.rx;
metric("cSlot",
hasSeg ? (segW + "<small> / " + segS + " / " + segR + " µs</small>") : "—",
hasSeg
? "work=" + segW + " send=" + segS + " rx=" + segR +
" · 总耗时看左侧「单拍耗时」"
: "cyc_us 未上报",
hasSeg ? "good" : "");
metric("cLambda", f0(r.lambda) + "<small>/s</small>",
(isNum(r.lambda) ? ("λ ÷ 实测Hz = " + (isNum(r.hz) && r.hz ? f2(r.lambda / r.hz) : "—") + " 帧/拍") : "—")
+ (r.untrusted ? " · " + r.untrusted + " 点对作废(gseq 跳变/倒退)" : ""),
isNum(r.lambda) ? (r.lambda > 0 ? "good" : "bad") : "");
/* drop 恒等于 λ−Hz,零信息量;主数字只报真丢帧:主站 seq 空洞 与 GD32 OVF
* (密排 STATUS 不进 rx_batch,两者对 STATUS 恒 0,只盖长帧/win 布局路径)。 */
const holes = Math.max(r.rxbLostMax || 0, r.rxbPendingMax || 0);
const tornN = E.tornAccum || 0;
metric("cDrop", holes + "<small>帧/拍</small>",
"主站空洞 " + (r.rxbLostMax || 0) + " · GD32 OVF " + (r.rxbPendingMax || 0) +
(holes > 0 || tornN ? " · 撕裂累计 " + tornN : "") +
" · 段1/3 drop " + f0(r.drop) + "/s(≡λ−Hz,不判丢帧" +
(r.untrusted ? " · " + r.untrusted + " 点作废" : "") + ")" +
((E.raw && E.raw.layout === "dense") ? " · 密排 STATUS 路径无丢帧仪表" : ""),
holes > 0 ? "bad" : (tornN > 0 ? "warn" : (r.drop === null && !holes ? "" : "good")));
/* 超期/跳拍:cyc_us.late/lost 随 state 每拍就有(最近 n 拍窗口),不必开逐拍环。
* 口径是**窗口计数**不是自启动累计;且只有 2026-09-11 之后的主站构建接了真值,
* 旧构建恒报 0 —— 看到 0 先确认后端版本,别当"没有超期"。 */
const cu = (st && st.cyc_us) || {};
const lateN = isNum(cu.late) ? cu.late : null, lostN = isNum(cu.lost) ? cu.lost : null;
const winN = isNum(cu.n) ? cu.n : 0;
const lateHz = (lateN !== null && winN > 1 && isNum(period)) ? lateN / (winN * period / 1000) : null;
metric("cSkip", (lateHz === null ? "—" : f1(lateHz)) + "<small>/s</small>",
lateN === null ? "cyc_us.late 未上报(旧后端恒 0 占位,不可当无超期)"
: "窗口 " + winN + " 拍:超期 " + lateN + " · 跳过 " + (lostN ?? "—") +
(isNum(r.skip) ? " · skip_total 累计 " + ((dbg && dbg.skip_total) ?? "—")
: " · 开逐拍环可看自启动累计"),
lateN === null ? "" : (lateHz < 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"));
/* 段5 病理仪表(600B dense/win):torn@586 撕裂 · reltimeout@592 RFD释放超时
* · overfull@590 FIFO满溢 · reject@594 内容拼接。1.6.13 根治后后三者+torn
* 健康态应恒 0;任一再涨=对应病理复发。旧 slim 布局 pdo_reentry@492 才是真重入。 */
if (load && isNum(load.rx_torn)) {
const [dt] = satDelta(E.tornPrev, load.rx_torn);
const [dr] = satDelta(E.reentryPrev, load.reltimeout != null ? load.reltimeout : load.pdo_reentry);
const [dov] = satDelta(E.overPrev, load.overfull);
const [drj] = satDelta(E.rejPrev, load.reject);
if (E.tornPrev !== null) E.tornAccum = (E.tornAccum || 0) + dt;
if (E.reentryPrev !== null) E.reentryAccum = (E.reentryAccum || 0) + dr;
if (E.overPrev !== null) E.overAccum = (E.overAccum || 0) + dov;
if (E.rejPrev !== null) E.rejAccum = (E.rejAccum || 0) + drj;
E.tornCap = E.tornCap || load.rx_torn >= 65535;
E.reentryCap = E.reentryCap || (load.reltimeout != null ? load.reltimeout : load.pdo_reentry) >= 65535;
E.tornPrev = load.rx_torn;
E.reentryPrev = load.reltimeout != null ? load.reltimeout : load.pdo_reentry;
E.overPrev = load.overfull; E.rejPrev = load.reject;
const tN = E.tornAccum || 0, rN = E.reentryAccum || 0;
const oN = E.overAccum || 0, jN = E.rejAccum || 0;
const any = tN || rN || oN || jN;
metric("cTorn",
tN + " / " + rN + "<small style='font-size:11px'> 撕裂/RFD</small>",
"累计增量 · 撕裂 " + tN + " · RFD超时 " + rN + " · FIFO满溢 " + oN + " · 拼接 " + jN +
((E.tornCap || E.reentryCap) ? " · 有计数已封顶(下界)" : "") +
(isNum(load.reject) ? "" : " · 旧后端无 reject/overfull"),
any === 0 ? "good" : "warn");
} else {
metric("cTorn", "—", "需 /api/app/load valid(撕裂/RFD/满溢/拼接 走段5)");
}
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) {
setPill("#ecatLink", "bad", "后端未交换:" + ((st.log || []).join(" / ") || "未知"));
} else if (!exchanging) {
setPill("#ecatLink", "warn", "PDO 循环没在跑(wkc " + (st ? st.wkc : "—") + "/" + (st ? st.expected_wkc : "—") +
"," + (((st && st.motors) || []).length - mv.length) + " 槽 seq 停更)→ 去电机页/EtherCAT 页连一次");
} else {
setPill("#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));
setPill("#ecatAl", s.state === 8 ? "ok" : "warn", "AL " + al + (s.al_status ? " code 0x" + s.al_status.toString(16).toUpperCase() : ""));
} else {
setPill("#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(GD32 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],
]);
}
/* ═══════════════════ 渲染:电机状态槽 ═══════════════════ */
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)) : "—"));
}
}
/* ═══════════════════ 渲染:批量接收区(按 cycle 去重) ═══════════════════ */
/** 帧样本计数(与 DOM 重建解耦:每拍重建关着时计数仍走这里) */
function countSeenFrames(raw) {
if (!raw || !isNum(raw.cycle) || raw.cycle === E.lastRawCycle) return;
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);
}
}
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 + " / GD32 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) + "%(全量速率看 λ)");
countSeenFrames(raw);
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",
raw && raw.layout === "dense"
? "本拍无长帧变更(STATUS 在上方状态槽 + 下方整轮槽表;CONFIG/版本变了才会进这里)"
: "这一拍没有新到的批量帧(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;
/* 1.6.21 密排整轮 raw:8B 头(count active boot magic gseqLE) + count×56B 槽
* 槽 = node@0 ch@1 online@2 dlc@3 seq@4..7 + 48B STATUS@8..55 */
const DENSE_HDR = 8, DENSE_SLOT = 56, DENSE_STATUS = 48;
function decodeDenseRound(hex) {
if (!hex || hex.length < DENSE_HDR * 2) return null;
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);
const head = { count: b[0], active: b[1], boot: b[2], magic: b[3], gseq: dv.getUint32(4, true) };
const slots = [];
for (let s = 0; s < head.count; s++) {
const o = DENSE_HDR + s * DENSE_SLOT;
if (o + DENSE_SLOT > b.length) break;
const dlc = b[o + 3];
let data = "";
const nd = Math.min(dlc || 0, 8);
for (let i = 0; i < nd; i++) data += b[o + 8 + i].toString(16).padStart(2, "0");
/* 48B 全长 hex(详情用) */
let full = "";
const nf = Math.min(dlc || 0, DENSE_STATUS);
for (let i = 0; i < nf; i++) full += b[o + 8 + i].toString(16).padStart(2, "0");
slots.push({ slot: s, node: b[o], ch: b[o + 1], online: b[o + 2] !== 0, dlc,
seq: dv.getUint32(o + 4, true), data: data.toUpperCase(), full: full.toUpperCase() });
}
return { head, slots };
}
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;
body.textContent = "";
if (raw && raw.layout === "dense") {
renderDenseRound(raw, body);
return;
}
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);
}
}
/* 1.6.21 密排整轮槽表:同一拍的线级原样,与上方状态槽解码结果对账用 */
function renderDenseRound(raw, body) {
const round = decodeDenseRound(raw.raw);
if (!round || !round.slots.length) {
const tr = el("tr");
tr.appendChild(el("td", "dim", "密排整轮 raw 为空或 count=0(这一拍没有电机槽)"));
tr.children[0].colSpan = 10;
body.appendChild(tr);
return;
}
if (!E.densLast) E.densLast = new Map();
const cap = document.getElementById("winSlotCap");
if (cap) cap.innerHTML =
"密排整轮槽 · count=" + round.head.count + " active=" + round.head.active +
" boot=" + round.head.boot + " magic=0x" + round.head.magic.toString(16).toUpperCase() +
" gseq=" + round.head.gseq.toLocaleString("en-US") +
"(node ch online dlc seq + 48B STATUS,空槽不发送)。" +
" 末列「本拍新数据」:<b>是</b> = seq 比上一拍大(差分命中,状态真变了);" +
"<b>否</b> = seq 未变,固件本拍跳过填充。";
for (const s of round.slots) {
const prev = E.densLast.get(s.node + ":" + s.ch);
const dseq = (prev === undefined) ? null : (s.seq - prev);
const isNew = dseq !== null && dseq !== 0;
E.densLast.set(s.node + ":" + s.ch, s.seq);
/* STATUS CAN ID:0x180+node(DBC v0.74,默认扩展帧在 MotorState 侧,
* 这里只标语义,真 ID 以状态槽/DBC 为准) */
const tr = el("tr");
const td = (v, cls) => tr.appendChild(el("td", cls, v));
td(s.slot, "num");
td(s.online ? "在线" : "离线", s.online ? "good" : "warn");
td(s.seq.toLocaleString("en-US"), "num");
td(dseq === null ? "—" : (dseq === 0 ? "0(未变)" : "+" + dseq), "num " + (dseq ? "good" : "dim"));
td(s.ch, "num");
td(s.dlc, "num");
td("0x" + (0x180 + s.node).toString(16).toUpperCase(), "num");
td("N" + s.node + " · STATUS");
td(hexShort(s.data, 16), "hex");
td(prev === undefined ? "首见" : (isNew ? "是(seq 前进)" : "否(差分跳过)"),
isNew || prev === undefined ? "good" : "dim");
tr.title = "48B STATUS: " + (s.full || s.data);
body.appendChild(tr);
}
}
/* ═══════════════════ 渲染:原始接收窗口 ═══════════════════ */
function renderRaw(st, raw) {
const x = (st && st.xrx) || null;
if (!E.rxraw && !raw) {
/* 原始窗口关:只刷 kv/xrx(来自 state,零额外请求),hex 区写提示不重建网格 */
kv("rawKv", [
["rxraw cycle", null],
["layout", "—(原始窗口未开)"],
["count / lost / pending", null],
["gseq", null],
["len (字节)", null],
["parsed 帧数", 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"],
]);
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");
if (grid.childNodes.length !== 1 || !grid.firstChild.classList || !grid.firstChild.classList.contains("hdr")) {
grid.textContent = "";
grid.appendChild(el("i", "hdr", "原始窗口未开启 —— 点「原始窗口:开」才会每拍读 /api/motor/rxraw(对 EtherCAT 有负载)"));
}
return;
}
/* rxraw 的三个头字节随布局变义:win=slot_count/wr_idx/ovf;
* dense=count/active/boot(lost/pending 对 STATUS 不适用) */
const isWin = raw && raw.layout === "win";
const isDense = raw && raw.layout === "dense";
const hLbl = isWin ? ["窗口槽数 slot_count", "写游标 wr_idx(下一格覆盖谁)", "GD32 OVF 未取先覆"]
: isDense ? ["count 密排电机数", "active", "boot"]
: ["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],
["gseq", isDense && isNum(raw.gseq) ? raw.gseq.toLocaleString("en-US") : 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 === "dense") {
/* 密排:8B 头 + count×56B 槽。头单独一行,每槽 56B=112 hex 切成 2 行 */
const hex = raw.raw;
if (hex.length >= 16) cell("头 " + hexGrp(hex.substr(0, 16)), "hdr");
for (let s = 0; s * DENSE_SLOT * 2 < hex.length - 16; s++) {
const off = 16 + s * DENSE_SLOT * 2, slotHex = hex.substr(off, DENSE_SLOT * 2);
if (!slotHex) break;
cell("槽" + s + " " + hexGrp(slotHex.substr(0, 16)), "slot");
cell(" " + hexGrp(slotHex.substr(16, 32)));
if (slotHex.length > 48) cell(" " + hexGrp(slotHex.substr(48, 32)));
if (slotHex.length > 80) cell(" " + hexGrp(slotHex.substr(80)));
}
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(160 * dpr);
c2.setTransform(dpr, 0, 0, dpr, 0, 0);
drawChart();
}
/** 每点:Hz / p50µs / maxµs(STATS_N=400 窗口) / lateHz(跳拍率) */
function pushChart(r, st) {
if (!isNum(r.hz)) return;
const cu = (st && st.cyc_us) || {};
const tot = cu.total || {};
const period = st && st.period_ms;
const winS = isNum(cu.n) && period > 0 ? (cu.n * period) / 1000 : null;
const lateHz = isNum(cu.late) && winS > 0 ? cu.late / winS : null;
const t = performance.now();
const ch = E.chart;
ch.t.push(t); ch.hz.push(r.hz);
ch.us.push(isNum(tot.p50) ? tot.p50 : null);
ch.usMax.push(isNum(tot.max) ? tot.max : null);
ch.lateHz.push(lateHz);
while (ch.t.length > 2 && t - ch.t[0] > CHART_WIN_MS) {
ch.t.shift(); ch.hz.shift(); ch.us.shift(); ch.usMax.shift(); ch.lateHz.shift();
}
}
function drawChart() {
const w = cvs.clientWidth, h = 160, pl = 46, pr = 52, 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;
c2.textAlign = "left";
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;
/* 右轴 µs:p50 恒画;max 开关打开时一并纳入量程,避免 max 顶出画布 */
const usPool = E.chart.us.slice();
if (E.chartOpt.max) usPool.push.apply(usPool, E.chart.usMax);
const usVals = usPool.filter(isNum);
const usMaxV = (usVals.length ? Math.max.apply(null, usVals) : 100) * 1.2;
const lateVals = E.chart.lateHz.filter(isNum);
const lateMaxV = E.chartOpt.skip
? Math.max(0.01, (lateVals.length ? Math.max.apply(null, lateVals) : 1) * 1.25)
: 0;
const Y1 = (v) => pt + (h - pt - pb) * (1 - v / hzMax);
const Y2 = (v) => pt + (h - pt - pb) * (1 - v / usMaxV);
const Y3 = (v) => pt + (h - pt - pb) * (1 - v / lateMaxV);
/* 紫:实测 Hz 面积+折线 */
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();
const line = (arr, Y, color, width, dash) => {
c2.beginPath();
let started = false;
c2.setLineDash(dash || []);
arr.forEach((v, i) => {
if (!isNum(v)) { started = false; return; }
const x = X(t[i]), y = Y(v);
if (!started) { c2.moveTo(x, y); started = true; } else c2.lineTo(x, y);
});
c2.strokeStyle = color; c2.lineWidth = width; c2.stroke();
c2.setLineDash([]);
};
/* 青:单拍 p50 µs */
line(E.chart.us, Y2, "#22d3ee", 1.4);
/* 琥珀:单拍 max µs(STATS_N=400 窗口)—— 开关 */
if (E.chartOpt.max) line(E.chart.usMax, Y2, "#fcbf3f", 1.6);
/* 红:跳拍 late/s(独立缩放)—— 开关 */
if (E.chartOpt.skip) line(E.chart.lateHz, Y3, "#ff6b6b", 1.5, [4, 3]);
c2.fillStyle = "#93a8c4"; c2.textAlign = "left";
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(usMaxV) + " µs", w - 3, pt + 8);
c2.fillText(f0(usMaxV / 2) + " µs", w - 3, pt + (h - pt - pb) / 2 + 3);
if (E.chartOpt.skip && lateVals.length) {
c2.fillStyle = "#ff6b6b";
c2.fillText(f1(lateMaxV / 1.25) + " late/s", w - 3, pt + 20);
}
c2.textAlign = "left"; c2.fillStyle = "#617a99";
const parts = ["← " + f1((t1 - t0) / 1000) + "s 窗口"];
parts.push("紫 Hz");
parts.push("青 p50µs");
if (E.chartOpt.max) parts.push("琥珀 maxµs(STATS_N=400)");
if (E.chartOpt.skip) parts.push("红 late/s(独立缩放)");
c2.fillText(parts.join(" · "), pl, h - 5);
}
$("#btnChartMax").onclick = () => {
E.chartOpt.max = !E.chartOpt.max;
$("#btnChartMax").textContent = "单拍 max:" + (E.chartOpt.max ? "开" : "关");
drawChart();
};
$("#btnChartSkip").onclick = () => {
E.chartOpt.skip = !E.chartOpt.skip;
$("#btnChartSkip").textContent = "跳拍:" + (E.chartOpt.skip ? "开" : "关");
drawChart();
};
window.addEventListener("resize", layoutChart);
/* ═══════════════════ 热改 PDO 周期 ═══════════════════ */
function runBusy(cls, text) { setPill("#runPill", cls, 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); }
};
$("#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.overAccum = 0; E.rejAccum = 0; E.overPrev = null; E.rejPrev = null;
E.densLast = null;
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 };
})();
</script>
</body>
</html>