变更点: 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/ 运行态
2326 lines
125 KiB
HTML
2326 lines
125 KiB
HTML
<!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">达成率 < 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>
|