Files
DCU_DesktopController/lab/spi_test.html
T
cbb 383535bd1d feat(web): 桌面原生桥 + 配置落盘 + 无设备静默态;排除调试脚本
变更点:
1. esp32_server.py
   - /api/motor/ui-state、/api/lab/ui-state:配置原子落盘 data/*.json(WebView2 清存储仍在)
   - _GuiApi.ecat_api/bridge_info:桌面壳经 GUI 进程裸 socket 直连 ecat_native,不经浏览器 HTTP
2. js/common/desktop-bridge.js(新)
   - 探测 pywebview.api(含 iframe top/parent),transport-ecat 优先走原生桥
3. connection.js + responsive.css + components.css
   - setQuietMode:离线且无传输时 html[data-dcu-calm=1] 掐常驻动画
   - 进度条斜纹仅 .live 挂载,空闲不再按刷新率空转重绘
4. device-link.js
   - 先查 native/health,已健康跳过 motor/start,避免 loop_stop 重进 OP 假超时
   - 周期强制 0.25ms + DC SYNC0 对齐;轮询自愈被配错的 DC/周期
5. motor/main.js + transport-ecat.js
   - 配置双写 localStorage + 磁盘 ui-state;pagehide/sendBeacon 刷盘;导入导出
   - 探测代际 probeGen;ECAT 失败阈值 12;跟踪 bootActive/slaveAl/slaveOp
6. lab/spi_test.html、ota/boot_ecat.js、can_monitor 等跟随桥/静默态
7. .gitignore
   - 排除 scripts/_* 与 anal/collect/measure/trace 等调试脚本、lab 产物、data/ 运行态
2026-09-20 22:58:00 +08:00

2326 lines
125 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/motor/pdoin · /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; --can-hi:#7deda6;
--ble:#5fa8ff;
--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; --radius-lg:16px;
--font:"PingFang SC","Microsoft YaHei","Segoe UI",system-ui,sans-serif;
--font-display:var(--font);
--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; }
/* ═══════ 页头命令栏(对齐 layout.css .header-bar) ═══════ */
.header-bar {
position:relative; z-index:20;
display:flex; align-items:center; gap:16px;
padding:16px 22px; margin-bottom:26px;
background:
radial-gradient(620px 240px at 88% -40%, rgba(47,216,193,.12), transparent 60%),
linear-gradient(160deg, var(--bg-panel) 0%, var(--bg-card) 70%);
border:1px solid var(--border);
border-radius:var(--radius-lg);
box-shadow:var(--shadow-card);
flex-wrap:wrap;
}
.header-bar::before {
content:""; position:absolute; top:0; left:0; right:0; height:2px;
background:linear-gradient(90deg, var(--esp32) 0%, var(--ble) 38%, var(--gd32) 78%, transparent 100%);
opacity:.85;
border-radius:var(--radius-lg) var(--radius-lg) 0 0;
}
.header-brand {
display:flex; align-items:center; gap:12px;
padding-right:20px; border-right:1px solid var(--border);
white-space:nowrap;
}
.brand-logo {
width:40px; height:40px; flex-shrink:0;
display:inline-flex; align-items:center; justify-content:center;
border-radius:11px;
background:linear-gradient(145deg, rgba(47,216,193,.16), rgba(95,168,255,.10));
border:1px solid rgba(47,216,193,.3);
box-shadow:0 0 18px rgba(47,216,193,.18), inset 0 0 12px rgba(47,216,193,.08);
color:var(--esp32);
}
.brand-logo svg { width:24px; height:24px; display:block; }
.brand-text { display:flex; flex-direction:column; line-height:1.15; }
.brand-text strong {
font-family:var(--font-display);
font-size:17px; font-weight:700; letter-spacing:.02em;
background:linear-gradient(92deg, var(--text) 30%, var(--esp32-hi) 100%);
-webkit-background-clip:text; background-clip:text;
-webkit-text-fill-color:transparent;
}
.brand-sub {
font-family:var(--mono);
font-size:10px; letter-spacing:.22em; text-transform:uppercase;
color:var(--text-label); margin-top:2px;
}
.header-status { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
.header-sub { display:flex; gap:10px; margin-left:auto; flex-wrap:wrap; align-items:center; }
.pill {
display:inline-flex; align-items:center; gap:7px;
padding:6px 13px; border-radius:999px;
font-size:12px; font-weight:500; font-family:var(--mono);
background:var(--bg-elevated); border:1px solid var(--border);
color:var(--text-dim); white-space:nowrap;
}
.pill .indicator { width:7px; height:7px; border-radius:50%; flex-shrink:0; background:var(--text-label); }
.pill.online .indicator { background:var(--green); box-shadow:0 0 8px rgba(61,220,142,.6); }
.pill.offline .indicator { background:var(--red); box-shadow:0 0 8px rgba(255,107,107,.5); }
.pill.live .indicator, .pill.processing .indicator { background:var(--amber); animation:blink 1s infinite; }
.pill.online { color:var(--can-hi); border-color:rgba(61,220,142,.35); }
.pill.offline { color:#ffb3b3; border-color:rgba(255,107,107,.3); }
.pill.live, .pill.processing { color:var(--gd32-hi); border-color:rgba(252,191,63,.35); }
@keyframes blink { 50% { opacity:.3; } }
.tabs { display:flex; gap:6px; }
.tab {
background:var(--bg-elevated); 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 18px 17px; /* 卡头用负 margin 通栏,正文保留侧/底内边距 */
}
.card::before {
content:""; position:absolute; top:0; left:0; right:0; height:2px;
background:linear-gradient(90deg, var(--card-accent, var(--ecat)), transparent 85%);
opacity:.7; pointer-events:none; z-index:1;
}
.card--mem { --card-accent:var(--mem); --card-accent-dim:var(--mem-dim); }
.card--esp32 { --card-accent:var(--esp32); --card-accent-dim:var(--esp32-dim); }
/* 卡头:card-head 与旧 h2 同一视觉,避免改一半把下面挤坏 */
.card-head,
.card > h2 {
display:flex; align-items:center; gap:10px; flex-wrap:wrap;
margin:0 -18px 14px;
padding:14px 18px;
border-bottom:1px solid var(--border-subtle);
background:rgba(255,255,255,.02);
font-family:var(--font-display);
font-size:13px; font-weight:600; letter-spacing:.05em;
color:var(--text); user-select:none;
line-height:1.3;
}
.card-head .ico {
width:28px; height:28px; flex-shrink:0;
display:inline-flex; align-items:center; justify-content:center;
font-size:15px; border-radius:8px;
background:var(--card-accent-dim, var(--mem-dim));
border:1px solid rgba(168,139,250,.28);
}
.card--esp32 .card-head .ico { border-color:rgba(47,216,193,.28); }
.card-head .hint2,
.card > h2 .hint2 {
font-family:var(--font);
font-size:11px; font-weight:400; letter-spacing:0; text-transform:none;
color:var(--text-label);
}
.card-body { padding:0; }
.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; }
/* 左右拨动开关 */
button.tsw {
display:inline-flex; align-items:center; gap:8px; background:var(--bg-elevated);
border:1px solid var(--border); border-radius:999px; padding:3px 12px 3px 4px;
font:inherit; font-size:12px; color:var(--text-dim); cursor:pointer;
transition:border-color .15s, color .15s;
}
button.tsw:hover:not(:disabled) { border-color:#3a5378; }
button.tsw .tr {
width:30px; height:16px; border-radius:99px; background:#2a3a52; position:relative;
flex:0 0 auto; transition:background .15s;
}
button.tsw .tr::after {
content:""; position:absolute; left:2px; top:2px; width:12px; height:12px;
border-radius:99px; background:#93a8c4; transition:left .15s, background .15s;
}
button.tsw.on { border-color:rgba(168,139,250,.55); color:var(--text); }
button.tsw.on .tr { background:var(--mem); }
button.tsw.on .tr::after { left:16px; background:#fff; }
button.tsw.amber.on { border-color:rgba(252,191,63,.6); }
button.tsw.amber.on .tr { background:var(--amber); }
button.tsw.amber.on .tr::after { background:#1a1400; }
button.tsw.live.on { border-color:rgba(61,220,142,.55); }
button.tsw.live.on .tr { background:var(--green); }
.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,行宽固定以便排满横向 */
#rawDecodeL { font-size:11.5px; line-height:1.5; }
/* 多列 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.dense-raw { grid-template-columns:1fr; }
/* paneFlex 内:去掉定高,跟着列高走 */
.paneFlex > .hexgrid.grow { height:auto; min-height:0; }
.hexgrid.dense-raw i.slot { margin-top:2px; }
.hexgrid i { font-style:normal; white-space:pre; }
.hexgrid i.hdr { color:var(--text-label); }
.hexgrid i.slot { color:var(--mem-hi); }
.hexgrid i.dec { color:var(--esp32); margin:2px 0 4px; }
.hexgrid i.blk { color:var(--text-dim); margin-top:8px; }
.hexgrid i.dim { color:var(--text-label); }
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; }
/* 原始数据主区:左宽右窄,raw 吃掉大部分横向空间 */
.raw-main { display:grid; grid-template-columns:minmax(0,2.2fr) minmax(0,1fr); gap:14px; }
.raw-side { min-width:0; }
@media (max-width:1080px){ .cols2, .raw-main { 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 class="header-bar">
<div class="header-brand">
<span class="brand-logo" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<rect x="6" y="6" width="12" height="12" rx="2.5"/>
<rect x="9.5" y="9.5" width="5" height="5" rx="1" fill="currentColor" stroke="none" opacity=".85"/>
<path d="M9 2v3M15 2v3M9 19v3M15 19v3M2 9h3M2 15h3M19 9h3M19 15h3"/>
</svg>
</span>
<div class="brand-text">
<strong>链路测速台</strong>
<span class="brand-sub">DCU · SPI / EtherCAT · SPEEDTEST</span>
</div>
</div>
<div class="header-status">
<span class="pill" id="ecatState"><span class="indicator"></span>未开始</span>
<span class="pill" id="ecatLink">链路 —</span>
<span class="pill" id="ecatAl">AL —</span>
</div>
<div class="header-sub">
<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>
</div>
</header>
<!-- ══════════════════════ SPI 面板 ══════════════════════ -->
<section id="pane-spi" hidden>
<div class="card card--esp32">
<div class="card-head"><span class="ico">⚙️</span> 测试参数 <span class="hint2">SPI 探针 0xA1 · 请求-应答往返</span></div>
<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="indicator"></span>空闲</span>
</div>
<div class="hint">
满帧 236B = 等效电机 OTA 0xD1 数据节奏;载荷 0 = 纯握手往返。<br>
判读:<b>失败必须为 0</b>;「往返 最快」是链路物理极限周期;「调度开销 平均」是固件自己量的事件驱动唤醒损耗,
「SPI 事务 平均」才是纯总线时间;下面五段拆解告诉你时间花在提交/发送/等应答/读响应/唤醒哪一段。<br>
设备无应答时最多等「超时」就自行判失败,按钮不会永久卡在等待态。
</div>
</div>
<div class="card card--esp32">
<div class="card-head"><span class="ico">📊</span> 本次结果 <span class="hint2">三段计时均取自固件原始行,不做二次推断</span></div>
<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 card--esp32">
<div class="card-head"><span class="ico">🕘</span> 本页历史 <span class="hint2">仅存内存,刷新即清空 —— 改参数前后对比用</span></div>
<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 card--mem">
<div class="card-head"><span class="ico">📡</span> 观测控制 <span class="hint2">本页只读过程数据缓存,不连接、不重启、不停 PDO 循环</span></div>
<div class="row">
<button id="btnLive" class="tsw live" title="开始/暂停本页对 EtherCAT 过程数据的只读轮询观测"><i class="tr"></i><em>实时观测</em></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="indicator"></span>空闲</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 card--mem">
<div class="card-head"><span class="ico">⚡</span> 实时状态 <span class="hint2">实测 PDO Hz · 单拍耗时 · CAN / 丢帧 / WKC</span></div>
<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">最慢请求往返</div></div>
</div>
<div class="hint">达成率 &lt; 100% = 节拍被软件拖住 · λ÷Hz ≈ 每拍 CAN 帧数 · 真丢帧看空洞/OVF,不看 drop</div>
</div>
<div class="card card--mem">
<div class="card-head"><span class="ico">📈</span> 实时曲线 <span class="hint2">滚动 90s · 紫 = PDO Hz · 青 = 单拍 p50 µs</span></div>
<div class="row" style="margin-bottom:8px">
<button id="btnChartMax" class="tsw" title="cyc_us.total.max(后端 STATS_N=400 拍窗口最大值)"><i class="tr"></i><em>单拍 max</em></button>
<button id="btnChartSkip" class="tsw" title="cyc_us.late / 窗口时长(late=起跑超期拍数)"><i class="tr"></i><em>跳拍</em></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 card--mem">
<div class="card-head"><span class="ico">🔌</span> 总线与从站 <span class="hint2">/api/status · health · state.head</span></div>
<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 card--mem">
<div class="card-head"><span class="ico">🔗</span> 桥接计数 <span class="hint2">state.bridge · 无按通道 TEC/REC</span></div>
<div class="kv" id="bridgeKv"></div>
<div class="hint">过程数据里没有逐通道 TEC/REC —— 没有的就显示 <code>—</code>,
不编 0 让"总线正常"和"没这个数据"长得一模一样。</div>
</div>
</div>
<div class="card card--mem">
<div class="card-head"><span class="ico">🎯</span> 收到的东西 ① 电机状态槽 <span class="hint2">state.motors[] · 48B STATUS · DBC v0.74</span></div>
<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 card--mem">
<div class="card-head"><span class="ico">📦</span> 收到的东西 ② 密排整轮 + 长帧事件 <span class="hint2">rxraw.parsed · 1.6.21 密排</span></div>
<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="tsw" title="帧表/密排槽表是否每拍重建 DOM。关=观测时不动这两块"><i class="tr"></i><em>每拍重建</em></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:200px">
<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>STATUS 48B 全量 hex</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 card--mem">
<div class="card-head"><span class="ico">🧾</span> 收到的东西 ③ 原始接收窗口 + 长帧 + 逐拍环 <span class="hint2">rxraw · pdoin · xrx · dbg(只读缓存拷贝)</span></div>
<div class="row" style="margin-bottom:8px">
<button id="btnRxraw" class="tsw" title="每拍额外读 /api/motor/rxraw + /api/motor/pdoin(段2 与整包 600B)。默认关"><i class="tr"></i><em>原始窗口</em></button>
<button id="btnFreeze" class="tsw amber" title="冻结后本面板定格在当前帧,不再随轮询刷新;再点恢复实时"><i class="tr"></i><em>冻结</em></button>
<button id="btnHeavyHex" class="tsw" title="原始 hex 网格是否每拍重建 DOM。关=观测时不动这块"><i class="tr"></i><em>hex每拍重建</em></button>
<button id="btnRing" class="tsw" title="每拍多打 /api/motor/dbg(约 160KB)。默认关,只在看抖动时开"><i class="tr"></i><em>逐拍环</em></button>
</div>
<div class="cols2" style="--pane-h:110px">
<div class="paneFlex">
<div class="kv" id="rawKv"></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:1.8em; overflow:hidden; margin-top:2px">逐拍环:关</div>
</div>
</div>
<div class="raw-main" style="margin-top:10px">
<div class="raw-side raw-left">
<div class="lbl" id="rawDecodeCap">原始字节 —— 整包 PDO(有 pdoin)/ 段2 rxraw;16B/行 多列</div>
<div id="rawDecodeL" class="hexgrid" style="height:320px; margin-top:4px"><i class="hdr">—</i></div>
</div>
<div class="raw-side raw-right">
<div class="lbl" id="rawDecodeCapR">解析摘要(不重复贴 raw)</div>
<div id="rawDecodeR" class="hexgrid dense-raw" style="height:320px; margin-top:4px"><i class="hdr">—</i></div>
</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);
}
}
/** 状态胶囊:与 header-bar .pill 同构(.indicator);ok/warn/bad 映射 online/live/offline */
function setPill(sel, cls, text) {
const n = $(sel);
if (!n) return;
const map = { ok: "online", warn: "live", bad: "offline", good: "online", live: "live" };
const k = map[cls] || cls || "";
n.className = "pill" + (k ? " " + k : "");
n.innerHTML = '<span class="indicator"></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; }
}
/* 面板开关持久化:localStorage 快路径 + 磁盘 data/spi_test_ui_state.json。
* 保存:实时观测 / 冻结 / 原始窗口 / hex·帧重建 / 逐拍环 / 采样率 / PDO 档位 / SPI 测参。
* PDO 档位同时写共享键 dcu_ecat_period_ms,电机页与 EtherCAT 后端启动时同源。 */
const UI_OPT_KEY = "spi_test_ui_opts";
const LAB_UI_API = "/api/lab/ui-state";
const LAB_UI_PATH_LABEL = "data/spi_test_ui_state.json";
const LS_ECAT_PERIOD = "dcu_ecat_period_ms";
let labOptsHadSave = false;
let _labDiskTimer = null, _labDiskPayload = null;
function collectUiOpts() {
const gv = (id) => { const n = document.getElementById(id); return n && n.value !== undefined ? n.value : ""; };
return {
savedAt: Date.now(),
v: 2,
live: !!(E.wantLive || E.live),
frozen: !!E.frozen,
rxraw: !!E.rxraw,
heavyHex: !!E.heavyHex,
heavyFrames: !!E.heavyFrames,
ring: !!E.ring,
chartMax: !!(E.chartOpt && E.chartOpt.max),
chartSkip: !!(E.chartOpt && E.chartOpt.skip),
hz: gv("hz"),
periodPick: gv("periodPick"),
frames: gv("frames"),
payload: gv("payload"),
spiTimeout: gv("spiTimeout"),
};
}
/** 把快照应用到 E + DOM(localStorage 恢复 / 磁盘 hydrate 共用)。 */
function applyUiOpts(o) {
if (!o || typeof o !== "object") return;
labOptsHadSave = true;
E.rxraw = !!o.rxraw;
E.heavyHex = !!o.heavyHex;
E.heavyFrames = !!o.heavyFrames;
E.ring = !!o.ring;
/* 冻结也恢复:用户明确要求按钮状态保存。空面板冻住无妨,解冻一键即可。 */
E.frozen = !!o.frozen;
E.wantLive = o.live !== undefined ? !!o.live : E.wantLive;
E.chartOpt = E.chartOpt || { max: false, skip: false };
E.chartOpt.max = !!o.chartMax;
E.chartOpt.skip = !!o.chartSkip;
applyToggleBtn("btnRxraw", E.rxraw, "原始窗口");
applyToggleBtn("btnHeavyHex", E.heavyHex, "hex每拍重建");
applyToggleBtn("btnHeavyFrames", E.heavyFrames, "每拍重建");
applyToggleBtn("btnRing", E.ring, "逐拍环");
applyToggleBtn("btnChartMax", E.chartOpt.max, "单拍 max");
applyToggleBtn("btnChartSkip", E.chartOpt.skip, "跳拍");
applyToggleBtn("btnLive", !!(E.live || E.wantLive), "实时观测");
setFreezeUi();
const hz = document.getElementById("hz");
if (hz && o.hz !== undefined && o.hz !== "") hz.value = o.hz;
const pick = document.getElementById("periodPick");
if (pick && o.periodPick !== undefined && o.periodPick !== null) pick.value = o.periodPick;
/* PDO 档位与电机页/后端共享:没写 periodPick 时用共享键兜底 */
if (pick && (pick.value === "" || pick.value == null)) {
try {
const shared = window.localStorage.getItem(LS_ECAT_PERIOD);
if (shared) pick.value = shared;
} catch (_) {}
}
if (pick && pick.value) {
try { window.localStorage.setItem(LS_ECAT_PERIOD, String(pick.value)); } catch (_) {}
}
const frames = document.getElementById("frames");
if (frames && o.frames) frames.value = o.frames;
const payload = document.getElementById("payload");
if (payload && o.payload !== undefined && o.payload !== "") payload.value = o.payload;
const stmo = document.getElementById("spiTimeout");
if (stmo && o.spiTimeout) stmo.value = o.spiTimeout;
if (E.rxraw) {
const b = document.getElementById("btnRxraw");
if (b) b.title = "每拍额外读 /api/motor/rxraw + /api/motor/pdoin(段2 与整包 600B)。对 EtherCAT 有负载,看完建议关掉";
}
}
function writePeriodShared(val) {
if (!val) return;
try { window.localStorage.setItem(LS_ECAT_PERIOD, String(val)); } catch (_) {}
}
function scheduleLabDiskSave(snapshot) {
_labDiskPayload = snapshot || collectUiOpts();
if (_labDiskTimer) clearTimeout(_labDiskTimer);
_labDiskTimer = setTimeout(flushLabDiskSave, 400);
}
function flushLabDiskSave() {
if (_labDiskTimer) { clearTimeout(_labDiskTimer); _labDiskTimer = null; }
const o = _labDiskPayload || collectUiOpts();
_labDiskPayload = null;
const body = JSON.stringify(o);
try {
fetch(LAB_UI_API, {
method: "POST",
headers: { "Content-Type": "application/json" },
body,
keepalive: true,
}).catch(() => {
try {
const blob = new Blob([body], { type: "application/json" });
if (navigator.sendBeacon) navigator.sendBeacon(LAB_UI_API, blob);
} catch (_) {}
});
} catch (_) {
try {
if (navigator.sendBeacon) navigator.sendBeacon(LAB_UI_API, new Blob([body], { type: "application/json" }));
} catch (_) {}
}
}
function saveUiOpts() {
const o = collectUiOpts();
ls(UI_OPT_KEY, JSON.stringify(o));
if (o.periodPick) writePeriodShared(o.periodPick);
scheduleLabDiskSave(o);
}
function restoreUiOpts() {
let o = null;
try { o = JSON.parse(ls(UI_OPT_KEY) || "null"); } catch (_) { o = null; }
if (!o) {
/* localStorage 空(WebView 清存储)→ 先用共享 PDO 档位兜底 */
const p = ls(LS_ECAT_PERIOD);
if (p) o = { periodPick: p, hz: "10", live: true };
}
if (!o) return;
applyUiOpts(o);
}
/** 磁盘优先 hydrate:比 savedAt,新者覆盖 localStorage。 */
async function hydrateLabUiFromDisk() {
let disk = null;
try {
const r = await fetch(LAB_UI_API, { cache: "no-store" });
if (r.ok) {
const j = await r.json();
disk = (j && j.ok && j.state) ? j.state : null;
}
} catch (_) { return; }
if (!disk || typeof disk !== "object") return;
let local = null;
try { local = JSON.parse(ls(UI_OPT_KEY) || "null"); } catch (_) {}
const diskAt = Number(disk.savedAt) || 0;
const localAt = local ? (Number(local.savedAt) || 0) : 0;
if (local && localAt > diskAt) {
applyUiOpts(local);
return;
}
applyUiOpts(disk);
try { ls(UI_OPT_KEY, JSON.stringify(disk)); } catch (_) {}
}
function applyToggleBtn(id, on, label) {
const n = document.getElementById(id);
if (!n) return;
n.classList.toggle("on", !!on);
n.setAttribute("aria-pressed", on ? "true" : "false");
const em = n.querySelector("em");
if (em) em.textContent = label;
else n.textContent = label + (on ? ":开" : ":关");
}
/* ═══════════════════ 面板切换 ═══════════════════ */
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();
/* 有保存 → 只按 wantLive 恢复;无保存 → 保持原「首次自动开观测」习惯 */
if (E.wantLive || (!labOptsHadSave && !E.everStarted)) startEcatIfIdle();
} else if (E.live) {
/* 切到 SPI:暂停轮询但记住 wantLive,回来自动恢复;不要走 stopEcat
* (会把 wantLive 清成 false 并落盘,刷新后就再也起不来了)。 */
E.live = false; E.pausedByHidden = false;
ticker.stop();
applyToggleBtn("btnLive", false, "实时观测");
setPill("#ecatState", "", "已暂停(在 SPI 页)");
E.wantLive = true;
}
}
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 与 ConnectionHub / 其它页共用 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, frozen: false,
frozenSnap: null, /* 冻结瞬间的 {st,raw,pdoin},解冻前不再重画 */
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, pdoin: 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, lastRenderedRawCycle: 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 只含段2(dense~176B / win~232B);pdoin 是整包 PDO 输入镜像(~600B)。 */
if (E.rxraw) {
try { raw = await eapi("motor/rxraw", {}, 4000); if (raw) rtt = Math.max(rtt, raw.__rtt || 0); }
catch (_) { raw = null; }
try {
const pin = await eapi("motor/pdoin", {}, 4000);
if (pin && pin.ok) { E.pdoin = pin; rtt = Math.max(rtt, pin.__rtt || 0); }
} catch (_) { /* 旧 ecat_native 无此路由时静默 */ }
}
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;
if (!E.rxraw) E.pdoin = null;
const rates = E.win.calc();
E.lastRates = rates;
renderMetrics(st, load, dbg, rates, rtt);
renderBus(st);
renderBridge(st);
renderMotors(st, rates);
/* 帧表 / hex 网格:各自模块的「每拍重建」开关(默认关)。关着时 DOM 冻结。
* 原始窗口开着时:cycle 变了就刷一次完整解码(本拍内容真正变了才值得重画)。 */
if (E.frozen) {
/* 冻结:只更新内存里的最新快照,不重画面板③ */
E.frozenSnap = { st, raw, pdoin: E.pdoin };
} else if (E.rxraw && raw) {
if (E.heavyFrames) renderFrames(st, raw);
else countSeenFrames(raw);
/* 完整解码必须跟上 raw:hex 网格也可每拍重建(dense 仅 ~176B,开销可忽略) */
renderRaw(st, raw);
E.lastRenderedRawCycle = raw.cycle;
} else {
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;
applyToggleBtn("btnLive", true, "实时观测");
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();
saveUiOpts();
}
function stopEcat() {
E.live = false; E.wantLive = false; E.pausedByHidden = false;
ticker.stop();
applyToggleBtn("btnLive", false, "实时观测");
setPill("#ecatState", "", "已暂停");
saveUiOpts();
}
/* 后台标签页:Worker 定时器不受节流,live 着=持续打代理。暂停但记住 wantLive。 */
function pauseEcatHidden() {
if (!E.live) return;
E.pausedByHidden = true;
E.live = false;
ticker.stop();
applyToggleBtn("btnLive", false, "后台已暂停");
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;
applyToggleBtn("btnRing", E.ring, "逐拍环");
if (!E.ring) { tblBeatsBody.textContent = ""; $("#beatStat").textContent = "逐拍环未开启。"; }
saveUiOpts();
};
$("#btnRxraw").onclick = () => {
E.rxraw = !E.rxraw;
applyToggleBtn("btnRxraw", E.rxraw, "原始窗口");
$("#btnRxraw").title = E.rxraw
? "每拍额外读 /api/motor/rxraw + /api/motor/pdoin(段2 与整包 600B)。对 EtherCAT 有负载,看完建议关掉"
: "每拍多打 /api/motor/rxraw + pdoin。默认关";
if (E.live) setPill("#ecatState", "warn", "观测中 · " + pollHz() + "Hz" + (E.rxraw ? " · rxraw" : ""));
if (!E.rxraw) {
E.raw = null;
E.lastRenderedRawCycle = null;
if (!E.frozen) renderRaw(E.lastState, null);
} else if (!E.frozen) {
if (E.heavyFrames && E.lastState && E.raw) renderFrames(E.lastState, E.raw);
renderRaw(E.lastState || null, E.raw || null);
if (E.raw) E.lastRenderedRawCycle = E.raw.cycle;
}
saveUiOpts();
};
function setFreezeUi() {
const b = document.getElementById("btnFreeze");
const capL = document.getElementById("rawDecodeCap");
const capR = document.getElementById("rawDecodeCapR");
if (b) {
applyToggleBtn("btnFreeze", E.frozen, "冻结");
b.title = E.frozen
? "已冻结:面板③定格在冻结瞬间那一帧,轮询不会改写。再点恢复实时。"
: "冻结后本面板定格在当前帧,不再随轮询刷新;再点恢复实时";
}
if (capL) capL.textContent = E.frozen
? "【已冻结】原始字节"
: "原始字节 —— 整包 PDO(有 pdoin)/ 段2 rxraw;16B/行 多列";
if (capR) capR.textContent = E.frozen
? "【已冻结】解析摘要"
: "解析摘要(不重复贴 raw)";
}
$("#btnFreeze").onclick = () => {
E.frozen = !E.frozen;
if (E.frozen) {
/* 冻结:锁住当前 DOM;同时记住快照(解冻重画用最新内存值,不回放冻结帧) */
E.frozenSnap = { st: E.lastState, raw: E.raw, pdoin: E.pdoin };
} else {
E.frozenSnap = null;
/* 解冻:立刻用最新内存数据重画,不等下一拍 */
if (E.rxraw) renderRaw(E.lastState || null, E.raw || null);
else renderRaw(E.lastState || null, null);
}
setFreezeUi();
saveUiOpts();
};
$("#btnHeavyFrames").onclick = () => {
E.heavyFrames = !E.heavyFrames;
applyToggleBtn("btnHeavyFrames", E.heavyFrames, "每拍重建");
if (E.heavyFrames && E.lastState && E.raw) renderFrames(E.lastState, E.raw);
saveUiOpts();
};
$("#btnHeavyHex").onclick = () => {
E.heavyHex = !E.heavyHex;
applyToggleBtn("btnHeavyHex", E.heavyHex, "hex每拍重建");
if (E.heavyHex && E.lastState && !E.frozen) {
if (E.rxraw && E.raw) {
renderRaw(E.lastState, E.raw);
E.lastRenderedRawCycle = E.raw.cycle;
} else renderRaw(E.lastState, null);
}
saveUiOpts();
};
$("#hz").onchange = () => {
E.win.reset();
if (E.live) ticker.update(1000 / pollHz());
saveUiOpts();
};
/* PDO 档位 / SPI 测参变更即落盘;档位同时写共享键供电机页/后端启动使用 */
["periodPick", "frames", "payload", "spiTimeout"].forEach((id) => {
const n = document.getElementById(id);
if (!n) return;
n.onchange = () => {
if (id === "periodPick") writePeriodShared(n.value);
saveUiOpts();
};
});
/* ═══════════════════ 渲染:指标卡 ═══════════════════ */
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 s = String(v || "");
const c = el("td", "hex", s ? s.replace(/(.{16})/g, "$1 ").trim() : "—");
c.style.whiteSpace = "normal";
c.style.wordBreak = "break-all";
c.style.maxWidth = "22em";
c.style.fontSize = "11px";
if (s) c.title = s;
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");
/* 48B STATUS 全量:112 hex 字符过长会撑破表,折行显示 + title 原文 */
const fullHex = s.full || s.data || "";
const cFull = el("td", "hex", fullHex ? fullHex.replace(/(.{16})/g, "$1 ").trim() : "—");
cFull.style.whiteSpace = "normal";
cFull.style.wordBreak = "break-all";
cFull.style.fontFamily = "var(--mono)";
cFull.style.fontSize = "11px";
cFull.style.maxWidth = "28em";
cFull.style.lineHeight = "1.45";
if (fullHex) cFull.title = fullHex;
tr.appendChild(cFull);
td(prev === undefined ? "首见" : (isNew ? "是(seq 前进)" : "否(差分跳过)"),
isNew || prev === undefined ? "good" : "dim");
body.appendChild(tr);
}
}
/** 把 hex 串按 offset 切成「字段名 + 全量字节」行(dense 完整解码用) */
function fmtHexBytes(hex, off, len) {
const h = String(hex || "").toUpperCase();
const s = h.substr(off * 2, len * 2);
return s ? s.replace(/(.{2})/g, "$1 ").trim() : "—";
}
/** xrx 长帧完整文本:ch/dlc/id + 全 payload(不截断) */
function xrxFullText(x) {
if (!x || !x.data) return "无长帧(xrx 未 valid)—— CONFIG/版本这类 >8B 的帧才会进这里";
return "ch=" + x.ch + " dlc=" + x.dlc + " id=0x" + (x.id >>> 0).toString(16).toUpperCase() +
" flags=" + (x.flags ?? "—") +
"\n" + String(x.data).replace(/(.{8})/g, "$1 ").trim();
}
/** 600B PDO 输入分区图(1.6.21 dense)—— 只读路由拼不齐整包镜像,这里按段展示能拿到的 */
const PDO_SEG = [
{ name: "段1 桥状态", off: 0, len: 32, src: "state.bridge" },
{ name: "段2 头+密排槽", off: 32, len: 512, src: "/api/motor/rxraw" },
{ name: "段3 长帧", off: 544, len: 40, src: "state.xrx" },
{ name: "段5 诊断", off: 584, len: 12, src: "app/load" },
{ name: "保留", off: 596, len: 4, src: "—" },
];
function pushDec(box, text, cls) {
const n = document.createElement("i");
n.className = cls || "";
n.textContent = text;
box.appendChild(n);
}
/** 左=raw 多列 hex(主);右=紧凑摘要(不重复贴 STATUS raw) */
function renderRawDecode(st, raw) {
const L = document.getElementById("rawDecodeL");
const R = document.getElementById("rawDecodeR");
if (!L) return;
L.textContent = "";
if (R) R.textContent = "";
const x = (st && st.xrx) || null;
const load = E.load || {};
if (!raw || !raw.raw) {
pushDec(L, E.rxraw
? "等待本拍 /api/motor/rxraw…(保持「开始实时观测」)"
: "原始窗口未开启 —— 点「原始窗口:开」", "hdr");
if (R) pushDec(R, "—", "hdr");
return;
}
const hex = String(raw.raw).toUpperCase();
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 pin = E.pdoin && E.pdoin.ok && E.pdoin.raw ? E.pdoin : null;
/* ── 左:原始字节(多列铺满)── */
const dumpHex = (label, h, baseOff) => {
if (!h) return;
pushDec(L, label, "blk");
for (let i = 0; i < h.length; i += 32) {
const offB = baseOff + i / 2;
pushDec(L, String(offB).padStart(4, " ") + " " + fmtHexBytes(h, i / 2, 16),
(offB === 0 || offB === 32 || offB === 544 || offB === 584) ? "slot" : null);
}
};
if (pin) {
dumpHex("PDO " + pin.len + "B · cycle " + (pin.cycle || "—"), String(pin.raw).toUpperCase(), 0);
} else {
dumpHex("rxraw 段2 " + raw.len + "B · " + raw.layout, hex, raw.layout === "dense" ? 32 : 0);
}
if (x && x.data) {
pushDec(L, "", "dim");
dumpHex("xrx dlc=" + x.dlc + " id=0x" + (x.id >>> 0).toString(16).toUpperCase(),
String(x.data).toUpperCase(), pin ? 552 : 0);
}
/* ── 右:摘要(不重复 raw)── */
if (!R) return;
if (raw.layout === "dense") {
const count = b[0], active = b[1], boot = b[2], magic = b[3];
const gseq = hex.length >= 16 ? dv.getUint32(4, true) : null;
pushDec(R, "头 count=" + count + " active=" + active + " boot=" + boot +
" magic=0x" + magic.toString(16).toUpperCase().padStart(2, "0") +
" gseq=" + (isNum(gseq) ? gseq.toLocaleString("en-US") : "—"), "blk");
const n = Math.max(0, Math.min(count | 0, Math.floor((hex.length / 2 - DENSE_HDR) / DENSE_SLOT) || 0));
for (let s = 0; s < n; s++) {
const o = DENSE_HDR + s * DENSE_SLOT;
const node = b[o], ch = b[o + 1], online = b[o + 2], dlc = b[o + 3];
const seq = dv.getUint32(o + 4, true);
pushDec(R, "槽" + s + " n" + node + " ch" + ch + (online ? " on" : " off") +
" dlc" + dlc + " seq" + seq.toLocaleString("en-US"), "slot");
const d = decodeStatus(fmtHexBytes(hex, o + 8, Math.min(dlc || 0, DENSE_STATUS)).replace(/ /g, ""));
if (d) {
const stBits = [d.motStatus ? "err" : null, d.magStatus ? "mag" : null,
d.thermal ? "hot" : null, d.eepromStatus ? "ee" + d.eepromStatus : null]
.filter(Boolean).join(" ") || "ok";
pushDec(R, " " + d.mcuTemp.toFixed(1) + "/" + d.motTemp.toFixed(1) + "°C" +
" " + d.powerVoltage.toFixed(1) + "V" +
" ang" + d.rtAngle.toFixed(1) +
" " + d.cpuUsage + "%" +
" " + stBits, "dec");
}
}
} else if (raw.layout === "win") {
pushDec(R, "win slots=" + raw.count + " wr=" + raw.lost + " ovf=" + raw.pending, "blk");
const n = Math.min(WIN_SLOT_COUNT, Math.floor((hex.length / 2) / WIN_SLOT_SIZE));
for (let s = 0; s < n; s++) {
const o = s * WIN_SLOT_SIZE;
const seq = dv.getUint32(o + 8, true);
if (!seq) { pushDec(R, "槽" + s + " 空", "dim"); continue; }
pushDec(R, "槽" + s + " ch" + b[o] + " dlc" + b[o + 1] +
" id0x" + dv.getUint32(o + 4, true).toString(16).toUpperCase() +
" seq" + seq + ((seq & 1) ? " 写入中" : ""), "slot");
}
} else {
pushDec(R, raw.layout + " len=" + raw.len + " count=" + raw.count, "blk");
}
if (x && x.data) {
pushDec(R, "", "dim");
pushDec(R, "xrx ch" + x.ch + " dlc" + x.dlc +
" id0x" + (x.id >>> 0).toString(16).toUpperCase(), "slot");
}
const frames = (raw && raw.parsed) || [];
pushDec(R, "", "dim");
pushDec(R, "parsed 新帧 " + frames.length, "blk");
frames.forEach((f, i) => {
const sub = f.id & 0xFFFF;
pushDec(R, "#" + i + " 0x" + (f.id >>> 0).toString(16).toUpperCase() +
" dlc" + f.dlc + " " + (SUB_NAME[sub] || ""), "slot");
});
pushDec(R, "", "dim");
pushDec(R, "torn=" + (isNum(load.rx_torn) ? load.rx_torn : "—") +
" relto=" + (isNum(load.reltimeout) ? load.reltimeout : "—") +
" ovr=" + (isNum(load.overfull) ? load.overfull : "—"), "dim");
}
/* ═══════════════════ 渲染:原始接收窗口 ═══════════════════ */
function setXrxFull(x) {
const xf = document.getElementById("xrxFull");
if (xf) xf.textContent = xrxFullText(x);
}
function setRawDecodeMsg(msg) {
const L = document.getElementById("rawDecodeL");
const R = document.getElementById("rawDecodeR");
if (L) { L.textContent = ""; pushDec(L, msg, "hdr"); }
if (R) { R.textContent = ""; pushDec(R, "—", "hdr"); }
}
function renderRaw(st, raw) {
const x = (st && st.xrx) || null;
/* rxraw 开着但本拍还没拿到 raw:不能继续解 raw.layout(会抛错,后面解码/hex 全不画) */
if (!raw) {
kv("rawKv", [
["rxraw cycle", null],
["layout", E.rxraw ? "—(等待 /api/motor/rxraw)" : "—(原始窗口未开)"],
["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"],
]);
setXrxFull(x);
setRawDecodeMsg(E.rxraw
? "原始窗口已开,等待本拍 /api/motor/rxraw…(需保持「开始实时观测」)"
: "原始窗口未开启 —— 点上方「原始窗口:开」,并保持「开始实时观测」。");
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 段2(rxraw)", raw ? raw.len : null],
["pdoin 整包", E.pdoin && E.pdoin.len ? E.pdoin.len + "B" : "—(旧 native 或未开窗)"],
["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"],
]);
setXrxFull(x);
renderRawDecode(st, raw);
}
/* ═══════════════════ 渲染:逐拍节拍环 ═══════════════════ */
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;
applyToggleBtn("btnChartMax", E.chartOpt.max, "单拍 max");
drawChart();
saveUiOpts();
};
$("#btnChartSkip").onclick = () => {
E.chartOpt.skip = !E.chartOpt.skip;
applyToggleBtn("btnChartSkip", E.chartOpt.skip, "跳拍");
drawChart();
saveUiOpts();
};
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;
if (ok) {
writePeriodShared(eff);
saveUiOpts();
}
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 = "";
};
/* ═══════════════════ 初始化 ═══════════════════ */
/* 先恢复配置(采样率/PDO 档位/开关/冻结/是否观测),再进面板 ——
* 否则 showPane 会用默认 10Hz 起观测,再被恢复值改掉却没重排节拍。 */
restoreUiOpts();
layoutChart();
showPane(ls("speedtest_pane") === "spi" ? "spi" : "ecat");
renderRaw(null, null); /* 面板③先画「未开启」占位,避免两块空白看起来像坏了 */
setFreezeUi();
/* 磁盘更新则覆盖(WebView 清 localStorage 后仍能找回上次档位/开关) */
hydrateLabUiFromDisk().then(() => {
if (PANES.ecat && !PANES.ecat.hidden && E.wantLive && !E.live) startEcatIfIdle();
setFreezeUi();
}).catch(() => {});
window.addEventListener("beforeunload", () => {
E.live = false; ticker.stop();
saveUiOpts();
flushLabDiskSave();
});
window.addEventListener("pagehide", () => { saveUiOpts(); flushLabDiskSave(); });
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") { saveUiOpts(); flushLabDiskSave(); }
});
/* 诊断入口:控制台里 __speedtest.E 能直接看当前累加的原始响应 */
window.__speedtest = { E, eapi, RateWindow, decodeStatus, renderRaw, renderRawDecode, saveUiOpts, restoreUiOpts, collectUiOpts, applyUiOpts };
})();
</script>
</body>
</html>