docs(lab): 更正 PRAM 写归因为从站侧成本;lan9252_pm 加 pmHold 看门狗豁免

This commit is contained in:
cbb
2026-09-13 04:13:28 +08:00
parent 209a869f96
commit 9bb804ec35
6 changed files with 1914 additions and 1881 deletions
@@ -0,0 +1,31 @@
# 诊断结论(本次核心交付)
你的怀疑正确,确实冲突,且共两层:
1. **主站楔死看门狗无豁免(自动拉起的推手)**
`G:\Robot_v2\DCU\tools\ecat\ecat_native.c` 的 wedge_worker 后台线程:WKC 异常持续 5s 即判死,之后每 ~8s 无限重拉 INIT→OP,直到成功,全程无「用户手动休眠」豁免。日志实锤(`build_native\native_stdout.log`):
`t=176002.9s 判死(从站掉 OP) → 重拉失败(枚举不到从站) → t=176011.0s 重拉成功已进 OP`,与你任务管理器图上 ~20s 流量缺口完全吻合。
2. **GD32 固件进睡眠时没冻结 HBI 访问者(芯片睡不住的根因)**
`EscLan9252::pmDrive()` 写完 PMT_CTRL 进入 D1-D3 后,GD32 侧 SSC 主循环 `MainLoop()→ECAT_Main()` 每圈仍无条件 HBI 读 0x220(AL 事件寄存器),另有 250ms AL mask 守护、5s AL 心跳,全部无睡眠门控。按 1.6.24 实测「任何 HBI 访问(含读)都会唤醒芯片」,**芯片进入睡眠后毫秒级就被 GD32 自己打回 D0**。你看到的低谷不是「睡了多久」,而是被唤醒后 SM/FMMU 失效、主站重建连接所需的时间。
3. 次要发现:电源页 `enterMode` 已正确暂停轮询并写 sessionStorage(同标签页安全),但换新标签页打开会丢标记 → 自动状态读(HBI 读)直接把芯片唤醒,属潜在坑。
# 修复计划
## Phase A:PC 侧(立即生效,不动固件)
- **A1** `ecat_native.c`:新增 `g_pm_hold` 标志 + `POST /api/pm/hold {on:0|1}`;在三处跳过——pdo_worker 的楔死判定(~859-908 行)、wedge_worker 巡检(~2940-2990 行)、`/api/motor/state` 路由快路径(~2262-2300 行);hold 时重置检测计时避免恢复后误触发。`build_new.bat` 重编,`verify_wedge_fix.py` 回归。
- **A2** `lab\lan9252_pm.html`:enterMode 成功后发 `pm/hold on=1`;手动唤醒/NRST 流程开头先发 `on=0`(恢复主站自愈)再走 recoverEcatMaster;banner 文案同步。
- **A3**(小改)睡眠标记另写一份带时间戳的 localStorage,新标签页打开时据此跳过自动状态读。
- 效果:休眠期间主站不再自动拉回 OP;但芯片仍会被 GD32 自己唤醒,EtherCAT 保持断开直到你手动唤醒(行为=「我睡了就是睡了」,但低功耗目的未真正达成)。
## Phase B:GD32 固件根因修复(需 cbuild 构建 + 刷板)
- **B1** `G:\Robot_v2\DCU\DCU_APP\GD32E508_DCU_APP\comm\esc_lan9252.cpp/.h`:新增全局挂起标志;pmDrive op=1/2/3 成功进睡眠时置位,op=4 唤醒成功/nrstDrive/hardwareReset 时清除。
- **B2** 门控点:`sys\task_runner.cpp` 的 `MainLoop()`、`ethercatAleMaskGuard()`、`ethercatAlHeartbeat()`;PDI/Sync0 EXTI ISR 顶部查标志直接返回(ISR 内 HBI 读同样会唤醒芯片)。0xC5/0xC4 轮询不碰 ESC,无需改。
- **B3** 核对唤醒后 SSC 阴影状态一致性(现有页面唤醒路径一律 NRST+motor/start,NRST 会全复位芯片)。
- **B4** 按 cbuild+vcpkg AC6+ninja 链构建刷板,刷完 `/api/motor/start` 恢复 OP。
- 加上 B 后芯片才能真正保持 D1-D3 低功耗态。
## 验证
- 进睡眠后任务管理器流量掉 0 并**保持**,native 日志无 WEDGE 重拉;GD32 串口 `[ECAT][HB]` 心跳停止(B 后);间隔 2s/10s/30s 查询状态仍为睡眠态;手动唤醒后流量恢复进 OP。
- hold=0 时正常楔死自愈回归不受影响。
+3 -3
View File
@@ -43,7 +43,7 @@ ul{padding-left:22px}
<h1>EtherCAT 单拍耗时 345µs→124µs:改动原理与验证</h1> <h1>EtherCAT 单拍耗时 345µs→124µs:改动原理与验证</h1>
<div class="meta">调查日期 2026-09-11 · 台子:i7-14700K + Intel I210(100 Mbps 全双工)+ DCU(GD32E508 + LAN9252)+ 3 台电机 · 源报告 <code>tools/ecat/REPORT_ecat_beat_tail_20260911.md</code> · 本页所有数字已对照源码与实机复测核验(<code>layout=dense</code>、rx p50=92.1µs、wkc 3/3、v1.6.22.0)</div> <div class="meta">调查日期 2026-09-11 · 台子:i7-14700K + Intel I210(100 Mbps 全双工)+ DCU(GD32E508 + LAN9252)+ 3 台电机 · 源报告 <code>tools/ecat/REPORT_ecat_beat_tail_20260911.md</code> · 本页所有数字已对照源码与实机复测核验(<code>layout=dense</code>、rx p50=92.1µs、wkc 3/3、v1.6.22.0)</div>
<div class="kpi"><div><b>345 → 124µs</b><span>9/11 当天 0.5ms 档整拍 p50 实测(cyc_us.work)</span></div><div><b>310 → 92µs</b><span>主站等回包 rx p50 —— 变化都在这一段</span></div><div><b>124.6µs</b><span>9/8 同为 win 布局的实测单拍(CSV)—— 345µs 并非 win 固有成本</span></div><div><b>0.44µs/B</b><span>PRAM 写单价(DWT 实测)—— 600B≈265µs/拍,两布局共同成本</span></div></div> <div class="kpi"><div><b>345 → 124µs</b><span>9/11 当天 0.5ms 档整拍 p50 实测(cyc_us.work)</span></div><div><b>310 → 92µs</b><span>主站等回包 rx p50 —— 变化都在这一段</span></div><div><b>124.6µs</b><span>9/8 同为 win 布局的实测单拍(CSV)—— 345µs 并非 win 固有成本</span></div><div><b>线速主导</b><span>主站 rx ≈ 帧在线上的往返(~1KB @100Mbit ≈ 80µs 量级);从站写 ESC 是另一段时间</span></div></div>
<div class="card"><h3>结论(2026-09-12 修订)</h3> <div class="card"><h3>结论(2026-09-12 修订)</h3>
<ul> <ul>
@@ -54,7 +54,7 @@ ul{padding-left:22px}
<div class="card bad"><h3>重大更正(2026-09-12 复核):220µs 的归因被动摇,两个变量被混在一起</h3> <div class="card bad"><h3>重大更正(2026-09-12 复核):220µs 的归因被动摇,两个变量被混在一起</h3>
<p>用户质疑"5 个 STATUS 变 3 个,怎么会差 20 倍"——顺着查,发现两件原调查没做的事:</p> <p>用户质疑"5 个 STATUS 变 3 个,怎么会差 20 倍"——顺着查,发现两件原调查没做的事:</p>
<ul> <ul>
<li><b>机制核对(代码层)</b>:两版 buildInput 逐行对比,纯 CPU 差仅 <b>1~3µs</b>(旧:5×48B + 5×44B 无条件拷贝;新:27 槽选最新 + 影子比对 + 3×48B)。对 ESC 的 PRAM 写两版同为整块 600B(DWT 实测 0.44µs/B → ≈265µs/拍,<code>ethercat_pdo_size.h</code>),且 A/B/A 证明它对主站往返不可见——<b>"删掉覆盖窗 / 差分填充省出的 CPU"解释不了 229µs</b>。另:差分填充在本台工况下基本不命中(STATUS ~1.25 帧/拍/台,槽 seq 几乎每拍都变,重建路径照样走)。</li> <li><b>机制核对(代码层)</b>:两版 buildInput 逐行对比,纯 CPU 差仅 <b>1~3µs</b>(旧:5×48B + 5×44B 无条件拷贝;新:27 槽选最新 + 影子比对 + 3×48B)。对 ESC 的 PRAM 写两版同为整块 600B(从站侧写 ESC 成本,与主站 rx 不是同一段时间),且 A/B/A 证明它对主站往返不可见——<b>"删掉覆盖窗 / 差分填充省出的 CPU"解释不了 229µs</b>。另:差分填充在本台工况下基本不命中(STATUS ~1.25 帧/拍/台,槽 seq 几乎每拍都变,重建路径照样走)。</li>
<li><b>反例数据(9/8 存量扫描)</b>:<code>lab/ecat_rate_sweep.csv</code>——win 布局、0.5ms 档 cyc_p50=<b>124.6µs</b>、达成 100%;1ms=126.9µs;0.125ms=119.5µs 且实测 7780Hz(单拍必须 ≤128µs 才可能)。与 dense 的 124µs 同水平。</li> <li><b>反例数据(9/8 存量扫描)</b>:<code>lab/ecat_rate_sweep.csv</code>——win 布局、0.5ms 档 cyc_p50=<b>124.6µs</b>、达成 100%;1ms=126.9µs;0.125ms=119.5µs 且实测 7780Hz(单拍必须 ≤128µs 才可能)。与 dense 的 124µs 同水平。</li>
</ul> </ul>
<p><b>混淆的变量</b>(刷板台账 git 史 + 主站 git 史):9/8 板上镜像 <code>201832b5…</code>(win)→ 9/10 前已刷 <code>4e84a354…</code>(win 期 1.6.15→1.6.20 演进);主站 9/9 03:05 提交 <code>97fab23</code>(三布局自适应、发现窗基址 {0,7,14,20}→{0,5,10,15,20,22}、覆盖窗按 seq 归一)。<b>345µs 出现在这两个改动之后、9/8 之前</b>——原归因把回归记在 1.6.21 密排头上,但密排也可能只是"绕开"了回归(dense 下主站强制 n_bases=1,不轮转窗口)。</p> <p><b>混淆的变量</b>(刷板台账 git 史 + 主站 git 史):9/8 板上镜像 <code>201832b5…</code>(win)→ 9/10 前已刷 <code>4e84a354…</code>(win 期 1.6.15→1.6.20 演进);主站 9/9 03:05 提交 <code>97fab23</code>(三布局自适应、发现窗基址 {0,7,14,20}→{0,5,10,15,20,22}、覆盖窗按 seq 归一)。<b>345µs 出现在这两个改动之后、9/8 之前</b>——原归因把回归记在 1.6.21 密排头上,但密排也可能只是"绕开"了回归(dense 下主站强制 n_bases=1,不轮转窗口)。</p>
@@ -103,7 +103,7 @@ ul{padding-left:22px}
<tr><th>段</th><th>0.5ms 实测 p50</th><th>从外到内的组成</th><th>出处 / 尚未拆开的部分</th></tr> <tr><th>段</th><th>0.5ms 实测 p50</th><th>从外到内的组成</th><th>出处 / 尚未拆开的部分</th></tr>
<tr><td>发送(蓝)</td><td>28.6µs</td><td>组帧 ≈0.2µs(组 LRW + 填输出区)→ <code>pcap_sendpacket</code> ≈28.4µs:NPF 栈把 1016B 交给 I210 驱动并提交发送</td><td><code>/api/motor/dbg</code> 拆段(sd_us 列)。驱动/DMA 内部无仪表;B 臂曾见发送 p99 348.9µs 的偶发阻塞(单窗样本,未立论)</td></tr> <tr><td>发送(蓝)</td><td>28.6µs</td><td>组帧 ≈0.2µs(组 LRW + 填输出区)→ <code>pcap_sendpacket</code> ≈28.4µs:NPF 栈把 1016B 交给 I210 驱动并提交发送</td><td><code>/api/motor/dbg</code> 拆段(sd_us 列)。驱动/DMA 内部无仪表;B 臂曾见发送 p99 348.9µs 的偶发阻塞(单窗样本,未立论)</td></tr>
<tr><td>线路(粉)</td><td>81.3µs</td><td>帧 1016B = 过程数据 984B(输出 384 + 输入 600)+ 以太网头 14 + EtherType 2 + 数据报头 10 + WKC 2 + FCS 4;8128bit ÷ 100Mbps;再加前导 8B + 帧间隙 12B ≈ +1.6µs;从站 ESC 硬件转发 µs 级</td><td>由帧构成计算,两布局逐字节同长——结构上不可能解释 220µs 差</td></tr> <tr><td>线路(粉)</td><td>81.3µs</td><td>帧 1016B = 过程数据 984B(输出 384 + 输入 600)+ 以太网头 14 + EtherType 2 + 数据报头 10 + WKC 2 + FCS 4;8128bit ÷ 100Mbps;再加前导 8B + 帧间隙 12B ≈ +1.6µs;从站 ESC 硬件转发 µs 级</td><td>由帧构成计算,两布局逐字节同长——结构上不可能解释 220µs 差</td></tr>
<tr><td>从站准备(红)· win</td><td>≈229µs</td><td>1.6.15 每拍在 PDI 里:① 按 win_base 轮转准备 27 槽表 ② 段4 覆盖窗 5×44B 拷贝(fillRxWindow/RxWinSlot)③ MotorState 重读 + 逐槽 48B 拷贝 ④ 600B 经 HBI/PRAM 推入 ESC RAM</td><td>实测差值 310−81.3。但 ①②③ 的纯 CPU 差按代码对比仅 1~3µs,④ 与整块 600B PRAM 写(≈265µs/拍)两布局同在且 A/B/A 证明不影响往返——229µs 的载体机制未闭环,且 9/8 同布局实测 124.6µs 表明"345µs=win 固有成本"的前提不成立(见"重大更正"卡)</td></tr> <tr><td>从站准备(红)· win</td><td>≈229µs</td><td>1.6.15 每拍在 PDI 里:① 按 win_base 轮转准备 27 槽表 ② 段4 覆盖窗 5×44B 拷贝 ③ MotorState 重读 + 逐槽 48B 拷贝 ④ 整块 600B 写入 ESC</td><td>实测差值 310−81.3。①②③ 纯 CPU 差仅 1~3µs;④ 是从站写 ESC(两布局同在、不影响主站往返)——229µs 载体未闭环;9/8 同布局 124.6µs 表明"345µs=win 固有"不成立</td></tr>
<tr><td>从站准备(红)· dense</td><td>≈10µs</td><td>差分填充命中:节点集合与各 seq 未变 → 整段复用影子,①②③全免;只剩 count 个槽的更新推送 + ESC 取数</td><td>rx 92.1 − 81.3。220µs 的差全部消失在这里</td></tr> <tr><td>从站准备(红)· dense</td><td>≈10µs</td><td>差分填充命中:节点集合与各 seq 未变 → 整段复用影子,①②③全免;只剩 count 个槽的更新推送 + ESC 取数</td><td>rx 92.1 − 81.3。220µs 的差全部消失在这里</td></tr>
</table> </table>
<p class="meta">口径备注:win 时代插桩拆段亦记录到 <code>pcap_sendpacket</code> 181.1µs、其后续中位 1 次 poll 即得帧(§四表)——该口径下发送调用内部吸收了部分等待,<b>发送/等回包两段的分界随仪表口径浮动</b>;但整拍总量(345)与"扣除线路后的从站侧总延迟"(≈229)不受此影响。另 1ms 档每拍 next_ex 轮询中位 79 次(轮询间隔 ~1.2µs),0.5ms 档为紧轮询。</p> <p class="meta">口径备注:win 时代插桩拆段亦记录到 <code>pcap_sendpacket</code> 181.1µs、其后续中位 1 次 poll 即得帧(§四表)——该口径下发送调用内部吸收了部分等待,<b>发送/等回包两段的分界随仪表口径浮动</b>;但整拍总量(345)与"扣除线路后的从站侧总延迟"(≈229)不受此影响。另 1ms 档每拍 next_ex 轮询中位 79 次(轮询间隔 ~1.2µs),0.5ms 档为紧轮询。</p>
+1 -1
View File
@@ -1 +1 @@
dda92d11876f059d27390232e88b5c9fa7c0eb57e442365f5247d4acdefb9b20 c6827f84e9b8fa07288d0fc2081d0efc48a6ef2c5b8124b85a406f3a7a02042a
+30 -1
View File
@@ -260,19 +260,31 @@
} }
/* 跨标签/重载保持睡眠:HBI 读会唤醒芯片,页面销毁后现场丢失会导致 /* 跨标签/重载保持睡眠:HBI 读会唤醒芯片,页面销毁后现场丢失会导致
* 切回时自动 poll(1E) 把睡眠叫醒。用 sessionStorage 记档位,重载时跳过自动读。 */ * 切回时自动 poll(1E) 把睡眠叫醒。sessionStorage 记本标签档位;另存一份
* localStorage 供换标签页/重开浏览器时兜底(宁可按睡眠处理少一次自动读,
* 也不能自动 1E 把芯片叫醒)。 */
var SS_SLEEP = "lan9252_pm_asleep"; var SS_SLEEP = "lan9252_pm_asleep";
var LS_SLEEP = "lan9252_pm_asleep_any";
function saveSleepFlag(n) { function saveSleepFlag(n) {
try { sessionStorage.setItem(SS_SLEEP, String(n)); } catch (e) {} try { sessionStorage.setItem(SS_SLEEP, String(n)); } catch (e) {}
/* localStorage: "n@ms" —— 带时间戳,换标签/重开浏览器可追溯入睡时刻 */
try { localStorage.setItem(LS_SLEEP, n + "@" + Date.now()); } catch (e) {}
} }
function clearSleepFlag() { function clearSleepFlag() {
try { sessionStorage.removeItem(SS_SLEEP); } catch (e) {} try { sessionStorage.removeItem(SS_SLEEP); } catch (e) {}
try { localStorage.removeItem(LS_SLEEP); } catch (e) {}
} }
function loadSleepFlag() { function loadSleepFlag() {
try { try {
var v = sessionStorage.getItem(SS_SLEEP); var v = sessionStorage.getItem(SS_SLEEP);
if (v === "1" || v === "2" || v === "3") return parseInt(v, 10); if (v === "1" || v === "2" || v === "3") return parseInt(v, 10);
} catch (e) {} } catch (e) {}
try {
var lv = localStorage.getItem(LS_SLEEP) || "";
/* 兼容旧值 "1"/"2"/"3" 与新值 "1@1760029000000" */
var m = String(lv).match(/^([123])(?:@(\d+))?$/);
if (m) return parseInt(m[1], 10);
} catch (e) {}
return 0; return 0;
} }
function renderAssumedSleep(n) { function renderAssumedSleep(n) {
@@ -299,6 +311,7 @@
/* 仅在确认芯片已离开睡眠(唤醒成功 / 读到 SLEEP_EN=0 / NRST)时清 flag */ /* 仅在确认芯片已离开睡眠(唤醒成功 / 读到 SLEEP_EN=0 / NRST)时清 flag */
function markAwake() { function markAwake() {
clearSleepFlag(); clearSleepFlag();
pmHold(0); /* 芯片已醒:解除主站看门狗豁免,恢复正常自愈 */
$("sleepBanner").textContent = $("sleepBanner").textContent =
"🌙 芯片处于低功耗档 —— EtherCAT 通信已中断,点「手动唤醒」恢复"; "🌙 芯片处于低功耗档 —— EtherCAT 通信已中断,点「手动唤醒」恢复";
} }
@@ -396,6 +409,17 @@
function ecatMotorStart(timeoutMs) { function ecatMotorStart(timeoutMs) {
return ecatPost("motor/start", {}, timeoutMs || 30000); return ecatPost("motor/start", {}, timeoutMs || 30000);
} }
/* 主站楔死看门狗豁免:休眠期间 ecat_native 不判死不重拉(否则芯片醒来
* 即被偷偷拉回 OP——2026-09-12 实测休眠 ~20s 自动"复活")。解除时主站
* 自带 60s 冷却,让本页唤醒流程先走完。best-effort:native 未起只记日志。 */
function pmHold(on) {
return ecatPost("pm/hold", { on: on }, 5000).then(function (r) {
log("主站看门狗豁免" + (on ? "置位" : "解除") +
(r.ok ? "" : "失败: " + r.log.join(";")), r.ok ? "ok" : "warn");
}).catch(function (e) {
log("主站看门狗豁免" + (on ? "置位" : "解除") + "失败: " + e.message, "warn");
});
}
function ecatEnsureOpen() { function ecatEnsureOpen() {
/* bus_open=0 时 motor/start 直接回「未连接」。与电机页 ensureOpen 同源。 */ /* bus_open=0 时 motor/start 直接回「未连接」。与电机页 ensureOpen 同源。 */
return ecatPost("native/health", {}, 6000).then(function (h) { return ecatPost("native/health", {}, 6000).then(function (h) {
@@ -420,6 +444,7 @@
}); });
} }
function recoverEcatMaster() { function recoverEcatMaster() {
pmHold(0); /* 唤醒流程开始即解除豁免(主站给 60s 冷却,不干扰本流程) */
log("重连 EtherCAT 主站:stop → open → NRST → motor/start …", "warn"); log("重连 EtherCAT 主站:stop → open → NRST → motor/start …", "warn");
/* 先停在跑的 PDO 循环,避免与 NRST/init 抢总线 */ /* 先停在跑的 PDO 循环,避免与 NRST/init 抢总线 */
return ecatPost("motor/stop", {}, 8000).catch(function () { return { ok: true, log: ["stop 跳过"] }; }) return ecatPost("motor/stop", {}, 8000).catch(function () { return { ok: true, log: ["stop 跳过"] }; })
@@ -519,6 +544,7 @@
pollPaused = true; pollPaused = true;
setConn(true, "睡眠中 · 轮询已暂停"); setConn(true, "睡眠中 · 轮询已暂停");
saveSleepFlag(n); saveSleepFlag(n);
pmHold(1); /* 请主站看门狗整段静默,别把睡着/刚醒的芯片偷偷拉回 OP */
renderStatus(b); renderStatus(b);
/* 固件返回可能是新 status 或旧字节;强制按 D{n} 展示并记住 */ /* 固件返回可能是新 status 或旧字节;强制按 D{n} 展示并记住 */
setCell("stSleep", "1", "v-warn"); setCell("stSleep", "1", "v-warn");
@@ -568,6 +594,9 @@
renderAssumedSleep(restoredSleep); renderAssumedSleep(restoredSleep);
setConn(true, "上次睡眠中 · 轮询已暂停"); setConn(true, "上次睡眠中 · 轮询已暂停");
log("恢复上次会话:D" + restoredSleep + " 睡眠中。为避免 HBI 读唤醒芯片,已跳过自动状态刷新;点「读状态」或「手动唤醒」继续。", "warn"); log("恢复上次会话:D" + restoredSleep + " 睡眠中。为避免 HBI 读唤醒芯片,已跳过自动状态刷新;点「读状态」或「手动唤醒」继续。", "warn");
/* 新标签/重开浏览器:native 可能刚重启导致 g_pm_hold=0,楔死线程会趁
* 芯片睡着时重拉(D1/D2 报文即唤醒)。必须重新置位豁免。 */
pmHold(1);
probeEspOnly(); probeEspOnly();
} else { } else {
log("就绪。状态每 2s 自动刷新;改设备 IP 请到 OTA 中继页(全局共享)。", "warn"); log("就绪。状态每 2s 自动刷新;改设备 IP 请到 OTA 中继页(全局共享)。", "warn");
+7 -7
View File
@@ -252,7 +252,7 @@ footer code{font-size:11px}
<div class="pills"> <div class="pills">
<span class="pill">真源 <b>ethercat_can_bridge.h</b></span> <span class="pill">真源 <b>ethercat_can_bridge.h</b></span>
<span class="pill">尺寸 <b>ethercat_pdo_size.h</b></span> <span class="pill">尺寸 <b>ethercat_pdo_size.h</b></span>
<span class="pill">PRAM <b>0.441 µs/B</b></span> <span class="pill">PRAM 写 <b>从站侧</b>(≠ 主站 rx)</span>
<span class="pill">魔数 <b>@35=0xA5</b></span> <span class="pill">魔数 <b>@35=0xA5</b></span>
<span class="pill">电机槽 <b>≤9 × 56 B</b></span> <span class="pill">电机槽 <b>≤9 × 56 B</b></span>
<span class="pill">稀疏写 <b>CONFIG=1</b></span> <span class="pill">稀疏写 <b>CONFIG=1</b></span>
@@ -437,20 +437,20 @@ for (i = 0; i &lt; count; i++) {
<div class="sec-head"> <div class="sec-head">
<span class="sec-no">4</span> <span class="sec-no">4</span>
<h2>每拍成本与 SM 预算</h2> <h2>每拍成本与 SM 预算</h2>
<span class="tagline">PRAM ≈0.441 µs/B · 稀疏写 1.6.22+</span> <span class="tagline">从站写 ESC 成本 · 稀疏写 1.6.22+(不是主站 rx)</span>
</div> </div>
<div class="cost-grid"> <div class="cost-grid">
<div class="cost-card c-cyan"> <div class="cost-card c-cyan">
<div class="lbl">输入写侧 · 3 电机稀疏</div> <div class="lbl">输入写侧 · 3 电机稀疏</div>
<div class="val">116</div> <div class="val">—</div>
<div class="unit">µs/拍 · 264 B×0.441 · 11.6% @1ms</div> <div class="unit">从站写 ESC · 按实占字节估(≠ 主站 rx)</div>
<div class="bar"><i style="width:11.6%"></i></div> <div class="bar"><i style="width:11.6%"></i></div>
</div> </div>
<div class="cost-card c-blue"> <div class="cost-card c-blue">
<div class="lbl">输入写侧 · 整包基线</div> <div class="lbl">输入写侧 · 整包基线</div>
<div class="val">265</div> <div class="val">—</div>
<div class="unit">µs/拍 · 600×0.441 · 26.5% @1ms</div> <div class="unit">从站写 ESC · 600B 整包(≠ 主站 rx)</div>
<div class="bar"><i style="width:26.5%"></i></div> <div class="bar"><i style="width:26.5%"></i></div>
</div> </div>
<div class="cost-card c-amber"> <div class="cost-card c-amber">
@@ -524,7 +524,7 @@ count=9 → 整包 600 B(两段相接,回退)</code></pre>
布局真源 <code>comm/ethercat_can_bridge.h</code>(1.6.21)· 尺寸 <code>comm/ethercat_pdo_size.h</code> · 布局真源 <code>comm/ethercat_can_bridge.h</code>(1.6.21)· 尺寸 <code>comm/ethercat_pdo_size.h</code> ·
固件版本 <code>config.h APP_FW_VERSION = 1.6.24</code> · 固件版本 <code>config.h APP_FW_VERSION = 1.6.24</code> ·
稀疏写 <code>ethercat_pdo.cpp sparseEscWritePlan</code> + <code>ecatappl.c PDO_InputMapping</code> · 稀疏写 <code>ethercat_pdo.cpp sparseEscWritePlan</code> + <code>ecatappl.c PDO_InputMapping</code> ·
成本系数 <code>0.441 µs/B</code> · 布局魔数 <code>kEcatMotLayoutMagic = 0xA5</code> 成本说明:从站写 ESC 与主站 rx 是两段时间 · 布局魔数 <code>kEcatMotLayoutMagic = 0xA5</code>
</footer> </footer>
</div> </div>
+304 -331
View File
@@ -25,10 +25,15 @@
4. 计数口径不能想当然:cycles 是 long long 可直接相减;bridge.drop / 4. 计数口径不能想当然:cycles 是 long long 可直接相减;bridge.drop /
rx_count 是 **u16**(4000/s 下 16s 绕一圈)必须回绕修正,单拍增量 >32768 rx_count 是 **u16**(4000/s 下 16s 绕一圈)必须回绕修正,单拍增量 >32768
视为采样率不足、该点作废并计数;head.global_seq 是 u32,λ 以它为准; 视为采样率不足、该点作废并计数;head.global_seq 是 u32,λ 以它为准;
rx_torn / pdo_reentry 是**饱和**计数(到 65535 停),封顶只能报"下界"。 rx_torn / reltimeout / overfull / reject 是**饱和**计数(到 65535 停),
封顶只能报"下界"。旧字段 pdo_reentry 在 600B 布局下实为 reltimeout@592。
5. 从站 load_perm 在从站停报时会保留残值,必须用 age_ms<1000 闸一下, 5. 从站 load_perm 在从站停报时会保留残值,必须用 age_ms<1000 闸一下,
否则会把楔死前的旧值当实测画进曲线。 否则会把楔死前的旧值当实测画进曲线。
6. 刷新率一律以**实测**(Δcycles/Δt)为准,名义 1000/period_ms 只作对照。 6. 刷新率一律以**实测**(Δcycles/Δt)为准,名义 1000/period_ms 只作对照。
【实时性】观测仍默认自动开、默认 10Hz。逐拍环 / 原始窗口 / 帧表·hex
「每拍重建」都在各自模块卡片里开关(默认关)。标签页隐藏时自动暂停。
EtherCAT 面板的定点测速/档位扫描模块已下线(热改周期仍保留)。
--> -->
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
@@ -81,8 +86,11 @@
.wrap { position:relative; z-index:1; max-width:1480px; margin:0 auto; padding:22px 26px 48px; } .wrap { position:relative; z-index:1; max-width:1480px; margin:0 auto; padding:22px 26px 48px; }
/* 面板内模块用 gap 分隔(对齐全站 .main-layout / .card+.card 的 --gap=18px 习惯) */
#pane-spi, #pane-ecat { display:flex; flex-direction:column; gap:18px; }
/* ═══════ 头部 + 面板切换 ═══════ */ /* ═══════ 头部 + 面板切换 ═══════ */
header { display:flex; flex-wrap:wrap; gap:14px; align-items:flex-end; margin-bottom:14px; } header { display:flex; flex-wrap:wrap; gap:14px; align-items:flex-end; margin-bottom:18px; }
h1 { font-size:21px; letter-spacing:.3px; } h1 { font-size:21px; letter-spacing:.3px; }
h1 .mark { color:var(--ecat-hi); } h1 .mark { color:var(--ecat-hi); }
.sub { color:var(--text-label); font-size:12px; margin-top:2px; } .sub { color:var(--text-label); font-size:12px; margin-top:2px; }
@@ -99,17 +107,30 @@
.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 { 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); } .tab[data-pane="ecat"].on .dot { background:var(--ecat); box-shadow:0 0 8px var(--ecat); }
/* ═══════ 卡片 ═══════ */ /* ═══════ 卡片(对齐 layout.css:色带 + 独立卡头 + 内容内边距) ═══════ */
.card { .card {
background:var(--bg-panel); border:1px solid var(--border); border-radius:var(--radius); position:relative;
padding:14px 16px; margin-bottom:14px; box-shadow:var(--shadow-card); background:linear-gradient(170deg, var(--bg-card) 0%, rgba(15,23,38,.92) 100%);
border:1px solid var(--border); border-radius:var(--radius);
overflow:hidden; box-shadow:var(--shadow-card);
padding:0 16px 16px;
} }
/* 顶部 2px 子系统色带(layout.css .card::before 同款) */
.card::before {
content:""; display:block; height:2px; margin:0 -16px;
background:linear-gradient(90deg, var(--ecat), transparent 85%);
opacity:.7; pointer-events:none;
}
#pane-spi .card::before { background:linear-gradient(90deg, var(--spi), transparent 85%); }
.card > h2 { .card > h2 {
margin:0 -16px 14px; padding:13px 16px 12px;
border-bottom:1px solid var(--border-subtle);
background:rgba(255,255,255,.02);
font-size:12px; font-weight:600; letter-spacing:.9px; text-transform:uppercase; font-size:12px; font-weight:600; letter-spacing:.9px; text-transform:uppercase;
color:var(--text-label); margin-bottom:11px; display:flex; align-items:center; gap:9px; flex-wrap:wrap; color:var(--text-label); display:flex; align-items:center; gap:9px; flex-wrap:wrap;
} }
.card > h2 .hint2 { font-size:11px; font-weight:400; letter-spacing:0; text-transform:none; color:var(--text-label); } .card > h2 .hint2 { font-size:11px; font-weight:400; letter-spacing:0; text-transform:none; color:var(--text-label); }
.card > h2::before { content:""; width:3px; height:12px; border-radius:2px; background:var(--ecat); } .card > h2::before { content:""; width:3px; height:12px; border-radius:2px; background:var(--ecat); flex:0 0 auto; }
#pane-spi .card > h2::before { background:var(--spi); } #pane-spi .card > h2::before { background:var(--spi); }
.row { display:flex; flex-wrap:wrap; gap:10px 14px; align-items:center; } .row { display:flex; flex-wrap:wrap; gap:10px 14px; align-items:center; }
@@ -209,7 +230,7 @@
.hint { color:var(--text-label); font-size:11.5px; line-height:1.75; margin-top:9px; } .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; } .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; } .hint code { font-family:var(--mono); color:var(--text-dim); background:var(--bg-input); padding:1px 4px; border-radius:4px; }
.cols2 { display:grid; grid-template-columns:1fr 1fr; gap:14px; } .cols2 { display:grid; grid-template-columns:1fr 1fr; gap:18px; }
@media (max-width:1080px){ .cols2 { grid-template-columns:1fr; } } @media (max-width:1080px){ .cols2 { grid-template-columns:1fr; } }
details > summary { cursor:pointer; font-size:11.5px; color:var(--text-label); padding:3px 0; } details > summary { cursor:pointer; font-size:11.5px; color:var(--text-label); padding:3px 0; }
details[open] > summary { color:var(--text-dim); } details[open] > summary { color:var(--text-dim); }
@@ -296,9 +317,9 @@
<h2>观测控制 <span class="hint2">本页只读过程数据缓存,不连接、不重启、不停 PDO 循环</span></h2> <h2>观测控制 <span class="hint2">本页只读过程数据缓存,不连接、不重启、不停 PDO 循环</span></h2>
<div class="row"> <div class="row">
<button id="btnLive" class="primary">开始实时观测</button> <button id="btnLive" class="primary">开始实时观测</button>
<button id="btnRing" class="ghost">逐拍环:关</button>
<label>采样率 <label>采样率
<select id="hz"> <select id="hz">
<option value="1">1 Hz</option>
<option value="2">2 Hz</option> <option value="2">2 Hz</option>
<option value="5">5 Hz</option> <option value="5">5 Hz</option>
<option value="10" selected>10 Hz</option> <option value="10" selected>10 Hz</option>
@@ -317,6 +338,7 @@
</select> </select>
</label> </label>
<button id="btnPeriod">热改周期</button> <button id="btnPeriod">热改周期</button>
<span id="runPill" class="pill"><span class="led"></span>空闲</span>
<span id="ecatState" class="pill"><span class="led"></span>未开始</span> <span id="ecatState" class="pill"><span class="led"></span>未开始</span>
<span id="ecatLink" class="pill">链路 —</span> <span id="ecatLink" class="pill">链路 —</span>
<span id="ecatAl" class="pill">AL —</span> <span id="ecatAl" class="pill">AL —</span>
@@ -325,7 +347,8 @@
热改周期走 <code>/api/motor/period</code>(循环每拍重读的变量,全程喂狗不断流,随时可调、可反复调)。 热改周期走 <code>/api/motor/period</code>(循环每拍重读的变量,全程喂狗不断流,随时可调、可反复调)。
<b>低于 1ms 档后端自动用自旋节拍,会占满一个 CPU 核</b>;从站固件修好前别低于 1ms —— 实测 <b>低于 1ms 档后端自动用自旋节拍,会占满一个 CPU 核</b>;从站固件修好前别低于 1ms —— 实测
0.25 / 0.125ms 档 GD32 顶到 99.5%,CAN 到帧率 λ 从 4000/s 塌到 149/s(瓶颈在从站不在主站, 0.25 / 0.125ms 档 GD32 顶到 99.5%,CAN 到帧率 λ 从 4000/s 塌到 149/s(瓶颈在从站不在主站,
只看主站达成率会整条看漏)。 只看主站达成率会整条看漏)。<br>
<b>观测默认 10Hz</b>。逐拍环 / 原始窗口 / 每拍重建都在各自模块卡片里开关(默认关)。标签页切到后台会自动暂停。
</div> </div>
</div> </div>
@@ -336,13 +359,13 @@
<div class="metric" id="cAchieve"><div class="k">达成率</div><div class="v">—</div><div class="f">名义 — Hz</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="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="cDuty"><div class="k">主站占空比</div><div class="v">—</div><div class="f">单拍 p50 ÷ 周期</div></div>
<div class="metric" id="cSlot"><div class="k">单电机状态刷新</div><div class="v">—<small>Hz</small></div><div class="f">slot_period_ms —</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="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">主站 seq 空洞 · GD32 OVF(抽样单拍值)</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="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="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="cWkc"><div class="k">WKC</div><div class="v">—</div><div class="f">期望 —</div></div>
<div class="metric" id="cTorn"><div class="k">撕裂 / 重入</div><div class="v">—</div><div class="f">rx_torn / pdo_reentry</div></div> <div class="metric" id="cTorn"><div class="k">CAN 病理仪表</div><div class="v">—</div><div class="f">撕裂 · RFD超时 · 满溢 · 拼接</div></div>
<div class="metric" id="cRtt"><div class="k">本页请求耗时</div><div class="v">—<small>ms</small></div><div class="f">本页→代理→native 最慢一个请求的往返(≠节拍耗时)</div></div> <div class="metric" id="cRtt"><div class="k">本页请求耗时</div><div class="v">—<small>ms</small></div><div class="f">本页→代理→native 最慢一个请求的往返(≠节拍耗时)</div></div>
</div> </div>
<div class="hint"> <div class="hint">
@@ -357,14 +380,21 @@
③ 段 4 覆盖窗口 <u>主站侧</u>按 seq 空洞算的 <code>rx_batch.lost</code>(<code>step/2−1</code>, ③ 段 4 覆盖窗口 <u>主站侧</u>按 seq 空洞算的 <code>rx_batch.lost</code>(<code>step/2−1</code>,
发布值恒为偶数,按 step−1 会把每帧虚报成丢 1 个);④ 段 4 <u>GD32 侧</u>未取先覆 发布值恒为偶数,按 step−1 会把每帧虚报成丢 1 个);④ 段 4 <u>GD32 侧</u>未取先覆
<code>rx_batch.pending</code>(= OVF,与 ③ 是两个独立仪表,正常应当同步增长); <code>rx_batch.pending</code>(= OVF,与 ③ 是两个独立仪表,正常应当同步增长);
⑤ CAN 取帧撕裂 <code>rx_torn</code>。③④ 是本页 10Hz 抽样看到的<u>单拍值</u>,只能当下界。 ⑤ CAN 取帧撕裂 <code>rx_torn</code>;⑥ FIFO 满溢 <code>overfull</code>;⑦ 内容拼接 <code>reject</code>。
③④ 是本页 10Hz 抽样看到的<u>单拍值</u>,只能当下界。密排 STATUS 不走段4,
真丢帧仪表只盖长帧/win 路径。
撕裂 / 重入是<u>饱和</u>计数(到 65535 停),封顶时只能报下界。 撕裂 / 重入是<u>饱和</u>计数(到 65535 停),封顶时只能报下界。
</div> </div>
</div> </div>
<div class="card"> <div class="card">
<h2>实时曲线 <span class="hint2">滚动 90s · 紫 = 实测 PDO Hz(左轴) · 青 = 单拍 p50 µs(右轴)</span></h2> <h2>实时曲线 <span class="hint2">滚动 90s · 紫 = 实测 PDO Hz(左轴) · 青 = 单拍 p50 µs(右轴)</span></h2>
<canvas id="chart" height="150"></canvas> <div class="row" style="margin-bottom:8px">
<button id="btnChartMax" class="sm ghost" title="cyc_us.total.max(后端 STATS_N=400 拍窗口最大值)">单拍 max:关</button>
<button id="btnChartSkip" class="sm ghost" title="cyc_us.late / 窗口时长(late=起跑超期拍数)">跳拍:关</button>
<span id="chartHint" class="hint" style="margin:0">开 max / 跳拍 可叠加显示;右轴 µs,跳拍独立缩放</span>
</div>
<canvas id="chart" height="160"></canvas>
</div> </div>
<div class="cols2"> <div class="cols2">
@@ -408,6 +438,7 @@
<div class="row" style="margin-bottom:8px"> <div class="row" style="margin-bottom:8px">
<span id="rxbPill" class="pill">本拍新帧 / seq 空洞 / GD32 OVF —</span> <span id="rxbPill" class="pill">本拍新帧 / seq 空洞 / GD32 OVF —</span>
<span id="rxbSeen" class="pill">本页帧样本 —</span> <span id="rxbSeen" class="pill">本页帧样本 —</span>
<button id="btnHeavyFrames" class="sm ghost" title="帧表/密排槽表是否每拍重建 DOM。关=观测时不动这两块">每拍重建:关</button>
<button id="btnRxbClear" class="sm ghost">清零计数</button> <button id="btnRxbClear" class="sm ghost">清零计数</button>
</div> </div>
<div class="scroll" style="height:230px"> <div class="scroll" style="height:230px">
@@ -443,6 +474,11 @@
<div class="card"> <div class="card">
<h2>收到的东西 ③ 原始接收窗口 + 长帧 + 逐拍环 <span class="hint2">/api/motor/rxraw · state.xrx · /api/motor/dbg(全为只读缓存拷贝)</span></h2> <h2>收到的东西 ③ 原始接收窗口 + 长帧 + 逐拍环 <span class="hint2">/api/motor/rxraw · state.xrx · /api/motor/dbg(全为只读缓存拷贝)</span></h2>
<div class="row" style="margin-bottom:8px">
<button id="btnRxraw" class="sm ghost" title="每拍多打 /api/motor/rxraw(约 600B hex)。默认关">原始窗口:关</button>
<button id="btnHeavyHex" class="sm ghost" title="原始 hex 网格是否每拍重建 DOM。关=观测时不动这块">hex每拍重建:关</button>
<button id="btnRing" class="sm ghost" title="每拍多打 /api/motor/dbg(约 160KB)。默认关,只在看抖动时开">逐拍环:关</button>
</div>
<div class="cols2" style="--pane-h:250px"> <div class="cols2" style="--pane-h:250px">
<div class="paneFlex"> <div class="paneFlex">
<div class="kv" id="rawKv"></div> <div class="kv" id="rawKv"></div>
@@ -467,35 +503,6 @@
<div id="rawHex" class="hexgrid" style="--hex-h:158px"><i class="hdr">—</i></div> <div id="rawHex" class="hexgrid" style="--hex-h:158px"><i class="hdr">—</i></div>
</div> </div>
</div> </div>
<div class="card">
<h2>测速 <span class="hint2">与 SPI 面板同一套习惯:跑一轮 → 出统计行 → 落历史表对比</span></h2>
<div class="row">
<label>定点时长 s <input id="runSec" class="narrow" value="10"></label>
<label>采样率 Hz <input id="runHz" class="narrow" value="10"></label>
<button id="btnEcatRun" class="primary">定点测速</button>
<span style="width:14px"></span>
<label>扫描档位 ms <input id="sweepList" style="width:150px" value="4,2,1,0.5"></label>
<label>每档驻留 s <input id="sweepDwell" class="narrow" value="10"></label>
<button id="btnSweep">档位扫描</button>
<button id="btnEcatAbort" class="stop" hidden>中止并恢复周期</button>
</div>
<div class="row"><span id="runPill" class="pill"><span class="led"></span>空闲</span><span id="runProg" class="hint" style="margin:0">—</span></div>
<div class="hint">
扫描逐档下发 <code>/api/motor/period</code>(热改,不停循环),每档采完<b>回读 period_ms 确认生效</b>
—— 不一致就是被别处的 localStorage 或另一个标签页劫持了,该档标红而不是照单收下。
结束(含中止/异常)必把周期恢复成扫描前的原值并回读校验。
低档位有把从站打满的风险(λ 塌陷、从站停报),页内不做"帮你自动试到崩"。<br>
<b>每档驻留要大于节拍环容量 2048×周期</b>(4ms 档 ≈8.2s、1ms 档 ≈2.05s),否则环里还是上一档的旧节拍,
页内会直接标注"本档抖动不可信"而不是给你个看着像样的 p50。
</div>
<div class="scroll" style="margin-top:10px">
<table id="tblRuns">
<thead><tr><th>时刻</th><th>类型</th><th class="num">周期 ms</th><th class="num">实测 Hz</th><th class="num">达成 %</th><th class="num">λ /s</th><th class="num">真丢帧 帧/拍</th><th class="num">skip /s</th><th class="num">单拍 p50/p90/max µs</th><th class="num">从站 %</th><th class="num">撕裂</th><th class="num">在线槽</th><th class="num">WKC</th><th class="num">采样</th><th>备注</th></tr></thead>
<tbody></tbody>
</table>
</div>
</div>
</section> </section>
</div> </div>
<script> <script>
@@ -539,7 +546,7 @@ function kv(boxId, rows) {
box.appendChild(d); box.appendChild(d);
} }
} }
/** 状态胶囊:三处(SPI / ECAT / 测速跑批)共用同一套 led + 文案结构 */ /** 状态胶囊:SPI / ECAT / 热改周期共用同一套 led + 文案结构 */
function setPill(sel, cls, text) { function setPill(sel, cls, text) {
const n = $(sel); n.className = "pill" + (cls ? " " + cls : ""); const n = $(sel); n.className = "pill" + (cls ? " " + cls : "");
n.innerHTML = '<span class="led"></span>'; n.appendChild(el("span", null, text)); n.innerHTML = '<span class="led"></span>'; n.appendChild(el("span", null, text));
@@ -560,6 +567,16 @@ function wrapDelta(prev, cur, bits) {
if (d < 0) d += m; if (d < 0) d += m;
return d; 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 收下并标注。 */ /** 饱和计数器(到 65535 停):返回 [增量, 是否封顶]。倒退说明后端重启过,按 0 收下并标注。 */
function satDelta(prev, cur) { function satDelta(prev, cur) {
if (prev === null || cur === null || cur === undefined) return [0, false]; if (prev === null || cur === null || cur === undefined) return [0, false];
@@ -599,7 +616,7 @@ function showPane(name) {
/* 首次进来自动开;之后只在"被切走时本来是开着"的场合恢复 */ /* 首次进来自动开;之后只在"被切走时本来是开着"的场合恢复 */
if (!E.everStarted || E.wantLive) startEcatIfIdle(); if (!E.everStarted || E.wantLive) startEcatIfIdle();
} else if (E.live) { } else if (E.live) {
/* 切到 SPI 就把看板轮询停掉:SPI 测的是 µs 级帧周期,这边 10Hz×3 条代理请求 /* 切到 SPI 就把看板轮询停掉:SPI 测的是 µs 级帧周期,这边 N Hz×M 条代理请求
* 会把自己的测量抬高 —— 先排除自己造的假象。 */ * 会把自己的测量抬高 —— 先排除自己造的假象。 */
const was = E.live; stopEcat(); E.wantLive = was; const was = E.live; stopEcat(); E.wantLive = was;
setPill("#ecatState", "", "已暂停(在 SPI 页)"); setPill("#ecatState", "", "已暂停(在 SPI 页)");
@@ -814,7 +831,15 @@ class RateWindow {
if (!(pair > 0)) continue; if (!(pair > 0)) continue;
dt += pair; dt += pair;
if (isNum(a.cycles) && isNum(b.cycles)) dCyc += (b.cycles - a.cycles); if (isNum(a.cycles) && isNum(b.cycles)) dCyc += (b.cycles - a.cycles);
if (isNum(a.gseq) && isNum(b.gseq)) dLam += wrapDelta(a.gseq, b.gseq, 32); if (isNum(a.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)) { if (isNum(a.drop) && isNum(b.drop)) {
const d = wrapDelta(a.drop, b.drop, 16); const d = wrapDelta(a.drop, b.drop, 16);
/* 单拍增量超过 u16 半程 = 采样率不足以分辨回绕,该点对 drop 作废并计数 */ /* 单拍增量超过 u16 半程 = 采样率不足以分辨回绕,该点对 drop 作废并计数 */
@@ -826,8 +851,8 @@ class RateWindow {
* (把 step 累加再除 dt 得到的是 λ 本身,那是个会冒充刷新率的假数。) */ * (把 step 累加再除 dt 得到的是 λ 本身,那是个会冒充刷新率的假数。) */
for (const k in b.seq) { for (const k in b.seq) {
if (a.seq[k] === undefined) continue; if (a.seq[k] === undefined) continue;
const d = wrapDelta(a.seq[k], b.seq[k], 32); const d = wrapDeltaBounded(a.seq[k], b.seq[k], 32, Math.max(50000, Math.ceil(pair * 200000)));
if (d === 0) continue; if (d === null || d === 0) continue;
(steps[k] = steps[k] || []).push(d); (steps[k] = steps[k] || []).push(d);
changes[k] = (changes[k] || 0) + 1; changes[k] = (changes[k] || 0) + 1;
} }
@@ -885,14 +910,19 @@ const fmtPeriod = (ms) => (!ms ? "—" : (ms < 1 ? Math.round(ms * 1000) + "µs"
/* ═══════════════════ EtherCAT:实时观测状态 ═══════════════════ */ /* ═══════════════════ EtherCAT:实时观测状态 ═══════════════════ */
const E = { const E = {
live: false, ring: false, wantLive: false, everStarted: false, live: false, ring: false, rxraw: false,
heavyFrames: false, heavyHex: false,
wantLive: false, everStarted: false, pausedByHidden: false,
win: new RateWindow(3000), win: new RateWindow(3000),
pollDts: [], pollHz: null, intervalMs: null, lastRates: null, pollDts: [], pollHz: null, intervalMs: null, lastRates: null,
lastState: null, status: null, health: null, load: null, raw: null, dbg: null, lastState: null, status: null, health: null, load: null, raw: null, dbg: null,
tornPrev: null, reentryPrev: null, tornCap: false, reentryCap: false, 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(), lastRawCycle: null, seenFrames: 0, seenT0: 0, subs: new Map(),
lastSeq: {}, seqAt: {}, densLast: null, lastSeq: {}, seqAt: {}, densLast: null,
chart: { t: [], hz: [], us: [] }, errors: 0, errText: "", chart: { t: [], hz: [], us: [], usMax: [], lateHz: [] },
chartOpt: { max: false, skip: false },
errors: 0, errText: "",
}; };
/** 槽 seq 停更多久算不在交换(要大于"扫完全表 + 抖动":period 4ms × 6 拍 = 24ms,2s 有充足余量) */ /** 槽 seq 停更多久算不在交换(要大于"扫完全表 + 抖动":period 4ms × 6 拍 = 24ms,2s 有充足余量) */
const STALE_OFFLINE_MS = 2000; const STALE_OFFLINE_MS = 2000;
@@ -966,7 +996,7 @@ const sleeper = (function () {
})(); })();
const wsleep = (ms) => sleeper.ms(Math.max(0, ms)); const wsleep = (ms) => sleeper.ms(Math.max(0, ms));
/** 一拍:state 之外再读 app/load(可选 dbg) 与 rxraw,计数与时刻成对喂给速率窗口 */ /** 一拍:state + app/load;dbg 仅逐拍环开时;rxraw 仅「原始窗口」开时。 */
async function handleTick(st) { async function handleTick(st) {
let load = null, dbg = null, raw = null, rtt = st.__rtt || 0; let load = null, dbg = null, raw = null, rtt = st.__rtt || 0;
try { try {
@@ -975,10 +1005,11 @@ async function handleTick(st) {
load = done[0]; dbg = done[1]; load = done[0]; dbg = done[1];
rtt = Math.max(rtt, (load && load.__rtt) || 0, (dbg && dbg.__rtt) || 0); rtt = Math.max(rtt, (load && load.__rtt) || 0, (dbg && dbg.__rtt) || 0);
} catch (e) { E.errors++; E.errText = (e && e.message) || String(e); } } catch (e) { E.errors++; E.errText = (e && e.message) || String(e); }
/* rxraw 顺带读:它带 cycle,能挡住"同一拍被重复读两次"的重复计数。 /* rxraw 响应体最大(约 600B hex + parsed),默认关。开启时才拉,并计入 rtt。 */
* 它的响应体最大(原始字节转 hex + 解析结果),所以必须计入 rtt,漏了这卡就系统性偏低。 */ if (E.rxraw) {
try { raw = await eapi("motor/rxraw", {}, 4000); if (raw) rtt = Math.max(rtt, raw.__rtt || 0); } try { raw = await eapi("motor/rxraw", {}, 4000); if (raw) rtt = Math.max(rtt, raw.__rtt || 0); }
catch (_) { raw = null; } catch (_) { raw = null; }
}
const t = performance.now(); // 与计数同一瞬间,不能事后再取 const t = performance.now(); // 与计数同一瞬间,不能事后再取
const b = st.bridge || {}, head = st.head || {}; const b = st.bridge || {}, head = st.head || {};
const seq = {}; const seq = {};
@@ -999,8 +1030,14 @@ async function handleTick(st) {
renderBus(st); renderBus(st);
renderBridge(st); renderBridge(st);
renderMotors(st, rates); renderMotors(st, rates);
if (raw) renderFrames(st, raw); /* 帧表 / hex 网格:各自模块的「每拍重建」开关(默认关)。关着时 DOM 冻结。 */
renderRaw(st, raw); if (E.rxraw && raw) {
if (E.heavyFrames) renderFrames(st, raw);
else countSeenFrames(raw); /* 计数照走,只是不重画表 */
if (E.heavyHex) renderRaw(st, raw);
} else if (E.heavyHex) {
renderRaw(st, null);
}
if (dbg) renderBeats(dbg); if (dbg) renderBeats(dbg);
pushChart(rates, st); pushChart(rates, st);
drawChart(); drawChart();
@@ -1008,10 +1045,11 @@ async function handleTick(st) {
function pollHz() { return clampNum($("#hz").value, 0.5, 20, 10); } function pollHz() { return clampNum($("#hz").value, 0.5, 20, 10); }
function startEcatIfIdle() { function startEcatIfIdle() {
if (E.live) return; if (E.live) return;
E.live = true; E.everStarted = true; E.wantLive = true; tickPrev = null; E.pollDts = []; 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; E.seenFrames = 0; E.seenT0 = performance.now(); E.lastRawCycle = null;
$("#btnLive").textContent = "暂停观测"; $("#btnLive").textContent = "暂停观测";
setPill("#ecatState", "warn", "观测中"); setPill("#ecatState", "warn", "观测中 · " + pollHz() + "Hz" + (E.rxraw ? " · rxraw" : ""));
eapi("status", {}, 6000).then((s) => { E.status = s; renderBus(E.lastState); }) eapi("status", {}, 6000).then((s) => { E.status = s; renderBus(E.lastState); })
.catch((e) => { E.status = null; E.errText = e.message; }); .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; }); eapi("native/health", {}, 6000).then((h) => { E.health = h; renderBus(E.lastState); }).catch(() => { E.health = null; });
@@ -1019,18 +1057,65 @@ function startEcatIfIdle() {
requestTick(); requestTick();
} }
function stopEcat() { function stopEcat() {
E.live = false; E.wantLive = false; E.live = false; E.wantLive = false; E.pausedByHidden = false;
ticker.stop(); ticker.stop();
$("#btnLive").textContent = "开始实时观测"; $("#btnLive").textContent = "开始实时观测";
setPill("#ecatState", "", "已暂停"); setPill("#ecatState", "", "已暂停");
} }
/* 后台标签页:Worker 定时器不受节流,live 着=持续打代理。暂停但记住 wantLive。 */
function pauseEcatHidden() {
if (!E.live) return;
E.pausedByHidden = true;
E.live = false;
ticker.stop();
$("#btnLive").textContent = "后台已暂停 · 点此恢复";
setPill("#ecatState", "", "后台已暂停");
}
function resumeEcatShown() {
if (!E.pausedByHidden || !E.wantLive) return;
E.pausedByHidden = false;
startEcatIfIdle();
}
document.addEventListener("visibilitychange", () => {
if (document.hidden) pauseEcatHidden();
else resumeEcatShown();
});
$("#btnLive").onclick = () => { E.live ? stopEcat() : startEcatIfIdle(); }; $("#btnLive").onclick = () => { E.live ? stopEcat() : startEcatIfIdle(); };
$("#btnRing").onclick = () => { $("#btnRing").onclick = () => {
E.ring = !E.ring; E.ring = !E.ring;
$("#btnRing").textContent = "逐拍环:" + (E.ring ? "开" : "关"); $("#btnRing").textContent = "逐拍环:" + (E.ring ? "开" : "关");
if (!E.ring) { tblBeatsBody.textContent = ""; $("#beatStat").textContent = "逐拍环未开启。"; } if (!E.ring) { tblBeatsBody.textContent = ""; $("#beatStat").textContent = "逐拍环未开启。"; }
}; };
$("#hz").onchange = () => { E.win.reset(); ticker.update(1000 / pollHz()); }; $("#btnRxraw").onclick = () => {
E.rxraw = !E.rxraw;
$("#btnRxraw").textContent = "原始窗口:" + (E.rxraw ? "开" : "关");
$("#btnRxraw").title = E.rxraw
? "每拍额外读 /api/motor/rxraw(约 600B hex)。对 EtherCAT 有负载,看完建议关掉"
: "每拍多打 /api/motor/rxraw(约 600B hex)。默认关";
if (E.live) setPill("#ecatState", "warn", "观测中 · " + pollHz() + "Hz" + (E.rxraw ? " · rxraw" : ""));
if (!E.rxraw) E.raw = null;
if (E.rxraw && E.lastState && E.raw) {
if (E.heavyFrames) renderFrames(E.lastState, E.raw);
if (E.heavyHex) renderRaw(E.lastState, E.raw);
}
};
$("#btnHeavyFrames").onclick = () => {
E.heavyFrames = !E.heavyFrames;
$("#btnHeavyFrames").textContent = "每拍重建:" + (E.heavyFrames ? "开" : "关");
if (E.heavyFrames && E.lastState && E.raw) renderFrames(E.lastState, E.raw);
};
$("#btnHeavyHex").onclick = () => {
E.heavyHex = !E.heavyHex;
$("#btnHeavyHex").textContent = "hex每拍重建:" + (E.heavyHex ? "开" : "关");
if (E.heavyHex && E.lastState) {
if (E.rxraw && E.raw) renderRaw(E.lastState, E.raw);
else renderRaw(E.lastState, null);
}
};
$("#hz").onchange = () => {
E.win.reset();
if (E.live) ticker.update(1000 / pollHz());
};
/* ═══════════════════ 渲染:指标卡 ═══════════════════ */ /* ═══════════════════ 渲染:指标卡 ═══════════════════ */
function renderMetrics(st, load, dbg, r, rtt) { function renderMetrics(st, load, dbg, r, rtt) {
@@ -1053,37 +1138,39 @@ function renderMetrics(st, load, dbg, r, rtt) {
metric("cDuty", f1(duty) + "<small>%</small>", "单拍 p50 ÷ 周期", metric("cDuty", f1(duty) + "<small>%</small>", "单拍 p50 ÷ 周期",
duty === null ? "" : (duty < 60 ? "good" : duty < 85 ? "warn" : "bad")); duty === null ? "" : (duty < 60 ? "good" : duty < 85 ? "warn" : "bad"));
/* 单电机状态刷新:用后端自己的窗口算术 1000/slot_period_ms(= period × win_bases 数)。 /* 单拍分段:cyc_us.work/send/rx 的 p50(µs)。
* 故意不用"相邻 seq 步长"反推 —— 采样一旦被节流(隐藏标签页 setTimeout 会掉到 * work=t0→te 应用+组帧+等应答全程;send=ts→tm 发送段;rx=tm→tr 收应答段。
* 1Hz),步长就变成"这一秒里的全部帧数",λ/步长会产出 1Hz 这种看着自洽的假数。 * max 冲到 1~2ms 时对一下哪一段先涨,比再放一个和 PDO 同数的卡有用。 */
* 实测 seq 变化次数只作为下界显示,并标清它受采样率限制。 */
const exchanging = !!(st && isNum(st.cycles) && st.cycles > 0 && isNum(r.hz) && r.hz > 0); const exchanging = !!(st && isNum(st.cycles) && st.cycles > 0 && isNum(r.hz) && r.hz > 0);
const mv = ((st && st.motors) || []).filter((m) => { const mv = ((st && st.motors) || []).filter((m) => {
const a = slotAge(m.node + ":" + m.ch); const a = slotAge(m.node + ":" + m.ch);
return m.online && a !== null && a <= STALE_OFFLINE_MS; return m.online && a !== null && a <= STALE_OFFLINE_MS;
}); });
const slotFromPeriod = (exchanging && isNum(st && st.slot_period_ms) && st.slot_period_ms > 0) const segW = f0((cyc.work || {}).p50), segS = f0((cyc.send || {}).p50), segR = f0((cyc.rx || {}).p50);
? 1000 / st.slot_period_ms : null; const hasSeg = cyc.work && cyc.send && cyc.rx;
const chVals = Object.values(r.motorChanges).filter(isNum); metric("cSlot",
metric("cSlot", (slotFromPeriod === null ? "—" : f0(slotFromPeriod)) + "<small>Hz</small>", hasSeg ? (segW + "<small> / " + segS + " / " + segR + " µs</small>") : "—",
slotFromPeriod === null ? "没在交换 —— slot_period_ms 是缓存旧值,不拿来当实测" hasSeg
: "1000÷slot_period " + f2(st && st.slot_period_ms) + "ms · 实测 seq 变化 ≥ " + ? "work=" + segW + " send=" + segS + " rx=" + segR +
f1(quantile(chVals, 0.5)) + "/s · 本页采样 " + f1(E.pollHz) + "Hz", " · 总耗时看左侧「单拍耗时」"
slotFromPeriod === null ? "bad" : "good"); : "cyc_us 未上报",
hasSeg ? "good" : "");
metric("cLambda", f0(r.lambda) + "<small>/s</small>", "λ ÷ 实测Hz = " + (isNum(r.lambda) && isNum(r.hz) && r.hz ? f2(r.lambda / r.hz) : "—") + " 帧/拍", 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") : ""); isNum(r.lambda) ? (r.lambda > 0 ? "good" : "bad") : "");
/* drop 是个会自证的数:段1/段3 只有一个"最近一帧"格子,每拍最多发布 1 帧, /* drop 恒等于 λ−Hz,零信息量;主数字只报真丢帧:主站 seq 空洞 与 GD32 OVF
* 于是它恒等于 λ − 实测Hz,拿它判好坏没有灵敏度(连"视野百分比"这种换算都不必做, * (密排 STATUS 不进 rx_batch,两者对 STATUS 恒 0,只盖长帧/win 布局路径)。 */
* 那是同一个数的另一种写法)。卡面只报真丢帧:主站 seq 空洞(rxbLostMax) 与 GD32
* OVF(rxbPendingMax) 取抽样单拍最大值(两者独立、正常应同步增长),颜色叠上撕裂累计。 */
const holes = Math.max(r.rxbLostMax || 0, r.rxbPendingMax || 0); const holes = Math.max(r.rxbLostMax || 0, r.rxbPendingMax || 0);
const tornN = E.tornAccum || 0; const tornN = E.tornAccum || 0;
metric("cDrop", holes + "<small>帧/拍</small>", metric("cDrop", holes + "<small>帧/拍</small>",
"主站空洞 " + (r.rxbLostMax || 0) + " · GD32 OVF " + (r.rxbPendingMax || 0) + "主站空洞 " + (r.rxbLostMax || 0) + " · GD32 OVF " + (r.rxbPendingMax || 0) +
" · 撕裂 " + tornN + " · 另有段1/段3 单格 drop " + f0(r.drop) + "/s(≡λ−Hz,不算丢帧" + (holes > 0 || tornN ? " · 撕裂累计 " + tornN : "") +
(r.untrusted ? " · " + r.untrusted + " 点作废(采样率不足)" : "") + ")", " · 段1/3 drop " + f0(r.drop) + "/s(≡λ−Hz,不判丢帧" +
(r.drop === null && !holes && !tornN) ? "" : (holes > 0 || tornN > 0 ? "bad" : "good")); (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 拍窗口),不必开逐拍环。 /* 超期/跳拍:cyc_us.late/lost 随 state 每拍就有(最近 n 拍窗口),不必开逐拍环。
* 口径是**窗口计数**不是自启动累计;且只有 2026-09-11 之后的主站构建接了真值, * 口径是**窗口计数**不是自启动累计;且只有 2026-09-11 之后的主站构建接了真值,
* 旧构建恒报 0 —— 看到 0 先确认后端版本,别当"没有超期"。 */ * 旧构建恒报 0 —— 看到 0 先确认后端版本,别当"没有超期"。 */
@@ -1108,22 +1195,37 @@ function renderMetrics(st, load, dbg, r, rtt) {
metric("cWkc", (wkc ?? "—") + " / " + (exp ?? "—"), "期望不符即从站丢帧/掉 OP 前兆", metric("cWkc", (wkc ?? "—") + " / " + (exp ?? "—"), "期望不符即从站丢帧/掉 OP 前兆",
wkc === null ? "" : (wkc === exp ? "good" : "bad")); 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)) { if (load && isNum(load.rx_torn)) {
const [dt] = satDelta(E.tornPrev, load.rx_torn); const [dt] = satDelta(E.tornPrev, load.rx_torn);
const [dr] = satDelta(E.reentryPrev, load.pdo_reentry); 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.tornPrev !== null) E.tornAccum = (E.tornAccum || 0) + dt;
if (E.reentryPrev !== null) E.reentryAccum = (E.reentryAccum || 0) + dr; 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.tornCap = E.tornCap || load.rx_torn >= 65535;
E.reentryCap = E.reentryCap || load.pdo_reentry >= 65535; E.reentryCap = E.reentryCap || (load.reltimeout != null ? load.reltimeout : load.pdo_reentry) >= 65535;
E.tornPrev = load.rx_torn; E.reentryPrev = load.pdo_reentry; E.tornPrev = load.rx_torn;
metric("cTorn", (E.tornAccum || 0) + " / " + (E.reentryAccum || 0), E.reentryPrev = load.reltimeout != null ? load.reltimeout : load.pdo_reentry;
"累计增量" + (E.tornCap || E.reentryCap ? " · 有计数已封顶(下界)" : ""), E.overPrev = load.overfull; E.rejPrev = load.reject;
(E.tornAccum || 0) === 0 && (E.reentryAccum || 0) === 0 ? "good" : "warn"); 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 { } else {
metric("cTorn", "—", "rx_torn / pdo_reentry 需 /api/app/load valid"); metric("cTorn", "—", "需 /api/app/load valid(撕裂/RFD/满溢/拼接 走段5)");
} }
metric("cRtt", f1(rtt) + "<small>ms</small>", metric("cRtt", f1(rtt) + "<small>ms</small>",
"最慢一个请求(state/app-load/dbg/rxraw 四取其一)· errors " + E.errors + "最慢一个请求(state/app-load[/dbg][/rxraw])· errors " + E.errors +
(E.errText ? " · " + E.errText.slice(0, 46) : ""), (E.errText ? " · " + E.errText.slice(0, 46) : ""),
rtt === null ? "bad" : rtt < 30 ? "good" : "warn"); rtt === null ? "bad" : rtt < 30 ? "good" : "warn");
@@ -1243,6 +1345,18 @@ function renderMotors(st, r) {
} }
/* ═══════════════════ 渲染:批量接收区(按 cycle 去重) ═══════════════════ */ /* ═══════════════════ 渲染:批量接收区(按 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) { function renderFrames(st, raw) {
/* 事件表用 rxraw.parsed 而不是 state.rx_batch.frames:主站保证 raw 与 parsed 同拍, /* 事件表用 rxraw.parsed 而不是 state.rx_batch.frames:主站保证 raw 与 parsed 同拍,
* 而 state 是另一次请求的另一拍 —— 混用会让"上表 2 行 / 槽表里只有 1 行是本拍放行" * 而 state 是另一次请求的另一拍 —— 混用会让"上表 2 行 / 槽表里只有 1 行是本拍放行"
@@ -1260,18 +1374,7 @@ function renderFrames(st, raw) {
$("#rxbSeen").textContent = "本页轮询采到的帧样本 " + E.seenFrames.toLocaleString("en-US") + " 条" + $("#rxbSeen").textContent = "本页轮询采到的帧样本 " + E.seenFrames.toLocaleString("en-US") + " 条" +
(duty === null ? "" : " · 约占全量 " + duty.toFixed(2) + "%(全量速率看 λ)"); (duty === null ? "" : " · 约占全量 " + duty.toFixed(2) + "%(全量速率看 λ)");
/* cycle 每个 PDO 周期都在涨(实测 102 拍对应 +102),所以它只能去掉"同一拍被 countSeenFrames(raw);
* 重复读两次";覆盖窗口本身的重复回显去不掉 —— 这也是上面要标"样本"的原因。 */
if (isNum(raw.cycle) && raw.cycle !== E.lastRawCycle) {
E.lastRawCycle = raw.cycle;
for (const f of (raw.parsed || [])) {
E.seenFrames++;
const sub = (f.id & 0xFFFF);
const rec = E.subs.get(sub) || { n: 0, dlc: f.dlc, data: f.data };
rec.n++; rec.dlc = f.dlc; rec.data = f.data;
E.subs.set(sub, rec);
}
}
const body = tblFramesBody; const body = tblFramesBody;
body.textContent = ""; body.textContent = "";
/* 同 id+同载荷可能有多个槽(就是用户看到的"重复"),放行的是 seq 最大的那个 */ /* 同 id+同载荷可能有多个槽(就是用户看到的"重复"),放行的是 seq 最大的那个 */
@@ -1462,6 +1565,33 @@ function renderDenseRound(raw, body) {
/* ═══════════════════ 渲染:原始接收窗口 ═══════════════════ */ /* ═══════════════════ 渲染:原始接收窗口 ═══════════════════ */
function renderRaw(st, raw) { function renderRaw(st, raw) {
const x = (st && st.xrx) || null; const x = (st && st.xrx) || null;
if (!E.rxraw && !raw) {
/* 原始窗口关:只刷 kv/xrx(来自 state,零额外请求),hex 区写提示不重建网格 */
kv("rawKv", [
["rxraw cycle", null],
["layout", "—(原始窗口未开)"],
["count / lost / pending", null],
["gseq", null],
["len (字节)", null],
["parsed 帧数", null],
["xrx ch/dlc/id", x ? x.ch + " / " + x.dlc + " / 0x" + (x.id >>> 0).toString(16).toUpperCase() : null],
["xrx data", x ? x.data : null],
["wr(楔死恢复)", isNum(st && st.wr) ? st.wr : null, (st && st.wr) ? "warn" : "good"],
]);
const xf = document.getElementById("xrxFull");
if (x && x.data) {
xf.textContent = "ch=" + x.ch + " dlc=" + x.dlc + " id=0x" + (x.id >>> 0).toString(16).toUpperCase() +
"\n" + x.data.replace(/(.{8})/g, "$1 ").trim();
} else {
xf.textContent = "无长帧(xrx 未 valid)—— CONFIG/版本这类 >8B 的帧才会进这里";
}
const grid = document.getElementById("rawHex");
if (grid.childNodes.length !== 1 || !grid.firstChild.classList || !grid.firstChild.classList.contains("hdr")) {
grid.textContent = "";
grid.appendChild(el("i", "hdr", "原始窗口未开启 —— 点「原始窗口:开」才会每拍读 /api/motor/rxraw(对 EtherCAT 有负载)"));
}
return;
}
/* rxraw 的三个头字节随布局变义:win=slot_count/wr_idx/ovf; /* rxraw 的三个头字节随布局变义:win=slot_count/wr_idx/ovf;
* dense=count/active/boot(lost/pending 对 STATUS 不适用) */ * dense=count/active/boot(lost/pending 对 STATUS 不适用) */
const isWin = raw && raw.layout === "win"; const isWin = raw && raw.layout === "win";
@@ -1558,25 +1688,36 @@ const CHART_WIN_MS = 90000;
function layoutChart() { function layoutChart() {
const dpr = window.devicePixelRatio || 1; const dpr = window.devicePixelRatio || 1;
const w = cvs.clientWidth || (cvs.parentNode && cvs.parentNode.clientWidth) || 900; const w = cvs.clientWidth || (cvs.parentNode && cvs.parentNode.clientWidth) || 900;
cvs.width = Math.round(w * dpr); cvs.height = Math.round(150 * dpr); cvs.width = Math.round(w * dpr); cvs.height = Math.round(160 * dpr);
c2.setTransform(dpr, 0, 0, dpr, 0, 0); c2.setTransform(dpr, 0, 0, dpr, 0, 0);
drawChart(); drawChart();
} }
/** 每点:Hz / p50µs / maxµs(STATS_N=400 窗口) / lateHz(跳拍率) */
function pushChart(r, st) { function pushChart(r, st) {
if (!isNum(r.hz)) return; if (!isNum(r.hz)) return;
const tot = ((st || {}).cyc_us || {}).total || {}; 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 t = performance.now();
E.chart.t.push(t); E.chart.hz.push(r.hz); E.chart.us.push(isNum(tot.p50) ? tot.p50 : null); const ch = E.chart;
while (E.chart.t.length > 2 && t - E.chart.t[0] > CHART_WIN_MS) { ch.t.push(t); ch.hz.push(r.hz);
E.chart.t.shift(); E.chart.hz.shift(); E.chart.us.shift(); 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() { function drawChart() {
const w = cvs.clientWidth, h = 150, pl = 46, pr = 46, pt = 10, pb = 18; const w = cvs.clientWidth, h = 160, pl = 46, pr = 52, pt = 10, pb = 18;
if (!w) return; if (!w) return;
c2.clearRect(0, 0, w, h); c2.clearRect(0, 0, w, h);
const t = E.chart.t; const t = E.chart.t;
c2.strokeStyle = "#1a2740"; c2.lineWidth = 1; c2.fillStyle = "#617a99"; c2.font = "10px " + getComputedStyle(document.body).fontFamily; 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++) { for (let i = 0; i <= 4; i++) {
const y = pt + (h - pt - pb) * i / 4; const y = pt + (h - pt - pb) * i / 4;
c2.beginPath(); c2.moveTo(pl, y); c2.lineTo(w - pr, y); c2.stroke(); c2.beginPath(); c2.moveTo(pl, y); c2.lineTo(w - pr, y); c2.stroke();
@@ -1585,11 +1726,20 @@ function drawChart() {
const t1 = t[t.length - 1], t0 = Math.max(t[0], t1 - CHART_WIN_MS); 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 X = (ts) => pl + (w - pl - pr) * (ts - t0) / Math.max(1, t1 - t0);
const hzMax = Math.max.apply(null, E.chart.hz.map((v) => v || 0)) * 1.15 || 10; const hzMax = Math.max.apply(null, E.chart.hz.map((v) => v || 0)) * 1.15 || 10;
const usVals = E.chart.us.filter(isNum); /* 右轴 µs:p50 恒画;max 开关打开时一并纳入量程,避免 max 顶出画布 */
const usMax = (usVals.length ? Math.max.apply(null, usVals) : 100) * 1.2; 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 Y1 = (v) => pt + (h - pt - pb) * (1 - v / hzMax);
const Y2 = (v) => pt + (h - pt - pb) * (1 - v / usMax); const Y2 = (v) => pt + (h - pt - pb) * (1 - v / usMaxV);
/* 面积 + 折线 */ const Y3 = (v) => pt + (h - pt - pb) * (1 - v / lateMaxV);
/* 紫:实测 Hz 面积+折线 */
c2.beginPath(); 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); }); 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.lineTo(X(t[t.length - 1]), h - pb); c2.lineTo(X(t[0]), h - pb); c2.closePath();
@@ -1597,26 +1747,57 @@ function drawChart() {
c2.beginPath(); 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); }); E.chart.hz.forEach((v, i) => { const x = X(t[i]), y = Y1(v || 0); i ? c2.lineTo(x, y) : c2.moveTo(x, y); });
c2.strokeStyle = "#a88bfa"; c2.lineWidth = 1.8; c2.stroke(); c2.strokeStyle = "#a88bfa"; c2.lineWidth = 1.8; c2.stroke();
c2.beginPath(); let started = false;
E.chart.us.forEach((v, i) => { 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; } if (!isNum(v)) { started = false; return; }
const x = X(t[i]), y = Y2(v); const x = X(t[i]), y = Y(v);
if (!started) { c2.moveTo(x, y); started = true; } else c2.lineTo(x, y); if (!started) { c2.moveTo(x, y); started = true; } else c2.lineTo(x, y);
}); });
c2.strokeStyle = "#22d3ee"; c2.lineWidth = 1.4; c2.stroke(); c2.strokeStyle = color; c2.lineWidth = width; c2.stroke();
c2.fillStyle = "#93a8c4"; 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) + " Hz", 3, pt + 8);
c2.fillText(f0(hzMax / 2) + " Hz", 3, pt + (h - pt - pb) / 2 + 3); c2.fillText(f0(hzMax / 2) + " Hz", 3, pt + (h - pt - pb) / 2 + 3);
c2.textAlign = "right"; c2.textAlign = "right";
c2.fillText(f0(usMax) + " µs", w - 3, pt + 8); c2.fillText(f0(usMaxV) + " µs", w - 3, pt + 8);
c2.fillText(f0(usMax / 2) + " µs", w - 3, pt + (h - pt - pb) / 2 + 3); c2.fillText(f0(usMaxV / 2) + " µs", w - 3, pt + (h - pt - pb) / 2 + 3);
c2.textAlign = "left"; if (E.chartOpt.skip && lateVals.length) {
c2.fillText("← " + f1((t1 - t0) / 1000) + "s 窗口 · 每点为速率窗口内 Δcycles/Δt", pl, h - 5); c2.fillStyle = "#ff6b6b";
c2.fillText(f1(lateMaxV / 1.25) + " late/s", w - 3, pt + 20);
}
c2.textAlign = "left"; c2.fillStyle = "#617a99";
const parts = ["← " + f1((t1 - t0) / 1000) + "s 窗口"];
parts.push("紫 Hz");
parts.push("青 p50µs");
if (E.chartOpt.max) parts.push("琥珀 maxµs(STATS_N=400)");
if (E.chartOpt.skip) parts.push("红 late/s(独立缩放)");
c2.fillText(parts.join(" · "), pl, h - 5);
} }
$("#btnChartMax").onclick = () => {
E.chartOpt.max = !E.chartOpt.max;
$("#btnChartMax").textContent = "单拍 max:" + (E.chartOpt.max ? "开" : "关");
drawChart();
};
$("#btnChartSkip").onclick = () => {
E.chartOpt.skip = !E.chartOpt.skip;
$("#btnChartSkip").textContent = "跳拍:" + (E.chartOpt.skip ? "开" : "关");
drawChart();
};
window.addEventListener("resize", layoutChart); window.addEventListener("resize", layoutChart);
/* ═══════════════════ 热改 PDO 周期 ═══════════════════ */ /* ═══════════════════ 热改 PDO 周期 ═══════════════════ */
const runProg = $("#runProg"), btnEcatAbort = $("#btnEcatAbort");
function runBusy(cls, text) { setPill("#runPill", cls, text); } function runBusy(cls, text) { setPill("#runPill", cls, text); }
$("#btnPeriod").onclick = async () => { $("#btnPeriod").onclick = async () => {
const v = parseFloat($("#periodPick").value); const v = parseFloat($("#periodPick").value);
@@ -1638,218 +1819,10 @@ $("#btnPeriod").onclick = async () => {
} catch (e) { runBusy("bad", "失败:" + e.message); } } catch (e) { runBusy("bad", "失败:" + e.message); }
}; };
/* ═══════════════════ 测速:定点 / 档位扫描 ═══════════════════ */
const RUN_BODY = $("#tblRuns tbody");
let E_ABORT = false;
function addRunRow(rec) {
const tr = el("tr");
const td = (v, cls) => tr.appendChild(el("td", cls, v === null || v === undefined ? "—" : v));
td(clock(new Date()));
td(rec.kind, "dim");
td(f2(rec.period), "num"); td(f0(rec.hz), "num good"); td(f1(rec.achieve), "num " + (rec.achieve > 97 ? "good" : rec.achieve > 90 ? "warn" : "bad"));
td(f0(rec.lambda), "num"); td(isNum(rec.holes) ? rec.holes : "—", "num " + (rec.holes > 0 ? "bad" : "good"));
td(f1(rec.skip), "num " + (rec.skip > 0.5 ? "bad" : "good"));
td(f1(rec.p50) + " / " + f1(rec.p90) + " / " + f1(rec.pmax), "num");
td(f1(rec.load), "num " + (rec.load > 90 ? "bad" : rec.load > 70 ? "warn" : "good"));
td((rec.torn || 0) + (rec.tornCapped ? "≥" : ""), "num " + (rec.torn ? "warn" : "good"));
td(rec.online + "/" + rec.slots, "num");
td(rec.wkc + "/" + rec.expected, "num " + (rec.wkc === rec.expected ? "good" : "bad"));
td(rec.samples, "num dim");
td(rec.note, "dim");
if (rec.hijack) tr.children[14].className = "bad";
RUN_BODY.insertBefore(tr, RUN_BODY.firstChild);
while (RUN_BODY.children.length > 200) RUN_BODY.removeChild(RUN_BODY.lastChild);
}
/**
* 在给定秒数内采一轮。pauseLive 由调用方负责,避免测速时混进看板轮询的负载。
* skip 与抖动取自收尾那一次 /api/motor/dbg:环只有 2048 拍,驻留越久越只代表末段 ——
* 这一点必须写在备注里,不能拿末段 2s 的抖动冒充整段。
*/
async function measureOnce(kind, seconds, hz, extraNote) {
const win = new RateWindow(seconds * 1000 + 60000);
const p50s = [], p90s = [], pmaxs = [], loads = [];
let torn = 0, tornCap = false, tornPrev = null, loadStale = 0, errs = 0, iters = 0;
let online = 0, slots = 0, wkc = null, expected = null, period = null, spin = null;
let dbg0 = null;
const seqPrev = {}, seqAt = {}; // 本函数自己的判活依据(快照 online 是锁存的历史标志)
const tEnd = performance.now() + seconds * 1000;
const interval = 1000 / hz;
try { dbg0 = await eapi("motor/dbg", {}, 8000); } catch (_) { dbg0 = null; }
while (performance.now() < tEnd) {
if (E_ABORT) break;
const tc = performance.now();
let st = null, load = null;
try {
const done = await Promise.all([eapi("motor/state", {}, 4000), eapi("app/load", {}, 4000)]);
st = done[0]; load = done[1];
} catch (e) { errs++; await wsleep(interval); continue; }
const t = performance.now(); // 与计数同一瞬间
iters++;
const b = st.bridge || {}, head = st.head || {}, cyc = st.cyc_us || {};
const seq = {};
for (const m of (st.motors || [])) {
const ky = m.node + ":" + m.ch;
if (!isNum(m.seq)) continue;
seq[ky] = m.seq;
if (seqPrev[ky] !== m.seq) { seqPrev[ky] = m.seq; seqAt[ky] = t; }
}
win.push({
t, cycles: isNum(st.cycles) ? st.cycles : null, gseq: isNum(head.global_seq) ? head.global_seq : null,
drop: b.valid && isNum(b.drop) ? b.drop : null,
seq, rxbLost: (st.rx_batch || {}).lost, rxbPending: (st.rx_batch || {}).pending,
});
if (cyc.total) { p50s.push(cyc.total.p50); p90s.push(cyc.total.p90); pmaxs.push(cyc.total.max); }
/* 从站停报时 load_perm 会留残值,不加 age 闸就是把旧值当实测写进结论 */
if (load && load.valid && isNum(load.load_perm) && (!isNum(load.age_ms) || load.age_ms < 1000)) loads.push(load.load_perm / 10);
else loadStale++;
if (load && isNum(load.rx_torn)) {
const [d, capped] = satDelta(tornPrev, load.rx_torn);
torn += d; tornCap = tornCap || capped; tornPrev = load.rx_torn;
}
period = isNum(st.period_ms) ? st.period_ms : period;
spin = typeof st.spin === "boolean" ? st.spin : spin;
wkc = isNum(st.wkc) ? st.wkc : wkc; expected = isNum(st.expected_wkc) ? st.expected_wkc : expected;
slots = (st.motors || []).length;
online = (st.motors || []).filter((m) => {
const a = seqAt[m.node + ":" + m.ch];
return m.online && isNum(a) && (t - a) <= STALE_OFFLINE_MS;
}).length;
const remain = tEnd - performance.now();
runProg.textContent = kind + " 剩余 " + (remain > 0 ? (remain / 1000).toFixed(1) : "0.0") + "s · 采样 " + iters + " 次";
await wsleep(Math.max(0, Math.min(interval - (performance.now() - tc), remain)));
}
let dbg1 = null;
try { dbg1 = await eapi("motor/dbg", {}, 8000); } catch (_) {}
const r = win.calc();
const nominal = period ? 1000 / period : null;
const gaps = [];
if (dbg1 && dbg1.cyc && dbg1.cyc.length > 2) {
for (let i = 1; i < dbg1.cyc.length; i++) {
const g = (dbg1.cyc[i][0] - dbg1.cyc[i - 1][0]) * 1e6;
if (g > 0 && g < 5e6) gaps.push(g);
}
}
const note = [];
if (extraNote) note.push(extraNote);
const achieved = r.span > 0 ? iters / r.span : null;
note.push("窗口 " + f2(r.span) + "s/" + iters + "拍 · 实测采样 " + f1(achieved) + "Hz(设定 " + f1(hz) + "Hz)");
if (isNum(achieved) && achieved < hz * 0.8) note.push("!! 采样被拖慢(后台标签页节流?),速率型判据精度下降");
if (r.untrusted) note.push("drop 有 " + r.untrusted + " 点因采样率不足作废");
if (loadStale) note.push("负载 " + loadStale + " 次陈旧已排除");
if (spin) note.push("自旋节拍占 1 核");
/* 节拍环容量 2048 拍:换档后驻留不足 2048×新周期时,环里大半还是换档前的旧节拍。
* 拿它算"本档抖动"或反判"档位没跑到"都是仪表自己造的假象(实测 4ms 档 3s 驻留
* 就报出 p50 2000µs —— 那是上一档 2ms 的节拍,而本档回读与 Δcycles/Δt 都对得上)。 */
const ringSpanS = gaps.length && isNum(period) ? (dbg1.cyc.length * period) / 1000 : null;
const ringTrustworthy = ringSpanS !== null && r.span > ringSpanS * 1.05;
if (ringSpanS === null) {
note.push("抖动无数据(逐拍环未取到)");
} else if (ringTrustworthy) {
note.push("抖动(环 " + dbg1.cyc.length + "拍已填满本档): p50 " + f0(quantile(gaps, 0.5)) +
" p90 " + f0(quantile(gaps, 0.9)) + " max " + f0(Math.max.apply(null, gaps)) + "µs");
} else {
note.push("本档抖动不可信:环 " + dbg1.cyc.length + "拍≈" + f1(ringSpanS) + "s > 驻留 " + f2(r.span) +
"s,内含换档前旧节拍(需驻留 >" + f1(ringSpanS) + "s)");
}
if (errs) note.push("请求失败 " + errs + " 次");
/* 档位到底跑没跑到,只认实测 Δcycles/Δt 对名义值的达成率 —— 不用环的拍间隔反推 */
const achievePct = (isNum(r.hz) && nominal) ? r.hz / nominal * 100 : null;
const hijack = isNum(achievePct) && achievePct < 90;
if (hijack) note.push("!! 实测 " + f0(r.hz) + "Hz 只对名义 " + f0(nominal) + "Hz 的 " + f1(achievePct) + "%,档位没真跑到");
return {
kind, period, hz: r.hz, achieve: (isNum(r.hz) && nominal) ? r.hz / nominal * 100 : null,
lambda: r.lambda, drop: r.drop,
holes: Math.max(r.rxbLostMax || 0, r.rxbPendingMax || 0),
skip: (dbg0 && dbg1 && isNum(dbg1.skip_total) && isNum(dbg0.skip_total))
? (dbg1.skip_total - dbg0.skip_total) / Math.max(1e-6, r.span) : null,
p50: quantile(p50s, 0.5), p90: quantile(p90s, 0.5), pmax: pmaxs.length ? Math.max.apply(null, pmaxs) : null,
load: quantile(loads, 0.5), torn, tornCapped: tornCap, online, slots, wkc, expected,
samples: iters, note: note.join(" · "), hijack,
};
}
async function withLivePaused(fn) {
const wasLive = E.live;
if (wasLive) { E.live = false; ticker.stop(); }
btnEcatAbort.hidden = false; E_ABORT = false;
for (const b of [$("#btnEcatRun"), $("#btnSweep"), $("#btnPeriod")]) b.disabled = true;
try { return await fn(); }
finally {
for (const b of [$("#btnEcatRun"), $("#btnSweep"), $("#btnPeriod")]) b.disabled = false;
btnEcatAbort.hidden = true;
if (wasLive) { E.live = true; tickPrev = null; setPill("#ecatState", "warn", "观测中"); ticker.start(1000 / pollHz()); requestTick(); }
else runBusy("", "空闲");
}
}
$("#btnEcatAbort").onclick = () => { E_ABORT = true; runBusy("warn", "中止中…"); };
$("#btnEcatRun").onclick = () => withLivePaused(async () => {
const sec = clampNum($("#runSec").value, 1, 600, 10);
const hz = clampNum($("#runHz").value, 1, 20, 10);
runBusy("warn", "定点测速中…");
try {
const rec = await measureOnce("定点", sec, hz, E.live ? "" : "已暂停看板轮询");
if (E_ABORT) { runBusy("bad", "已中止"); return; }
addRunRow(rec);
runBusy(rec.hijack ? "bad" : "ok", "完成 " + f0(rec.hz) + "Hz / 达成 " + f1(rec.achieve) + "% / λ " + f0(rec.lambda));
} catch (e) { runBusy("bad", "失败:" + e.message); }
finally { runProg.textContent = "—"; }
});
$("#btnSweep").onclick = () => withLivePaused(async () => {
const periods = String($("#sweepList").value || "").split(",").map((x) => parseFloat(x.trim())).filter((x) => x > 0);
const dwell = clampNum($("#sweepDwell").value, 2, 600, 10);
const hz = clampNum($("#runHz").value, 1, 20, 10);
if (!periods.length) { runBusy("warn", "没有有效档位"); return; }
let orig = null;
try {
const st0 = await eapi("motor/state", {}, 4000);
orig = isNum(st0.period_ms) ? st0.period_ms : null;
} catch (e) { runBusy("bad", "读不到快照:" + e.message); return; }
if (orig === null) { runBusy("bad", "PDO 循环没在跑,无法安全扫描(避免用 motor/start 重启总线)"); return; }
try {
for (const ms of periods) {
if (E_ABORT) { runBusy("bad", "已中止,正在恢复周期…"); break; }
const set = await eapi("motor/period", { period_ms: ms }, 8000);
if (set.ok !== true) { addRunRow({ kind: "扫描", period: ms, note: "档位被拒:" + (set.log || []).join(" / "), hijack: true, online: 0, slots: 0, torn: 0 }); continue; }
/* 生效确认:热调异步落到循环线程,且可能被别处拽回 */
let eff = null;
for (let i = 0; i < 12; i++) {
await wsleep(250);
eff = (await eapi("motor/state", {}, 4000)).period_ms;
if (isNum(eff) && Math.abs(eff - ms) < 1e-6) break;
}
const hijacked = !(isNum(eff) && Math.abs(eff - ms) < 1e-6);
runBusy("warn", "档位 " + ms + "ms · 驻留 " + dwell + "s…");
const rec = await measureOnce("扫描 " + ms + "ms", dwell, hz,
hijacked ? ("下发 " + ms + "ms 但生效 " + eff + "ms(疑 localStorage 劫持或有他端在设)") : "");
rec.period = eff; rec.hijack = rec.hijack || hijacked;
if (E_ABORT) { addRunRow(Object.assign(rec, { note: "中止时部分样本 · " + rec.note })); break; }
addRunRow(rec);
}
} catch (e) {
runBusy("bad", "扫描异常:" + e.message);
} finally {
/* 无论正常跑完还是中止,都必须把周期放回原值并回读校验 —— 总线是共享的 */
try {
await eapi("motor/period", { period_ms: orig }, 8000);
let back = null;
for (let i = 0; i < 12; i++) {
await wsleep(250);
back = (await eapi("motor/state", {}, 4000)).period_ms;
if (isNum(back) && Math.abs(back - orig) < 1e-6) break;
}
runBusy(isNum(back) && Math.abs(back - orig) < 1e-6 ? "ok" : "bad",
isNum(back) && Math.abs(back - orig) < 1e-6 ? ("扫描结束,已恢复 " + orig + "ms")
: ("!! 恢复失败:设定 " + orig + "ms 回读 " + back + "ms,请手动确认"));
} catch (e) { runBusy("bad", "!! 恢复周期请求失败:" + e.message); }
runProg.textContent = "—";
}
});
$("#btnRxbClear").onclick = () => { $("#btnRxbClear").onclick = () => {
E.seenFrames = 0; E.subs.clear(); E.lastRawCycle = null; E.tornPrev = null; E.reentryPrev = null; 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.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.densLast = null;
E.seenT0 = performance.now(); E.seenT0 = performance.now();
/* 暂停观测时不会再渲染,标签必须就地归零,否则点了像没生效 */ /* 暂停观测时不会再渲染,标签必须就地归零,否则点了像没生效 */
@@ -1862,7 +1835,7 @@ showPane(ls("speedtest_pane") === "spi" ? "spi" : "ecat");
layoutChart(); layoutChart();
window.addEventListener("beforeunload", () => { E.live = false; ticker.stop(); }); window.addEventListener("beforeunload", () => { E.live = false; ticker.stop(); });
/* 诊断入口:控制台里 __speedtest.E 能直接看当前累加的原始响应 */ /* 诊断入口:控制台里 __speedtest.E 能直接看当前累加的原始响应 */
window.__speedtest = { E, eapi, RateWindow, decodeStatus, measureOnce }; window.__speedtest = { E, eapi, RateWindow, decodeStatus };
})(); })();
</script> </script>
</body> </body>