feat(motor): 反馈刷新率 Hz 适配(0x0400/0x0401 扩展格式)+ ECAT 密排槽与判活自适应

反馈刷新率(配套电机固件 v0.4.1:0x0400 DLC4/0x0401 DLC2 扩展格式,
选择器 0x0100=STATUS · 0x0101=CONFIG 帧,协议值为周期 ms):
- 「节点 ID」页签新增 STATUS/CONFIG 刷新率读取/写入;界面单位一律 Hz
  (写入按 周期ms=round(1000/Hz) 换算下发,读取反算 Hz 并附原始周期,
  取整有损如实展示),0=停发,写入掉电保存;配置值以「读取」回读电机为准
- WiFi 应答回执走 0xC1 per-ID 快照(/can/feedback 全量 entries):
  GD32 0xC0 ACK 仅 1 字节状态、不携带电机回帧;同 ID 回帧快照只存最新
  一条,故一次快照只配一次请求(STATUS/CONFIG 分开读写)
- ECAT 回执走批量接收区 takeNodeIdReport,按长度分派 2B 旧格式/4B GET/5B SET ACK
- GET_NODE_ID 迁移到扩展选择器 0x0000,兼容旧固件 2B 节点 ID 应答
  (SET DLC2 旧格式行为不变)
- transport-wifi 新增 fetchFeedbackFull(全量 CAN 反馈快照)

EtherCAT/基础设施(密排槽固件 1.6.21 配套):
- 快照槽位 5→9 一拍整轮(扫描周期数 6→1);判活阈值随实测 seq 间隔
  自适应 clamp(3×EWMA, 2s, 30s),秒级回报档不再被固定 2s 误判离线;
  STATUS 面板 Hz 明确为网页到达口径(按槽 seq 实测电机回报率的方案已否掉)
- esp32_server.py:ecat_native 单实例收紧(后起独占 + taskkill 残留实例,
  双实例并存会抢 Npcap 致后起者抓不到从站)+ native stdout 落盘
- flash_gd32.py:刷写后校验「GD32 真的在跑新代码」(版本变化或 uptime 归零),
  防"写入≠在跑"假台账
- lab:新增 LAN9252 电源诊断台、PDO 布局页;SPI 测速并入 EtherCAT 接收看板
  并更名「SPI/ECAT 通信测速」;移除 continuity_test.html;新增 ECAT 单拍耗时
  优化报告;/_local/ 排除入库;测试同步更新(62+58 项全过)
This commit is contained in:
cbb
2026-09-12 20:10:47 +08:00
parent 81096d0425
commit 209a869f96
21 changed files with 2550 additions and 981 deletions
+3
View File
@@ -23,6 +23,9 @@ scripts/_*_out.txt
# 一次性排障探针(跟着某次现场问题写的,问题闭环后即失效) # 一次性排障探针(跟着某次现场问题写的,问题闭环后即失效)
scripts/probe_*.py scripts/probe_*.py
scripts/selftest_*.py scripts/selftest_*.py
# 本地一次性排障产物(页面测试驱动 / 诊断脚本 / 截图 / 测试报告),不入库
/_local/
# 例外:这两个已入库,是可复用的通用采样器 # 例外:这两个已入库,是可复用的通用采样器
!scripts/probe_ecat_slot_atomicity.py !scripts/probe_ecat_slot_atomicity.py
!scripts/probe_ecat_vs_wifi.py !scripts/probe_ecat_vs_wifi.py
+289
View File
@@ -0,0 +1,289 @@
<!DOCTYPE html>
<html lang="zh-CN"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>EtherCAT 单拍耗时 345µs→124µs:改动原理与验证</title>
<style>
:root{--fg:#111827;--mut:#6b7280;--bd:#e5e7eb;--bg:#f8fafc;--acc:#1d4ed8;--warn:#b45309;--bad:#b91c1c;--ok:#047857}
*{box-sizing:border-box}
body{margin:0;background:#fff;color:var(--fg);
font:15.5px/1.72 -apple-system,"Segoe UI","Microsoft YaHei",system-ui,sans-serif}
main{max-width:1120px;margin:0 auto;padding:40px 28px 90px}
h1{font-size:29px;line-height:1.3;margin:0 0 6px;letter-spacing:-.2px}
h2{font-size:20px;margin:44px 0 12px;padding-bottom:7px;border-bottom:2px solid var(--bd)}
h3{font-size:16px;margin:26px 0 8px;color:#374151}
p,li{margin:8px 0}
.meta{color:var(--mut);font-size:13.5px;margin-bottom:26px}
.card{background:var(--bg);border:1px solid var(--bd);border-left:4px solid var(--acc);
border-radius:8px;padding:16px 20px;margin:18px 0}
.card.warn{border-left-color:var(--warn);background:#fffbeb}
.card.bad{border-left-color:var(--bad);background:#fef2f2}
.card.ok{border-left-color:var(--ok);background:#ecfdf5}
.card h3{margin:0 0 8px;font-size:15px;color:inherit}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(430px,1fr));gap:18px;margin:18px 0}
.fig{border:1px solid var(--bd);border-radius:8px;padding:12px 10px 8px;background:#fff}
.fig > .cap{font-size:12.5px;font-weight:600;color:#374151;margin:0 0 4px 8px}
.fig > .sub{font-size:11.5px;color:var(--mut);margin:0 0 4px 8px}
table{border-collapse:collapse;width:100%;font-size:13px;margin:14px 0}
th,td{border:1px solid var(--bd);padding:6px 9px;text-align:right}
th{background:#f1f5f9;font-weight:600;text-align:center}
td:first-child,th:first-child{text-align:left}
tr.best td{background:#ecfdf5;font-weight:600}
code{background:#f1f5f9;padding:1.5px 5px;border-radius:4px;font-size:12.5px;
font-family:ui-monospace,"Cascadia Mono",Consolas,monospace}
.kpi{display:flex;gap:14px;flex-wrap:wrap;margin:22px 0 8px}
.kpi div{flex:1 1 168px;border:1px solid var(--bd);border-radius:8px;padding:13px 15px;background:#fff}
.kpi b{display:block;font-size:23px;line-height:1.25;color:var(--acc)}
.kpi span{font-size:12px;color:var(--mut)}
ul{padding-left:22px}
.foot{margin-top:44px;padding-top:16px;border-top:1px solid var(--bd);
font-size:12.5px;color:var(--mut)}
.legend{font-size:12px;color:#374151;margin:2px 0 4px 8px}
.legend i{display:inline-block;width:11px;height:11px;border-radius:2px;margin:0 4px -1px 10px}
</style></head><body><main>
<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="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="card"><h3>结论(2026-09-12 修订)</h3>
<ul>
<li><b>实测事实不变</b>:9/11 当天 0.5ms 档整拍从 345µs 变 124µs、rx 从 ~310µs 变 92.1µs(≈纯线路 81.3µs + ~10µs),与板子重启进 1.6.21/22 密排同时发生;1.6.22 稀疏写开关 A/B/A 无可测影响(92.2 / 92.2 / 93.0µs)。</li>
<li><b>但"345µs 是 win 布局的固有成本、密排省出 220µs"这条归因已被动摇</b>:9/8 的存量扫描在同样 win 布局下实测单拍 <b>124.6µs</b>(0.125ms 档 7780Hz 的硬约束),与 dense 相同——345µs 是 9/10 夜才出现的异常态,不是 win 的常态。详见下方"重大更正"卡。</li>
<li><b>仍然成立的独立事实</b>:刷写 ≠ 生效(镜像 01:44 刷入、约 10 小时后断电重启才生效,三方计数器锚点);台账只能证明"写进去了"——<code>flash_gd32.py</code> 已加生效守卫。</li>
</ul></div>
<div class="card bad"><h3>重大更正(2026-09-12 复核):220µs 的归因被动摇,两个变量被混在一起</h3>
<p>用户质疑"5 个 STATUS 变 3 个,怎么会差 20 倍"——顺着查,发现两件原调查没做的事:</p>
<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>反例数据(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>
<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>:① 刷回 <code>959e2ae</code>(win 固件)+ <code>build_native/ecat_native.prev.exe</code>(9/6 构建,早于 97fab23;它不认识 dense 布局,必须配 win 板)→ 回 ~124µs 即主站回归实锤;② 同 win 板 + 现主站 → 345µs 复现即双向确认;③ 若 prev.exe 也 345µs ⇒ 回归在从站 win 期固件演进里,对 1.6.15→1.6.20 bisect;④ win 模式读 DWT 剖分计数器 0x2000 sub5/6/9/10(PDI_Isr 全程 / 输出映射 / 输入映射 / 其中写侧)——SDO 走独占路由会停环掉 OP,需专门开窗。</p></div>
<h2>一、一拍的生命周期:210µs 差在哪一环</h2>
<p>主站每一拍做的事:<b>组帧 → <code>pcap_sendpacket</code> 发出 → 等帧从线路绕回来(<code>pcap_next_ex</code>)→ 解码</b>。等回包(rx)的时长 = 线路传输时间 + <b>从站把这一拍的输入镜像备好的时间</b>——LAN9252 的 SM2 事件触发 PDI 中断,从站要在 ESC 把输入区搬上线路之前把 600B 输入镜像写好,主站就得等这段活干完。</p>
<div class="grid">
<div class="fig"><div class="cap">① 同一 0.5ms 档、同一静默判据下的一拍构成(µs)</div>
<div class="legend"><i style="background:#94a3b8"></i>主站自身(pre+post 合计 0.3µs)<i style="background:#2563eb"></i>发送(组帧 0.2 + pcap_sendpacket ≈28.4)<i style="background:#fca5a5"></i>线路传输 81.3µs(两布局相同)<i style="background:#dc2626"></i>从站每拍准备(PDI buildInput)</div>
<svg viewBox="0 0 620 212" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="一拍构成对比">
<line x1="150" y1="180" x2="600" y2="180" stroke="#9aa3af" stroke-width="1.2"/>
<line x1="150" y1="28" x2="150" y2="180" stroke="#9aa3af" stroke-width="1.2"/>
<line x1="278" y1="176" x2="278" y2="184" stroke="#9aa3af"/><text x="278" y="197" text-anchor="middle" font-size="10.5" fill="#6b7280">100</text>
<line x1="406" y1="176" x2="406" y2="184" stroke="#9aa3af"/><text x="406" y="197" text-anchor="middle" font-size="10.5" fill="#6b7280">200</text>
<line x1="534" y1="176" x2="534" y2="184" stroke="#9aa3af"/><text x="534" y="197" text-anchor="middle" font-size="10.5" fill="#6b7280">300</text>
<text x="150" y="197" text-anchor="middle" font-size="10.5" fill="#6b7280">0</text>
<text x="596" y="197" text-anchor="middle" font-size="10.5" fill="#6b7280">µs</text>
<text x="142" y="64" text-anchor="end" font-size="12" fill="#374151">1.6.15 滑窗</text>
<text x="170" y="40" text-anchor="middle" font-size="10.5" fill="#1e40af">发送 ≈28.6µs</text>
<rect x="150" y="48" width="2" height="24" fill="#94a3b8"/>
<rect x="152" y="48" width="36.6" height="24" fill="#2563eb"/>
<rect x="188.6" y="48" width="104.1" height="24" fill="#fca5a5"/>
<rect x="292.7" y="48" width="293.1" height="24" fill="#dc2626"/>
<rect x="585.8" y="48" width="2" height="24" fill="#94a3b8"/>
<text x="240" y="64" text-anchor="middle" font-size="10.5" fill="#7f1d1d" font-weight="600">线路 81.3</text>
<text x="439" y="64" text-anchor="middle" font-size="10.5" fill="#fff" font-weight="600">从站每拍准备 ≈229µs</text>
<text x="152" y="86" text-anchor="start" font-size="11" font-weight="600" fill="#b91c1c">rx 往返 ≈310µs = 线路 81.3 + 从站准备 ≈229 · 整拍 p50 345µs · 达成 93.9%</text>
<text x="142" y="134" text-anchor="end" font-size="12" fill="#374151">1.6.21 密排</text>
<text x="308" y="110" text-anchor="start" font-size="11.5" font-weight="600" fill="#047857">整拍 p50 124µs · 达成 99.9%</text>
<rect x="150" y="118" width="2" height="24" fill="#94a3b8"/>
<rect x="152" y="118" width="36.6" height="24" fill="#2563eb"/>
<rect x="188.6" y="118" width="104.1" height="24" fill="#fca5a5"/>
<rect x="292.7" y="118" width="12.8" height="24" fill="#dc2626"/>
<rect x="305.5" y="118" width="2" height="24" fill="#94a3b8"/>
<text x="240" y="134" text-anchor="middle" font-size="10.5" fill="#7f1d1d" font-weight="600">线路 81.3</text>
<text x="152" y="156" text-anchor="start" font-size="11" font-weight="600" fill="#047857">rx 92.1µs = 线路 81.3 + 从站准备 ≈10(差分填充命中,接近纯线时)</text>
<line x1="292.7" y1="30" x2="292.7" y2="172" stroke="#dc2626" stroke-width="1" stroke-dasharray="4,3" opacity="0.55"/>
<text x="298" y="36" font-size="10.5" fill="#b91c1c">虚线处线路段用完(28.6+81.3≈110µs),之后整段在等从站</text>
</svg>
<div class="sub">数字出处:整拍与 rx 取自同机同档静默窗实测(win 345 / 310;dense 124 / 92.2,1ms 档复测 92.1);发送 ≈28.6µs 为 0.5ms 档 A 臂 p50(1ms 档 dbg 拆段 26.6~31.3µs,内含组帧 0.2µs);线路 81.3µs = 1016B×8÷100Mbps;win 从站准备 ≈229 = 310−81.3,与固件自量 buildInput 357µs(更重负载下所测)同量级。三段的逐层组成见下表。</div>
</div>
</div>
<h3>三段逐层拆解(对应图中蓝 / 粉 / 红三个色块)</h3>
<table>
<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>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>从站准备(红)· dense</td><td>≈10µs</td><td>差分填充命中:节点集合与各 seq 未变 → 整段复用影子,①②③全免;只剩 count 个槽的更新推送 + ESC 取数</td><td>rx 92.1 − 81.3。220µs 的差全部消失在这里</td></tr>
</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><b>原理(按 9/11 当天的观测口径):</b>SM 长度(600B)与线速两布局完全相同,所以"线路那一截"不变;rx 的差值实测全部落在"等从站"这一段。但这段差值的载体机制存在重大疑点——两版 buildInput 的纯 CPU 差仅 1~3µs,且 9/8 同布局实测 124.6µs 表明 345µs 并非 win 固有成本——见开头"重大更正"卡。图①对比的是 9/11 上午 win 异常态 vs 午后 dense 的实测,不代表两种布局的固有差距。</p>
<h2>二、改动原理 A:从站输入映射 1.6.15 滑窗 → 1.6.21 密排整轮</h2>
<p>两种布局的输入区都是 600B(SM 长度、SII 都不变),只靠输入区第 35 字节的魔数 <code>0xA5</code> 区分(<code>ecat_native.c:74-84</code>,固件 <code>ethercat_can_bridge.h:154</code> 同款注释):</p>
<div class="grid">
<div class="fig"><div class="cap">② 输入 PDO 600B 布局对比(按字节偏移)</div>
<svg viewBox="0 0 620 252" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="输入区布局对比">
<text x="142" y="68" text-anchor="end" font-size="12" fill="#374151">1.6.15 滑窗</text>
<rect x="150" y="52" width="22.4" height="26" fill="#cbd5e1"/>
<rect x="172.4" y="52" width="5.6" height="26" fill="#64748b"/>
<rect x="178" y="52" width="196" height="26" fill="#2563eb"/>
<rect x="374" y="52" width="28" height="26" fill="#7c3aed"/>
<rect x="402" y="52" width="156.8" height="26" fill="#dc2626"/>
<rect x="558.8" y="52" width="11.2" height="26" fill="#16a34a"/>
<text x="276" y="68" text-anchor="middle" font-size="10.5" fill="#fff" font-weight="600">段2 STATUS 5×56B(固定 5 槽,空槽占位)</text>
<text x="480" y="47" text-anchor="middle" font-size="10.5" fill="#b91c1c" font-weight="600">段4 覆盖窗 5×44B(每拍维护)</text>
<text x="480" y="90" text-anchor="middle" font-size="10" fill="#7f1d1d">fillRxWindow / RxWinSlot 整套</text>
<text x="161" y="47" text-anchor="middle" font-size="10" fill="#475569">桥计数</text>
<text x="142" y="178" text-anchor="end" font-size="12" fill="#374151">1.6.21 密排</text>
<rect x="150" y="162" width="22.4" height="26" fill="#cbd5e1"/>
<rect x="172.4" y="162" width="5.6" height="26" fill="#64748b"/>
<rect x="178" y="162" width="117.6" height="26" fill="#2563eb"/>
<rect x="295.6" y="162" width="235.2" height="26" fill="#eff6ff" stroke="#93c5fd" stroke-dasharray="4,3"/>
<rect x="530.8" y="162" width="28" height="26" fill="#7c3aed"/>
<rect x="558.8" y="162" width="11.2" height="26" fill="#16a34a"/>
<text x="236" y="178" text-anchor="middle" font-size="10.5" fill="#fff" font-weight="600">密排槽×3(56B/台)</text>
<text x="413" y="157" text-anchor="middle" font-size="10" fill="#1d4ed8">按 count 扩展,最多 9 台(空节点不占位)</text>
<text x="413" y="200" text-anchor="middle" font-size="10" fill="#1d4ed8">1.6.22 稀疏写:这段不再推入 ESC RAM</text>
<text x="413" y="178" text-anchor="middle" font-size="10" fill="#6b7280">count=3 时空闲</text>
<text x="161" y="157" text-anchor="middle" font-size="10" fill="#475569">桥计数</text>
<line x1="150" y1="216" x2="570" y2="216" stroke="#9aa3af" stroke-width="1.2"/>
<text x="150" y="233" text-anchor="middle" font-size="10" fill="#6b7280">0</text>
<text x="220" y="233" text-anchor="middle" font-size="10" fill="#6b7280">100</text>
<text x="290" y="233" text-anchor="middle" font-size="10" fill="#6b7280">200</text>
<text x="360" y="233" text-anchor="middle" font-size="10" fill="#6b7280">300</text>
<text x="430" y="233" text-anchor="middle" font-size="10" fill="#6b7280">400</text>
<text x="500" y="233" text-anchor="middle" font-size="10" fill="#6b7280">500</text>
<text x="570" y="233" text-anchor="middle" font-size="10" fill="#6b7280">600(字节)</text>
<text x="150" y="248" font-size="10" fill="#6b7280">头区 32..39:win= active/boot/win_base/win_count/gseq@36 · 密排= count/active/boot/magic@35/gseq@36 —— 两种布局 gseq 同在偏移 36</text>
</svg>
<div class="sub">段3 长帧(CONFIG/版本应答):win 在 320..359,密排前移到 544..583;段5 诊断两者都在 584..599。主站 <code>decode_input</code> 每拍判 magic@35 自适应,两端可任意先后升级。</div>
</div>
</div>
<h3>1.6.21 的三个子改动,各砍掉什么活</h3>
<div class="card"><h3>① 密排整轮:固定 5 槽 + win_base 轮转扫 27 槽 → count 个密排槽</h3>
<p>旧方案段 2 固定 5 个 STATUS 槽(空槽 node_id=0 也占位),主站按 <code>win_base</code> 轮转窗口才能盖到 9 节点×3 通道=27 槽的表——接 3 台电机却会看到 5 个状态帧。新方案一拍直接带齐总线上全部电机(本台 3 台=3 槽×56B),槽内按 node_id 升序,主站按 <code>count</code> 迭代。砍掉的是<b>主站轮转窗口带来的多拍刷新分频</b>与从站"为凑满 5 槽而做的填充"。</p></div>
<div class="card"><h3>② 删段 4 覆盖窗口(fillRxWindow / RxWinSlot 整套)</h3>
<p>旧方案每拍要把非 STATUS 帧抄进 5×44B 的历史覆盖窗反复重发——这是纯粹的每拍 CPU 拷贝活。新方案非 STATUS 帧只走段 1/3 的"最近一帧",覆盖窗整套删除(图中 win 红段在密排侧不复存在)。</p></div>
<div class="card"><h3>③ 差分填充:节点集合与各 seq 未变时整段复用影子</h3>
<p>注意一个容易误解的点:电机"不动"≠数据不变——STATUS 帧按电机自己的节奏持续上报(本台 3 台合计 ~3750 帧/s,每台 ~1.25 帧/拍),所以大多数拍里槽 seq 都变了、重建路径照样走(实测槽 seq 每 ~1 拍刷新一次)。差分填充真正的收益是"没变的槽不重读不重拷、重建范围从无条件 5 槽缩到实际变化的槽"——量级是 µs 级的,<b>不能用它解释 rx 310→92</b>(见开头"重大更正"卡)。</p></div>
<h2>三、改动原理 B:1.6.22 稀疏写为何对主站往返"无效"</h2>
<p>1.6.22 的思路:PDI 往 ESC RAM 写输入镜像时按 count 只写「段1+段2 有效区」与「段3+段5」,跳过空槽区,省 <code>HW_EscWriteIsr</code> 的 PRAM 字节。<b>方向是对的,但省错了地方</b>——这里有一个关键的不对称:</p>
<div class="card warn"><h3>不对称原理:差分填充有效、稀疏写无效</h3>
<ul>
<li>主站等的关键路径 = <b>SM 交换</b>(ESC 引擎把 600B 输入区搬上线路)+ 交换前从站把镜像<b>备好</b>。</li>
<li><b>差分填充砍的是"备好镜像"的 CPU 活</b>(MotorState 重读、逐槽拷贝、覆盖窗维护)→ 直接缩短关键路径 ⇒ rx 310→92µs。</li>
<li><b>稀疏写砍的是"推镜像进 ESC RAM"的字节数</b>,但 ESC 引擎上线搬运量由 <b>SM 长度(600B)</b>决定,不看 PDI 实际写了多少 → 主站等待不变。</li>
</ul></div>
<table>
<tr><th>A/B/A 轮次(只改这一个开关)</th><th>板上版本</th><th>0.5ms 达成</th><th>长拍%</th><th>发送 p50/p99 µs</th><th>等回包 rx p50/p99 µs</th><th>load_perm</th></tr>
<tr class="best"><td>A:稀疏写=1(出厂)</td><td>1.6.22.0</td><td>99.90%</td><td>0.10</td><td>28.6 / 45.9</td><td>92.2 / 190.9</td><td>489</td></tr>
<tr><td>B:整包写=0(A/B 基线)</td><td>1.6.22.1</td><td>99.42%</td><td>0.83</td><td>29.8 / 348.9</td><td>92.2 / 210.4</td><td>441</td></tr>
<tr class="best"><td>A 回归</td><td>1.6.22.0</td><td>99.85%</td><td>0.10</td><td>29.4 / 86.9</td><td>93.0 / 101.2</td><td>—</td></tr>
</table>
<p class="meta">判读:rx p50 三轮 92.2/92.2/93.0µs ⇒ 稀疏写对主站往返无可测影响;<code>load_perm</code> 反而在整包写侧更低(441 vs 489)⇒ 它不能当这项的验收口径(真正该看 ESC 对象 0x2000 sub10,需独占路由停循环才能读,未做)。B 臂发送 p99 348.9µs 与 A 臂不同量级,但单窗样本不足以立论。当前 <code>config.h</code> 已回置出厂 A 臂(<code>CONFIG_ECAT_SPARSE_ESC_WRITE=1</code>)。</p>
<h2>四、配套改动:把"看不见的东西"变成仪表</h2>
<p>排查过程暴露了四个观测缺口,对应的改动与各自原理:</p>
<table>
<tr><th>改动</th><th>解决什么问题</th><th>原理</th><th>验证</th></tr>
<tr><td><code>flash_gd32.py</code> 生效守卫</td><td>台账只能证明"写进去了",不能证明"板上在跑"(本次 10 小时空窗的根因)</td><td>刷前记 <code>ota.gd32_app</code> 版本 + <code>gd32_adc.uptime_sec</code>;刷完必须"版本变了<b>或</b> uptime 归零"才写台账,否则非零退出不写</td><td>两轮真实刷写 1.6.22.0→.1→.0 通过</td></tr>
<tr><td><code>ecat_native.c</code> <code>cyc_us.late/lost</code> 接真值</td><td>两字段旧为硬编码 0,超期/跳拍完全不可见</td><td>最近 STATS_N 拍窗口内统计:<code>late</code>=起跑即超期(k&gt;0)的拍数,<code>lost</code>=这些拍跳过的周期数(累计仍在 skip_total)</td><td>重载窗 late=21/lost=28,健康窗 0/0</td></tr>
<tr><td><code>/api/motor/dbg</code> 尾部追加两列</td><td>发送段是黑盒:不知 sendpacket 花多久、轮询了几次</td><td>第 7/8 列 = 本拍 <code>pcap_sendpacket</code> 净耗时与 <code>pcap_next_ex</code> 次数;旧列序不变,按前 6 列下标读的消费方不受影响</td><td>0.5ms 档 181µs 调用、每拍中位 1 次 poll</td></tr>
<tr><td><code>soem-src/oshw/win32/nicdrv.c</code> pcap 调用计数</td><td>主/从栈调用混在一起没法归因</td><td>只在 <code>stacknumber==0</code>(主栈)累加 QPC 计数,冗余栈不计防双计</td><td>A/B 与旧构建行为一致(中性)</td></tr>
<tr><td><code>watch_beat_tail.py</code>(新)</td><td>跨分钟现象无法回溯(布局翻转恰好发生在两次采样之间)</td><td>每 10s 只读一轮 <code>motor/state</code>+<code>rxraw</code>+<code>app/load</code>,落 27 列 CSV(layout/work/send/rx/late/lost/λ/达成)</td><td><code>--once</code> 跑通</td></tr>
<tr><td><code>transport-ecat.js</code> 常量适配</td><td>前端仍按旧 5 槽/6 周期口径显示</td><td>密排下一拍整轮:<code>MOT_SLOTS_PER_PDO</code> 5→9、<code>MOTOR_SCAN_CYCLES</code> 6→1;能否拿齐电机由后端 decode 决定,常量只影响展示口径</td><td>电机页原样复用,无回归</td></tr>
<tr><td><code>lab/spi_test.html</code> 跳拍卡</td><td>旧版需开逐拍环才有超期数据</td><td>直接读 <code>cyc_us.late/lost</code>(最近 400 拍窗),并注明旧后端恒报 0 不可当"无超期"</td><td>真机复跑成功,<code>node --check</code> 通过</td></tr>
</table>
<h2>五、证据:dense 是真的,以及为什么今天才生效</h2>
<div class="grid">
<div class="fig"><div class="cap">③ 判据:槽 seq 距当前 gseq 的滞后分布(111 次连续采样 ×3 槽=333 样本)</div>
<svg viewBox="0 0 560 232" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="槽滞后分布">
<rect x="376" y="20" width="160" height="180" fill="#dc2626" opacity="0.06"/>
<rect x="376" y="20" width="160" height="180" fill="none" stroke="#dc2626" stroke-dasharray="4,3" opacity="0.5"/>
<text x="456" y="52" text-anchor="middle" font-size="10.5" fill="#b91c1c" font-weight="600">滑窗方案预期落点</text>
<text x="456" y="68" text-anchor="middle" font-size="10" fill="#7f1d1d">每槽每 ≥2 拍才轮到一次</text>
<text x="456" y="82" text-anchor="middle" font-size="10" fill="#7f1d1d">滞后应 ≈4~8 帧 —— 实测 0 个样本</text>
<rect x="73" y="24.5" width="34" height="170.5" fill="#2563eb"/>
<rect x="125" y="63.3" width="34" height="131.7" fill="#2563eb"/>
<rect x="177" y="75.7" width="34" height="119.3" fill="#2563eb"/>
<rect x="229" y="102" width="34" height="93" fill="#2563eb"/>
<rect x="281" y="193.5" width="34" height="1.5" fill="#2563eb"/>
<text x="90" y="20" text-anchor="middle" font-size="10" fill="#1e40af">110</text>
<text x="142" y="58" text-anchor="middle" font-size="10" fill="#1e40af">85</text>
<text x="194" y="71" text-anchor="middle" font-size="10" fill="#1e40af">77</text>
<text x="246" y="97" text-anchor="middle" font-size="10" fill="#1e40af">60</text>
<text x="298" y="189" text-anchor="middle" font-size="10" fill="#1e40af">1</text>
<line x1="60" y1="195" x2="536" y2="195" stroke="#9aa3af" stroke-width="1.2"/>
<text x="90" y="211" text-anchor="middle" font-size="10.5" fill="#6b7280">0</text>
<text x="142" y="211" text-anchor="middle" font-size="10.5" fill="#6b7280">1</text>
<text x="194" y="211" text-anchor="middle" font-size="10.5" fill="#6b7280">2</text>
<text x="246" y="211" text-anchor="middle" font-size="10.5" fill="#6b7280">3</text>
<text x="298" y="211" text-anchor="middle" font-size="10.5" fill="#6b7280">4</text>
<text x="456" y="211" text-anchor="middle" font-size="10.5" fill="#6b7280">≥6(帧)</text>
<text x="60" y="16" font-size="10.5" fill="#6b7280">样本数 · λ≈3.75 帧/拍(3750 帧/s @ 1kHz)</text>
<text x="280" y="228" text-anchor="middle" font-size="11" fill="#374151">槽 seq 滞后于当前 global_seq(帧数)</text>
</svg>
<div class="sub">三台电机落在槽 2/5/8:滑窗方案盖住它们需 ≥2 个基址轮转 → 每槽每 ≥2 拍才刷新、滞后应集中在 4~8 帧。实测 1/3 样本滞后为 0、全部 ≤4 帧 ⇒ 只能是"每拍带齐整轮"的密排语义。seq 与 gseq 都是 GD32 自己盖的号,滞后能对上同时证明解码偏移没错。(原报告曾把"gseq≡rx_count"当最强证据,复核发现两种布局 gseq 同在偏移 36,该条不能区分布局,已撤换为本判据。)</div>
</div>
<div class="fig"><div class="cap">④ 时间线:刷写 ≠ 生效</div>
<svg viewBox="0 0 620 150" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="时间线">
<line x1="70" y1="70" x2="560" y2="70" stroke="#9aa3af" stroke-width="1.6"/>
<circle cx="100" cy="70" r="5" fill="#2563eb"/>
<circle cx="290" cy="70" r="5" fill="#dc2626"/>
<circle cx="430" cy="70" r="5" fill="#b45309"/>
<circle cx="530" cy="70" r="5" fill="#047857"/>
<text x="100" y="30" text-anchor="middle" font-size="11" fill="#374151">01:44 镜像刷入 1.6.22</text>
<text x="100" y="45" text-anchor="middle" font-size="10" fill="#6b7280">台账 sha=hex 展平 ✓</text>
<text x="290" y="30" text-anchor="middle" font-size="11" fill="#b91c1c">白天:仍在跑老代码</text>
<text x="290" y="45" text-anchor="middle" font-size="10" fill="#6b7280">主站实测 win 布局 · 232B · 整拍 345µs</text>
<text x="430" y="100" text-anchor="middle" font-size="11" fill="#b45309">中午断电重启</text>
<text x="530" y="100" text-anchor="middle" font-size="11" fill="#047857">生效:dense · 124µs</text>
<path d="M100,84 L100,96 L430,96 L430,84" fill="none" stroke="#b45309" stroke-width="1.4"/>
<text x="265" y="115" text-anchor="middle" font-size="11.5" font-weight="600" fill="#b45309">≈10 小时:写入 ≠ 生效</text>
<text x="70" y="140" font-size="10" fill="#6b7280">三方计数器锚点(不依赖系统时钟):主站进程存活 2084s · GD32 桥计数归零 1914s(gseq÷λ)· ESP32 上电 1922s —— 后两者差 8s,GD32 与 ESP32 同次上电;板子比主站晚 ~2.8 分钟重启,且 uptime 单调增(只重启过一次)</text>
</svg>
<div class="sub">主站在 02:3x 直接读到 <code>layout="win" len=232</code> 配 345µs——刷入后 10 小时仍是老代码在跑。台账 <code>_last_gd32_sha.txt</code> 当时就与 Release hex 展平一致,即"写进去了"为真,"在跑了"为假。(A/B/A 之后台账现为 dda92d11…,对应 12:46 刷回 A 臂的产物,本次复核独立展平重算一致。)</div>
</div>
</div>
<h3>同机同日前后对照(观察性证据)</h3>
<table>
<tr><th>从站布局</th><th>rx 往返</th><th>整拍 p50</th><th>达成</th><th>长拍&gt;1.4×</th><th>跳拍 k&gt;0</th><th>gap p99</th></tr>
<tr><td><code>win</code>(1.6.15 滑窗)</td><td>~310µs</td><td>345µs</td><td>93.9%(1879Hz)</td><td>7.9%</td><td>8.6%</td><td>1144µs</td></tr>
<tr class="best"><td><code>dense</code>(1.6.21/22 密排)</td><td>92.2µs</td><td>123µs</td><td>99.9%(1998Hz)</td><td>0.10%</td><td>~0.6%</td><td>=周期本身</td></tr>
</table>
<p class="meta">0.5ms 档余量从 155µs 变为 377µs:旧方案任何调度抖动都会成片跳拍,新方案长拍基本消失。另 1ms 档现网 <code>late/lost</code>(k&gt;0 口径,连 1µs 超期都算)为个位数/400 拍窗,与"长拍&gt;1.4×≈0%"不矛盾——两套阈值灵敏度不同,并列引用时必须标注口径。</p>
<h2>六、被实测排除的原因(别再重测)</h2>
<table>
<tr><th>候选解释</th><th>排除依据</th></tr>
<tr><td>线路变快 / 帧变小</td><td>两种布局 Ibytes 同为 600B(<code>ecat_native.c:74</code> 与固件头文件双侧注释),帧长 ~1016B 不变,线速仍 100Mbps 全双工</td></tr>
<tr><td>收帧超时(EC_TIMEOUTRET)</td><td>预算 2000µs(µs 单位),11,264 拍里仅 1 拍 EC_NOFRAME;win 时代 rx p50 7~9µs 起步,预算从不消耗——收紧它是自证式"变好"</td></tr>
<tr><td>主站构建差异 / 双实例</td><td>插桩版与旧构建 A/B 一致;单进程单端口;旧 exe 留档 <code>build_native/ecat_native.pre-instr.exe</code></td></tr>
<tr><td>观察者负载</td><td>面板全量 10Hz×3、单 dbg 10Hz(≈1.5MB/s JSON)、零请求静默窗,三者结果在噪声内相同</td></tr>
<tr><td>CPU 抢占</td><td>另开 4 / 12 个纯算线程压机,send/rx/work 一动不动</td></tr>
<tr><td>"PRAM 逐 DWORD 轮数 150→45"</td><td>被 A/B/A 否:整包写 600B 的 B 臂同样 92µs——写侧省字节数不进入主站关键路径(§三不对称原理)</td></tr>
</table>
<h2>七、复核更正(本次排查自己错过又改掉的判断)</h2>
<div class="card bad"><h3>证据层的两处撤换</h3>
<ul>
<li><b>"gseq≡rx_count,差恒 0"是同义反复</b>:<code>MOTW_OFF_GLOBAL_SEQ</code> 与 <code>MOTW_OFF_GSEQ_DENSE</code> 都是偏移 36(<code>ecat_native.c:69,82</code>),两种布局读同一字节,分不开 dense 与 win。已撤换为 §五-③ 的槽滞后判据。</li>
<li><b>魔数 0xA5 无法从主机侧直接读</b>:<code>/api/motor/rxraw</code> 返回的是批量接收区缓冲快照,不是 PDO 输入镜像;路由表中没有任何路由吐输入镜像。故"直接验魔数"这条路不存在,dense 判定只能靠上述间接判据 + 60s×30 与 111 次采样稳定性(layout 恒 dense、len 恒 176、count 恒 3、win_bases 恒 None)。</li>
</ul></div>
<div class="card bad"><h3>限制声明的一处更正(本次复核新增)</h3>
<ul>
<li><b>"DCU_APP 不在任何 git 仓库、布局无法 A/B"不成立</b>:<code>DCU_APP</code> 是独立 git 仓库(38 个提交)——<code>959e2ae</code> = 1.6.14~1.6.20 滑窗覆盖窗源码、<code>1742d9a</code> = 1.6.21 密排;命令行构建链(cbuild + AC6)现成。因此"win→dense 换来 220µs"这一环<b>可以做受控实验钉死</b>:checkout <code>959e2ae</code> 重编刷板 → 预期 rx 回到 ~310µs → 再刷回 1.6.22(flash_gd32.py 生效守卫会留台账)。属刷板操作,未执行。</li>
<li><b>遗留:重刷同一版本无法证明生效</b>——<code>gd32_adc.uptime_sec</code> 实测不随 GD32 换镜像归零,守卫的"uptime 归零"分支不可靠,目前只能靠版本号变化。根治要 BOOT 上报复位计数/复位原因,或 OTA 完成后强制 APP 复位回读。</li>
</ul></div>
<div class="card warn"><h3>归因的置信度边界(2026-09-12 修订)</h3>
<ul>
<li>已受控验证:稀疏写开关(A/B/A,单臂 2048 拍静默窗,效应上界 ≲1µs/拍);排除项(§六);"刷写≠生效"时间锚点(§五)。</li>
<li><b>已动摇</b>:win→dense 与 220µs 的因果归因——9/8 同布局实测 124.6µs 的反例 + 两版 buildInput 纯 CPU 差仅 1~3µs。原"三条独立吻合"里"旧值≈357µs"一条存疑:357µs 是 1.6.11 时代(496B 输入 + 环形批量区)的测量,与滑窗 600B 版不同代。</li>
<li>待做:开头"重大更正"卡的刷板 A/B(959e2ae + prev.exe 双轮)是当前唯一能闭合因果的实验。</li>
</ul></div>
<h2>八、遗留与下一步</h2>
<div class="card"><h3>按优先级</h3>
<ul>
<li><b>布局 A/B 钉死归因(方案已更新)</b>:刷回 <code>959e2ae</code>(win 固件)后做两轮——配 <code>ecat_native.prev.exe</code>(旧主站,≤78fe6cf)与现主站各测一轮 0.5ms:124µs / 345µs 的分野即判定回归在主站还是从站 win 期固件;再用 0x2000 sub5/6/9/10 定位。刷板会中断 OP 会话,需专门安排。</li>
<li><b>0.25ms 档第一次有理论余量</b>(124/250=50%),值得按同一静默判据实测——但注意此前结论:从站 SSC 的 SM2 事件路径在快档有自己的墙,别只看主站达成率。</li>
<li><b>重刷同版本的生效证明</b>:BOOT 上报复位计数,或 OTA 后强制 APP 复位回读版本。</li>
<li><code>0x2000 sub10</code>(EscWrite 周期)精确验收需独占路由(停循环、从站掉 OP),要专门开一窗。</li>
<li>ms 级离群未清零:dense 时代仍见 work max 2153µs / rx max 1658µs(p90 仅 ~110µs),量级无害但没定位。</li>
<li>本次全部改动<b>未提交</b>(tools/ecat 3 文件+报告、tools/ESP32 7 文件、DCU_APP 4 文件),旧主站 exe 留档在 <code>build_native/ecat_native.pre-instr.exe</code>。</li>
</ul></div>
<div class="foot">本报告依据 <code>tools/ecat/REPORT_ecat_beat_tail_20260911.md</code>、当日调查会话与复核证据整理;所有代码引证(偏移定义、357µs、路由表、台账 sha)已于 2026-09-11 对照源码与实机独立核验,实时复测 rx p50=92.1µs / layout=dense / wkc 3/3 / v1.6.22.0。相关页面:<a href="ECAT_RATE_TRADEOFF.html">ECAT_RATE_TRADEOFF.html</a>(周期×故障率×CPU 调研)· <code>lab/spi_test.html</code>(SPI/ECAT 测速)。</div>
</main></body></html>
+1 -1
View File
@@ -60,7 +60,7 @@ ul{padding-left:22px}
<h2>三、机理与上限推算</h2> <h2>三、机理与上限推算</h2>
<div class="card"><h3>上限已实测:≈8.2kHz,墙是单拍 ≈121µs 的硬耗时</h3><p><b>证据链</b>:无 CAN 设定 0.25 → 0.0625ms 五档,实测循环率在 ≤0.125ms (8kHz)前跟随设定,再快就被钉死在 8196–8238Hz;与此同时主站单拍耗时 <code>cyc p50</code> 在<b>所有档位</b>(含 2ms 慢档)恒为 117–126µs,1/121µs ≈ 8.2kHz,与顶墙实测严丝合缝。即:每拍有一段不可压缩的「发送→从站处理→回读+主站处理」往返时间,周期设定小于它时就堵在这里。</p><p><b>排除项</b>:不是主机 CPU 不够 —— 顶墙档主机 CPU 只有 ~85%(自旋等待占比变小);不是从站拒答 —— wkc 全程 3/3。<code>load_perm</code> 在 ≥4kHz 全饱和 99.x%,既定位不了拐点也喂不准模型,早先「两项模型 load=F/T+R → 2.7/3.5kHz」的外推已被满速补测证伪,仅留档。</p><p><b>未闭合的一处</b>:121µs 里主站协议栈与从站 ESC/固件各占多少,本轮数据分不开 —— 需给固件 <code>fillRxWindow</code> 加直接计时(固件改动,需批准)或抓总线帧时序才能归因。</p></div> <div class="card"><h3>上限已实测:≈8.2kHz,墙是单拍 ≈121µs 的硬耗时</h3><p><b>证据链</b>:无 CAN 设定 0.25 → 0.0625ms 五档,实测循环率在 ≤0.125ms (8kHz)前跟随设定,再快就被钉死在 8196–8238Hz;与此同时主站单拍耗时 <code>cyc p50</code> 在<b>所有档位</b>(含 2ms 慢档)恒为 117–126µs,1/121µs ≈ 8.2kHz,与顶墙实测严丝合缝。即:每拍有一段不可压缩的「发送→从站处理→回读+主站处理」往返时间,周期设定小于它时就堵在这里。</p><p><b>排除项</b>:不是主机 CPU 不够 —— 顶墙档主机 CPU 只有 ~85%(自旋等待占比变小);不是从站拒答 —— wkc 全程 3/3。<code>load_perm</code> 在 ≥4kHz 全饱和 99.x%,既定位不了拐点也喂不准模型,早先「两项模型 load=F/T+R → 2.7/3.5kHz」的外推已被满速补测证伪,仅留档。</p><p><b>未闭合的一处</b>:121µs 里主站协议栈与从站 ESC/固件各占多少,本轮数据分不开 —— 需给固件 <code>fillRxWindow</code> 加直接计时(固件改动,需批准)或抓总线帧时序才能归因。</p></div>
<h2>四、判据口径与踩过的坑</h2> <h2>四、判据口径与踩过的坑</h2>
<div class="card warn"><h3>仪表陷阱(每条都实际踩过)</h3><ul><li><b>【本次真正翻车处】把两个同速计数器相除当成比率</b> —— <code>drop</code>/<code>rx_count</code> 都约 4000/s,相除恒得 100%,看着像"全丢",其实只是<b>同速量之比</b>。判丢帧必须有<b>独立</b>的分子与分母:本次真正的独立证据是「STATUS 实测 4001 帧/s 正常刷新」+「CONFIG 事件流去重后仅约 92 帧/s」,两者一起否掉了 100% 丢帧。<b>任何"A/B 比率"指标,先问 A 与 B 是否独立计数。</b></li><li><b>从站停报后 <code>load_perm</code> 保留残值</b> —— 实测 <code>age_ms=214338</code> 时仍报 99.2%。任何取 load_perm 的脚本必须先过 <code>age_ms</code> 闸。<b>扫描脚本已加该闸</b>并新增 <code>gd32_load_samples</code>/<code>gd32_load_stale</code> 两列。</li><li><b><code>load_perm</code> 不是整机 CPU 占用率</b> —— 固件 <code>sys/cpu_load.h</code> 定义收窄为"中断+非延迟处理段",<b>排除</b> SSC MainLoop 轮询;当前 free-run(<code>dc=0</code>)下 PDO 应用走主循环轮询路径、<b>不计入</b>。所以不能把"load→100%"读成"CPU 耗尽"。</li><li><b><code>OVF</code> 每拍被 <code>s_rxWinOvf=0</code> 清零</b> —— 从站侧没有可用的累计丢帧计数。读它恒 0 <b>不能</b>当"无丢帧"证据(我据此误判 <code>bridge.drop</code> 是死字段,结论已收回)。</li><li><b><code>/api/can/cadence</code> 只存最近 512 拍</b> —— 0.25ms 档 =128ms、0.125ms=64ms,环没装满时取到的 <code>count</code>/<code>lost</code> 是<b>行数</b>不是每拍帧数。对应 CSV 列已从结论排除。</li><li><b>u16 回绕</b>:<code>drop</code> 在 4000/s 下 16 秒绕一圈 → 10Hz 逐次累加 + 取模,单次增量&gt;32768 记为作废。<b>本次作废点数=0</b>。</li><li><b>「seq 值域 − distinct」不是丢帧率</b>:报 99.686%,方向与 drop≈λ 一致但分母含 STATUS 全流量,口径不干净 → 只作旁证。</li><li><b>周期可被 localStorage 劫持</b>(<code>transport-ecat.js:227-228</code>)→ 每档下发后回读校验。本次六档 <code>hijacked=no</code>、native PID 全程 29284 未变。</li><li><b><code>cyc_us.late</code>/<code>lost</code> 恒 0</b>(遗留占位,未接线),未使用。</li></ul></div> <div class="card warn"><h3>仪表陷阱(每条都实际踩过)</h3><ul><li><b>【本次真正翻车处】把两个同速计数器相除当成比率</b> —— <code>drop</code>/<code>rx_count</code> 都约 4000/s,相除恒得 100%,看着像"全丢",其实只是<b>同速量之比</b>。判丢帧必须有<b>独立</b>的分子与分母:本次真正的独立证据是「STATUS 实测 4001 帧/s 正常刷新」+「CONFIG 事件流去重后仅约 92 帧/s」,两者一起否掉了 100% 丢帧。<b>任何"A/B 比率"指标,先问 A 与 B 是否独立计数。</b></li><li><b>从站停报后 <code>load_perm</code> 保留残值</b> —— 实测 <code>age_ms=214338</code> 时仍报 99.2%。任何取 load_perm 的脚本必须先过 <code>age_ms</code> 闸。<b>扫描脚本已加该闸</b>并新增 <code>gd32_load_samples</code>/<code>gd32_load_stale</code> 两列。</li><li><b><code>load_perm</code> 不是整机 CPU 占用率</b> —— 固件 <code>sys/cpu_load.h</code> 定义收窄为"中断+非延迟处理段",<b>排除</b> SSC MainLoop 轮询;当前 free-run(<code>dc=0</code>)下 PDO 应用走主循环轮询路径、<b>不计入</b>。所以不能把"load→100%"读成"CPU 耗尽"。</li><li><b><code>OVF</code> 每拍被 <code>s_rxWinOvf=0</code> 清零</b> —— GD32 侧没有可用的累计丢帧计数。读它恒 0 <b>不能</b>当"无丢帧"证据(我据此误判 <code>bridge.drop</code> 是死字段,结论已收回)。</li><li><b><code>/api/can/cadence</code> 只存最近 512 拍</b> —— 0.25ms 档 =128ms、0.125ms=64ms,环没装满时取到的 <code>count</code>/<code>lost</code> 是<b>行数</b>不是每拍帧数。对应 CSV 列已从结论排除。</li><li><b>u16 回绕</b>:<code>drop</code> 在 4000/s 下 16 秒绕一圈 → 10Hz 逐次累加 + 取模,单次增量&gt;32768 记为作废。<b>本次作废点数=0</b>。</li><li><b>「seq 值域 − distinct」不是丢帧率</b>:报 99.686%,方向与 drop≈λ 一致但分母含 STATUS 全流量,口径不干净 → 只作旁证。</li><li><b>周期可被 localStorage 劫持</b>(<code>transport-ecat.js:227-228</code>)→ 每档下发后回读校验。本次六档 <code>hijacked=no</code>、native PID 全程 29284 未变。</li><li><b><code>cyc_us.late</code>/<code>lost</code> 恒 0</b>(遗留占位,未接线),未使用。</li></ul></div>
<h2>五、可信度边界</h2> <h2>五、可信度边界</h2>
<div class="card bad"><h3>这份数据不能证明什么</h3><ul><li><b>板载固件版本无法确认</b>:烧写台账 <code>201832b5a8e258d6…</code> 与在盘 .hex 展平哈希(63d6ab9b…、e6a6e75f…)<b>均不匹配</b>,且产物时间戳晚于台账。→ 本报告不复用任何历史固件结论。</li><li>主站在测量期间又重编过(源码 02:39 / exe 02:40,PID 起于 02:32),无法证明运行中的进程就是盘上那份。</li><li><b><code>rx_torn</code> 与 <code>pdo_reentry</code> 整段恒零 = 无检出功效</b>,不是"无撕裂"。二者分别需固件 1.6.6/1.6.7+,版本未知 → 无法区分"没发生"与"字段根本没实现、恒读 0"。</li><li>λ≈3993/s 是本次 CAN 工况。<b>λ 变则负担变</b>,0.5ms 这个数不可外搬到别流量。</li><li>单台单次,未做重复性与温漂验证。</li></ul></div> <div class="card bad"><h3>这份数据不能证明什么</h3><ul><li><b>板载固件版本无法确认</b>:烧写台账 <code>201832b5a8e258d6…</code> 与在盘 .hex 展平哈希(63d6ab9b…、e6a6e75f…)<b>均不匹配</b>,且产物时间戳晚于台账。→ 本报告不复用任何历史固件结论。</li><li>主站在测量期间又重编过(源码 02:39 / exe 02:40,PID 起于 02:32),无法证明运行中的进程就是盘上那份。</li><li><b><code>rx_torn</code> 与 <code>pdo_reentry</code> 整段恒零 = 无检出功效</b>,不是"无撕裂"。二者分别需固件 1.6.6/1.6.7+,版本未知 → 无法区分"没发生"与"字段根本没实现、恒读 0"。</li><li>λ≈3993/s 是本次 CAN 工况。<b>λ 变则负担变</b>,0.5ms 这个数不可外搬到别流量。</li><li>单台单次,未做重复性与温漂验证。</li></ul></div>
<h2>六、下一步</h2> <h2>六、下一步</h2>
+13 -2
View File
@@ -46,8 +46,7 @@ esp32_app.vbs esp32_web.vbs serve.bat 三个启动入口
| `pages/can.html` | CAN 通信(DBC 解析、消息编辑与批量发送) | `js/can.js`、`js/can/*` | | `pages/can.html` | CAN 通信(DBC 解析、消息编辑与批量发送) | `js/can.js`、`js/can/*` |
| `pages/can_monitor.html` | CAN 接收监控(独立页;也被 `can.html` 内嵌复用) | `js/can_monitor.js` | | `pages/can_monitor.html` | CAN 接收监控(独立页;也被 `can.html` 内嵌复用) | `js/can_monitor.js` |
| `pages/motor.html` | 电机控制台(CAN FD,WiFi / EtherCAT 双链路) | `js/motor/main.js`、`js/motor/transport*.js`、`js/motor/tick.worker.js` | | `pages/motor.html` | 电机控制台(CAN FD,WiFi / EtherCAT 双链路) | `js/motor/main.js`、`js/motor/transport*.js`、`js/motor/tick.worker.js` |
| `lab/continuity_test.html` | LAN9252 通断测试(自包含,含 WebGL 液态玻璃效果) | 内联 | | `lab/spi_test.html` | 【临时功能 SPI-SPEEDTEST】SPI/ECAT 通信测速(标签名「SPI/ECAT 通信测速」;含 EtherCAT 接收看板,完全自包含,删文件即可移除) | 内联 |
| `lab/spi_test.html` | 【临时功能 SPI-SPEEDTEST】SPI 链路测速,完全自包含,删文件即可移除 | 内联 |
## 目录约定 ## 目录约定
@@ -116,6 +115,18 @@ esp32_app.vbs esp32_web.vbs serve.bat 三个启动入口
2. 「≈N Hz」理论值 —— 6ms 硬算是 166Hz。这时只显示 `statusHzText` 2. 「≈N Hz」理论值 —— 6ms 硬算是 166Hz。这时只显示 `statusHzText`
按数据到达时间实测出来的频率(其滑动窗口在该档改用固定 2s, 按数据到达时间实测出来的频率(其滑动窗口在该档改用固定 2s,
否则 `6ms × 2 × 10 = 120ms` 的旧数据 cutoff 会把刚到的样本全清空)。 否则 `6ms × 2 × 10 = 120ms` 的旧数据 cutoff 会把刚到的样本全清空)。
- **STATUS 面板的 Hz 显示的是网页实际刷新率(数据到达口径),不是电机的
CAN 回报率**(`recvTs` 滑窗实测)。电机 CAN 回报率可变且可能高于链路承载
(WiFi 慢于电机时界面拿不到中间帧),这时按电机回报率显示就是谎报 ——
面板要如实反映"这份数据每秒真到几次",快链路自动跑出高值,慢链路如实
反映卡顿。按槽 seq 实测电机回报率的方案实现过、被明确否掉(2026-09-12:
用户要的恰是到达口径),seq 现在只用于下面的判活。
- **ECAT 判活阈值随实测回报周期自适应**(`staleLimitMs` = clamp(3×EWMA, 2s, 30s))。
固定 2s 的问题:电机回报周期调到秒级后,两次**正常**回报之间就超过 2s,
节点会反复判离线(LED 闪烁、波形画虚线、面板切离线覆盖层)。现在阈值随该槽
实测 seq 间隔放大到 3 倍,下限 2s 仍兜底快周期,上限 30s 防一次长停顿把
EWMA 拉飞后迟迟不判离线;突发降速收敛前最多闪一次离线(无法预知,固有延迟)。
WiFi 链路的 online 仍以固件判定为准(固件仓库另有阈值,前端不越权改写)。
- **轮询不要快过绘制上限**:1ms 档实测能跑到 **~550 Hz**(`ecat_native` 侧 - **轮询不要快过绘制上限**:1ms 档实测能跑到 **~550 Hz**(`ecat_native` 侧
写字节 ÷ 快照 1.6KB 反推),但画面最多只能显示 160 Hz —— 多出来的 390 次/s 写字节 ÷ 快照 1.6KB 反推),但画面最多只能显示 160 Hz —— 多出来的 390 次/s
在 renderer / 网络服务 / browser 进程 / Python 代理上各点一次,实测这条链 在 renderer / 网络服务 / browser 进程 / Python 代理上各点一次,实测这条链
+169 -59
View File
@@ -142,16 +142,17 @@ g_ecat_port = None # 记住上次探到的端口, 避免每次请求都从 809
# 换成原生守护进程 build_native\ # 换成原生守护进程 build_native\
# ecat_native.exe (C/SOEM, 由 build_native.bat 编译)。路由/JSON 契约与 Python # ecat_native.exe (C/SOEM, 由 build_native.bat 编译)。路由/JSON 契约与 Python
# 逐字段对齐, 见 tools/ecat/MIGRATION.md。三条必须小心的地方(native 原样镜像了这套契约): # 逐字段对齐, 见 tools/ecat/MIGRATION.md。三条必须小心的地方(native 原样镜像了这套契约):
# 1) **必须先探测再拉起**。native 启动时**无条件清场**(后起的独占): 对 # 1) **单实例、后起独占**(2026-09-12 收紧): 只有本进程拉起且仍存活的子进程才复用;
# 8090..8095 的旧实例逐个调 /api/shutdown(从站回 INIT)再兜底强杀。 # 起新进程前 taskkill 全部残留 ecat_native.exe。两个实例并存会抢 Npcap
# 已有实例时仍只复用不重复拉起 —— 清场是给"并发拉起撞上"兜底的, # (后起的抓不到从站、HTTP 超时、PDO 空转)。native 启动时也会 kill_stale_instances,
# 不该被用来每次踢掉用户手开的实例。 # 与本处强杀双重保险。
# 2) 拉起时**必须带 --parent-pid**: 让 native 在本服务退出后自我了断, # 2) 拉起时**必须带 --parent-pid**: 让 native 在本服务退出后自我了断,
# 不留孤儿进程占着 Npcap 适配器和端口。(与清场无关, 清场总是执行) # 不留孤儿进程占着 Npcap 适配器和端口。
# 3) 不再需要 venv/pythonw —— 单文件 exe, 无 Python 运行时依赖。 # 3) 不再需要 venv/pythonw —— 单文件 exe, 无 Python 运行时依赖。
ECAT_DIR = os.path.normpath(os.path.join(BASE_DIR, "..", "ecat")) ECAT_DIR = os.path.normpath(os.path.join(BASE_DIR, "..", "ecat"))
ECAT_NATIVE_EXE = os.path.join(ECAT_DIR, "build_native", "ecat_native.exe") ECAT_NATIVE_EXE = os.path.join(ECAT_DIR, "build_native", "ecat_native.exe")
g_ecat_child = None # 由本服务拉起的 ecat 服务子进程(复用已有实例时为 None) g_ecat_child = None # 由本服务拉起的 ecat 服务子进程(复用已有实例时为 None)
g_ecat_log_fp = None # native stdout 落盘句柄(见 _ecat_autostart 内注释)
g_ecat_lock = threading.Lock() # 串行化自动启动, 避免并发请求拉起多个进程 g_ecat_lock = threading.Lock() # 串行化自动启动, 避免并发请求拉起多个进程
@@ -201,56 +202,128 @@ def _ecat_find_port():
return None return None
def _kill_all_ecat_native():
"""强杀所有 ecat_native.exe,返回杀掉的个数。
两个 native 并存会抢 Npcap 适配器:后起的抓不到从站、HTTP 超时、
PDO 空转。启动新实例前必须保证旧的死透。只按进程名杀,不碰 python。
"""
try:
out = subprocess.check_output(
["tasklist", "/FI", "IMAGENAME eq ecat_native.exe",
"/FO", "CSV", "/NH"],
text=True, stderr=subprocess.DEVNULL, timeout=8,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
) or ""
except Exception:
return 0
pids = []
for line in (out or "").splitlines():
parts = [p.strip().strip('"') for p in line.split(",")]
if len(parts) >= 2 and parts[0].lower() == "ecat_native.exe":
try:
pids.append(int(parts[1]))
except ValueError:
pass
if not pids:
return 0
for pid in pids:
try:
subprocess.run(
["taskkill", "/F", "/PID", str(pid)],
capture_output=True, timeout=5,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
except Exception:
pass
# 等 Npcap 句柄随进程退出由内核回收
deadline = time.time() + 3.0
while time.time() < deadline:
try:
again = subprocess.check_output(
["tasklist", "/FI", "IMAGENAME eq ecat_native.exe",
"/FO", "CSV", "/NH"],
text=True, stderr=subprocess.DEVNULL, timeout=4,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
) or ""
if "ecat_native.exe" not in (again or "").lower():
break
except Exception:
break
time.sleep(0.2)
else:
time.sleep(0.3)
return len(pids)
def _ecat_autostart(): def _ecat_autostart():
"""按需拉起 tools/ecat 的服务进程。返回 (port, log_lines)。 """按需拉起 tools/ecat 的服务进程。返回 (port, log_lines)。
port 为 None 表示拉不起来(缺 exe / 起了但没监听)。 单实例策略(2026-09-12):
已有实例时**只复用不重启** —— 详见文件头 ECAT_DIR 附近的注释: - 本进程子进程仍存活 → 只等它听口,**绝不 kill_all**(否则启动中的实例
native 独立启动会清场(优雅关掉旧实例), 不能重复拉起。 会被自己下一次请求杀掉,形成 8s kill_stale 死循环)。
- 子进程已死 / 从未拉起 → 先 taskkill 全部残留 ecat_native,再起唯一实例。
port 为 None 表示拉不起来。
""" """
global g_ecat_child, g_ecat_port global g_ecat_child, g_ecat_port, g_ecat_log_fp
with g_ecat_lock: with g_ecat_lock:
port = _ecat_find_port()
if port: # 已经有人在跑(用户手开的, 或我们上次拉起的)
g_ecat_port = port
return port, []
logs = [] logs = []
# ── 我们自己拉起的还活着:只复用/等它,不杀 ──
if g_ecat_child is not None and g_ecat_child.poll() is None:
port = _ecat_find_port()
if port:
g_ecat_port = port
return port, []
# 仍在启动(kill_stale/绑口最多约 10s):等完再判
deadline = time.time() + 10
while time.time() < deadline:
if g_ecat_child.poll() is not None:
break
port = _ecat_find_port()
if port:
g_ecat_port = port
return port, []
time.sleep(0.3)
if g_ecat_child.poll() is None:
return None, logs + ["EtherCAT 服务仍在启动中(未监听端口),请稍候再试"]
g_ecat_child = None
if g_ecat_child is not None and g_ecat_child.poll() is not None:
g_ecat_child = None
if not os.path.isfile(ECAT_NATIVE_EXE): if not os.path.isfile(ECAT_NATIVE_EXE):
return None, [ return None, [
f"找不到 EtherCAT 原生守护进程: {ECAT_NATIVE_EXE}", f"找不到 EtherCAT 原生守护进程: {ECAT_NATIVE_EXE}",
"请先运行 tools\\ecat\\build_native.bat 编译(需 VS2022 + Npcap)。", "请先运行 tools\\ecat\\build_native.bat 编译(需 VS2022 + Npcap)。",
] ]
# 复用已被本进程拉起但已退出的槽位 # 起新进程前清光旧的:禁止双实例抢网卡
if g_ecat_child is not None and g_ecat_child.poll() is not None: killed = _kill_all_ecat_native()
g_ecat_child = None if killed:
if g_ecat_child is None: logs.append(f"已清掉 {killed} 个残留 ecat_native(单实例,防抢网卡)")
try:
# --parent-pid: 让 native 在本服务退出后自我了断(不留孤儿占
# Npcap/端口)。native 侧的清场与它无关, 启动时无条件执行。
# --mask 0x10: PDO 自旋线程钉单个 LP4(i7-14700K P核2), 避开 DPC
# 集中的核0 与 E 核, 与 ecat_web.bat 同值, 换机需按拓扑改。
# 掩码**只能给单比特**: 多比特(如 0x50=LP4|LP6)不是"两个核一起
# 自旋", 而是授权调度器在这一个线程上来回迁移, 迁移期间的迟到拍
# 由 skip_total 计入、直接压低周期达成率。不钉的话随调度器漂移,
# 1ms 档周期抖动明显放大。
# cwd=tools/ecat: --web 的相对路径(src/web)按此解析。
g_ecat_child = subprocess.Popen(
[ECAT_NATIVE_EXE, "--substr", "I210", "--mask", "0x10",
"--web", "src/web", "--parent-pid", str(os.getpid())],
cwd=ECAT_DIR,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
stdin=subprocess.DEVNULL,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
logs.append(f"已自动启动 EtherCAT 原生守护进程(pid {g_ecat_child.pid})")
except OSError as e:
return None, [f"启动 EtherCAT 服务失败: {type(e).__name__}: {e}"]
# 等它把端口监听起来。native 启动即绑口(ec_init 是懒的, 在 open 路由里), try:
# 给 10s 足够; 老 Python 版要等 pysoem 导入 + Npcap 枚举, 才需要 20s。 # 日志落文件而不是 DEVNULL:native 的 stdout 是崩溃/楔死/自动重拉的
deadline = time.time() + 10 # 唯一现场(WEDGE、kill_stale、进 OP 全打这里),DEVNULL 吞掉后只能靠
# Windows 事件日志反推(2026-09-12 教训)。追加写,重启服务不清零。
if g_ecat_log_fp is None:
g_ecat_log_fp = open(
os.path.join(ECAT_DIR, "build_native", "native_stdout.log"),
"ab", buffering=0)
g_ecat_child = subprocess.Popen(
[ECAT_NATIVE_EXE, "--substr", "I210", "--mask", "0x50",
"--web", "src/web", "--parent-pid", str(os.getpid())],
cwd=ECAT_DIR,
stdout=g_ecat_log_fp, stderr=subprocess.STDOUT,
stdin=subprocess.DEVNULL,
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
)
logs.append(f"已自动启动 EtherCAT 原生守护进程(pid {g_ecat_child.pid})")
except OSError as e:
return None, logs + [f"启动 EtherCAT 服务失败: {type(e).__name__}: {e}"]
deadline = time.time() + 12
while time.time() < deadline: while time.time() < deadline:
if g_ecat_child.poll() is not None: if g_ecat_child.poll() is not None:
return None, logs + [ return None, logs + [
@@ -263,10 +336,10 @@ def _ecat_autostart():
logs.append(f"EtherCAT 服务已就绪(端口 {port})") logs.append(f"EtherCAT 服务已就绪(端口 {port})")
return port, logs return port, logs
time.sleep(0.3) time.sleep(0.3)
return None, logs + ["EtherCAT 服务启动超时(10s 内未监听端口)"] return None, logs + ["EtherCAT 服务启动超时(12s 内未监听端口)"]
def _ecat_post(port, path, body, timeout): def _ecat_post(port, path, body, timeout, connect_timeout=None):
"""向 EtherCAT 后端(native)转发一次 POST, 返回 (status, body_bytes)。 """向 EtherCAT 后端(native)转发一次 POST, 返回 (status, body_bytes)。
**不要改回 urllib/http.client 并给它传 timeout。** 那样每次请求会多花约 10ms: **不要改回 urllib/http.client 并给它传 timeout。** 那样每次请求会多花约 10ms:
@@ -281,6 +354,11 @@ def _ecat_post(port, path, body, timeout):
所以这里手写最小 HTTP/1.0 客户端: 连接阶段**不设** timeout(本机回环连接 所以这里手写最小 HTTP/1.0 客户端: 连接阶段**不设** timeout(本机回环连接
要么立刻成功要么立刻 ECONNREFUSED, 不会挂住), 连上之后才设读写超时。 要么立刻成功要么立刻 ECONNREFUSED, 不会挂住), 连上之后才设读写超时。
connect_timeout: 只给冷路径(逐个试漂移端口)用的连接超时。**热路径(缓存
端口)必须保持 None(阻塞连接)**——本机对死端口无论阻塞还是 settimeout 都
不是秒拒(阻塞 ~2s, settimeout(0.3) 吃满 0.3s 才回 WOULDBLOCK), 但活端口
的阻塞 connect 只要 ~0.6ms, 两害相权取其轻。
""" """
payload = ( payload = (
f"POST {path} HTTP/1.0\r\n" f"POST {path} HTTP/1.0\r\n"
@@ -291,9 +369,9 @@ def _ecat_post(port, path, body, timeout):
"\r\n" "\r\n"
).encode("ascii") + (body or b"") ).encode("ascii") + (body or b"")
sock = socket.create_connection(("127.0.0.1", port)) # 注意: 不传 timeout sock = socket.create_connection(("127.0.0.1", port), timeout=connect_timeout)
try: try:
sock.settimeout(timeout) # 连上之后再设: 只作用于收发, 不影响 connect sock.settimeout(timeout) # 连上之后再设: 只作用于收发, 不影响已完成的 connect
sock.sendall(payload) sock.sendall(payload)
chunks = [] chunks = []
while True: while True:
@@ -323,16 +401,28 @@ def _ecat_proxy(path, body, timeout=6.0):
连不上时返回 503 + 与后端同构的 {"ok":false,"log":[...]} 信封, 连不上时返回 503 + 与后端同构的 {"ok":false,"log":[...]} 信封,
这样前端只有一条错误处理路径 —— 它不需要知道中间还隔着一层代理。 这样前端只有一条错误处理路径 —— 它不需要知道中间还隔着一层代理。
2026-09-12: 先用 _port_taken 快探,全灭则立刻 _ecat_autostart;
不再对 6 个死端口各等 timeout 秒(那样要 30s+ 才会拉起服务)。
""" """
global g_ecat_port global g_ecat_port
# /ecat/api/motor/state → /api/motor/state (只剥掉 "/ecat" 前缀, 保留 /api) # /ecat/api/motor/state → /api/motor/state (只剥掉 "/ecat" 前缀, 保留 /api)
target = "/api/" + path[len(ECAT_PROXY_PREFIX):] target = "/api/" + path[len(ECAT_PROXY_PREFIX):]
# 端口尝试顺序: 先缓存口, 失败再试其余漂移口(带短连接超时), 全灭才拉起。
# **健康路径上绝不能对死端口做预探测** —— 本机 Windows 对回环死端口的
# connect 不是秒拒(阻塞 ~2s, settimeout(0.3) 吃满 0.3s 回 WOULDBLOCK),
# 2026-09-12 实测每请求预探 5 个死端口 = 恒定 1.5s, 电机页"本页请求耗时
# 1517ms"、刷新被按到 0.66Hz 皆此。缓存口命中的代价只有 ~0.6ms。
ports = ([g_ecat_port] if g_ecat_port else []) + \ ports = ([g_ecat_port] if g_ecat_port else []) + \
[p for p in ECAT_PORTS if p != g_ecat_port] [p for p in ECAT_PORTS if p != g_ecat_port]
last = None last = None
for port in ports: for port in ports:
try: try:
status, data = _ecat_post(port, target, body, timeout) # 缓存口连不上大概率是后端挪了/死了, 阻塞 connect ~2s 可接受;
# 其余候选口用 0.3s 连接超时, 避免逐口 2s(全灭时 12s+)。
status, data = _ecat_post(
port, target, body, timeout,
connect_timeout=None if port == g_ecat_port else 0.3)
g_ecat_port = port g_ecat_port = port
return status, data return status, data
except Exception as e: # 连不上/超时 → 试下一个端口 except Exception as e: # 连不上/超时 → 试下一个端口
@@ -340,10 +430,13 @@ def _ecat_proxy(path, body, timeout=6.0):
if g_ecat_port == port: if g_ecat_port == port:
g_ecat_port = None # 缓存端口失效, 下轮重新全量探测 g_ecat_port = None # 缓存端口失效, 下轮重新全量探测
# 一个端口都不通 → 服务没在跑, 按需自动拉起后重试一次。 # 无存活端口或全失败 → 单实例拉起后重试一次
# 放在这里而不是每次请求前探测: 正常情况(服务在跑)一次都不会走到, try:
# 不给高频的 /motor/state 轮询增加任何开销。 port, start_logs = _ecat_autostart()
port, start_logs = _ecat_autostart() except Exception as e:
last = e
port = None
start_logs = [f"autostart 异常: {type(e).__name__}: {e}"]
if port: if port:
try: try:
status, data = _ecat_post(port, target, body, timeout) status, data = _ecat_post(port, target, body, timeout)
@@ -522,6 +615,10 @@ class Handler(SimpleHTTPRequestHandler):
self.send_response(200) self.send_response(200)
self.send_header("Content-Type", "text/html; charset=utf-8") self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(body))) self.send_header("Content-Length", str(len(body)))
# WebView2 无 Cache-Control 时按启发式缓存 HTML,而注入刷新只是
# load_url(同 URL) 不回源——改了页面窗口里永远还是旧版(实测)。
# no-cache = 可缓存但每次必须 revalidate,配合 Last-Modified 变更即回源。
self.send_header("Cache-Control", "no-cache")
self.end_headers() self.end_headers()
self.wfile.write(body) self.wfile.write(body)
else: else:
@@ -552,8 +649,14 @@ class Handler(SimpleHTTPRequestHandler):
timeout = 30.0 if path.endswith(("/op/start", "/motor/start", timeout = 30.0 if path.endswith(("/op/start", "/motor/start",
"/flash", "/eeprom/write", "/flash", "/eeprom/write",
"/eeprom/read", "/eeprom/verify")) else 6.0 "/eeprom/read", "/eeprom/verify")) else 6.0
status, body = _ecat_proxy(path, raw, timeout) try:
_note_ecat_loop(path, status, body) status, body = _ecat_proxy(path, raw, timeout)
_note_ecat_loop(path, status, body)
except Exception as e:
# 窗口模式 stderr 是父进程 PIPE,写满会冻死服务 —— 不要 traceback
status, body = 503, json.dumps(
{"ok": False, "log": [f"代理异常: {type(e).__name__}: {e}"]},
ensure_ascii=False).encode("utf-8")
self._post_json(body, status) self._post_json(body, status)
return return
self.send_error(404) self.send_error(404)
@@ -900,7 +1003,10 @@ def _run_window(webview, args):
"--parent-pid", str(os.getpid())], "ESP32 调试控制台启动失败") "--parent-pid", str(os.getpid())], "ESP32 调试控制台启动失败")
g_child = child g_child = child
g_port = port g_port = port
url = f"http://127.0.0.1:{port}/tabs.html" # 启动 URL 带启动时间戳: 每次开窗必然缓存 miss。老缓存条目没有 no-cache
# 指令时 WebView2 仍可能直接复用,时间戳强制绕开——配合 HTML 响应的
# Cache-Control: no-cache,此后改页面窗口刷新即可见。
url = f"http://127.0.0.1:{port}/tabs.html?v={int(time.time())}"
for _ in range(50): # 等 HTTP 就绪(最多 ~10s) for _ in range(50): # 等 HTTP 就绪(最多 ~10s)
try: try:
urllib.request.urlopen(url, timeout=1) urllib.request.urlopen(url, timeout=1)
@@ -911,6 +1017,10 @@ def _run_window(webview, args):
os._exit(1) os._exit(1)
time.sleep(0.2) time.sleep(0.2)
# 保留 GPU 加速(页面依赖)。曾默认 --disable-gpu,用户要求恢复。
# 白屏现象:首帧可见后立刻全白,更像 renderer/GPU 在 loaded 后崩溃,
# 或 watchdog 误 load_url 二次导航 —— 此处不再自动二次 load。
def _monitor(): def _monitor():
while True: while True:
if focus_evt.is_set(): if focus_evt.is_set():
@@ -923,17 +1033,17 @@ def _run_window(webview, args):
threading.Thread(target=_monitor, daemon=True).start() threading.Thread(target=_monitor, daemon=True).start()
g_win = webview.create_window("ESP32 调试控制台 — DCU", url, g_win = webview.create_window("ESP32 调试控制台 — DCU", url,
width=1600, height=1000, min_size=(1100, 720), width=1600, height=1000, min_size=(1100, 720),
js_api=_GuiApi(), hidden=True, text_select=True) js_api=_GuiApi(), hidden=False, text_select=True)
print(f"桌面窗口模式: {url} (关窗即退出)") print(f"桌面窗口模式: {url} (关窗即退出)")
def _show_watchdog(): def _show_watchdog():
# 页面加载后由 JS 调 page_loaded → show; 若 20s 仍未显示则强制 show # 只负责前台,不再二次 load_url(二次导航会造成「闪一下再白」)
deadline = time.time() + 20 deadline = time.time() + 15
while time.time() < deadline: while time.time() < deadline:
if g_win.events.loaded.is_set(): if g_win.events.loaded.is_set():
time.sleep(1) time.sleep(0.3)
break break
time.sleep(0.5) time.sleep(0.4)
try: try:
g_win.show() g_win.show()
except Exception: except Exception:
+229 -47
View File
@@ -29,6 +29,7 @@
STATUS 0x000N0100 · CMD 0x000N0200(DLC32) · CMD_CONFIG 0x000N0301(DLC24) STATUS 0x000N0100 · CMD 0x000N0200(DLC32) · CMD_CONFIG 0x000N0301(DLC24)
GET_VERSION 请求/回报共用 0x000N00FF(同 ID, DLC24) GET_VERSION 请求/回报共用 0x000N00FF(同 ID, DLC24)
CANFD_BOOT 0x000N0000 · NODE_ID SET/GET 0x000N0400/0401 CANFD_BOOT 0x000N0000 · NODE_ID SET/GET 0x000N0400/0401
(扩展格式 DLC4/2:选择器 0x0000=节点ID · 0x0100/0x0101=STATUS/CONFIG 刷新周期 ms)
═══════════════════════════════════════════════════════════════ */ ═══════════════════════════════════════════════════════════════ */
"use strict"; "use strict";
@@ -180,7 +181,7 @@ function getSharedDeviceIp() {
const PERSIST_FIELDS = [ const PERSIST_FIELDS = [
"modeSelect", "inVoltage", "inVelocity", "inAngle", "modeSelect", "inVoltage", "inVelocity", "inAngle",
"pidCurP", "pidVelP", "pidAngP", "repeatPeriod", "chSelect", "pidCurP", "pidVelP", "pidAngP", "repeatPeriod", "chSelect",
"nodeIdInput", "nodeIdInput", "periodStatusInput", "periodConfigInput",
]; ];
function saveUiState() { function saveUiState() {
@@ -1610,8 +1611,14 @@ nodeListEl.addEventListener("keydown", (e) => {
const RECV_HZ_WINDOW = 10; const RECV_HZ_WINDOW = 10;
/* STATUS 面板右上角频率:基于实际数据到达时间计算,不用用户设定的数值做死算。 /* STATUS 面板右上角频率:基于实际数据到达时间计算,不用用户设定的数值做死算。
* 保持近 RECV_HZ_WINDOW 次的到达时间戳滑动窗口,平均间隔 → Hz。 * 要显示的就是**网页实际刷新率**(数据每秒真到几次):电机 CAN 回报率可能高于
* 链路承载(WiFi 慢于电机时界面拿不到中间帧),按电机回报率显示会谎报;
* 按到达时间实测则快链路自动跑出高值、慢链路如实反映卡顿。
* 保持近 RECV_HZ_WINDOW 次的到达时间戳滑动窗口,平均间隔 → Hz;
* 窗口未满时回退到按 refreshIntervalMs 理论值(首次显示前不空窗)。 */ * 窗口未满时回退到按 refreshIntervalMs 理论值(首次显示前不空窗)。 */
function hzText(hz) {
return (hz >= 10 ? hz.toFixed(0) : hz.toFixed(1)) + " Hz";
}
function statusHzText() { function statusHzText() {
const ts = state.recvTs; const ts = state.recvTs;
/* 过滤掉旧窗口残留(与最近一次间隔超过 2× 设定间隔的视为旧数据丢弃)。 /* 过滤掉旧窗口残留(与最近一次间隔超过 2× 设定间隔的视为旧数据丢弃)。
@@ -1626,16 +1633,12 @@ function statusHzText() {
if (ts.length >= 2) { if (ts.length >= 2) {
const span = ts[ts.length - 1] - ts[0]; const span = ts[ts.length - 1] - ts[0];
const avg = span / (ts.length - 1); const avg = span / (ts.length - 1);
if (avg > 0 && avg < 60000) { if (avg > 0 && avg < 60000) return hzText(1000 / avg);
const hz = 1000 / avg;
return (hz >= 10 ? hz.toFixed(0) : hz.toFixed(1)) + " Hz";
}
} }
/* 窗口数据不足时回退到理论值,不让面板空着。 /* 窗口数据不足时回退到理论值,不让面板空着。
* 不限速档没有有意义的理论值(6ms≠166Hz),显示 "…" 等实测数据到齐。 */ * 不限速档没有有意义的理论值(6ms≠166Hz),显示 "…" 等实测数据到齐。 */
if (isUnthrottled(iv)) return "\u2026 Hz"; if (isUnthrottled(iv)) return "\u2026 Hz";
const hz = 1000 / iv; return hzText(1000 / iv);
return (hz >= 10 ? hz.toFixed(0) : hz.toFixed(1)) + " Hz";
} }
function updateNodeLabels() { function updateNodeLabels() {
const n = state.selectedNode; const n = state.selectedNode;
@@ -2729,7 +2732,217 @@ $("#queryVerBtn").addEventListener("click", async () => {
); );
}); });
/* ═══════════════════════ 节点 ID(DBC:SET 0x000N0400 / GET 0x000N0401,DLC 2) ═══════════════════════ */ /* ═══════════ 节点 ID / 反馈刷新率参数(SET 0x000N0400 / GET 0x000N0401) ═══════════
* 电机 v0.4.1 起支持扩展格式:
* SET DLC4 [选择器 u16 LE][值 u16 LE] → 单播回 5B ACK [status][选择器][值]
* GET DLC2 [选择器 u16 LE] → 回 4B [选择器][值]
* 选择器:0x0000=节点 ID(仅单播)、0x0100/0x0101=STATUS/CONFIG 帧刷新周期 ms
* (0=停发;电机写 Flash 掉电保存,SET 即时生效并清发送分频计数器)。
* SET DLC1~2 与 GET DLC<=1 保持旧格式不变;旧固件对 GET 任何 DLC 都回 2B 节点
* ID,对 SET DLC4 按旧语义把前两字节当节点号 —— 刷新率选择器(≥0x0100)远超
* 合法范围 1~9,必被旧固件 NAK,无害。
*
* 回执取法(两链路不同,见 queryParam/setParam):
* WiFi —— GD32 的 0xC0 ACK 只有 1 字节状态、不带电机回帧;回帧落在 GD32
* per-ID 快照表(0xC1),经 fetchFeedbackFull 按需拉取。同一 ID 的
* 回帧快照只存最新一条 → 一次快照只配一次请求(STATUS/CONFIG
* 分开读写,不做同 ID 批量)。
* EtherCAT —— 回帧异步经批量接收区(motor/rx)回来,takeNodeIdReport 取走
* 即清,按长度区分 4B 扩展 / 2B 旧格式 / 5B SET ACK。 */
/* 参数选择器(= 电机 config.h 的 CANFD_PARAM_NODE_ID / 反馈帧基础 ID) */
const PARAM_SEL_NODE_ID = 0x0000;
const PARAM_SEL_STATUS = 0x0100; /* STATUS(0x000N0100) 帧刷新周期选择器 */
const PARAM_SEL_CONFIG = 0x0101; /* CONFIG(0x000N0101) 帧刷新周期选择器 */
function u16LeAt(bytes, i) { return bytes[i] | (bytes[i + 1] << 8); }
/** WiFi:从 GD32 per-ID 快照表取最近一帧指定 ID 的数据(请求-应答回执通道)。
* 回帧在请求发出后 ~1ms 落表,这里留一个全量查询(~12-24ms)的窗口再拉。 */
async function readReplyFromSnapshot(replyId) {
await sleep(80);
let fb;
try { fb = await state.tp.fetchFeedbackFull(); } catch (e) { return null; }
const ch = (fb && fb.channels) ? fb.channels[state.ch] : null;
if (!ch || !ch.valid || !Array.isArray(ch.entries)) return null;
const hit = ch.entries.find((e) => e && Number(e.id) === replyId);
return hit ? hexToBytes(hit.data) : null;
}
/** ECAT:应答异步经批量接收区回来,短轮询取走即清(与旧节点 ID 读取同款)。 */
async function takeReplyPolling(node, waitMs) {
if (!state.tp.takeNodeIdReport) return null;
const deadline = nowMs() + waitMs;
while (nowMs() < deadline) {
await sleep(120);
const hex = state.tp.takeNodeIdReport(node);
if (hex) return hexToBytes(hex);
}
return null;
}
/**
* GET 0x000N0401 扩展格式:查选择器对应值。
* @returns {ok, val?, oldFw?, desc?} oldFw=true 表示旧固件(只回 2B 节点 ID,
* 没有刷新率参数概念,val 即节点号,调用方不要当周期用)。
*/
async function queryParam(node, selector) {
const bytes = new Uint8Array([selector & 0xFF, (selector >> 8) & 0xFF]);
const id = ID_GET_NODE_ID(node);
const res = await sendOneFrameWhenFree(id, 2, bytesToHexStr(bytes));
if (!res.ok) {
return { ok: false, desc: res.desc || "发送失败", networkError: res.networkError, busy: res.busy };
}
let rb = null;
const r = res.results && res.results[0];
if (r && r.recv && r.recv_len >= 2) {
const b = hexToBytes(r.recv);
if (b.length === 2 || b.length === 4) rb = b; /* 未来 GD32 ACK 带回帧时直读 */
}
if (!rb && state.tp.fetchFeedbackFull) rb = await readReplyFromSnapshot(id);
if (!rb) rb = await takeReplyPolling(node, 1500);
if (!rb) return { ok: false, desc: "未收到应答(电机离线或未响应)" };
if (rb.length >= 4) {
const sel = u16LeAt(rb, 0), val = u16LeAt(rb, 2);
if (sel !== selector) {
return { ok: false, desc: `应答选择器不符(0x${sel.toString(16).padStart(4, "0")})` };
}
return { ok: true, val };
}
if (rb.length === 2) return { ok: true, oldFw: true, val: u16LeAt(rb, 0) };
return { ok: false, desc: "应答格式异常" };
}
/**
* SET 0x000N0400 扩展格式(DLC4):设置选择器对应值。电机先写 Flash、成功后
* 才生效并回 ACK;status=0 为 NACK(Flash 写入失败)。
*/
async function setParam(node, selector, value) {
const bytes = new Uint8Array([
selector & 0xFF, (selector >> 8) & 0xFF,
value & 0xFF, (value >> 8) & 0xFF,
]);
const id = ID_SET_NODE_ID(node);
const res = await sendOneFrameWhenFree(id, 4, bytesToHexStr(bytes));
if (!res.ok) {
return { ok: false, desc: res.desc || "发送失败", networkError: res.networkError, busy: res.busy };
}
let rb = null;
const r = res.results && res.results[0];
if (r && r.recv && r.recv_len >= 5) rb = hexToBytes(r.recv);
if (!rb && state.tp.fetchFeedbackFull) rb = await readReplyFromSnapshot(id);
if (!rb) rb = await takeReplyPolling(node, 1500);
if (!rb) return { ok: false, desc: "未收到应答(电机离线或未响应)" };
if (rb.length >= 5) {
const st = rb[0], sel = u16LeAt(rb, 1);
if (sel !== selector) {
return { ok: false, desc: `应答选择器不符(0x${sel.toString(16).padStart(4, "0")})` };
}
return st === 1 ? { ok: true } : { ok: false, desc: "电机 NACK(Flash 写入失败或参数非法)" };
}
/* 3B NAK(旧格式)或其它长度:多半是电机固件过旧,把 DLC4 当旧格式节点设置 */
return { ok: false, desc: "应答格式异常(电机固件不支持刷新率参数?需 v0.4.1+)" };
}
/* ── 频率 ↔ 周期换算(电机按 1ms 节拍分频,上限 1000Hz;0=停发) ──
* 界面一律用 Hz,协议值是周期 ms:写入时 hz→ms 取整,读取时 ms→hz 反算。
* 取整有损(300Hz→3ms→实际 333.3Hz),所以读回后如实显示换算值。 */
const PERIOD_MAX_HZ = 1000;
function hzFromPeriodMs(ms) {
return ms === 0 ? 0 : 1000 / ms;
}
/* 读回的周期 → 显示文本(0=停发;<10Hz 带一位小数) */
function periodToHzText(ms) {
if (ms === 0) return "停发";
const hz = 1000 / ms;
return (hz >= 10 ? hz.toFixed(0) : hz.toFixed(1)) + " Hz";
}
/* 读回的周期 → 填进 Hz 输入框的值(保留两位小数,够表达 0.01Hz 粒度) */
function periodToHzInput(ms) {
if (ms === 0) return 0;
return Math.round((1000 / ms) * 100) / 100;
}
function periodRowEls(kind) {
return kind === "status"
? { sel: PARAM_SEL_STATUS, name: "STATUS", input: $("#periodStatusInput") }
: { sel: PARAM_SEL_CONFIG, name: "CONFIG", input: $("#periodConfigInput") };
}
async function readPeriodRow(kind) {
if (!state.connected) { toast("设备未连接", "err"); return; }
if (!requireNode()) return;
if (inBusCooldown()) return;
const node = state.selectedNode;
const row = periodRowEls(kind);
const result = $("#periodResult");
const id = ID_GET_NODE_ID(node);
result.textContent = `${row.name} 读取中…`;
const r = await queryParam(node, row.sel);
const time = new Date().toLocaleTimeString("zh-CN", { hour12: false });
$("#sbLastSend").textContent = `最近发送: GET 刷新率(${row.name}) → ${hexId(id)} [2B] … ${time}`;
if (r.ok && !r.oldFw) {
row.input.value = periodToHzInput(r.val);
result.textContent = `${row.name} 刷新率 = ${periodToHzText(r.val)}(周期 ${r.val} ms) · 节点 ${node}`;
toast(`${row.name} 刷新率已读取: ${periodToHzText(r.val)}`, "ok");
} else if (r.ok && r.oldFw) {
result.textContent = `${row.name} 读取失败:电机固件过旧(回报的是节点 ID),不支持刷新率参数`;
toast("电机固件不支持刷新率参数(需 v0.4.1+)", "warn");
} else if (!r.networkError && !r.busy) {
result.textContent = `${row.name} 读取失败: ${r.desc}`;
toast(`${row.name} 读取失败: ${r.desc}`, "err");
}
}
async function writePeriodRow(kind) {
if (!state.connected) { toast("设备未连接", "err"); return; }
if (!requireNode()) return;
if (inBusCooldown()) return;
const node = state.selectedNode;
const row = periodRowEls(kind);
const result = $("#periodResult");
/* 输入是 Hz;协议值是周期 ms = round(1000/Hz)(电机 1ms 节拍分频) */
const hz = parseFloat(row.input.value);
if (!Number.isFinite(hz) || hz < 0 || hz > PERIOD_MAX_HZ) {
toast(`${row.name} 刷新率范围 0~${PERIOD_MAX_HZ} Hz(0=停发)`, "err");
return;
}
const period = hz === 0 ? 0 : Math.round(1000 / hz);
if (hz > 0 && (period < 1 || period > 0xFFFF)) {
toast(`${row.name} 刷新率过低(周期超出 1~65535 ms)`, "err");
return;
}
const warn = (hz === 0 && kind === "status")
? "STATUS 帧将停发,右侧状态面板会显示离线(电机仍可控可刷写)。"
: (hz === 0 ? "CONFIG 帧将停发,目标值回显将不可用。" : "");
const ok = await showModal("设置反馈刷新率",
`将节点 ${node} 的 ${row.name} 帧刷新率设为 ${row.input.value} Hz(周期 ${period} ms),立即生效并写入电机 Flash。${warn}`);
if (!ok) return;
const id = ID_SET_NODE_ID(node);
result.textContent = `${row.name} 写入中…`;
const r = await setParam(node, row.sel, period);
const time = new Date().toLocaleTimeString("zh-CN", { hour12: false });
$("#sbLastSend").textContent = `最近发送: SET 刷新率(${row.name}) → ${hexId(id)} [4B] ${period}ms(${row.input.value}Hz) … ${time}`;
if (r.ok) {
state.txCount++;
result.textContent = `${row.name} 刷新率 = ${periodToHzText(period)}(周期 ${period} ms)已生效并写入 Flash · 节点 ${node}`;
toast(`${row.name} 刷新率已设为 ${periodToHzText(period)}`, "ok");
saveUiState();
} else if (!r.networkError && !r.busy) {
result.textContent = `${row.name} 写入失败: ${r.desc}`;
toast(`${row.name} 写入失败: ${r.desc}`, "err");
}
}
$("#getPeriodStatusBtn").addEventListener("click", () => readPeriodRow("status"));
$("#setPeriodStatusBtn").addEventListener("click", () => writePeriodRow("status"));
$("#getPeriodConfigBtn").addEventListener("click", () => readPeriodRow("config"));
$("#setPeriodConfigBtn").addEventListener("click", () => writePeriodRow("config"));
/* ── 节点 ID 设置/读取(旧格式 DLC2 设置保持不变;读取走扩展选择器 0x0000) ── */
$("#setNodeIdBtn").addEventListener("click", async () => { $("#setNodeIdBtn").addEventListener("click", async () => {
if (!state.connected) { toast("设备未连接", "err"); return; } if (!state.connected) { toast("设备未连接", "err"); return; }
if (!requireNode()) return; if (!requireNode()) return;
@@ -2739,7 +2952,7 @@ $("#setNodeIdBtn").addEventListener("click", async () => {
const cur = state.selectedNode; const cur = state.selectedNode;
const ok = await showModal("设置节点 ID", `将节点 ${cur} 的地址修改为 ${newId},CAN ID 前缀将变为 0x000${nodeHex(newId)}xxxx。修改立即生效并写入 EEPROM。`); const ok = await showModal("设置节点 ID", `将节点 ${cur} 的地址修改为 ${newId},CAN ID 前缀将变为 0x000${nodeHex(newId)}xxxx。修改立即生效并写入 EEPROM。`);
if (!ok) return; if (!ok) return;
/* CANFD_SET_NODE_ID:NodeID_Set 0|16@1+ = 新节点号 u16 LE */ /* CANFD_SET_NODE_ID 旧格式 DLC2:NodeID_Set 0|16@1+ = 新节点号 u16 LE */
const bytes = new Uint8Array(2); const bytes = new Uint8Array(2);
bytes[0] = newId & 0xFF; bytes[0] = newId & 0xFF;
bytes[1] = (newId >> 8) & 0xFF; bytes[1] = (newId >> 8) & 0xFF;
@@ -2763,47 +2976,16 @@ $("#getNodeIdBtn").addEventListener("click", async () => {
if (!requireNode()) return; if (!requireNode()) return;
if (inBusCooldown()) return; if (inBusCooldown()) return;
const cur = state.selectedNode; const cur = state.selectedNode;
/* CANFD_GET_NODE_ID 0x000N0401 DLC2:NodeID_Request 0|16@1+ */ /* 扩展格式:选择器 0x0000=节点 ID。电机 v0.4.1 回 4B [选择器][值];
const bytes = new Uint8Array(2); * 旧固件忽略载荷回 2B 节点号,queryParam 按长度分派,两者都兼容。 */
bytes[0] = cur & 0xFF;
bytes[1] = 0;
const id = ID_GET_NODE_ID(cur); const id = ID_GET_NODE_ID(cur);
const res = await sendOneFrameWhenFree(id, 2, bytesToHexStr(bytes)); const res = await queryParam(cur, PARAM_SEL_NODE_ID);
const time = new Date().toLocaleTimeString("zh-CN", { hour12: false }); const time = new Date().toLocaleTimeString("zh-CN", { hour12: false });
$("#sbLastSend").textContent = `最近发送: GET_NODE_ID → ${hexId(id)} [2B] cur=${cur} … ${time}`; $("#sbLastSend").textContent = `最近发送: GET_NODE_ID → ${hexId(id)} [2B] sel=0x0000 … ${time}`;
if (res.ok) { if (res.ok) {
state.txCount++; state.txCount++;
let reported = null; $("#nodeIdResult").textContent = `节点 ${cur} 回报 ID: ${res.val} · ${hexId(id)}`;
const r = res.results && res.results[0]; toast(`GET_NODE_ID → Node ${cur} 已读取: ${res.val}`, "ok");
if (r && r.recv) {
/* WiFi:SPI 请求-应答,回执里直接带 recv */
const rb = hexToBytes(r.recv);
if (rb.length >= 2) reported = rb[0] | (rb[1] << 8);
else if (rb.length >= 1) reported = rb[0];
} else if (state.tp.takeNodeIdReport) {
/* EtherCAT:应答是异步经批量接收区回来的,等几拍再取。
* 不能像 WiFi 那样直接读回执 —— 那里没有 recv 字段,
* 早期版本会把"没收到应答"显示成"回报=当前节点号",等于假装成功。 */
const deadline = nowMs() + 1500;
while (nowMs() < deadline) {
await sleep(120);
const hex = state.tp.takeNodeIdReport(cur);
if (hex) {
const rb = hexToBytes(hex);
if (rb.length >= 2) reported = rb[0] | (rb[1] << 8);
else if (rb.length >= 1) reported = rb[0];
break;
}
}
}
if (reported == null) {
$("#nodeIdResult").textContent = `节点 ${cur} 未回报 ID(已发出请求 ${hexId(id)})`;
toast(`GET_NODE_ID → Node ${cur} 无应答`, "warn");
return;
}
$("#nodeIdResult").textContent =
`节点 ${cur} 回报 ID: ${reported} · ${hexId(id)}`;
toast(`GET_NODE_ID → Node ${cur} 已读取: ${reported}`, "ok");
} else if (!res.networkError && !res.busy) { } else if (!res.networkError && !res.busy) {
$("#nodeIdResult").textContent = `读取失败: ${res.desc}`; $("#nodeIdResult").textContent = `读取失败: ${res.desc}`;
toast(`GET_NODE_ID 失败: ${res.desc}`, "err"); toast(`GET_NODE_ID 失败: ${res.desc}`, "err");
+33
View File
@@ -17,6 +17,10 @@
* 见 UNTHROTTLED_MAX_MS。 * 见 UNTHROTTLED_MAX_MS。
* 共用一个值的实际后果:ECAT 的快值带到 WiFi 上会让 HTTP 请求排队 * 共用一个值的实际后果:ECAT 的快值带到 WiFi 上会让 HTTP 请求排队
* (tickStuck 常亮黄),WiFi 的 250ms 带到 ECAT 上又白白浪费了本地快照的低延迟。 * (tickStuck 常亮黄),WiFi 的 250ms 带到 ECAT 上又白白浪费了本地快照的低延迟。
*
* 后半部分还住着一块与「实测数据率」相关的纯逻辑(同样为了脱离 DOM 自测):
* staleLimitMs —— EtherCAT 槽判活的离线阈值,随实测 seq 间隔自适应,
* transport-ecat.js 用它,慢速回报的电机不再被固定 2s 误判离线。
*/ */
"use strict"; "use strict";
@@ -105,3 +109,32 @@ export function pickRefresh(map, tpId) {
if (Number.isFinite(n)) return clampFor(tpId, n); if (Number.isFinite(n)) return clampFor(tpId, n);
return REFRESH_DEFAULTS[tpId] || REFRESH_DEFAULTS.wifi; return REFRESH_DEFAULTS[tpId] || REFRESH_DEFAULTS.wifi;
} }
/* ═══════════ ECAT 槽判活阈值:随实测 seq 间隔自适应 ═══════════
*
* 背景:电机 CAN 回报率可变(可在电机侧调档),回报周期调到秒级后 seq 几秒
* 才动一次,固定 2s 判活阈值会在两次**正常**回报之间把电机判离线。
* 注:STATUS 面板的 Hz 刻意**不**按 seq 实测电机回报率 —— 面板要显示的是
* 网页实际刷新率(数据到达口径),WiFi 慢于电机时按电机档位显示就是谎报;
* seq 只用于这里的判活。 */
/**
* 槽 seq 停涨多久算离线的**下限**(ms),生效阈值 = staleLimitMs(实测间隔)。
* 下限 2s 的来由(历史):要大于「扫完全表 + 一点抖动」—— 1.6.15 滑窗时代
* period 10ms × 6 周期 = 60ms,2s 有充足余量;1.6.21 密排后一拍整轮,更不是问题。
*/
export const STALE_OFFLINE_MS = 2000;
/** 生效阈值 = 实测 seq 变化间隔(EWMA) × 该系数。3 倍 ≈ 连丢 3 拍才判离线。 */
export const STALE_IV_FACTOR = 3;
/** 阈值上限:防一次长停顿(后台挂起/总线冻结恢复)把 EWMA 拉飞后迟迟不判离线。 */
export const STALE_MAX_MS = 30000;
/**
* 该槽的离线阈值:clamp(3 × EWMA(实测 seq 变化间隔), 2s, 30s)。
* 电机 CAN 回报率可变:回报周期调到秒级后,固定 2s 会在两次正常回报之间
* 把电机判离线(LED 闪烁/波形虚线/面板切离线层),阈值必须跟着间隔放大。
* ivMs = 0(还没观察到第二次变化)时返回下限 2s,与旧固定阈值行为一致。
*/
export function staleLimitMs(ivMs) {
return Math.max(STALE_OFFLINE_MS, Math.min(STALE_MAX_MS, (ivMs || 0) * STALE_IV_FACTOR));
}
+35 -18
View File
@@ -32,24 +32,31 @@
*/ */
"use strict"; "use strict";
/** 单个 PDO 周期回传的状态槽数(与 ecat_native.c 的 MOT_SLOTS_PER_PDO 一致; /* 判活阈值纯逻辑(随实测 seq 间隔自适应)住在 refresh-rate.js 的
* 固件 1.6.4 起槽长 56B = 8B 头 + 48B STATUS,段 2 的 280B 正好放 5 个) */ * 「ECAT 槽判活阈值」一节,纯函数以便脱离 DOM 自测。 */
const MOT_SLOTS_PER_PDO = 5; import { staleLimitMs } from "./refresh-rate.js";
/** 状态槽总数 = 9 节点 × 3 通道(ecat_native.c 的 MOTOR_SLOT_COUNT) */
/** 单个 PDO 周期回传的状态槽数。
* 固件 1.6.21 起段 2 密排整轮:一拍带齐总线电机(最多 9),空槽不占位。
* 旧 1.6.15 滑窗为 5 槽;后端 decode_input 按 magic@35 自适应,本常量仅
* 影响"扫完全表"的展示口径,不再决定能否拿齐电机。 */
const MOT_SLOTS_PER_PDO = 9;
/** 状态槽总数 = 9 节点 × 3 通道(ecat_native.c 的 MOTOR_SLOT_COUNT)。
* 1.6.21 密排后每节点只报最新一条,经 (node-1)*3+ch 落回本表。 */
const MOTOR_SLOT_COUNT = 27; const MOTOR_SLOT_COUNT = 27;
/** 扫完全表所需周期数(= ⌈MOTOR_SLOT_COUNT / MOT_SLOTS_PER_PDO⌉ = ⌈27/5⌉ = 6)。 /** 扫完全表所需周期数。1.6.21 密排下恒 1(一拍整轮);旧滑窗为 ⌈27/5⌉=6。
* 注意:这**只**是开机首圈全表扫描的窗口数,不是稳态下单槽的刷新分频 —— * 显示"数据多新"请用快照里的 slot_period_ms。 */
* 后端会把窗口收缩到只盖有主的槽(窗口自适应),稳态下在线电机 const MOTOR_SCAN_CYCLES = 1;
* 每个 PDO 周期都刷新。要显示"数据多新"请用快照里的 slot_period_ms。 */
const MOTOR_SCAN_CYCLES = 6;
/** /**
* 槽 seq 停止增长多久算离线(ms)。 * 槽 seq 判活阈值:**不写死** —— 随该槽实测 seq 变化间隔自适应
* 为什么需要它:后端的快照把见过的槽**永久累积**,电机拔了也不会消失, * clamp(3×EWMA, 2s, 30s),见 refresh-rate.js 的 staleLimitMs()。
* 只能靠 seq 是否还在涨来判活。阈值要大于"扫完全表 + 一点抖动",否则 * 为什么需要判活:后端的快照把见过的槽**永久累积**,电机拔了也不会消失,
* 慢周期下会把正常电机误判离线:period 10ms × 6 周期 = 60ms,取 2s 有充足余量。 * 只能靠 seq 是否还在涨来判活。为什么必须自适应:电机 CAN 回报率可变,
* 回报周期调到秒级后,固定 2s 会在两次正常回报之间把正常电机判离线
* (LED 闪烁、波形画虚线、面板切离线覆盖层);下限 2s 仍兜底快周期
* (要大于"扫完全表 + 一点抖动",1.6.15 滑窗时代 period 10ms × 6 周期 = 60ms)。
*/ */
const STALE_OFFLINE_MS = 2000;
/** 位掩码 → 逻辑处理器号列表。JS 移位只有 32 位,所以用除法逐位取。 */ /** 位掩码 → 逻辑处理器号列表。JS 移位只有 32 位,所以用除法逐位取。 */
function pcoreList(mask) { function pcoreList(mask) {
@@ -101,7 +108,7 @@ export function createEcatTransport(ctx) {
const configs = new Map(); // node -> 24B CONFIG hex(0x000N0101) const configs = new Map(); // node -> 24B CONFIG hex(0x000N0101)
const versions = new Map(); // node -> {node, seq, version} 供 fetchVersionNode const versions = new Map(); // node -> {node, seq, version} 供 fetchVersionNode
const nodeIdReports = new Map();// node -> 应答 hex(0x000N0400/0401) const nodeIdReports = new Map();// node -> 应答 hex(0x000N0400/0401)
const seqSeen = new Map(); // "ch:node" -> {seq, at, rx} 判活 + 计更新次数 const seqSeen = new Map(); // "ch:node" -> {seq, at, rx, iv} 判活 + 计更新次数;iv=实测变化间隔 EWMA(ms)
let periodMs = 0; // 后台 PDO 循环周期(由 /api/motor/start 回报) let periodMs = 0; // 后台 PDO 循环周期(由 /api/motor/start 回报)
/* 单个电机状态的真实刷新周期(ms,来自快照的 slot_period_ms)。 /* 单个电机状态的真实刷新周期(ms,来自快照的 slot_period_ms)。
* 与 periodMs 的区别是要点:PDO 周期是"总线跑多快",而某个电机的状态要等 * 与 periodMs 的区别是要点:PDO 周期是"总线跑多快",而某个电机的状态要等
@@ -278,7 +285,8 @@ export function createEcatTransport(ctx) {
/** /**
* 快照 → WiFi 的 /motor/state 契约。 * 快照 → WiFi 的 /motor/state 契约。
* 关键点:online 不能直接用从站给的标志位,还要叠一层 seq 判活(见 STALE_OFFLINE_MS)。 * 关键点:online 不能直接用从站给的标志位,还要叠一层 seq 判活
* (阈值随实测回报周期自适应,见 refresh-rate.js 的 staleLimitMs)。
*/ */
function snapshotToMotorState(snap) { function snapshotToMotorState(snap) {
const now = Date.now(); const now = Date.now();
@@ -288,12 +296,21 @@ export function createEcatTransport(ctx) {
const prev = seqSeen.get(key); const prev = seqSeen.get(key);
let rx = prev ? prev.rx : 0; let rx = prev ? prev.rx : 0;
let at = prev ? prev.at : now; let at = prev ? prev.at : now;
let iv = prev ? prev.iv : 0;
if (!prev || prev.seq !== m.seq) { if (!prev || prev.seq !== m.seq) {
if (prev) {
/* 实测变化间隔 EWMA(α=0.3):判活阈值跟着电机回报率走。电机改回报
* 档后 ~3 次新间隔收敛;突发降速无法预知,收敛前最多闪一次离线。 */
const d = now - prev.at;
iv = iv ? iv + (d - iv) * 0.3 : d;
}
rx = rx + 1; // 本地累计"看到过多少次更新",补 WiFi 的 rx_count 语义 rx = rx + 1; // 本地累计"看到过多少次更新",补 WiFi 的 rx_count 语义
at = now; at = now;
} }
seqSeen.set(key, { seq: m.seq, at, rx }); seqSeen.set(key, { seq: m.seq, at, rx, iv });
const fresh = (now - at) <= STALE_OFFLINE_MS; /* 从站说在线 **且** seq 还在动才算在线:快照永不删除历史槽。
* 阈值随实测回报周期放大(慢速电机不误判),下限 2s 兜底快周期。 */
const fresh = (now - at) <= staleLimitMs(iv);
motors.push({ motors.push({
node: m.node, node: m.node,
ch: m.ch, ch: m.ch,
+8
View File
@@ -192,6 +192,14 @@ export function createWifiTransport(ctx) {
async fetchFeedback() { return getJson("/can/feedback?lite=1", 1500); }, async fetchFeedback() { return getJson("/can/feedback?lite=1", 1500); },
/**
* 全量 CAN 反馈快照(含 per-ID entries)。
* 请求-应答类命令(节点 ID / 反馈刷新率参数 0x0400·0x0401)在 WiFi 链路的
* 回执通道:GD32 的 0xC0 ACK 只有 1 字节状态、不携带电机回帧,回帧落在
* GD32 的 per-ID 快照表里,经这里按需拉取。ECAT 链路不需要(走 motor/rx)。
*/
async fetchFeedbackFull() { return getJson("/can/feedback", 2500); },
/** /**
* 发一批帧。frames = [{id,dlc,data}],opts.ch = 批级通道。 * 发一批帧。frames = [{id,dlc,data}],opts.ch = 批级通道。
* @returns {Promise<object[]>} 已按帧数补齐的 results[] * @returns {Promise<object[]>} 已按帧数补齐的 results[]
+1 -1
View File
@@ -1 +1 @@
4e84a35431a6435613f13d1374f4df8e60082257186562500a0bcf0662fd8b9e dda92d11876f059d27390232e88b5c9fa7c0eb57e442365f5247d4acdefb9b20
-754
View File
@@ -1,754 +0,0 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>LAN9252 通断测试</title>
<style>
/* === WebGL 液态玻璃 (参考 yzrt/html-liquid-glass-effect-webgl) ===
参数:IOR=1.5 / Thickness=50 / Normal=10 / Blur=0 / Highlight=4 / Radius=26
实现:SVG feDisplacementMap 做真实背景像素折射位移 + saturate/contrast 色散
+ 边缘 4px 白色高光带 (Highlight Width=4)
+ 边缘 1px 暗描边 (Normal Strength=10)
所有组件统一用此风格,旧 yzrt .box 多层 inset 阴影已舍弃
*/
:root{
--grid:#E1E1E1;
--text:#f0f0f5;
--text-2:#c8c8d0;
--text-3:#8a8a95;
--separator:rgba(255,255,255,.08);
--blue:#5b9eff;
--green:#30d158;
--red:#ff453a;
--orange:#ff9f0a;
--purple:#bf5af2;
}
*{box-sizing:border-box;margin:0;padding:0;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;transition:.25s;}
html,body{height:100%;}
body{
font-family:"Microsoft YaHei","SF Pro Display","Segoe UI",sans-serif;
color:var(--text);font-size:15px;line-height:1.5;
min-height:100vh;padding:32px 20px 80px;
position:relative;overflow-x:hidden;
/* 彩色 blur 球 + 细点阵 — 让液态玻璃折射位移清晰可见
色球提供颜色参照(折射色散),点阵提供位置参照(位移扭曲) */
background:
/* 细点阵:让折射位移有参照物 */
radial-gradient(circle at 50% 50%, rgba(255,255,255,.12) 1px, transparent 1.5px) 0 0/24px 24px,
/* 彩色 blur 球:中饱和度,不要太鲜艳以免干扰文字 */
radial-gradient(circle at 15% 20%, #4a90e2 0%, transparent 35%),
radial-gradient(circle at 85% 15%, #b06ab3 0%, transparent 38%),
radial-gradient(circle at 75% 80%, #e27d60 0%, transparent 40%),
radial-gradient(circle at 20% 75%, #41d3a3 0%, transparent 38%),
radial-gradient(circle at 50% 50%, #f0a500 0%, transparent 45%),
/* 底色渐变 */
linear-gradient(135deg, #2d3561 0%, #2d4a6b 50%, #1a2a45 100%);
background-attachment:fixed;
}
/* 大色球:增强折射参照(放固定层,不会被玻璃遮挡) */
body::before{
content:"";position:fixed;inset:0;z-index:-1;pointer-events:none;
background:
radial-gradient(circle at 30% 40%, rgba(255,200,100,.18) 0%, transparent 20%),
radial-gradient(circle at 70% 60%, rgba(100,200,255,.15) 0%, transparent 25%);
}
.wrap{max-width:1100px;margin:0 auto;position:relative;z-index:1;}
h1{
font-size:36px;font-weight:700;letter-spacing:-.03em;margin-bottom:6px;color:#fff;
text-shadow:0 2px 8px rgba(0,0,0,.4);
}
.sub{color:rgba(255,255,255,.85);font-size:15px;margin-bottom:24px;font-weight:500;text-shadow:0 1px 4px rgba(0,0,0,.3);}
/* === 玻璃基础类 — 纯 WebGL 液态玻璃(无辅助效果) ===
IOR1.5/Thick50 → SVG feDisplacementMap 背景折射位移 + saturate/contrast 色散
Blur0 → 不模糊
Highlight4 → 边缘 8px 白色高光带(略扩散)
Normal10 → 边缘 2px 暗影
Radius26 → 圆角 */
.glass,.glass-strong{
position:relative;z-index:0;isolation:isolate;
border-radius:26px;
border:none;
background:transparent;
backdrop-filter:url(#glassRefract) saturate(1.4) contrast(1.15);
-webkit-backdrop-filter:url(#glassRefract) saturate(1.4) contrast(1.15);
box-shadow:
inset 0 0 8px rgba(255,255,255,.6), /* Highlight Width=4 → 略向中间扩散 */
inset 0 0 2px rgba(0,0,0,.6), /* Normal Strength=10 */
0 4px 12px rgba(0,0,0,.18);
}
.glass-strong{
box-shadow:
inset 0 0 10px rgba(255,255,255,.7),
inset 0 0 2px rgba(0,0,0,.6),
0 6px 16px rgba(0,0,0,.2);
}
.glass-subtle{
position:relative;z-index:0;isolation:isolate;
border-radius:18px;
border:none;
background:transparent;
backdrop-filter:url(#glassRefract) saturate(1.3) contrast(1.1);
-webkit-backdrop-filter:url(#glassRefract) saturate(1.3) contrast(1.1);
box-shadow:
inset 0 0 6px rgba(255,255,255,.5),
inset 0 0 2px rgba(0,0,0,.5),
0 2px 8px rgba(0,0,0,.14);
}
.card{padding:24px;margin-bottom:16px;}
.card > *{position:relative;z-index:1;}
.card h2{
font-size:22px;font-weight:700;letter-spacing:-.02em;margin-bottom:6px;color:#fff;
text-shadow:0 1px 6px rgba(0,0,0,.3);
}
.card-desc{font-size:13px;color:var(--text-2);margin-bottom:16px;}
/* 警告条 — 纯 WebGL 玻璃 + 橙红染色 */
.warn{
color:#fff;font-size:13px;margin-bottom:24px;padding:14px 18px;
position:relative;z-index:0;isolation:isolate;
background:linear-gradient(135deg,rgba(255,159,10,.35),rgba(255,69,58,.3));
backdrop-filter:url(#glassRefract) saturate(1.4) contrast(1.15);
-webkit-backdrop-filter:url(#glassRefract) saturate(1.4) contrast(1.15);
border:none;
border-radius:26px;
box-shadow:
inset 0 0 8px rgba(255,255,255,.5),
inset 0 0 2px rgba(0,0,0,.5),
0 4px 12px rgba(0,0,0,.16);
display:flex;align-items:flex-start;gap:10px;font-weight:500;
text-shadow:0 1px 3px rgba(0,0,0,.4);
}
.warn > *{position:relative;z-index:1;}
.warn-icon{font-size:16px;flex-shrink:0;}
/* === 按钮 — 纯 WebGL 液态玻璃(无辅助效果) === */
button{
--btn-glow:rgba(0,0,0,.3);
position:relative;z-index:0;isolation:isolate;
background:transparent;
backdrop-filter:url(#glassRefract) saturate(1.4) contrast(1.15);
-webkit-backdrop-filter:url(#glassRefract) saturate(1.4) contrast(1.15);
color:var(--text);
border:none;
border-radius:26px;
padding:11px 22px;
font-size:14px;font-weight:600;font-family:inherit;
cursor:pointer;letter-spacing:-.01em;
box-shadow:
inset 0 0 8px rgba(255,255,255,.6),
inset 0 0 2px rgba(0,0,0,.6),
0 2px 6px rgba(0,0,0,.16);
}
button:hover{
transform:translateY(-1px);
backdrop-filter:url(#glassRefract) saturate(1.5) contrast(1.2);
-webkit-backdrop-filter:url(#glassRefract) saturate(1.5) contrast(1.2);
box-shadow:
inset 0 0 10px rgba(255,255,255,.7),
inset 0 0 2px rgba(0,0,0,.6),
0 4px 10px rgba(0,0,0,.2);
}
button:active{transform:translateY(0);}
/* 液体弹动:点击时像液态玻璃被按压后弹性回弹
注:用 transform-origin:center 保证对称变形;
background 用 background-size:200% 避免渐变被拉伸变形;
box-shadow 跟随收缩,保持形状一致 */
button.pressed{
animation:liquidPress .6s cubic-bezier(.25,1.5,.5,1) 1;
background-size:200% 200% !important;
background-position:center !important;
}
@keyframes liquidPress{
0%{transform:scale(1,1);box-shadow:inset 0 0 8px rgba(255,255,255,.5),inset 0 0 2px rgba(0,0,0,.6),0 3px 10px var(--btn-glow,rgba(0,0,0,.3));}
25%{transform:scale(.90,.86);border-radius:32px;box-shadow:inset 0 0 6px rgba(255,255,255,.4),inset 0 0 2px rgba(0,0,0,.6),0 1px 4px var(--btn-glow,rgba(0,0,0,.3));}
45%{transform:scale(1.06,.94);border-radius:22px;box-shadow:inset 0 0 10px rgba(255,255,255,.6),inset 0 0 2px rgba(0,0,0,.6),0 4px 12px var(--btn-glow,rgba(0,0,0,.3));}
65%{transform:scale(.97,1.03);border-radius:28px;box-shadow:inset 0 0 9px rgba(255,255,255,.55),inset 0 0 2px rgba(0,0,0,.6),0 3px 9px var(--btn-glow,rgba(0,0,0,.3));}
82%{transform:scale(1.01,.99);border-radius:25px;box-shadow:inset 0 0 8px rgba(255,255,255,.5),inset 0 0 2px rgba(0,0,0,.6),0 3px 10px var(--btn-glow,rgba(0,0,0,.3));}
100%{transform:scale(1,1);border-radius:26px;box-shadow:inset 0 0 8px rgba(255,255,255,.5),inset 0 0 2px rgba(0,0,0,.6),0 3px 10px var(--btn-glow,rgba(0,0,0,.3));}
}
button.selected{
--btn-glow:rgba(37,99,235,.45);
background:linear-gradient(135deg,rgba(37,99,235,.82),rgba(29,78,216,.82));
background-size:200% 200%;
color:#fff;
box-shadow:
inset 0 0 8px rgba(255,255,255,.5),
inset 0 0 2px rgba(0,0,0,.6),
0 3px 10px rgba(37,99,235,.45);
}
button.selected:hover{background:linear-gradient(135deg,rgba(29,78,216,.88),rgba(37,99,235,.88));}
button.confirm-btn{
--btn-glow:rgba(22,163,74,.4);
background:linear-gradient(135deg,rgba(22,163,74,.8),rgba(21,128,61,.8));
background-size:200% 200%;
color:#fff;
box-shadow:
inset 0 0 8px rgba(255,255,255,.5),
inset 0 0 2px rgba(0,0,0,.6),
0 3px 10px rgba(22,163,74,.4);
}
button.confirm-btn:hover{background:linear-gradient(135deg,rgba(21,128,61,.88),rgba(22,163,74,.88));}
button.confirm-btn:disabled{
--btn-glow:rgba(0,0,0,.2);
background:rgba(140,140,145,.3);color:rgba(255,255,255,.8);
cursor:not-allowed;filter:brightness(.85);
box-shadow:inset 0 0 2px rgba(0,0,0,.4);transform:none;
}
button.loop-btn{
--btn-glow:rgba(124,58,237,.4);
background:linear-gradient(135deg,rgba(124,58,237,.8),rgba(109,40,217,.8));
background-size:200% 200%;
color:#fff;
box-shadow:
inset 0 0 8px rgba(255,255,255,.5),
inset 0 0 2px rgba(0,0,0,.6),
0 3px 10px rgba(124,58,237,.4);
}
button.loop-btn:hover{background:linear-gradient(135deg,rgba(109,40,217,.92),rgba(124,58,237,.92));}
button.loop-btn.active{
--btn-glow:rgba(234,88,12,.5);
background:linear-gradient(135deg,rgba(234,88,12,.92),rgba(194,65,12,.92));
background-size:200% 200%;
animation:pulse 2s infinite;
}
button.stop-btn{
--btn-glow:rgba(220,38,38,.4);
background:linear-gradient(135deg,rgba(220,38,38,.8),rgba(185,28,28,.8));
background-size:200% 200%;
color:#fff;
box-shadow:
inset 0 0 8px rgba(255,255,255,.5),
inset 0 0 2px rgba(0,0,0,.6),
0 3px 10px rgba(220,38,38,.4);
}
button.stop-btn:hover{background:linear-gradient(135deg,rgba(185,28,28,.88),rgba(220,38,38,.88));}
@keyframes pulse{
0%,100%{box-shadow:inset 0 0 2px rgba(0,0,0,.7),0 0 0 0 rgba(234,88,12,.5);}
50%{box-shadow:inset 0 0 2px rgba(0,0,0,.7),0 0 0 12px rgba(234,88,12,0);}
}
.btn-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(170px,1fr));gap:10px;}
.ctrl-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:10px;}
.section-label{
font-size:11px;color:var(--text-3);text-transform:uppercase;letter-spacing:.08em;
margin:18px 0 10px;font-weight:700;
}
.section-label:first-child{margin-top:0;}
/* SVG 引脚图容器 — 纯 WebGL 玻璃 */
.pin-svg-wrap{
display:flex;justify-content:center;margin:12px 0;padding:20px;
position:relative;z-index:0;isolation:isolate;
background:transparent;
backdrop-filter:url(#glassRefract) saturate(1.4) contrast(1.15);
-webkit-backdrop-filter:url(#glassRefract) saturate(1.4) contrast(1.15);
border-radius:18px;
border:none;
box-shadow:
inset 0 0 6px rgba(255,255,255,.5),
inset 0 0 2px rgba(0,0,0,.5),
0 2px 8px rgba(0,0,0,.14);
}
.pin-svg-wrap svg{max-width:100%;height:auto;}
.pin-rect{stroke:rgba(255,255,255,.3);stroke-width:.5;transition:fill .3s ease;}
.pin-num{fill:rgba(255,255,255,.7);font-size:12px;font-family:ui-monospace,"SF Mono",monospace;}
.pin-name{fill:rgba(255,255,255,.9);font-size:11px;font-family:ui-monospace,"SF Mono",monospace;}
.pin-name-data{fill:#7fd3ff;font-weight:600;}
.pin-name-ctrl{fill:#ffb74d;font-weight:600;}
.chip-body{fill:url(#chipGrad);stroke:rgba(255,255,255,.25);stroke-width:1;}
.chip-label{fill:#fff;font-size:22px;font-weight:700;text-anchor:middle;}
.chip-sub{fill:rgba(255,255,255,.7);font-size:10px;text-anchor:middle;}
.pin1-dot{fill:var(--blue);}
.pin-legend{
display:flex;gap:18px;justify-content:center;flex-wrap:wrap;margin-top:16px;
font-size:12px;color:var(--text-2);padding-top:16px;
border-top:1px solid var(--separator);font-weight:500;
position:relative;z-index:1;
}
.pin-legend span{display:flex;align-items:center;gap:6px;}
.pin-legend .swatch{
width:14px;height:14px;border-radius:4px;display:inline-block;
box-shadow:0 1px 2px rgba(0,0,0,.2),inset 0 1px 0 rgba(255,255,255,.5);
}
/* 连接 / 操作 */
.connect-row{display:flex;align-items:center;gap:12px;flex-wrap:wrap;font-size:14px;position:relative;z-index:1;}
.connect-label{color:var(--text-2);font-weight:500;}
.connect-ip{
color:var(--blue);font-weight:700;
font-family:ui-monospace,"SF Mono",monospace;font-size:15px;
background:rgba(37,99,235,.15);padding:5px 14px;border-radius:26px;
border:none;
box-shadow:inset 0 0 6px rgba(255,255,255,.4),inset 0 0 2px rgba(0,0,0,.3);
}
.connect-hint{color:var(--text-3);font-size:12px;}
.action-row{display:flex;gap:10px;flex-wrap:wrap;position:relative;z-index:1;}
.action-desc{
font-size:13px;color:var(--text-2);margin-top:16px;padding-top:14px;
border-top:1px solid var(--separator);line-height:1.7;position:relative;z-index:1;
}
.action-desc strong{color:var(--text);font-weight:600;}
/* 日志面板 — 纯 WebGL 玻璃 */
.log-panel{padding:0;overflow:hidden;}
.log-panel-header{
padding:18px 24px 14px;border-bottom:1px solid var(--separator);
display:flex;align-items:center;justify-content:space-between;gap:10px;flex-wrap:wrap;
background:transparent;position:relative;z-index:1;
}
.log-panel-title{font-size:17px;font-weight:700;letter-spacing:-.01em;}
.log-filters{display:flex;gap:6px;}
.log-filter{
position:relative;z-index:0;isolation:isolate;
padding:5px 14px;border-radius:26px;font-size:12px;font-weight:600;
border:none;background:transparent;color:var(--text-2);
cursor:pointer;font-family:inherit;
backdrop-filter:url(#glassRefract) saturate(1.3) contrast(1.1);
-webkit-backdrop-filter:url(#glassRefract) saturate(1.3) contrast(1.1);
box-shadow:
inset 0 0 5px rgba(255,255,255,.4),
inset 0 0 2px rgba(0,0,0,.4),
0 1px 3px rgba(0,0,0,.1);
}
.log-filter.active{
background:linear-gradient(135deg,rgba(37,99,235,.82),rgba(29,78,216,.82));
color:#fff;
box-shadow:
inset 0 0 8px rgba(255,255,255,.5),
inset 0 0 2px rgba(0,0,0,.6),
0 2px 6px rgba(37,99,235,.35);
}
.log-filter:hover:not(.active){color:var(--text);}
.log-scroll{
padding:14px 24px;font-family:ui-monospace,"SF Mono","Cascadia Code",monospace;
font-size:12px;line-height:1.8;max-height:380px;overflow-y:auto;
background:transparent;position:relative;z-index:1;
}
.log-scroll::-webkit-scrollbar{width:6px;}
.log-scroll::-webkit-scrollbar-track{background:transparent;}
.log-scroll::-webkit-scrollbar-thumb{background:rgba(0,0,0,.2);border-radius:3px;}
.log-scroll::-webkit-scrollbar-thumb:hover{background:rgba(0,0,0,.35);}
.log-line{display:flex;gap:8px;padding:3px 0;border-bottom:1px solid rgba(255,255,255,.05);}
.log-line .lt{color:var(--text-3);flex-shrink:0;}
.log-line .lv{font-weight:700;min-width:18px;flex-shrink:0;}
.log-line .lv.E{color:var(--red);}
.log-line .lv.I{color:var(--blue);}
.log-line .lg{color:var(--purple);flex-shrink:0;}
.log-line .lm{color:#fff;word-break:break-all;}
.log-foot{
display:flex;justify-content:space-between;align-items:center;
padding:12px 24px;border-top:1px solid var(--separator);
font-size:12px;color:var(--text-3);font-weight:500;
background:transparent;position:relative;z-index:1;
}
</style>
</head>
<body>
<!-- SVG 折射滤镜:模拟 WebGL IOR=1.5 Thickness=50 的背景位移折射 -->
<svg style="position:absolute;width:0;height:0;overflow:hidden" aria-hidden="true">
<defs>
<filter id="glassRefract" x="-20%" y="-20%" width="140%" height="140%">
<feTurbulence type="fractalNoise" baseFrequency="0.012 0.012" numOctaves="2" seed="7" result="noise"/>
<feGaussianBlur in="noise" stdDeviation="1.5" result="softNoise"/>
<feDisplacementMap in="SourceGraphic" in2="softNoise" scale="14" xChannelSelector="R" yChannelSelector="G"/>
</filter>
</defs>
</svg>
<div class="wrap">
<h1>LAN9252 通断测试</h1>
<div class="sub">通过 ESP32 /spi/send → SPI 0xA2 → GD32 驱动 EXMC 引脚,万用表测 LAN9252 端电压</div>
<div class="warn">
<span class="warn-icon">⚠️</span>
<span>不点按钮时 GD32 引脚为高阻输入态(安全)。点击模式后先预览引脚图,点"确认应用"才实际驱动引脚。测量完毕请点"停止/恢复"释放。</span>
</div>
<!-- 连接 -->
<div class="glass card">
<div class="connect-row">
<span class="connect-label">设备 IP</span>
<span id="ipDisplay" class="connect-ip">读取中...</span>
<span class="connect-hint">共用主页"设备连接"配置的 IP</span>
</div>
</div>
<!-- 引脚图 -->
<div class="glass-strong card">
<h2>LAN9252 64-QFN 引脚图</h2>
<p class="card-desc">俯视,Pin1 右上角。点击下方模式按钮预览引脚电压,点"确认应用"发送命令。绿色=3.3V,红色=0V,橙色=控制线测试中。</p>
<div class="pin-svg-wrap">
<svg id="pinSvg" width="720" height="720" viewBox="0 0 720 720">
<defs>
<linearGradient id="chipGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="rgba(60,60,80,.6)"/>
<stop offset="100%" stop-color="rgba(40,40,60,.5)"/>
</linearGradient>
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="3.5" result="blur"/>
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
</filter>
</defs>
</svg>
</div>
<div class="pin-legend">
<span><span class="swatch" style="background:#16a34a;"></span> HIGH 3.3V</span>
<span><span class="swatch" style="background:#dc2626;"></span> LOW 0V</span>
<span><span class="swatch" style="background:#ea580c;"></span> 控制线测试</span>
<span><span class="swatch" style="background:rgba(232,232,237,.8);"></span> 数据线空闲</span>
<span><span class="swatch" style="background:rgba(255,245,230,.8);"></span> 控制线空闲</span>
<span><span class="swatch" style="background:rgba(240,240,243,.8);"></span> 电源/其他</span>
<span><span class="swatch" style="background:#2563eb;border-radius:50%;"></span> Pin1</span>
</div>
</div>
<!-- 模式选择 -->
<div class="glass card">
<h2>模式选择</h2>
<p class="card-desc">点击预览引脚图,再点"确认应用"发送命令</p>
<div class="section-label">数据线 pattern · D0-D15</div>
<div class="btn-grid">
<button class="mode-btn" data-action="01">0xFFFF 全高</button>
<button class="mode-btn" data-action="02">0x0000 全低</button>
<button class="mode-btn" data-action="03">0xAAAA 奇数高</button>
<button class="mode-btn" data-action="04">0x5555 偶数高</button>
</div>
<div class="section-label">控制线测试</div>
<div class="ctrl-grid">
<button class="mode-btn" data-action="05">CS 拉低 · PD7→Pin28</button>
<button class="mode-btn" data-action="06">RD 拉低 · PD4→Pin31</button>
<button class="mode-btn" data-action="07">WR 拉低 · PD5→Pin30</button>
<button class="mode-btn" data-action="08">A1 拉高 · PD11→Pin25</button>
<button class="mode-btn" data-action="09">A2 拉高 · PD12→Pin29</button>
</div>
<div class="section-label">读周期保持 · 判定芯片是否驱动总线</div>
<div class="ctrl-grid">
<button class="mode-btn" data-action="10">读保持 · CS#+RD# 持续低</button>
</div>
<p class="card-desc" style="margin-top:10px;">
数据线切为高阻输入、CS#+RD# 持续拉低(读 IDX_0)。用万用表在 <strong>LAN9252 芯片引脚</strong> 上量数据线:
芯片活着 → D2(Pin12)、D4(Pin49)、D5(Pin50)、D9(Pin19)、D12(Pin21) ≈ 3.3V,其余数据脚 ≈ 0V(全部确定电平,即 0x1234);
全部中电平/漂移 → 芯片未驱动总线。图上预览色 = 芯片应答的期望值。
</p>
</div>
<!-- 操作 -->
<div class="glass card">
<h2>操作</h2>
<div class="action-row">
<button class="confirm-btn" id="confirmBtn" disabled>✓ 确认应用</button>
<button class="loop-btn" id="loopBtn">🔄 循环切换</button>
<button class="stop-btn" id="stopBtn">⏹ 停止 / 恢复</button>
</div>
<p class="action-desc">
<strong>确认应用</strong> · 发送当前选中的模式到 GD32<br>
<strong>循环切换</strong> · 每5s在 0x0000 ↔ 当前选中模式 之间切换(需先选数据线 pattern)<br>
<strong>停止 / 恢复</strong> · 恢复 EXMC 正常模式
</p>
</div>
<!-- 日志 -->
<div class="glass log-panel">
<div class="log-panel-header">
<span class="log-panel-title">操作日志</span>
<div class="log-filters">
<button class="log-filter active" onclick="setLogFilter('all',this)">全部</button>
<button class="log-filter" onclick="setLogFilter('E',this)">错误</button>
<button class="log-filter" onclick="setLogFilter('I',this)">信息</button>
<button class="log-filter" onclick="clearLogs()">清除</button>
</div>
</div>
<div class="log-scroll" id="logScroll"></div>
<div class="log-foot">
<span>共 <span id="logCount">0</span> 条</span>
<span style="font-family:ui-monospace,monospace;">SPI 0xA2 · UDP 6666</span>
</div>
</div>
</div><!-- /.wrap -->
<script>
/* ===== LAN9252 64-QFN 引脚表 ===== */
var pinData=[
{n:1,name:"OSCI",type:"other"},{n:2,name:"OSCO",type:"other"},
{n:3,name:"VDD12",type:"power"},{n:4,name:"VSS",type:"gnd"},
{n:5,name:"VDD33",type:"power"},{n:6,name:"VDDCR",type:"power"},
{n:7,name:"REG_EN",type:"other"},{n:8,name:"FXLOSEN",type:"other"},
{n:9,name:"FXSDA",type:"other"},{n:10,name:"FXSDB",type:"other"},
{n:11,name:"RST#",type:"other"},{n:12,name:"D2",type:"data",d:2},
{n:13,name:"D1",type:"data",d:1},{n:14,name:"VDDIO",type:"power"},
{n:15,name:"D14",type:"data",d:14},{n:16,name:"D13",type:"data",d:13},
{n:17,name:"D0",type:"data",d:0},{n:18,name:"SYNC1",type:"other"},
{n:19,name:"D9",type:"data",d:9},{n:20,name:"VDDIO",type:"power"},
{n:21,name:"D12",type:"data",d:12},{n:22,name:"D11",type:"data",d:11},
{n:23,name:"D10",type:"data",d:10},{n:24,name:"VDDCR",type:"power"},
{n:25,name:"A1",type:"ctrl",ctrl:"A1"},{n:26,name:"A3",type:"other"},
{n:27,name:"A4",type:"other"},{n:28,name:"CS",type:"ctrl",ctrl:"CS"},
{n:29,name:"A2",type:"ctrl",ctrl:"A2"},{n:30,name:"WR",type:"ctrl",ctrl:"WR"},
{n:31,name:"RD",type:"ctrl",ctrl:"RD"},{n:32,name:"VDDIO",type:"power"},
{n:33,name:"D15",type:"data",d:15},{n:34,name:"SYNC0",type:"other"},
{n:35,name:"D3",type:"data",d:3},{n:36,name:"D6",type:"data",d:6},
{n:37,name:"VDDIO",type:"power"},{n:38,name:"VDDCR",type:"power"},
{n:39,name:"D7",type:"data",d:7},{n:40,name:"D8",type:"data",d:8},
{n:41,name:"TESTMODE",type:"other"},{n:42,name:"EESDA",type:"other"},
{n:43,name:"EESCL",type:"other"},{n:44,name:"IRQ",type:"other"},
{n:45,name:"RUNLED",type:"other"},{n:46,name:"MODE1",type:"other"},
{n:47,name:"VDDIO",type:"power"},{n:48,name:"MODE0",type:"other"},
{n:49,name:"D4",type:"data",d:4},{n:50,name:"D5",type:"data",d:5},
{n:51,name:"TXRX1",type:"power"},{n:52,name:"TXNA",type:"other"},
{n:53,name:"TXPA",type:"other"},{n:54,name:"RXNA",type:"other"},
{n:55,name:"RXPA",type:"other"},{n:56,name:"VDD12",type:"power"},
{n:57,name:"RBIAS",type:"other"},{n:58,name:"VDD33B",type:"power"},
{n:59,name:"VDD12",type:"power"},{n:60,name:"RXPB",type:"other"},
{n:61,name:"RXNB",type:"other"},{n:62,name:"TXPB",type:"other"},
{n:63,name:"TXNB",type:"other"},{n:64,name:"TXRX2",type:"power"}
];
var actionMap={
"01":{t:"data",val:0xFFFF,label:"0xFFFF 全高"},"02":{t:"data",val:0x0000,label:"0x0000 全低"},
"03":{t:"data",val:0xAAAA,label:"0xAAAA 奇数高"},"04":{t:"data",val:0x5555,label:"0x5555 偶数高"},
"05":{t:"ctrl",val:"CS",label:"CS 拉低"},"06":{t:"ctrl",val:"RD",label:"RD 拉低"},
"07":{t:"ctrl",val:"WR",label:"WR 拉低"},"08":{t:"ctrl",val:"A1",label:"A1 拉高"},
"09":{t:"ctrl",val:"A2",label:"A2 拉高"},
"10":{t:"readhold",val:0x1234,label:"读周期保持(CS#+RD# 持续低)"},
"00":{t:"stop",label:"停止/恢复"}
};
/* ===== SVG ===== */
var SVG_NS="http://www.w3.org/2000/svg";
var CHIP_X1=120,CHIP_Y1=120,CHIP_X2=600,CHIP_Y2=600;
var CHIP_W=CHIP_X2-CHIP_X1,PIN_GAP=CHIP_W/16,PIN_OFF=PIN_GAP/2;
var DEFAULT_COLORS={data:"rgba(200,200,210,.5)",ctrl:"rgba(255,200,150,.5)",power:"rgba(220,220,230,.5)",gnd:"rgba(180,180,190,.5)",other:"rgba(210,210,220,.5)"};
function el(tag,attrs,text){
var e=document.createElementNS(SVG_NS,tag);
for(var k in attrs)e.setAttribute(k,attrs[k]);
if(text!==undefined)e.textContent=text;
return e;
}
function getPinPos(n){
if(n>=1&&n<=16){
/* 顶部:引脚上方,名称在上、编号在下 */
var x=CHIP_X2-PIN_OFF-(n-1)*PIN_GAP;
return{rx:x-7,ry:CHIP_Y1-14,rw:14,rh:14,tx:x,ty:CHIP_Y1-16,anchor:"middle",ny:CHIP_Y1-32};
}else if(n>=17&&n<=32){
/* 左侧:引脚左方,编号在上、名称在下(竖向堆叠) */
var y=CHIP_Y1+PIN_OFF+(n-17)*PIN_GAP;
return{rx:CHIP_X1-14,ry:y-7,rw:14,rh:14,tx:CHIP_X1-18,ty:y-1,anchor:"end",ny:y+13};
}else if(n>=33&&n<=48){
/* 底部:引脚下方,编号在上、名称在下 */
var x=CHIP_X1+PIN_OFF+(n-33)*PIN_GAP;
return{rx:x-7,ry:CHIP_Y2,rw:14,rh:14,tx:x,ty:CHIP_Y2+22,anchor:"middle",ny:CHIP_Y2+38};
}else{
/* 右侧:引脚右方,编号在上、名称在下(竖向堆叠) */
var y=CHIP_Y2-PIN_OFF-(n-49)*PIN_GAP;
return{rx:CHIP_X2,ry:y-7,rw:14,rh:14,tx:CHIP_X2+18,ty:y-1,anchor:"start",ny:y+13};
}
}
var pinRects={};
function buildSvg(){
var svg=document.getElementById("pinSvg");
svg.appendChild(el("rect",{class:"chip-body",x:CHIP_X1,y:CHIP_Y1,width:CHIP_W,height:CHIP_W,rx:14}));
svg.appendChild(el("text",{class:"chip-label",x:360,y:345},"LAN9252"));
svg.appendChild(el("text",{class:"chip-sub",x:360,y:368},"64-QFN"));
svg.appendChild(el("text",{class:"chip-sub",x:360,y:386},"Pin1 → 右上角"));
svg.appendChild(el("circle",{class:"pin1-dot",cx:CHIP_X2-14,cy:CHIP_Y1+14,r:5}));
for(var i=0;i<pinData.length;i++){
var p=pinData[i],pos=getPinPos(p.n);
var rect=el("rect",{class:"pin-rect",x:pos.rx,y:pos.ry,width:pos.rw,height:pos.rh,rx:3,id:"pin-"+p.n});
rect.setAttribute("fill",DEFAULT_COLORS[p.type]||DEFAULT_COLORS.other);
svg.appendChild(rect);
pinRects[p.n]=rect;
svg.appendChild(el("text",{class:"pin-num",x:pos.tx,y:pos.ty,"text-anchor":pos.anchor},String(p.n)));
var nc="pin-name";
if(p.type==="data")nc="pin-name pin-name-data";
else if(p.type==="ctrl")nc="pin-name pin-name-ctrl";
svg.appendChild(el("text",{class:nc,x:pos.tx,y:pos.ny||pos.ty,"text-anchor":pos.anchor},p.name));
}
}
function updatePinColors(actionHex){
var act=actionMap[actionHex];if(!act)return;
for(var i=0;i<pinData.length;i++){
var p=pinData[i],color=DEFAULT_COLORS[p.type]||DEFAULT_COLORS.other;
var glow=false;
if(act.t==="data"&&p.type==="data"){
color=(act.val&(1<<p.d))?"#30d158":"#ff453a";glow=true;
}else if(act.t==="ctrl"&&p.type==="ctrl"&&p.ctrl===act.val){
color="#ff9f0a";glow=true;
}else if(act.t==="readhold"){
/* CS/RD 由 GD32 拉低(橙), 数据线 = 芯片应答期望值 0x1234 */
if(p.type==="ctrl"&&(p.ctrl==="CS"||p.ctrl==="RD")){color="#ff9f0a";glow=true;}
else if(p.type==="data"){color=(act.val&(1<<p.d))?"#30d158":"#ff453a";glow=true;}
}
pinRects[p.n].setAttribute("fill",color);
if(glow)pinRects[p.n].setAttribute("filter","url(#glow)");
else pinRects[p.n].removeAttribute("filter");
}
}
function resetPinColors(){
for(var i=0;i<pinData.length;i++){
var p=pinData[i];
pinRects[p.n].setAttribute("fill",DEFAULT_COLORS[p.type]||DEFAULT_COLORS.other);
pinRects[p.n].removeAttribute("filter");
}
}
/* ===== IP ===== */
var LS_ESP_IP="dcu_esp_ip",DEFAULT_IP="192.168.31.127";
function getIp(){try{var s=localStorage.getItem(LS_ESP_IP);if(s&&s.trim())return s.trim();}catch(e){}return DEFAULT_IP;}
document.getElementById("ipDisplay").textContent=getIp();
window.addEventListener("storage",function(e){if(e.key===LS_ESP_IP)document.getElementById("ipDisplay").textContent=getIp();});
/* ===== 日志 ===== */
var logScroll=document.getElementById("logScroll"),logCountEl=document.getElementById("logCount"),logCount=0,logFilter="all";
function esc(s){return String(s==null?"":s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#39;");}
function addLog(level,tag,msg){
var line=document.createElement("div");
line.className="log-line";
if(logFilter!=="all"&&level!==logFilter){line.style.display="none";}
/* msg/tag 含设备响应内容(如 /spi/send JSON),拼 innerHTML 前必须转义 */
line.innerHTML='<span class="lt">['+new Date().toLocaleTimeString()+']</span>'+
'<span class="lv '+esc(level)+'">'+esc(level)+'</span>'+
'<span class="lg">['+esc(tag)+']</span>'+
'<span class="lm">'+esc(msg)+'</span>';
logScroll.insertBefore(line,logScroll.firstChild);
logCount++;logCountEl.textContent=logCount;
while(logScroll.children.length>200)logScroll.removeChild(logScroll.lastChild);
}
function setLogFilter(f,btn){
logFilter=f;
document.querySelectorAll(".log-filter").forEach(function(b){b.classList.remove("active");});
btn.classList.add("active");
logScroll.querySelectorAll(".log-line").forEach(function(l){
var lv=l.querySelector(".lv");
if(f==="all"||(lv&&lv.classList.contains(f)))l.style.display="";
else l.style.display="none";
});
}
function clearLogs(){logScroll.innerHTML="";logCount=0;logCountEl.textContent=0;}
/* ===== 按钮:液体弹动效果 ===== */
document.addEventListener("click",function(e){
var btn=e.target.closest("button");
if(!btn)return;
btn.classList.remove("pressed");
void btn.offsetWidth; /* 强制 reflow,允许重复触发 */
btn.classList.add("pressed");
var done=function(){
btn.classList.remove("pressed");
btn.removeEventListener("animationend",done);
};
btn.addEventListener("animationend",done);
},true);
/* ===== 模式选择 ===== */
var selectedAction=null,lastModeBtn=null;
document.querySelectorAll(".mode-btn").forEach(function(btn){
btn.addEventListener("click",function(){
var action=btn.getAttribute("data-action");
if(lastModeBtn)lastModeBtn.classList.remove("selected");
btn.classList.add("selected");
lastModeBtn=btn;
selectedAction=action;
updatePinColors(action);
document.getElementById("confirmBtn").disabled=false;
addLog("I","预览","选中: "+actionMap[action].label);
});
});
/* ===== 发送 ===== */
var busy=false;
function sendAction(actionHex){
if(busy)return;
var ip=getIp();if(!ip){addLog("E","错误","请先在主页配置 ESP32 IP");return;}
busy=true;
var label=actionMap[actionHex]?actionMap[actionHex].label:actionHex;
addLog("I","发送","action=0x"+actionHex+" ("+label+")");
fetch("http://"+ip+"/spi/send",{
method:"POST",headers:{"Content-Type":"application/json"},
body:JSON.stringify({type:"A2",data:actionHex,timeout_ms:2000}),
signal:AbortSignal.timeout(3000)
})
.then(function(r){if(!r.ok)throw new Error("HTTP "+r.status);return r.json();})
.then(function(j){
if(j.status==="ok"&&j.resp_type==="80")
addLog("I","成功",label+" → ACK"+(j.recv?" 0x"+j.recv:""));
else if(j.status==="ok"&&j.resp_type==="82")
addLog("E","失败",label+" → GD32 ERROR");
else
addLog("E","异常",label+" → "+JSON.stringify(j));
})
.catch(function(err){addLog("E","网络",label+" → "+err.message);})
.then(function(){busy=false;});
}
document.getElementById("confirmBtn").addEventListener("click",function(){
if(selectedAction)sendAction(selectedAction);
});
document.getElementById("stopBtn").addEventListener("click",function(){
stopLoop();
sendAction("00");
resetPinColors();
if(lastModeBtn)lastModeBtn.classList.remove("selected");
lastModeBtn=null;selectedAction=null;
document.getElementById("confirmBtn").disabled=true;
addLog("I","恢复","EXMC 恢复正常模式");
});
/* ===== 循环: 0x0000 ↔ 当前模式 ===== */
var loopTimer=null,loopToggle=false;
var loopBtn=document.getElementById("loopBtn");
loopBtn.addEventListener("click",function(){
if(loopTimer)stopLoop();else startLoop();
});
function startLoop(){
if(!selectedAction||actionMap[selectedAction].t!=="data"){
addLog("E","循环","请先选择一个数据线 pattern 模式");
return;
}
stopLoop();
var baseAction=selectedAction;
loopBtn.textContent="⏸ 停止循环";
loopBtn.classList.add("active");
addLog("I","循环","开始:每5s在 0x0000 ↔ "+actionMap[baseAction].label+" 切换");
loopToggle=false;
loopBtn.setAttribute("data-base",baseAction);
doLoopStep();
loopTimer=setInterval(doLoopStep,5000);
}
function stopLoop(){
if(loopTimer){clearInterval(loopTimer);loopTimer=null;}
loopBtn.textContent="🔄 循环切换";
loopBtn.classList.remove("active");
loopBtn.removeAttribute("data-base");
}
function doLoopStep(){
var baseAction=loopBtn.getAttribute("data-base");if(!baseAction)return;
loopToggle=!loopToggle;
var action=loopToggle?baseAction:"02";
if(lastModeBtn)lastModeBtn.classList.remove("selected");
var btn=document.querySelector('[data-action="'+action+'"]');
if(btn){btn.classList.add("selected");lastModeBtn=btn;}
selectedAction=action;
updatePinColors(action);
sendAction(action);
}
buildSvg();
addLog("I","系统","通断测试工具就绪");
</script>
</body>
</html>
+579
View File
@@ -0,0 +1,579 @@
<!DOCTYPE html>
<!--
lan9252_pm.html - LAN9252 芯片级电源管理诊断台
─────────────────────────────────────────────
风格复用全站设计体系(css/base.css + layout.css + components.css,与 OTA 中继/电机页同源),
布局沿用 .main-layout 双列栅格:左 400px 操作列(休眠/恢复),右侧观测列(状态仪表+日志)。
设备连接不设独立区块:直接读全局 localStorage['dcu_esp_ip'](js/common/base-url.js 同 key
同默认值 192.168.31.127,与 OTA/CAN/电机页共享,改 IP 请到 OTA 页)。
设备侧(DCU_APP 1.6.23):SPI 0xA2 action 30~34 → EscLan9252::pmDrive()
30(0x1E)=读状态 31(0x1F)=进 D1 32(0x20)=进 D2 33(0x21)=进 D3 34(0x22)=手动唤醒
NRST 兜底复用 action 20/21 → EscLan9252::nrstDrive()(拉低/推挽高 = 复位脉冲)
HTTP 链路:POST {ip}/spi/send body={type:"A2", data:"<action hex>", timeout_ms}
响应 recv hex 首字节 = status:bit0=READY bit1=PM_SLEEP_EN bit3:2=PM_MODE;
0xEE=无效 action 0xE1=HBI 未就绪;唤醒另有 1=成功 0=超时。
恢复可靠性(为什么睡不死):ESP32↔GD32 的 SPI 链路与 LAN9252 完全解耦(独立外设/中断),
芯片睡得再深 0xA2 命令照样送达 GD32;NRST 硬复位覆盖 LAN9252 的一切软件可达状态
(D3 睡死/HBI 假死都在内);GD32 自身挂了还有断电重启兜底(固件 hardwareReset 自动重走
受控复位序列)。页面常驻 2s 自动状态轮询,睡没睡一目了然。
-->
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LAN9252 电源管理台</title>
<link rel="icon" href="data:,">
<link rel="stylesheet" href="../css/base.css?v=857f83a5">
<link rel="stylesheet" href="../css/layout.css?v=acd17001">
<link rel="stylesheet" href="../css/components.css?v=a2c3d4e5">
<link rel="stylesheet" href="../css/responsive.css?v=46449483">
<style>
/* 本页专属少量补充(令牌/组件全走 base/layout/components) */
.pm-grid { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; margin-bottom:14px; }
.pm-cell { background:var(--bg-input); border:1px solid var(--border); border-radius:var(--radius-sm); padding:10px 12px; }
.pm-cell .k { color:var(--text-label); font-size:.7rem; letter-spacing:.08em; display:block; margin-bottom:2px; }
.pm-cell .v { font-family:var(--font-display); font-size:1.5rem; font-weight:700; }
.v-ok { color:var(--green); } .v-warn { color:var(--gd32-hi); } .v-bad { color:var(--red); } .v-dim { color:var(--text-label); }
.pm-sleep-banner { display:none; margin-bottom:14px; padding:11px 15px; border-radius:var(--radius-sm);
background:var(--gd32-dim); border:1px solid rgba(252,191,63,.4); color:var(--gd32-hi);
font-weight:600; font-size:.88rem; align-items:center; gap:10px; }
.pm-sleep-banner.show { display:flex; }
.pm-vbtn { display:flex; flex-direction:column; gap:9px; }
.pm-vbtn .btn { width:100%; }
.pm-table { width:100%; border-collapse:collapse; font-size:.78rem; }
.pm-table th,.pm-table td { border:1px solid var(--border); padding:5px 10px; text-align:left; }
.pm-table th { color:var(--text-label); font-weight:600; background:var(--bg-card); }
.pm-table code { font-family:var(--mono); color:var(--mem-hi); }
.pm-log { background:var(--bg-input); border:1px solid var(--border); border-radius:var(--radius-sm);
font-family:var(--mono); font-size:.73rem; padding:10px 12px; height:300px; overflow-y:auto; }
.pm-log div { padding:1px 0; color:var(--text-dim); word-break:break-all; }
.pm-log .t { color:var(--text-label); }
.pm-log .ok { color:var(--green); } .pm-log .warn { color:var(--gd32-hi); } .pm-log .err { color:var(--red); }
.btn-armed { background:var(--red) !important; color:#fff !important; animation:blink 1s infinite; }
.pm-note { color:var(--text-dim); font-size:.78rem; margin-top:10px; line-height:1.7; }
.pm-note b { color:var(--gd32-hi); font-weight:600; }
.pm-note code { font-family:var(--mono); color:var(--mem-hi); background:var(--bg-card); border-radius:4px; padding:0 5px; }
.pm-chain { list-style:none; margin:0 0 4px; padding:0; }
.pm-chain li { position:relative; padding:6px 0 6px 34px; font-size:.82rem; color:var(--text-dim); }
.pm-chain li b { color:var(--text); }
.pm-chain li::before { content:attr(data-n); position:absolute; left:0; top:5px; width:22px; height:22px;
border-radius:50%; background:var(--gd32-dim); color:var(--gd32-hi); font-size:.72rem; font-weight:700;
display:flex; align-items:center; justify-content:center; border:1px solid rgba(252,191,63,.4); }
.pm-chain li + li::after { content:""; position:absolute; left:10px; top:-14px; height:14px;
border-left:1px dashed var(--border-strong); }
</style>
</head>
<body>
<div class="container">
<!-- ====== HEADER ====== -->
<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">
<path d="M12 3v8"/><path d="M6.3 6.5a8 8 0 1 0 11.4 0"/><path d="M8 2h8" opacity=".5"/>
</svg>
</span>
<div class="brand-text">
<strong>LAN9252 电源管理台</strong>
<span class="brand-sub">EtherCAT ESC · D1/D2/D3 休眠 · DCU_APP ≥1.6.23</span>
</div>
</div>
<div class="header-status">
<span class="pill offline" id="pillConn"><span class="indicator"></span><span id="pillConnTxt">连接中…</span></span>
<span class="pill" id="pillIp">IP --</span>
</div>
</header>
<!-- ====== MAIN ====== -->
<div class="main-layout">
<!-- 左列:操作 -->
<div>
<div class="card card--mem">
<div class="card-head"><span class="ico">🌙</span> 进入低功耗档</div>
<div class="card-body">
<div class="pm-vbtn">
<button class="btn btn-primary" id="btnD1">进 D1 (31)</button>
<button class="btn btn-primary" id="btnD2">进 D2 (32)</button>
<button class="btn btn-danger" id="btnD3">进 D3 (33)</button>
</div>
<div class="pm-note">
<b>D1</b> 系统时钟关、晶振+PLL 保持(有自动唤醒路径)。<br>
<b>D2</b> PLL 请求关(两 PHY 已掉电才真关)。<br>
<b>D3</b> 晶振/PLL 全关,最省电、<b>只能手动唤醒</b>(二次确认)。<br>
<b>休眠期间 EtherCAT 中断、主站 wkc 报错属预期</b>;ESC 寄存器配置全保留,唤醒后自清睡眠位。
D3 进入前固件自动掉电两 PHY,唤醒后自动恢复(自协商需数秒)。
</div>
</div>
</div>
<div class="card card--gd32">
<div class="card-head"><span class="ico">🛟</span> 恢复链 —— 为什么睡不死</div>
<div class="card-body">
<ul class="pm-chain">
<li data-n="1"><b>action 34 软唤醒</b>(正常路径,≤2ms)</li>
<li data-n="2"><b>NRST 硬复位</b>:PB10 拉低 60ms 释放,等效上电复位,覆盖 D3 睡死/HBI 假死等一切软件状态</li>
<li data-n="3"><b>GD32 断电重启</b>:复位期间 PB10 浮空由 R44 上拉维持,重启后固件 hardwareReset() 自动重走受控复位</li>
</ul>
<div class="btn-row" style="margin-top:8px">
<button class="btn btn-danger" id="btnNrst" style="width:100%">🛟 ECAT_NRST 硬复位 LAN9252</button>
</div>
<div class="pm-note">
<b>可靠性</b>:ESP32↔GD32 的 SPI 链路与 LAN9252 完全解耦(独立外设/中断),芯片睡得再深
<code>0xA2</code> 照样送达 GD32——恢复失败的唯一前提是 ESP32 和 GD32 同时失能,那已不是休眠问题。<br>
NRST 复位会清掉 SSC 已配置的芯片状态,EtherCAT 需主站重新走状态机。
</div>
</div>
</div>
<div class="card card--status">
<div class="card-head"><span class="ico">📋</span> status 字节判读(recv 首字节)</div>
<div class="card-body">
<table class="pm-table">
<tr><th>位/值</th><th>含义</th></tr>
<tr><td><code>bit0</code></td><td>READY:1=D0 且 PLL 稳定</td></tr>
<tr><td><code>bit1</code></td><td>PM_SLEEP_EN:1=低功耗档</td></tr>
<tr><td><code>bit3:2</code></td><td>PM_MODE 寄存器位(仅 SLEEP_EN=1 时有效;未睡眠可能残留旧档)</td></tr>
<tr><td><code>0xEE</code></td><td>无效 action</td></tr>
<tr><td><code>0xE1</code></td><td>HBI 未就绪(勿操作电源)</td></tr>
<tr><td><code>唤醒 1/0</code></td><td>成功 / 10ms 超时</td></tr>
</table>
</div>
</div>
</div>
<!-- 右列:观测 -->
<div>
<div class="card card--mem">
<div class="card-head"><span class="ico">🔋</span> 芯片电源状态 <span style="margin-left:auto;font-size:.68rem;color:var(--text-label);font-weight:400">2s 自动刷新</span></div>
<div class="card-body">
<div class="pm-sleep-banner" id="sleepBanner">🌙 芯片处于低功耗档 —— EtherCAT 通信已中断,点「手动唤醒」恢复</div>
<div class="pm-grid">
<div class="pm-cell"><span class="k">READY</span><span class="v v-dim" id="stReady">--</span></div>
<div class="pm-cell"><span class="k">SLEEP_EN</span><span class="v v-dim" id="stSleep">--</span></div>
<div class="pm-cell"><span class="k">电源档</span><span class="v v-dim" id="stMode">--</span></div>
<div class="pm-cell"><span class="k">原始字节</span><span class="v v-dim" id="stRaw">--</span></div>
</div>
<div class="btn-row" style="margin-top:0">
<button class="btn btn-outline" id="btnStatus">读状态 (30)</button>
<button class="btn btn-success" id="btnWake">⏻ 手动唤醒 (34)</button>
</div>
<div class="pm-note">唤醒轮询上限 10ms(手册标称 ≤2ms)。超时多半是芯片不在睡眠或 HBI 异常——以「读状态」为准。</div>
</div>
</div>
<div class="card card--mem">
<div class="card-head"><span class="ico">📜</span> 操作日志</div>
<div class="card-body" style="padding-top:10px">
<div class="pm-log" id="log"></div>
</div>
</div>
</div>
</div>
</div>
<script>
(function () {
"use strict";
var $ = function (id) { return document.getElementById(id); };
var logBox = $("log");
/* 与 js/common/base-url.js 同 key 同默认值:全局唯一设备 IP,不设独立连接区 */
var LS_IP = "dcu_esp_ip", DEFAULT_IP = "192.168.31.127";
function deviceIp() {
try { var s = localStorage.getItem(LS_IP); if (s && s.trim()) return s.trim(); } catch (e) {}
return DEFAULT_IP;
}
$("pillIp").textContent = "IP " + deviceIp();
function log(msg, cls) {
var d = document.createElement("div");
if (cls) d.className = cls;
var t = document.createElement("span");
t.className = "t";
t.textContent = "[" + new Date().toLocaleTimeString("zh-CN", { hour12: false }) + "] ";
d.appendChild(t);
d.appendChild(document.createTextNode(msg));
logBox.appendChild(d);
while (logBox.childNodes.length > 300) logBox.removeChild(logBox.firstChild);
logBox.scrollTop = logBox.scrollHeight;
}
/* 通用 0xA2 透传:actionHex 单字节 hex。recv hex 首字节 = status */
function sendA2(actionHex, timeoutMs) {
return fetch("http://" + deviceIp() + "/spi/send", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ type: "A2", data: actionHex, timeout_ms: timeoutMs || 1500 }),
signal: AbortSignal.timeout((timeoutMs || 1500) + 2000),
}).then(function (r) {
return r.json().then(function (j) {
if (!r.ok || j.status !== "ok") throw new Error(j.message || ("HTTP " + r.status));
var hex = String(j.recv || "");
if (hex.length < 2) throw new Error("响应 payload 异常: " + JSON.stringify(hex));
return parseInt(hex.slice(0, 2), 16);
});
});
}
var MODES = ["D0", "D1", "D2", "D3"];
/* 有效电源档:SLEEP_EN=0 时芯片在 D0 工作(EtherCAT/READY 正常),
* PM_MODE 位可能残留上次进档写入的值(板级实测常停在 D2=0x09)。
* 只展示寄存器 MODE 会误导;有效档以 SLEEP_EN 为准。 */
function effectiveMode(b) {
if (b === 0xEE || b === 0xE1) return null;
if ((b & 2) === 0) return "D0";
return MODES[(b >> 2) & 3];
}
function rawMode(b) {
return MODES[(b >> 2) & 3];
}
function describe(b) {
if (b === 0xEE) return "无效 action (0xEE)";
if (b === 0xE1) return "HBI 未就绪 (0xE1),请确认 GD32 已启动且 ECAT init 通过";
var eff = effectiveMode(b), reg = rawMode(b);
var extra = ((b & 2) === 0 && reg !== "D0")
? "(寄存器 PM_MODE 残留 " + reg + ",未睡眠 → 有效 D0)" : "";
return "READY=" + (b & 1) + " SLEEP_EN=" + ((b >> 1) & 1) +
" 有效档=" + eff + " 寄存器MODE=" + reg + extra +
" (0x" + b.toString(16).toUpperCase().padStart(2, "0") + ")";
}
function setCell(id, text, cls) {
var e = $(id); e.textContent = text; e.className = "v " + cls;
}
function renderStatus(b) {
var ready = (b & 1) !== 0, sleep = (b & 2) !== 0;
var mode = effectiveMode(b) || MODES[(b >> 2) & 3];
setCell("stReady", ready ? "1" : "0", ready ? "v-ok" : "v-warn");
setCell("stSleep", sleep ? "1" : "0", sleep ? "v-warn" : "v-ok");
setCell("stMode", mode, mode === "D0" ? "v-ok" : "v-warn");
setCell("stRaw", "0x" + b.toString(16).toUpperCase().padStart(2, "0"), "v-dim");
$("sleepBanner").className = "pm-sleep-banner" + (sleep ? " show" : "");
}
/* 跨标签/重载保持睡眠:HBI 读会唤醒芯片,页面销毁后现场丢失会导致
* 切回时自动 poll(1E) 把睡眠叫醒。用 sessionStorage 记档位,重载时跳过自动读。 */
var SS_SLEEP = "lan9252_pm_asleep";
function saveSleepFlag(n) {
try { sessionStorage.setItem(SS_SLEEP, String(n)); } catch (e) {}
}
function clearSleepFlag() {
try { sessionStorage.removeItem(SS_SLEEP); } catch (e) {}
}
function loadSleepFlag() {
try {
var v = sessionStorage.getItem(SS_SLEEP);
if (v === "1" || v === "2" || v === "3") return parseInt(v, 10);
} catch (e) {}
return 0;
}
function renderAssumedSleep(n) {
/* 不读设备,按上次会话档位恢复 UI(芯片可能仍睡着) */
setCell("stReady", "--", "v-dim");
setCell("stSleep", "1", "v-warn");
setCell("stMode", "D" + n, "v-warn");
setCell("stRaw", "未读", "v-dim");
$("sleepBanner").textContent =
"🌙 上次会话处于 D" + n + " —— 已跳过自动状态读(避免 HBI 读唤醒)。点「手动唤醒」或「读状态」继续(读会唤醒)";
$("sleepBanner").className = "pm-sleep-banner show";
}
/* ---- 自动状态轮询:连接后 2s 一次;读到睡眠态即暂停(1.6.24 实测 HBI 读
* 事务本身会唤醒睡眠中的芯片,轮询等于不停叫醒它);连败 3 次标红停轮。
* pollGen:丢弃 enterMode/唤醒 之后仍在途的过期 1E 响应,避免竞态覆盖 UI。 ---- */
var pollFail = 0, pollTimer = null, pollPaused = false, pollGen = 0;
function setConn(ok, text) {
var p = $("pillConn");
p.className = "pill " + (ok ? "online" : "offline");
$("pillConnTxt").textContent = text;
}
function bumpPollGen() { return ++pollGen; }
/* 仅在确认芯片已离开睡眠(唤醒成功 / 读到 SLEEP_EN=0 / NRST)时清 flag */
function markAwake() {
clearSleepFlag();
$("sleepBanner").textContent =
"🌙 芯片处于低功耗档 —— EtherCAT 通信已中断,点「手动唤醒」恢复";
}
function pollOnce() {
if (pollPaused) return;
var gen = bumpPollGen();
sendA2("1E").then(function (b) {
if (gen !== pollGen || pollPaused) return; /* 过期响应 */
pollFail = 0;
setConn(true, "已连接");
renderStatus(b);
if ((b & 2) !== 0 && b !== 0xEE && b !== 0xE1) {
/* 睡眠态:本读已把它唤醒;暂停轮询等用户主动操作 */
pollPaused = true;
setConn(true, "睡眠中 · 自动刷新已暂停");
log("检测到睡眠态(本次读取已将其唤醒)。轮询暂停;点「读状态」或「唤醒」后恢复。", "warn");
} else {
if (b !== 0xEE && b !== 0xE1) markAwake();
pollTimer = setTimeout(pollOnce, 2000);
}
}).catch(function () {
if (gen !== pollGen || pollPaused) return;
pollFail++;
if (pollFail >= 3) {
setConn(false, "连接失败 · 5s 后重试");
pollTimer = setTimeout(probe, 5000);
} else {
pollTimer = setTimeout(pollOnce, 2000);
}
});
}
function resumePolling() {
if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }
pollPaused = false;
bumpPollGen();
pollTimer = setTimeout(pollOnce, 2000);
}
/* ESP32 可达性探测:只打 /system/status,不碰 LAN9252 HBI(睡眠恢复路径用) */
function probeEspOnly() {
setConn(false, "连接中…");
fetch("http://" + deviceIp() + "/system/status", { signal: AbortSignal.timeout(2500) })
.then(function (r) {
if (!r.ok) throw new Error("HTTP " + r.status);
setConn(true, "上次睡眠中 · 轮询已暂停");
log("ESP32 可达: " + deviceIp() + "(未读 LAN9252,保持睡眠)", "ok");
})
.catch(function (e) {
setConn(false, "连接失败 · 5s 后重试");
log("连接失败: " + e.message + "(IP 取自 OTA 页的设备地址 " + deviceIp() + ")", "err");
pollTimer = setTimeout(probeEspOnly, 5000);
});
}
function probe() {
setConn(false, "连接中…");
fetch("http://" + deviceIp() + "/system/status", { signal: AbortSignal.timeout(2500) })
.then(function (r) {
if (!r.ok) throw new Error("HTTP " + r.status);
setConn(true, "已连接");
log("设备可达: " + deviceIp(), "ok");
pollOnce();
})
.catch(function (e) {
setConn(false, "连接失败 · 5s 后重试");
log("连接失败: " + e.message + "(IP 取自 OTA 页的设备地址 " + deviceIp() + ")", "err");
pollTimer = setTimeout(probe, 5000);
});
}
function busy(btn, fn) {
btn.addEventListener("click", function () {
btn.disabled = true;
Promise.resolve().then(fn).catch(function (e) { log("失败: " + e.message, "err"); })
.then(function () { btn.disabled = false; });
});
}
/* 唤醒/NRST 后重拉 EtherCAT 主站 OP。
* 实测(2026-09-12):软唤醒后芯片 READY≠主站可进 OP——ESC SM/FMMU 在睡眠后失效,
* motor/start 卡在 SAFEOP AL=0x001E Invalid input configuration(偶发能过、多数失败),
* 电机页还会每 2s 探测重试把同一错误刷屏。
* 可靠路径只有:NRST 清 SSC → 等 EEPROM/ESC 启动 → motor/start 走 INIT→PREOP→SAFEOP→OP。
* 故唤醒后一律先 NRST,不再「先试 start 失败再 NRST」。 */
function ecatPost(path, body, timeoutMs) {
return fetch("/ecat/api/" + path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body || {}),
signal: AbortSignal.timeout(timeoutMs || 30000),
}).then(function (r) {
return r.json().then(function (j) {
return { ok: !!(j && j.ok), log: (j && j.log) || [], json: j };
});
});
}
function ecatMotorStart(timeoutMs) {
return ecatPost("motor/start", {}, timeoutMs || 30000);
}
function ecatEnsureOpen() {
/* bus_open=0 时 motor/start 直接回「未连接」。与电机页 ensureOpen 同源。 */
return ecatPost("native/health", {}, 6000).then(function (h) {
var open = h.json && h.json.bus_open;
if (open) return { ok: true, log: ["总线已 open"], json: h.json };
var adapter = "I210";
try { adapter = localStorage.getItem("dcu_ecat_adapter") || "I210"; } catch (e) {}
log("总线未 open,调用 /api/open (" + adapter + ") …");
return ecatPost("open", { substr: adapter }, 20000);
});
}
function nrstPulse() {
log("NRST 拉低 100ms …", "warn");
return sendA2("15").then(function () {
return new Promise(function (r) { setTimeout(r, 100); });
}).then(function () {
return sendA2("14");
}).then(function () {
/* EEPROM 加载 + HBI 探针:2s 不够时 start 会「重枚举没找到从站」 */
log("NRST 已释放,等待 ESC/EEPROM 启动 4s …");
return new Promise(function (r) { setTimeout(r, 4000); });
});
}
function recoverEcatMaster() {
log("重连 EtherCAT 主站:stop → open → NRST → motor/start …", "warn");
/* 先停在跑的 PDO 循环,避免与 NRST/init 抢总线 */
return ecatPost("motor/stop", {}, 8000).catch(function () { return { ok: true, log: ["stop 跳过"] }; })
.then(function (s) {
if (s && s.log && s.log.length) log("stop: " + s.log.join(";"));
return ecatEnsureOpen();
})
.then(function (op) {
if (!op.ok) log("open 失败: " + op.log.join(";"), "warn");
else log("open: " + op.log.join(";"), "ok");
return nrstPulse();
})
.then(function () {
return sendA2("1E");
})
.then(function (b) {
log("复位后状态: " + describe(b));
renderStatus(b);
markAwake();
return ecatMotorStart(30000);
})
.then(function (r) {
if (r.ok) {
log("主站已进 OP:" + r.log.join(";"), "ok");
setConn(true, "已连接");
resumePolling();
return r;
}
log("motor/start 失败:" + r.log.join(";") + " → 再 stop+open+NRST 重试", "err");
return ecatPost("motor/stop", {}, 8000)
.then(function () { return ecatEnsureOpen(); })
.then(function () { return nrstPulse(); })
.then(function () { return ecatMotorStart(30000); })
.then(function (r2) {
if (r2.ok) {
log("第二次重试主站已进 OP:" + r2.log.join(";"), "ok");
setConn(true, "已连接");
} else {
log("两次后仍失败:" + r2.log.join(";") + "。请查网线/供电,或到电机页手动连接。", "err");
}
resumePolling();
return r2;
});
})
.catch(function (e) {
log("重连主站异常: " + e.message + "(ecat_native 未起?)", "err");
resumePolling();
return { ok: false, log: [e.message] };
});
}
busy($("btnStatus"), function () {
return sendA2("1E").then(function (b) {
renderStatus(b);
log("状态: " + describe(b));
if ((b & 2) === 0 && b !== 0xEE && b !== 0xE1) markAwake();
resumePolling();
});
});
busy($("btnWake"), function () {
log("手动唤醒(HBI 写 BYTE_TEST)…");
bumpPollGen();
clearSleepFlag();
return sendA2("22").then(function (b) {
if (b === 1) {
log("唤醒成功: " + describe(b), "ok");
renderStatus(b);
markAwake();
setConn(true, "已连接");
/* 芯片 READY ≠ 主站 OP;必须 NRST 再 start,否则 SAFEOP AL=0x001E */
return recoverEcatMaster();
} else if (b === 0xEE || b === 0xE1) {
log("设备拒绝: " + describe(b), "err");
renderStatus(b);
resumePolling();
} else {
log("唤醒超时(status=0x" + b.toString(16) + "),改走 NRST 硬复位恢复", "err");
renderStatus(b);
return recoverEcatMaster();
}
});
});
function enterMode(n, hex) {
log("进入 D" + n + " …" + (n === 3 ? "(先掉电两 PHY)" : ""));
/* 作废在途 1E,防止旧 poll 响应把睡眠 UI 打回 D0 */
bumpPollGen();
return sendA2(hex).then(function (b) {
if (b === 0xEE || b === 0xE1) {
log("设备拒绝: " + describe(b), "err");
renderStatus(b);
return;
}
log("已进入 D" + n + "(固件置信返回,未读回)。", "warn");
if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; }
pollPaused = true;
setConn(true, "睡眠中 · 轮询已暂停");
saveSleepFlag(n);
renderStatus(b);
/* 固件返回可能是新 status 或旧字节;强制按 D{n} 展示并记住 */
setCell("stSleep", "1", "v-warn");
setCell("stMode", "D" + n, "v-warn");
$("sleepBanner").textContent =
"🌙 芯片处于低功耗档 —— EtherCAT 通信已中断,点「手动唤醒」恢复(切页回来会保持此睡眠态)";
$("sleepBanner").className = "pm-sleep-banner show";
});
}
busy($("btnD1"), function () { return enterMode(1, "1F"); });
busy($("btnD2"), function () { return enterMode(2, "20"); });
/* D3 二次确认:3 秒内再点一次才执行 */
(function () {
var armed = false, timer = null;
$("btnD3").addEventListener("click", function () {
if (!armed) {
armed = true;
$("btnD3").classList.add("btn-armed");
$("btnD3").textContent = "⚠ 再点一次确认进 D3";
timer = setTimeout(function () { disarm(); }, 3000);
return;
}
clearTimeout(timer); disarm();
$("btnD3").disabled = true;
enterMode(3, "21").catch(function (e) { log("失败: " + e.message, "err"); })
.then(function () { $("btnD3").disabled = false; });
});
function disarm() {
armed = false;
$("btnD3").classList.remove("btn-armed");
$("btnD3").textContent = "进 D3 (33)";
}
})();
/* NRST 硬复位:0x15 拉低 → 100ms → 0x14 释放 → 等 2s → 重拉主站 */
busy($("btnNrst"), function () {
bumpPollGen();
clearSleepFlag();
return recoverEcatMaster();
});
/* 启动:若上次会话记着睡眠档,跳过自动 1E(会唤醒),只探 ESP32 可达性 */
var restoredSleep = loadSleepFlag();
if (restoredSleep) {
pollPaused = true;
renderAssumedSleep(restoredSleep);
setConn(true, "上次睡眠中 · 轮询已暂停");
log("恢复上次会话:D" + restoredSleep + " 睡眠中。为避免 HBI 读唤醒芯片,已跳过自动状态刷新;点「读状态」或「手动唤醒」继续。", "warn");
probeEspOnly();
} else {
log("就绪。状态每 2s 自动刷新;改设备 IP 请到 OTA 中继页(全局共享)。", "warn");
probe();
}
})();
</script>
</body>
</html>
+902
View File
@@ -0,0 +1,902 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>DCU EtherCAT PDO · 字节布局 1.6.21 · 固件 1.6.24</title>
<style>
:root{
--bg:#070b10; --panel:#101820; --panel2:#141e28;
--line:#1c2834; --line2:#2a3a4a;
--ink:#d8e4f0; --dim:#6d8299; --dim2:#4a5d72;
--cyan:#00d4a0; --blue:#4c9aff; --amber:#f0b429; --violet:#a78bfa;
--rose:#ff5c7a; --slate:#8b9cb0;
--mono:"Cascadia Code","Cascadia Mono","Consolas",monospace;
--sans:"Segoe UI","Microsoft YaHei",system-ui,sans-serif;
}
*{box-sizing:border-box}
body{
margin:0;padding:0 0 72px;
background:
radial-gradient(1100px 500px at 8% -8%, rgba(0,212,160,.07), transparent 50%),
radial-gradient(800px 420px at 95% 0%, rgba(76,154,255,.05), transparent 45%),
var(--bg);
color:var(--ink);
font:14px/1.7 var(--sans);
-webkit-font-smoothing:antialiased;
}
.wrap{max-width:1120px;margin:0 auto;padding:0 28px}
/* header */
header{
border-bottom:1px solid var(--line);
background:rgba(7,11,16,.92);
backdrop-filter:blur(10px);
position:sticky;top:0;z-index:40;
}
.hd-in{
max-width:1120px;margin:0 auto;padding:14px 28px;
display:flex;align-items:center;gap:20px;flex-wrap:wrap;
}
.brand{display:flex;align-items:center;gap:12px}
.brand h1{margin:0;font-size:18px;font-weight:650;letter-spacing:.2px}
.ver{
font:650 11px/1 var(--mono);color:#07120f;background:var(--cyan);
padding:5px 8px;border-radius:5px;
}
.sub{color:var(--dim);font-size:12.5px}
.stats{display:flex;gap:8px;margin-left:auto;flex-wrap:wrap}
.stat{
padding:6px 12px;border:1px solid var(--line);border-radius:7px;
background:var(--panel);min-width:86px;
}
.stat b{display:block;font:650 14px/1.3 var(--mono)}
.stat span{display:block;font-size:10.5px;color:var(--dim);letter-spacing:.3px}
/* sections */
section{padding:40px 0 4px}
.sec-head{
display:flex;align-items:center;gap:12px;
margin:0 0 18px;padding-bottom:10px;border-bottom:1px solid var(--line);
}
.sec-no{
font:700 11px/1 var(--mono);color:#07120f;background:var(--cyan);
width:24px;height:24px;border-radius:50%;display:grid;place-items:center;
}
.sec-head h2{margin:0;font-size:17px;font-weight:650}
.sec-head .tagline{margin-left:auto;color:var(--dim);font:12px var(--mono)}
/* cards */
.card{
background:var(--panel);border:1px solid var(--line);border-radius:12px;
overflow:hidden;margin-bottom:16px;
}
.card-hd{
display:flex;align-items:center;gap:10px;flex-wrap:wrap;
padding:12px 16px;border-bottom:1px solid var(--line);background:var(--panel2);
}
.card-hd h3{margin:0;font-size:13.5px;font-weight:650}
.card-hd .meta{margin-left:auto;color:var(--dim);font:11.5px var(--mono)}
.card-bd{padding:16px}
/* callout */
.callout{
border:1px solid var(--line);border-left:3px solid var(--cyan);
border-radius:10px;background:var(--panel);
padding:14px 16px;margin:0 0 16px;font-size:13.5px;
}
.callout.warn{border-left-color:var(--amber)}
.callout.danger{border-left-color:var(--rose)}
.callout h4{margin:0 0 8px;font-size:13px;font-weight:650;color:var(--cyan)}
.callout.warn h4{color:var(--amber)}
.callout.danger h4{color:var(--rose)}
.callout ul{margin:8px 0 0;padding-left:20px}
.callout li{margin:5px 0}
.callout p{margin:0 0 8px}
.callout p:last-child{margin:0}
/* pills / legend */
.pills{display:flex;gap:8px;flex-wrap:wrap;margin:22px 0 18px}
.pill{
font:11.5px var(--mono);color:var(--dim);
border:1px solid var(--line);border-radius:99px;
padding:5px 11px;background:var(--panel);
}
.pill b{color:var(--ink);font-weight:650}
.legend{
display:flex;flex-wrap:wrap;gap:10px 18px;
margin:0 0 18px;font-size:12px;color:var(--dim);
}
.legend i{
display:inline-block;width:11px;height:11px;border-radius:3px;
margin-right:6px;vertical-align:-1px;
}
/* svg maps */
.map-wrap{margin:0 0 8px}
.map-wrap + .map-wrap{margin-top:14px}
.map-wrap svg{display:block;width:100%;height:auto}
.seg-label{
font:600 11px var(--mono);color:var(--dim);
margin:18px 0 8px;letter-spacing:.2px;
}
.seg-label:first-child{margin-top:0}
/* field bar (HTML, 防 SVG 叠压) */
.fbar{
display:flex;gap:2px;height:52px;
border:1px solid var(--line);border-radius:6px;overflow:hidden;
background:#0a1218;
}
.fcell{
position:relative;display:flex;flex-direction:column;
align-items:center;justify-content:center;gap:2px;
min-width:0;padding:4px 2px;overflow:hidden;
border-right:1px solid rgba(0,0,0,.45);
}
.fcell:last-child{border-right:0}
.fcell .fn{
font:650 11px/1.2 var(--mono);color:var(--ink);
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%;
}
.fcell .fo{
font:10px/1.2 var(--mono);color:var(--dim);
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%;
}
.fcell.narrow .fn{font-size:10px}
.fcell.pad .fn,.fcell.pad .fo{color:var(--dim2)}
.ruler{
display:flex;gap:2px;margin-top:4px;
font:10px var(--mono);color:var(--dim2);
}
.ruler span{min-width:0;overflow:hidden;white-space:nowrap}
/* tables in cards */
.tbl-card{margin-top:0}
table{border-collapse:collapse;width:100%;font-size:13px}
th,td{
border-bottom:1px solid var(--line);
padding:9px 12px;text-align:left;vertical-align:top;
}
th{
color:var(--dim);font-weight:600;font-size:11px;
letter-spacing:.4px;text-transform:uppercase;
background:var(--panel2);
}
td.n,th.n{text-align:right;font-family:var(--mono);white-space:nowrap}
td.mono{font-family:var(--mono);white-space:nowrap;color:var(--dim)}
tbody tr:hover td{background:rgba(0,212,160,.035)}
tbody tr:last-child td{border-bottom:0}
tfoot td{font-weight:650;background:var(--panel2);border-bottom:0}
.dot{
display:inline-block;width:9px;height:9px;border-radius:2px;
margin-right:8px;vertical-align:0;
}
.chip{
display:inline-block;padding:2px 8px;border-radius:99px;
font:600 11px/1.5 var(--mono);
}
.chip.fix{background:rgba(255,92,122,.12);color:var(--rose)}
.chip.adp{background:rgba(0,212,160,.12);color:var(--cyan)}
.chip.hdr{background:rgba(240,180,41,.12);color:var(--amber)}
.chip.gone{background:rgba(167,139,250,.12);color:var(--violet)}
.chip.note{background:rgba(139,156,176,.12);color:var(--slate)}
code{
font-family:var(--mono);font-size:12px;
background:#0a1218;padding:1px 6px;border-radius:3px;border:1px solid var(--line);
}
pre{
margin:10px 0 0;padding:12px 14px;
background:#0a1218;border:1px solid var(--line);border-radius:8px;
overflow-x:auto;font:12px/1.6 var(--mono);color:var(--ink);
}
pre code{background:none;border:0;padding:0}
/* cost */
.cost-grid{
display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin:0 0 16px;
}
@media(max-width:800px){.cost-grid{grid-template-columns:1fr}}
.cost-card{
background:var(--panel);border:1px solid var(--line);border-radius:10px;
padding:14px 16px;
}
.cost-card .lbl{font-size:11px;color:var(--dim);letter-spacing:.4px;text-transform:uppercase}
.cost-card .val{font:650 32px/1.2 var(--mono);margin:6px 0 2px}
.cost-card .unit{font-size:12.5px;color:var(--dim)}
.cost-card .bar{height:5px;border-radius:3px;background:var(--line);margin-top:12px;overflow:hidden}
.cost-card .bar i{display:block;height:100%;border-radius:3px}
.cost-card.c-cyan .val{color:var(--cyan)}
.cost-card.c-cyan .bar i{background:var(--cyan)}
.cost-card.c-blue .val{color:var(--blue)}
.cost-card.c-blue .bar i{background:var(--blue)}
.cost-card.c-amber .val{color:var(--amber)}
.cost-card.c-amber .bar i{background:var(--amber)}
footer{
margin-top:48px;padding-top:18px;border-top:1px solid var(--line);
color:var(--dim);font-size:12px;
}
footer code{font-size:11px}
@media(max-width:720px){
.wrap,.hd-in{padding-left:16px;padding-right:16px}
.stats{width:100%;margin-left:0}
.fbar{height:46px}
.fcell .fn{font-size:10px}
}
</style>
</head>
<body>
<header>
<div class="hd-in">
<div class="brand">
<h1>DCU EtherCAT PDO 布局</h1>
<span class="ver">布局 1.6.21</span>
<span class="sub">固件 1.6.24 · 字段级 · 稀疏写</span>
</div>
<div class="stats">
<div class="stat"><b>384 B</b><span>输出 SM2</span></div>
<div class="stat"><b>600 B</b><span>输入 SM3</span></div>
<div class="stat"><b>96–600 B</b><span>稀疏写实占</span></div>
<div class="stat"><b>40 B</b><span>DPRAM 余量</span></div>
</div>
</div>
</header>
<div class="wrap">
<div class="pills">
<span class="pill">真源 <b>ethercat_can_bridge.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">魔数 <b>@35=0xA5</b></span>
<span class="pill">电机槽 <b>≤9 × 56 B</b></span>
<span class="pill">稀疏写 <b>CONFIG=1</b></span>
</div>
<div class="callout">
<h4>布局 1.6.21 · 固件 1.6.24 · 写侧 1.6.22 起稀疏</h4>
<ul>
<li><b>字节布局仍是 1.6.21</b>:段 2 密排、删段 4、魔数 <code>@35=0xA5</code>;out 384 + in 600 不变,SII/EEPROM 无需重刷。</li>
<li><b>1.6.22 稀疏写 ESC</b>(<code>CONFIG_ECAT_SPARSE_ESC_WRITE=1</code>):按 <code>count</code> 只写「段1+段2有效区」与「段3+段5」,跳过空槽区。主站解码不变,仍只按 count 迭代。</li>
<li><b>1.6.23/1.6.24</b> 为 LAN9252 电源管理诊断(pmDrive D1/D2/D3),不影响 PDO 布局。</li>
<li>回退整包 600B 的条件:开关置 0 / magic≠0xA5 / count 越界 / count 撑满(9 电机两段相接)。</li>
</ul>
</div>
<div class="legend">
<span><i style="background:var(--blue)"></i>桥状态 / 快照</span>
<span><i style="background:var(--amber)"></i>段头 / 元数据</span>
<span><i style="background:var(--cyan)"></i>电机密排槽</span>
<span><i style="background:var(--violet)"></i>批量发送区</span>
<span><i style="background:var(--rose)"></i>诊断</span>
<span><i style="background:var(--slate)"></i>保留 / 填充</span>
</div>
<!-- ═══ 输入 PDO ═══ -->
<section id="in">
<div class="sec-head">
<span class="sec-no">1</span>
<h2>输入 PDO · 600 B · 从站 → 主站</h2>
<span class="tagline">稀疏写 96–600 B · 整包 ≈265 µs · 3 电机 ≈116 µs</span>
</div>
<div class="card">
<div class="card-hd">
<h3>段总览</h3>
<span class="meta">SM3 · 0x1880–0x1F67 · 3-buffer · 写侧稀疏</span>
</div>
<div class="card-bd">
<div class="map-wrap" id="in-overview"></div>
</div>
</div>
<div class="card">
<div class="card-hd">
<h3>段内字段</h3>
<span class="meta">每段独占全宽 · 宽度 ∝ 字节数</span>
</div>
<div class="card-bd" id="in-fields"></div>
</div>
<div class="card tbl-card">
<div class="card-hd"><h3>字段明细</h3></div>
<table>
<thead>
<tr>
<th>段 / 字段</th><th class="n">偏移</th><th class="n">宽</th>
<th>类型</th><th>内容</th><th>属性</th>
</tr>
</thead>
<tbody id="in-tbody"></tbody>
<tfoot>
<tr>
<td colspan="2">合计</td>
<td class="n">600</td>
<td></td>
<td>逻辑总长;写侧按 count 稀疏(见下)</td>
<td class="n">96–600 B</td>
</tr>
</tfoot>
</table>
</div>
</section>
<!-- ═══ 输出 PDO ═══ -->
<section id="out">
<div class="sec-head">
<span class="sec-no">2</span>
<h2>输出 PDO · 384 B · 主站 → 从站</h2>
<span class="tagline">pramRead(384) · 96 DWORD · ≈169 µs 折算</span>
</div>
<div class="card">
<div class="card-hd">
<h3>段总览</h3>
<span class="meta">SM2 · 0x1400–0x187F · WIN@8 忽略</span>
</div>
<div class="card-bd">
<div class="map-wrap" id="out-overview"></div>
</div>
</div>
<div class="card">
<div class="card-hd">
<h3>段内字段</h3>
<span class="meta">每段独占全宽 · 宽度 ∝ 字节数</span>
</div>
<div class="card-bd" id="out-fields"></div>
</div>
<div class="card tbl-card">
<div class="card-hd"><h3>字段明细</h3></div>
<table>
<thead>
<tr>
<th>段 / 字段</th><th class="n">偏移</th><th class="n">宽</th>
<th>类型</th><th>内容</th><th>属性</th>
</tr>
</thead>
<tbody id="out-tbody"></tbody>
<tfoot>
<tr>
<td colspan="2">合计</td>
<td class="n">384</td>
<td></td>
<td>每拍一次 <code>pramRead(384)</code>(未实测)</td>
<td class="n">≈169 µs</td>
</tr>
</tfoot>
</table>
</div>
</section>
<!-- ═══ 电机槽 ═══ -->
<section id="slot">
<div class="sec-head">
<span class="sec-no">3</span>
<h2>电机槽内部 · 56 B × ≤9</h2>
<span class="tagline">PDO 地址 = 40 + i×56 · node_id 升序</span>
</div>
<div class="card">
<div class="card-hd">
<h3>密排槽区 @40..543</h3>
<span class="meta">空节点不占位 · count 决定有效槽数</span>
</div>
<div class="card-bd">
<div class="map-wrap" id="slot-strip"></div>
<div class="seg-label">单槽 56 B 字段</div>
<div id="slot-fields"></div>
</div>
</div>
<div class="card">
<div class="card-hd"><h3>主站解码</h3></div>
<div class="card-bd">
<pre><code>count = in[32]; // 0..9
magic = in[35]; // 必须 0xA5
for (i = 0; i &lt; count; i++) {
p = in + 40 + i*56;
node = p[0]; ch = p[1]; online = p[2];
seq = le32(p+4);
status = p+8; // 48 B
}</code></pre>
<p style="margin:14px 0 0;color:var(--dim);font-size:13px">
同号多通道取 <b style="color:var(--ink)">seq 最新</b>的一条;写侧 seqlock 奇数(写入中)的槽本拍省略、下拍自愈。
节点集合与各 seq/online 未变时整段复用影子区(CPU 差分),<b style="color:var(--ink)">PRAM 写侧仍按 count 决定字节数</b>。
<b style="color:var(--amber)">1.6.22+ 稀疏写</b>:ESC 中 <code>40+count×56 .. 543</code> 为上次残留,
主站<b style="color:var(--ink)">只许按 count 迭代</b>,不得读空洞区。
</p>
</div>
</div>
<div class="card tbl-card">
<div class="card-hd"><h3>单槽字段明细</h3></div>
<table>
<thead>
<tr><th>字段</th><th class="n">槽内偏移</th><th class="n">宽</th><th>类型</th><th>内容</th></tr>
</thead>
<tbody id="slot-tbody"></tbody>
<tfoot>
<tr>
<td colspan="2">单槽合计</td><td class="n">56</td>
<td colspan="2">8 B 头 + 48 B STATUS</td>
</tr>
</tfoot>
</table>
</div>
</section>
<!-- ═══ 成本 ═══ -->
<section id="cost">
<div class="sec-head">
<span class="sec-no">4</span>
<h2>每拍成本与 SM 预算</h2>
<span class="tagline">PRAM ≈0.441 µs/B · 稀疏写 1.6.22+</span>
</div>
<div class="cost-grid">
<div class="cost-card c-cyan">
<div class="lbl">输入写侧 · 3 电机稀疏</div>
<div class="val">116</div>
<div class="unit">µs/拍 · 264 B×0.441 · 11.6% @1ms</div>
<div class="bar"><i style="width:11.6%"></i></div>
</div>
<div class="cost-card c-blue">
<div class="lbl">输入写侧 · 整包基线</div>
<div class="val">265</div>
<div class="unit">µs/拍 · 600×0.441 · 26.5% @1ms</div>
<div class="bar"><i style="width:26.5%"></i></div>
</div>
<div class="cost-card c-amber">
<div class="lbl">输出读侧 · 探针命中</div>
<div class="val">12</div>
<div class="unit">B 探针 · 无沿时跳过 384B 全量读</div>
<div class="bar"><i style="width:5%"></i></div>
</div>
</div>
<div class="callout">
<h4>1.6.22 稀疏写 ESC · 写侧字节随 count 缩</h4>
<p><code>CONFIG_ECAT_SPARSE_ESC_WRITE=1</code>(默认开)。<code>HW_EscWriteIsr</code> 只写两段:</p>
<pre><code>段A = [0 .. 40+count×56) // 段1 桥状态 + 段2 头 + 有效密排槽
段B = [544 .. 600) // 段3 长帧 + 段5 诊断 + 保留
跳过 = [40+count×56 .. 544) // 空槽区(ESC 中为 SM 缓冲上次残留)
count=0 → 96 B ≈ 42 µs
count=3 → 264 B ≈ 116 µs
count=8 → 544 B ≈ 240 µs
count=9 → 整包 600 B(两段相接,回退)</code></pre>
<p style="margin-top:10px;color:var(--dim);font-size:12.5px">
APPL 仍整填 <code>aPdInputData</code>(对齐 SDO 0x6000);省的是 PRAM 搬运时间,不是 RAM 拷贝。
主站必须继续只按 <code>count</code> 迭代——空洞区内容是上次残留,不可读。
</p>
</div>
<div class="callout warn">
<h4>实测锚点:从站自报负载</h4>
<p><b>1 ms 档 49.2% → 0.5 ms 档 81.4% → 0.25 ms 档 98.7%</b>,同时 λ 从 6000 帧/s 塌到 317 帧/s —— 饱和发生在从站,不在主站的 Npcap 或钉核上。</p>
<p style="color:var(--dim);font-size:12.5px">
上表为 1.6.21 整包基线。1.6.22 稀疏写开启后写侧字节随 count 缩,同工况 load_perm 应显著下降;
验收看 0x2000 sub10(EscWrite 周期)与 load_perm,同工况开关各测一轮。
</p>
</div>
<div class="card">
<div class="card-hd">
<h3>DPRAM 预算 · out + in ≤ 1024 B</h3>
<span class="meta">LAN9252 · 过程区 0x1400–0x1FFF · 3-buffer</span>
</div>
<div class="card-bd">
<div class="map-wrap" id="dpram-map"></div>
<p style="margin:14px 0 0;color:var(--dim);font-size:12.5px">
邮箱 SM0/SM1 各 256 B 固定占 0x1000–0x13FF;SM2/SM3 均为 3-buffer(control 0x64/0x20,bit1=0),实占 (out+in)×3。
要再大只有两条路:邮箱压小(上限约 1280 B,代价 SDO/FoE 变慢)或改 1-buffer(上限 3072 B,代价失去三缓冲并发保护)。
</p>
</div>
</div>
</section>
<!-- ═══ 段4 删除 ═══ -->
<section id="seg4">
<div class="sec-head">
<span class="sec-no">5</span>
<h2>段 4 历史覆盖窗口 · 已删除</h2>
<span class="tagline">1.6.15 引入 → 1.6.21 删除</span>
</div>
<div class="callout danger">
<h4>为什么删</h4>
<p>1.6.15 曾把批量接收区做成「定长覆盖窗口」:5 个固定格子(每格 44 B = 12 B 头 + 32 B 数据)+ 写游标绕圈。它是「最近 5 帧」而不是队列 —— 第 6 帧盖掉第 1 帧,未取走的计入 <code>OVF</code>。</p>
<ul>
<li>覆盖窗口反复重发最近几条非 STATUS 帧,属于「历史帧 / 重复帧」,对电机状态视图是噪声;</li>
<li>用户实测病理:接 3 个电机,网页却看到 5 个状态帧 —— 旧段 2 固定 5 槽里空槽 <code>node_id=0</code> 也占位,主站不滤空槽就多报。</li>
</ul>
<p style="margin-top:10px">本版语义:<b>每一拍只携带「总线上已识别电机的完整一轮状态」</b>。版本/CONFIG 仍走段 1/段 3「最近一帧」快照。对象字典 sub21 恒 0;<code>596..599</code> 保留空洞。</p>
</div>
</section>
<footer>
布局真源 <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>ethercat_pdo.cpp sparseEscWritePlan</code> + <code>ecatappl.c PDO_InputMapping</code> ·
成本系数 <code>0.441 µs/B</code> · 布局魔数 <code>kEcatMotLayoutMagic = 0xA5</code>
</footer>
</div>
<script>
const C = {
blue:'#4c9aff', amber:'#f0b429', cyan:'#00d4a0',
violet:'#a78bfa', rose:'#ff5c7a', slate:'#8b9cb0',
dim:'#6d8299', line:'#1c2834', ink:'#d8e4f0',
};
/* ── 段定义(总览用)── */
const IN_SEGS = [
{name:'段1 桥状态', off:0, len:32, color:C.blue},
{name:'段2 头', off:32, len:8, color:C.amber},
{name:'段2 密排槽', off:40, len:504, color:C.cyan},
{name:'段3 长帧', off:544, len:40, color:C.blue},
{name:'段5 诊断', off:584, len:12, color:C.rose},
{name:'保留', off:596, len:4, color:C.slate},
];
const OUT_SEGS = [
{name:'段1 单帧发送', off:0, len:48, color:C.blue},
{name:'段2 批量发送', off:48, len:336, color:C.violet},
];
/* ── 字段(按段分组)── */
const IN_GROUPS = [
{title:'段 1 · 桥状态 0..31 · 32 B', total:32, fields:[
{off:0,len:1,name:'ack',color:C.blue,type:'u8',txt:'回显已执行的发送序号',attr:'固定'},
{off:1,len:1,name:'ch',color:C.blue,type:'u8',txt:'该帧来自哪个通道 0/1/2',attr:'固定'},
{off:2,len:1,name:'dlc',color:C.blue,type:'u8',txt:'原始 DLC(截断前)',attr:'固定'},
{off:3,len:1,name:'flags',color:C.blue,type:'u8',txt:'bit0=有过接收;bit1=截断到 8B',attr:'固定'},
{off:4,len:4,name:'id',color:C.blue,type:'u32le',txt:'最近一帧 CAN ID',attr:'固定'},
{off:8,len:8,name:'data',color:C.blue,type:'u8[8]',txt:'短快照(长帧见段 3)',attr:'固定'},
{off:16,len:4,name:'rx_count',color:C.blue,type:'u32le',txt:'累计接收帧数',attr:'固定'},
{off:20,len:1,name:'txres',color:C.blue,type:'u8',txt:'最近发送结果(0=OK)',attr:'固定'},
{off:21,len:1,name:'backlog',color:C.blue,type:'u8',txt:'该通道软件 TX 队列积压',attr:'固定'},
{off:22,len:2,name:'txcnt',color:C.blue,type:'u16le',txt:'累计发送受理帧数',attr:'固定'},
{off:24,len:2,name:'drop',color:C.blue,type:'u16le',txt:'快照「未取先覆」次数',attr:'固定'},
{off:26,len:6,name:'pad',color:C.slate,type:'pad',txt:'段 1 尾部填充',attr:'保留'},
]},
{title:'段 2 · 头 32..39 · 8 B', total:8, fields:[
{off:32,len:1,name:'count',color:C.amber,type:'u8',txt:'本拍密排电机数 0..9',attr:'头部'},
{off:33,len:1,name:'active',color:C.amber,type:'u8',txt:'在线电机数(可与 count 不同)',attr:'头部'},
{off:34,len:1,name:'boot',color:C.amber,type:'u8',txt:'1=近 3s 收到过 0x103 BOOT',attr:'头部'},
{off:35,len:1,name:'magic',color:C.amber,type:'u8',txt:'恒 0xA5:密排标识',attr:'头部'},
{off:36,len:4,name:'gseq',color:C.amber,type:'u32le',txt:'状态表全局刷新序号',attr:'头部'},
]},
{title:'段 2 · 密排槽 40..543 · 最多 9×56 B', total:504, fields:[
{off:40,len:504,name:'slots[9]',color:C.cyan,type:'9×56B',txt:'密排:每槽 node/ch/online/dlc/seq + 48B STATUS;空节点不占位',attr:'密排'},
]},
{title:'段 3 · 长帧快照 544..583 · 40 B', total:40, fields:[
{off:544,len:1,name:'ch',color:C.blue,type:'u8',txt:'通道',attr:'固定'},
{off:545,len:1,name:'dlc',color:C.blue,type:'u8',txt:'原始 DLC(可达 32)',attr:'固定'},
{off:546,len:1,name:'flags',color:C.blue,type:'u8',txt:'bit0=有效',attr:'固定'},
{off:547,len:1,name:'rsvd',color:C.slate,type:'u8',txt:'保留',attr:'保留'},
{off:548,len:4,name:'id',color:C.blue,type:'u32le',txt:'CAN ID',attr:'固定'},
{off:552,len:32,name:'data',color:C.blue,type:'u8[32]',txt:'完整 payload(CONFIG / 版本帧)',attr:'固定'},
]},
{title:'段 5 · 诊断 584..595 · 12 B', total:12, fields:[
{off:584,len:2,name:'load',color:C.rose,type:'u16le',txt:'负载率 0.1%(1000=100%)',attr:'固定'},
{off:586,len:2,name:'torn',color:C.rose,type:'u16le',txt:'CAN 取帧撕裂丢帧数',attr:'固定'},
{off:588,len:2,name:'seq',color:C.rose,type:'u16le',txt:'诊断区序号,1s 窗口 +1',attr:'固定'},
{off:590,len:2,name:'RFO0',color:C.rose,type:'u16le',txt:'CAN RX FIFO 满溢计数',attr:'固定'},
{off:592,len:2,name:'relto',color:C.rose,type:'u16le',txt:'RFD0 释放握手超时计数',attr:'固定'},
{off:594,len:2,name:'reject',color:C.rose,type:'u16le',txt:'内容级拼接帧命中计数',attr:'固定'},
]},
{title:'保留 596..599 · 4 B', total:4, fields:[
{off:596,len:4,name:'—',color:C.slate,type:'pad',txt:'原段 4 空洞',attr:'保留'},
]},
];
const OUT_GROUPS = [
{title:'段 1 · 单帧发送 0..47 · 48 B', total:48, fields:[
{off:0,len:1,name:'seq',color:C.blue,type:'u8',txt:'变化沿触发一次发送',attr:'固定'},
{off:1,len:1,name:'ch',color:C.blue,type:'u8',txt:'目标通道 0/1/2',attr:'固定'},
{off:2,len:1,name:'dlc',color:C.blue,type:'u8',txt:'0..32;0=只发批量',attr:'固定'},
{off:3,len:1,name:'flags',color:C.blue,type:'u8',txt:'bit0=1 请求清零统计',attr:'固定'},
{off:4,len:4,name:'id',color:C.blue,type:'u32le',txt:'CAN ID(29 位扩展帧)',attr:'固定'},
{off:8,len:1,name:'win',color:C.slate,type:'u8',txt:'1.6.21 起忽略',attr:'忽略'},
{off:9,len:7,name:'pad',color:C.slate,type:'pad',txt:'对齐到 16B 边界',attr:'保留'},
{off:16,len:32,name:'data',color:C.blue,type:'u8[32]',txt:'一帧电机 CMD 数据区',attr:'固定'},
]},
{title:'段 2 · 批量发送 48..383 · 336 B', total:336, fields:[
{off:48,len:1,name:'count',color:C.violet,type:'u8',txt:'本次要发的帧数',attr:'头部'},
{off:49,len:1,name:'flags',color:C.violet,type:'u8',txt:'bit0=1 遇错即停',attr:'头部'},
{off:50,len:2,name:'rsvd',color:C.slate,type:'pad',txt:'帧数组 8B 对齐',attr:'保留'},
{off:52,len:332,name:'items[]',color:C.violet,type:'变长',txt:'每项 8B 头 + dlc 数据(CANFD ≤64B/帧)',attr:'批量'},
]},
];
const SLOT_FIELDS = [
{off:0,len:1,name:'node',color:C.cyan,type:'u8',txt:'节点号 1..9(密排下不会出现 0)',attr:'头部'},
{off:1,len:1,name:'ch',color:C.cyan,type:'u8',txt:'CAN 通道 0/1/2',attr:'头部'},
{off:2,len:1,name:'online',color:C.cyan,type:'u8',txt:'1=在线',attr:'头部'},
{off:3,len:1,name:'dlc',color:C.cyan,type:'u8',txt:'最后一帧 STATUS 的 DLC(正常 48)',attr:'头部'},
{off:4,len:4,name:'seq',color:C.amber,type:'u32le',txt:'STATUS 刷新序号',attr:'头部'},
{off:8,len:48,name:'status',color:C.blue,type:'u8[48]',txt:'原始 STATUS payload',attr:'固定'},
];
/* ── SVG helpers ── */
function svgEl(tag, attrs, text) {
const e = document.createElementNS('http://www.w3.org/2000/svg', tag);
for (const k in attrs) e.setAttribute(k, attrs[k]);
if (text != null) e.textContent = text;
return e;
}
/* 段总览:只画大块,标签宽敞 */
function drawOverview(container, segs, total) {
const W = 1000, pad = 6, barY = 28, barH = 56, H = barY + barH + 28;
const inner = W - pad * 2;
const svg = svgEl('svg', { viewBox:`0 0 ${W} ${H}`, role:'img' });
const x0 = b => pad + (b / total) * inner;
const xw = n => (n / total) * inner;
// ruler
const tick = total >= 400 ? 100 : 50;
for (let t = 0; t <= total; t += tick) {
const x = x0(t);
svg.appendChild(svgEl('line', { x1:x,y1:16,x2:x,y2:24, stroke:C.line,'stroke-width':1 }));
svg.appendChild(svgEl('text', {
x, y:12, 'text-anchor': t===0 ? 'start' : t===total ? 'end' : 'middle',
fill:C.dim, 'font-size':10, 'font-family':'Cascadia Code,Consolas,monospace'
}, String(t)));
}
svg.appendChild(svgEl('line', { x1:pad,y1:24,x2:W-pad,y2:24, stroke:C.line,'stroke-width':1 }));
segs.forEach(s => {
const x = x0(s.off), w = xw(s.len);
const g = svgEl('g');
g.appendChild(svgEl('title', {}, `${s.name} @${s.off} · ${s.len} B`));
g.appendChild(svgEl('rect', {
x, y:barY, width:w, height:barH,
fill:s.color, 'fill-opacity':0.22,
stroke:s.color, 'stroke-width':1.2, rx:4
}));
g.appendChild(svgEl('rect', {
x, y:barY, width:w, height:5, fill:s.color, rx:2
}));
if (w >= 56) {
g.appendChild(svgEl('text', {
x:x+w/2, y:barY+barH/2-4, 'text-anchor':'middle', 'dominant-baseline':'central',
fill:C.ink, 'font-size': w>=120 ? 13 : 11,
'font-family':'Cascadia Code,Consolas,monospace', 'font-weight':650
}, s.name));
g.appendChild(svgEl('text', {
x:x+w/2, y:barY+barH/2+14, 'text-anchor':'middle',
fill:C.dim, 'font-size':10, 'font-family':'Cascadia Code,Consolas,monospace'
}, `${s.off}..${s.off+s.len-1} · ${s.len}B`));
} else {
// 窄段:标签放在下方
g.appendChild(svgEl('line', {
x1:x+w/2, y1:barY+barH, x2:x+w/2, y2:barY+barH+8,
stroke:s.color, 'stroke-width':1
}));
g.appendChild(svgEl('text', {
x:x+w/2, y:barY+barH+20, 'text-anchor':'middle',
fill:s.color, 'font-size':10, 'font-family':'Cascadia Code,Consolas,monospace'
}, `${s.name.replace('段','')}`));
}
svg.appendChild(g);
});
container.innerHTML = '';
container.appendChild(svg);
}
/* 段内字段条:HTML flex,宽度 ∝ 字节,不叠压 */
function drawFieldBar(host, group) {
const wrap = document.createElement('div');
wrap.style.marginBottom = '22px';
const label = document.createElement('div');
label.className = 'seg-label';
label.textContent = group.title;
wrap.appendChild(label);
const bar = document.createElement('div');
bar.className = 'fbar';
group.fields.forEach(f => {
const cell = document.createElement('div');
cell.className = 'fcell' + (f.len <= 2 ? ' narrow' : '') + (f.attr === '保留' || f.name === 'pad' || f.name === '—' || f.name === 'rsvd' ? ' pad' : '');
cell.style.flex = `${f.len} 1 0`;
cell.style.background = f.color;
cell.style.opacity = f.attr === '保留' || f.name === 'pad' || f.name === '—' || f.name === 'rsvd' ? '0.35' : '0.85';
cell.title = `${f.name} @${f.off} · ${f.len} B · ${f.type}\n${f.txt}`;
const fn = document.createElement('div');
fn.className = 'fn';
fn.textContent = f.name;
cell.appendChild(fn);
if (f.len >= 2 || group.total <= 16) {
const fo = document.createElement('div');
fo.className = 'fo';
fo.textContent = `@${f.off}`;
cell.appendChild(fo);
}
bar.appendChild(cell);
});
wrap.appendChild(bar);
// 底部偏移尺
const ruler = document.createElement('div');
ruler.className = 'ruler';
group.fields.forEach(f => {
const sp = document.createElement('span');
sp.style.flex = `${f.len} 1 0`;
sp.textContent = f.len >= 4 ? String(f.off) : '';
ruler.appendChild(sp);
});
wrap.appendChild(ruler);
host.appendChild(wrap);
}
function drawFieldGroups(hostId, groups) {
const host = document.getElementById(hostId);
host.innerHTML = '';
groups.forEach(g => drawFieldBar(host, g));
}
/* 槽区示意 */
function drawSlotStrip(container) {
const W = 1000, H = 70;
const svg = svgEl('svg', { viewBox:`0 0 ${W} ${H}`, role:'img' });
const pad = 4, gap = 6;
const n = 9;
const totalGaps = gap * (n - 1);
const slotW = (W - pad * 2 - totalGaps) / n;
svg.appendChild(svgEl('text', {
x:pad, y:14, fill:C.dim, 'font-size':11,
'font-family':'Cascadia Code,Consolas,monospace'
}, '9 个等宽槽 · 每槽 56 B · 密排后有效槽 = count · 空节点不占位'));
for (let i = 0; i < n; i++) {
const x = pad + i * (slotW + gap);
const g = svgEl('g');
g.appendChild(svgEl('title', {}, `slot[${i}] · 56 B`));
g.appendChild(svgEl('rect', {
x, y:26, width:slotW, height:34,
fill:C.cyan, 'fill-opacity': i===0 ? 0.4 : 0.14,
stroke:C.cyan, 'stroke-width': i===0 ? 1.6 : 0.8,
rx:4
}));
g.appendChild(svgEl('text', {
x:x+slotW/2, y:43, 'text-anchor':'middle', 'dominant-baseline':'central',
fill: i===0 ? C.ink : C.dim, 'font-size':12,
'font-family':'Cascadia Code,Consolas,monospace', 'font-weight': i===0 ? 650 : 400
}, i===0 ? 'slot 0' : `slot ${i}`));
svg.appendChild(g);
}
container.innerHTML = '';
container.appendChild(svg);
}
/* DPRAM 预算 */
function drawDpram(container) {
const W = 1000, H = 100;
const svg = svgEl('svg', { viewBox:`0 0 ${W} ${H}`, role:'img' });
const pad = 10, inner = W - pad * 2, total = 1024;
const y = 30, h = 36;
const x0 = b => pad + (b / total) * inner;
const xw = n => (n / total) * inner;
svg.appendChild(svgEl('text', {
x:pad, y:16, fill:C.dim, 'font-size':11,
'font-family':'Cascadia Code,Consolas,monospace'
}, '逻辑预算 (out+in) ≤ 1024 B · 3-buffer 等效 · 1.6.22+ 写侧按 count 稀疏'));
function block(from, len, color, label, opacity) {
const g = svgEl('g');
g.appendChild(svgEl('title', {}, `${label} · ${len} B`));
g.appendChild(svgEl('rect', {
x:x0(from), y, width:xw(len), height:h,
fill:color, 'fill-opacity':opacity, stroke:color, 'stroke-width':1.2, rx:4
}));
if (xw(len) > 48) {
g.appendChild(svgEl('text', {
x:x0(from)+xw(len)/2, y:y+h/2,
'text-anchor':'middle', 'dominant-baseline':'central',
fill:C.ink, 'font-size':12, 'font-family':'Cascadia Code,Consolas,monospace', 'font-weight':650
}, label));
}
svg.appendChild(g);
}
block(0, 384, C.blue, 'OUT 384', 0.28);
block(384, 600, C.cyan, 'IN 600', 0.28);
block(984, 40, C.slate, '', 0.22);
// 余量标注放在下方,避免与 984/1024 刻度挤在一起
svg.appendChild(svgEl('text', {
x:x0(1004), y:y+h+18, 'text-anchor':'middle',
fill:C.slate, 'font-size':10, 'font-family':'Cascadia Code,Consolas,monospace'
}, '余 40'));
[0, 384, 984, 1024].forEach((t, i) => {
const x = x0(t);
svg.appendChild(svgEl('line', { x1:x,y1:y,x2:x,y2:y-6, stroke:C.line,'stroke-width':1 }));
svg.appendChild(svgEl('text', {
x, y:y-10,
'text-anchor': i===0 ? 'start' : i===3 ? 'end' : 'middle',
fill:C.dim, 'font-size':10, 'font-family':'Cascadia Code,Consolas,monospace'
}, String(t)));
});
svg.appendChild(svgEl('text', {
x:pad, y:H-8, fill:C.dim, 'font-size':11,
'font-family':'Cascadia Code,Consolas,monospace'
}, 'SM2 0x1400–0x187F (3×384) · SM3 0x1880–0x1F67 (3×600) · DPRAM 侧余 120 B'));
container.innerHTML = '';
container.appendChild(svg);
}
/* 表格 */
function attrChip(a) {
const map = {固定:'fix',密排:'adp',头部:'hdr',保留:'note',忽略:'gone',批量:'adp'};
return `<span class="chip ${map[a]||'note'}">${a}</span>`;
}
function fillTable(tbody, groups, segOf) {
const rows = [];
groups.forEach(g => {
g.fields.forEach(f => {
rows.push(`
<tr>
<td>
<span class="dot" style="background:${f.color}"></span>
<code>${f.name}</code>
<span style="color:var(--dim);font-size:12px;margin-left:6px">${segOf(g)}</span>
</td>
<td class="n">${f.off}</td>
<td class="n">${f.len}</td>
<td class="mono">${f.type}</td>
<td>${f.txt}</td>
<td>${attrChip(f.attr)}</td>
</tr>`);
});
});
tbody.innerHTML = rows.join('');
}
function fillFlatTable(tbody, fields) {
tbody.innerHTML = fields.map(f => `
<tr>
<td><span class="dot" style="background:${f.color}"></span><code>${f.name}</code></td>
<td class="n">+${f.off}</td>
<td class="n">${f.len}</td>
<td class="mono">${f.type}</td>
<td>${f.txt}</td>
</tr>`).join('');
}
/* boot */
drawOverview(document.getElementById('in-overview'), IN_SEGS, 600);
drawOverview(document.getElementById('out-overview'), OUT_SEGS, 384);
drawFieldGroups('in-fields', IN_GROUPS);
drawFieldGroups('out-fields', OUT_GROUPS);
drawSlotStrip(document.getElementById('slot-strip'));
const slotHost = document.getElementById('slot-fields');
[{title:'单槽 56 B', total:56, fields:SLOT_FIELDS}].forEach(g => drawFieldBar(slotHost, g));
drawDpram(document.getElementById('dpram-map'));
fillTable(document.getElementById('in-tbody'), IN_GROUPS, g => g.title.split('·')[0].trim());
fillTable(document.getElementById('out-tbody'), OUT_GROUPS, g => g.title.split('·')[0].trim());
fillFlatTable(document.getElementById('slot-tbody'), SLOT_FIELDS);
</script>
</body>
</html>
+177 -87
View File
@@ -43,9 +43,8 @@
--border:#21304a; --border-strong:#2e4262; --border-subtle:#1a2740; --border:#21304a; --border-strong:#2e4262; --border-subtle:#1a2740;
--text:#e9f0fa; --text-dim:#93a8c4; --text-label:#617a99; --text:#e9f0fa; --text-dim:#93a8c4; --text-label:#617a99;
--esp32:#2fd8c1; --esp32-hi:#6ff2e0; --esp32-dim:rgba(47,216,193,.14); --esp32:#2fd8c1; --esp32-hi:#6ff2e0; --esp32-dim:rgba(47,216,193,.14);
--gd32:#fcbf3f; --gd32-hi:#ffd97a; --gd32-dim:rgba(252,191,63,.14); --gd32:#fcbf3f; --gd32-dim:rgba(252,191,63,.14);
--can:#43d97b; --can-dim:rgba(67,217,123,.14); --can:#43d97b;
--motor:#22d3ee; --motor-hi:#67e8f9; --motor-dim:rgba(34,211,238,.14);
--mem:#a88bfa; --mem-hi:#c7b3ff; --mem-dim:rgba(168,139,250,.14); --mem:#a88bfa; --mem-hi:#c7b3ff; --mem-dim:rgba(168,139,250,.14);
--green:#3ddc8e; --amber:#fcbf3f; --red:#ff6b6b; --red-dim:rgba(255,107,107,.14); --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); --spi:var(--esp32); --ecat:var(--mem); --ecat-hi:var(--mem-hi); --ecat-dim:var(--mem-dim);
@@ -266,7 +265,7 @@
<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="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 class="metric"><div class="k">失败帧</div><div class="v" id="mFail">—</div><div class="f">必须为 0</div></div>
</div> </div>
<div id="segWrap" style="margin-top:12px"> <div style="margin-top:12px">
<div class="k" style="font-size:11px; color:var(--text-label); margin-bottom:5px">单帧分段平均(固件五段拆解,占总往返比例)</div> <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="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 id="segLegend" class="hint" style="margin-top:6px">跑一次测速后出现</div>
@@ -333,18 +332,18 @@
<div class="card"> <div class="card">
<h2>刷新率 <span class="hint2">实测 = Δcycles/Δt;名义 = 1000/period_ms 仅作对照,两者差值就是软件调度开销</span></h2> <h2>刷新率 <span class="hint2">实测 = Δcycles/Δt;名义 = 1000/period_ms 仅作对照,两者差值就是软件调度开销</span></h2>
<div class="metrics"> <div class="metrics">
<div class="metric hero" id="cHz"><div class="k">实测 PDO 刷新率</div><div class="v" id="eHz">—<small>Hz</small></div><div class="f" id="eHzF">cycles 差分</div></div> <div class="metric 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" id="eAchieve">—</div><div class="f" id="eAchieveF">名义 — 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" id="eBus">—<small>µs</small></div><div class="f" id="eBusF">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" id="eDuty">—</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" id="eSlot">—<small>Hz</small></div><div class="f" id="eSlotF">slot_period_ms —</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="cLambda"><div class="k">CAN 到帧率 λ</div><div class="v" id="eLambda">—<small>/s</small></div><div class="f">Δglobal_seq (u32)</div></div> <div class="metric" id="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" id="eDrop">—<small>帧/拍</small></div><div class="f" id="eDropF">主站 seq 空洞 · 从站 OVF(抽样单拍值)</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="cSkip"><div class="k">跳拍</div><div class="v" id="eSkip">—<small>/s</small></div><div class="f" id="eSkipF">累计 —</div></div> <div class="metric" id="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" id="eLoad">—</div><div class="f" id="eLoadF">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" id="eWkc">—</div><div class="f" id="eWkcF">期望 —</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" id="eTorn">—</div><div class="f" id="eTornF">rx_torn / pdo_reentry</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="cRtt"><div class="k">本页请求耗时</div><div class="v" id="eRtt">—<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">
判读:<b>达成率 &lt; 100%</b> = 节拍被软件拖住;<b>λ ÷ 实测Hz</b> = 每拍到达几帧 CAN(当前 1ms 档实测 6 帧/拍)。<br> 判读:<b>达成率 &lt; 100%</b> = 节拍被软件拖住;<b>λ ÷ 实测Hz</b> = 每拍到达几帧 CAN(当前 1ms 档实测 6 帧/拍)。<br>
@@ -356,7 +355,7 @@
<b>真丢帧逐层看</b>:① CAN 控制器 err/TEC/REC;② CAN 软件 RX 队列满(深度 16)的 <b>真丢帧逐层看</b>:① CAN 控制器 err/TEC/REC;② CAN 软件 RX 队列满(深度 16)的
<code>rx_drop_count</code> —— 只在 ESP32 <code>/can/feedback</code> 有,过程数据里没有; <code>rx_drop_count</code> —— 只在 ESP32 <code>/can/feedback</code> 有,过程数据里没有;
③ 段 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>从站侧</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>。③④ 是本页 10Hz 抽样看到的<u>单拍值</u>,只能当下界。
撕裂 / 重入是<u>饱和</u>计数(到 65535 停),封顶时只能报下界。 撕裂 / 重入是<u>饱和</u>计数(到 65535 停),封顶时只能报下界。
@@ -405,9 +404,9 @@
B32-35 —— 补 0 会把"无数据"显示成"初始化正常"。</div> B32-35 —— 补 0 会把"无数据"显示成"初始化正常"。</div>
</div> </div>
<div class="card"> <div class="card">
<h2>收到的东西 ② 批量接收区 <span class="hint2">rxraw.parsed(与下面槽表同一拍快照)· 段 4 覆盖窗口去重后的事件流</span></h2> <h2>收到的东西 ② 密排整轮 + 长帧事件 <span class="hint2">rxraw.parsed · 1.6.21 密排:段2整轮槽 + 段3长帧变更</span></h2>
<div class="row" style="margin-bottom:8px"> <div class="row" style="margin-bottom:8px">
<span id="rxbPill" class="pill">本拍新帧 / seq 空洞 / 从站 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="btnRxbClear" class="sm ghost">清零计数</button> <button id="btnRxbClear" class="sm ghost">清零计数</button>
</div> </div>
@@ -417,13 +416,12 @@
<tbody></tbody> <tbody></tbody>
</table> </table>
</div> </div>
<div style="font-size:11px; color:var(--text-label); margin:11px 0 5px"> <div style="font-size:11px; color:var(--text-label); margin:11px 0 5px" id="winSlotCap">
覆盖窗口 5 槽原样(从站每拍整块拷出,写游标绕圈覆盖 —— <b>槽下标 ≠ 到达顺序</b>,seq 才是顺序)。 密排整轮槽(GD32 每拍原样拷出 count×56B:node ch online dlc seq + 48B STATUS)。
末列「本拍新上报」:<b>是</b> = 这一拍第一次见到这帧,主站把它交给上层;<b>否</b> = 它是上一拍就交过的旧帧 末列「本拍新数据」:<b>是</b> = seq 比上一拍大(差分命中,状态真变了);<b>否</b> = seq 未变,固件本拍跳过填充。</div>
(窗口只留"最近 5 帧",一帧会连续几拍都躺在里面,但不会被重复上报)。</div>
<div class="scroll" style="height:170px"> <div class="scroll" style="height:170px">
<table id="tblWinSlots"> <table id="tblWinSlots">
<thead><tr><th class="num">槽</th><th>状态</th><th class="num">seq</th><th class="num">Δseq</th><th class="num">CH</th><th class="num">DLC</th><th class="num">CAN ID</th><th>节点/子码</th><th>载荷前 8B</th><th>本拍新上报</th></tr></thead> <thead><tr><th class="num">槽</th><th>状态</th><th class="num">seq</th><th class="num">Δseq</th><th class="num">CH</th><th class="num">DLC</th><th class="num">CAN ID</th><th>节点/子码</th><th>载荷前 8B</th><th>本拍新数据</th></tr></thead>
<tbody></tbody> <tbody></tbody>
</table> </table>
</div> </div>
@@ -433,18 +431,14 @@
</div> </div>
</details> </details>
<div class="hint"> <div class="hint">
<b>同一 ID + 同一载荷出现两次不是重复显示</b>:主站已按每槽 seq 去重,同一帧不会报第二次。 <b>密排整轮(1.6.21)</b>:接 N 个电机本拍就发 N 个状态,空槽不发送。
实测 120 个窗口快照里「同 ID 且 seq 相同」0 次、「同 ID 但 seq 不同」240 次 —— 那是<b>两帧真的各到达一次</b>, STATUS 全在上方状态槽解码;本表是同一拍的<b>线级原样</b>(seq / online / 前 8B 载荷),
只是电机空闲时 CONFIG 回报的 24B 逐字节相同,肉眼看一模一样。看下面槽表的 seq 列即可分辨。 用来核对"差分是否真的跳过了未变电机"。<br>
<br><b>"前面像少了一帧"也不是丢帧</b>:① 主站每拍只把 seq 比上次大的槽交给上层,上一拍已经交过的帧 <b>长帧(CONFIG 24B / 版本应答)</b>:段 3「最近一帧」,内容变了才进上面的事件表
仍躺在窗口里(就是末列的"否"),但不会再进上面的表;② 页面 10Hz 采样、PDO 1kHz,两次采样之间约 (主站按 ch/dlc/id/data 逐字节比对,相同不重复上报)。逐字节的真凭实据在下面第 ③ 区。<br>
100 拍的窗口内容已被覆盖,看不见(所以"样本数"只占全量约 0.8%,全量看 λ 那张卡)。 本表看不到的还有三层 —— CAN 控制器 err/TEC/REC、GD32 软件 RX 队列满丢(深度 16,
本表能看到的丢帧判据只有两个:<code>seq 空洞</code>(主站侧)与 <code>OVF</code>(从站侧), 只在 ESP32 <code>/can/feedback</code> 有)、CAN 取帧撕裂 <code>rx_torn</code>。
上面 pill 里实时显示,实测同为 0;<u>本表看不到的还有三层</u> —— CAN 控制器 err/TEC/REC、 逐层对账跑 <code>scripts/can_bridge_reconcile.py</code>。</div>
GD32 软件 RX 队列满丢(深度 16,只在 ESP32 <code>/can/feedback</code> 有)、CAN 取帧撕裂
<code>rx_torn</code>。逐层对账跑 <code>scripts/can_bridge_reconcile.py</code>。
<br>长帧(CONFIG 24B / 版本应答)在 8B 短快照里会被截断,所以后端另开 <code>state.xrx</code> 长帧槽,
逐字节的真凭实据在下面第 ③ 区的原始窗口里。</div>
</div> </div>
<div class="card"> <div class="card">
@@ -453,8 +447,8 @@
<div class="paneFlex"> <div class="paneFlex">
<div class="kv" id="rawKv"></div> <div class="kv" id="rawKv"></div>
<div class="hint cap" style="height:2.6em"> <div class="hint cap" style="height:2.6em">
<code>layout</code>:win=1.6.15 定长覆盖窗口 / slim=变长 / old=旧布局,解析器必须按 layout 选, <code>layout</code>:dense=1.6.21 密排整轮 / win=1.6.15 定长覆盖窗口 / slim=变长 / old=旧布局,
否则拿变长游标解定长窗口会整片错位。</div> 解析器必须按 layout 选,否则拿变长游标解定长窗口会整片错位。</div>
<div class="grow" style="margin-top:6px; min-height:0; display:flex; flex-direction:column"> <div class="grow" style="margin-top:6px; min-height:0; display:flex; flex-direction:column">
<div class="lbl">xrx 长帧完整载荷(kv 里那行是截断的,这里不截)</div> <div class="lbl">xrx 长帧完整载荷(kv 里那行是截断的,这里不截)</div>
<pre id="xrxFull" class="grow" style="margin-top:4px">—</pre> <pre id="xrxFull" class="grow" style="margin-top:4px">—</pre>
@@ -469,7 +463,7 @@
</div> </div>
</div> </div>
<div style="margin-top:10px"> <div style="margin-top:10px">
<div class="lbl">覆盖窗口原始字节 —— 按 44B/槽分组:头 12B(ch dlc flags pad ‖ id LE ‖ seq LE)+ 数据 32B(两行)</div> <div class="lbl">接收区原始字节 —— dense:8B 整轮头 + count×56B 槽;win:44B/槽 覆盖窗口</div>
<div id="rawHex" class="hexgrid" style="--hex-h:158px"><i class="hdr">—</i></div> <div id="rawHex" class="hexgrid" style="--hex-h:158px"><i class="hdr">—</i></div>
</div> </div>
</div> </div>
@@ -545,6 +539,11 @@ function kv(boxId, rows) {
box.appendChild(d); box.appendChild(d);
} }
} }
/** 状态胶囊:三处(SPI / ECAT / 测速跑批)共用同一套 led + 文案结构 */
function setPill(sel, cls, text) {
const n = $(sel); n.className = "pill" + (cls ? " " + cls : "");
n.innerHTML = '<span class="led"></span>'; n.appendChild(el("span", null, text));
}
const isNum = (v) => typeof v === "number" && isFinite(v); const isNum = (v) => typeof v === "number" && isFinite(v);
const f1 = (v) => (isNum(v) ? v.toFixed(1) : "—"); const f1 = (v) => (isNum(v) ? v.toFixed(1) : "—");
const f2 = (v) => (isNum(v) ? v.toFixed(2) : "—"); const f2 = (v) => (isNum(v) ? v.toFixed(2) : "—");
@@ -603,7 +602,7 @@ function showPane(name) {
/* 切到 SPI 就把看板轮询停掉:SPI 测的是 µs 级帧周期,这边 10Hz×3 条代理请求 /* 切到 SPI 就把看板轮询停掉:SPI 测的是 µs 级帧周期,这边 10Hz×3 条代理请求
* 会把自己的测量抬高 —— 先排除自己造的假象。 */ * 会把自己的测量抬高 —— 先排除自己造的假象。 */
const was = E.live; stopEcat(); E.wantLive = was; const was = E.live; stopEcat(); E.wantLive = was;
ecatPill("#ecatState", "", "已暂停(在 SPI 页)"); setPill("#ecatState", "", "已暂停(在 SPI 页)");
} }
} }
for (const b of $$(".tab")) b.onclick = () => showPane(b.dataset.pane); for (const b of $$(".tab")) b.onclick = () => showPane(b.dataset.pane);
@@ -611,7 +610,7 @@ for (const b of $$(".tab")) b.onclick = () => showPane(b.dataset.pane);
/* ═══════════════════ SPI 测速面板 ═══════════════════ /* ═══════════════════ SPI 测速面板 ═══════════════════
* 设备端点无应答时原实现会把按钮永久卡在 disabled(fetch 没有超时),这就是"卡死"。 * 设备端点无应答时原实现会把按钮永久卡在 disabled(fetch 没有超时),这就是"卡死"。
* 这里每次请求都带 AbortController + 超时,并额外给一个手动中止口。 */ * 这里每次请求都带 AbortController + 超时,并额外给一个手动中止口。 */
const spiOut = $("#out"), spiHist = $("#hist"), spiPill = $("#spiState"), btnAbort = $("#btnAbort"); const spiOut = $("#out"), spiHist = $("#hist"), btnAbort = $("#btnAbort");
const spiBtns = [$("#btnRun"), $("#btnRtt"), $("#btnStress")]; const spiBtns = [$("#btnRun"), $("#btnRtt"), $("#btnStress")];
let spiCtl = null; let spiCtl = null;
try { const saved = ls("spi_test_ip"); if (saved) $("#ip").value = saved; } catch (_) {} try { const saved = ls("spi_test_ip"); if (saved) $("#ip").value = saved; } catch (_) {}
@@ -621,9 +620,7 @@ if (!ls("spi_test_ip")) { const shared = ls("dcu_esp_ip"); if (shared) $("#ip").
function spiBusy(busy, cls, text) { function spiBusy(busy, cls, text) {
for (const b of spiBtns) b.disabled = busy; for (const b of spiBtns) b.disabled = busy;
btnAbort.hidden = !busy; btnAbort.hidden = !busy;
spiPill.className = "pill" + (cls ? " " + cls : ""); setPill("#spiState", cls, text);
spiPill.innerHTML = '<span class="led"></span>';
spiPill.appendChild(el("span", null, text));
} }
/** 固件返回是分行的中文纯文本;逐行取数,缺行就留 null(不能补 0 —— 会把"没这项数据"显示成"0µs") */ /** 固件返回是分行的中文纯文本;逐行取数,缺行就留 null(不能补 0 —— 会把"没这项数据"显示成"0µs") */
function spiParse(text) { function spiParse(text) {
@@ -804,21 +801,20 @@ class RateWindow {
/** 返回窗口内的各类速率;无法判定的一律给 null,不猜 */ /** 返回窗口内的各类速率;无法判定的一律给 null,不猜 */
calc() { calc() {
const s = this.s, out = { const s = this.s, out = {
span: 0, pairs: 0, hz: null, lambda: null, drop: null, rx: null, skip: null, span: 0, hz: null, lambda: null, drop: null, skip: null,
untrusted: 0, motorStep: {}, motorChanges: {}, untrusted: 0, motorStep: {}, motorChanges: {},
rxbLostMax: 0, rxbPendingMax: 0, samples: s.length, rxbLostMax: 0, rxbPendingMax: 0,
}; };
if (s.length < 2) return out; if (s.length < 2) return out;
let dCyc = 0, dLam = 0, dDrop = 0, dRx = 0, dSkip = null, dt = 0; let dCyc = 0, dLam = 0, dDrop = 0, dSkip = null, dt = 0;
const steps = {}, changes = {}; const steps = {}, changes = {};
for (let i = 1; i < s.length; i++) { for (let i = 1; i < s.length; i++) {
const a = s[i - 1], b = s[i]; const a = s[i - 1], b = s[i];
const pair = (b.t - a.t) / 1000; const pair = (b.t - a.t) / 1000;
if (!(pair > 0)) continue; if (!(pair > 0)) continue;
dt += pair; out.pairs++; 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)) dLam += wrapDelta(a.gseq, b.gseq, 32);
if (isNum(a.rx) && isNum(b.rx)) dRx += wrapDelta(a.rx, b.rx, 32);
if (isNum(a.drop) && isNum(b.drop)) { 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 作废并计数 */
@@ -840,7 +836,7 @@ class RateWindow {
} }
out.span = dt; out.span = dt;
if (dt > 0) { if (dt > 0) {
out.hz = dCyc / dt; out.lambda = dLam / dt; out.rx = dRx / dt; out.drop = dDrop / dt; out.hz = dCyc / dt; out.lambda = dLam / dt; out.drop = dDrop / dt;
if (dSkip !== null) out.skip = dSkip / dt; if (dSkip !== null) out.skip = dSkip / dt;
for (const k in steps) { for (const k in steps) {
out.motorStep[k] = quantile(steps[k], 0.5); out.motorStep[k] = quantile(steps[k], 0.5);
@@ -882,7 +878,7 @@ const pcores = (mask) => {
const out = []; let m = mask >>> 0; const out = []; let m = mask >>> 0;
for (let b = 0; b < 32 && m; b++, m >>>= 1) if (m & 1) out.push(b); for (let b = 0; b < 32 && m; b++, m >>>= 1) if (m & 1) out.push(b);
let hi = Math.floor(mask / 4294967296); let hi = Math.floor(mask / 4294967296);
for (let b = 0; b < 32 && hi; b++, hi >>>= 1) if (m & 1) out.push(b + 32); for (let b = 0; b < 32 && hi; b++, hi >>>= 1) if (hi & 1) out.push(b + 32);
return out; return out;
}; };
const fmtPeriod = (ms) => (!ms ? "—" : (ms < 1 ? Math.round(ms * 1000) + "µs" : ms + "ms")); const fmtPeriod = (ms) => (!ms ? "—" : (ms < 1 ? Math.round(ms * 1000) + "µs" : ms + "ms"));
@@ -895,7 +891,7 @@ const E = {
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,
lastRawCycle: null, seenFrames: 0, seenT0: 0, subs: new Map(), lastRawCycle: null, seenFrames: 0, seenT0: 0, subs: new Map(),
lastSeq: {}, seqAt: {}, lastSeq: {}, seqAt: {}, densLast: null,
chart: { t: [], hz: [], us: [] }, errors: 0, errText: "", chart: { t: [], hz: [], us: [] }, errors: 0, errText: "",
}; };
/** 槽 seq 停更多久算不在交换(要大于"扫完全表 + 抖动":period 4ms × 6 拍 = 24ms,2s 有充足余量) */ /** 槽 seq 停更多久算不在交换(要大于"扫完全表 + 抖动":period 4ms × 6 拍 = 24ms,2s 有充足余量) */
@@ -905,11 +901,6 @@ const tblMotorsBody = $("#tblMotors tbody"), tblFramesBody = $("#tblFrames tbody
const tblSubsBody = $("#tblSubs tbody"), tblBeatsBody = $("#tblBeats tbody"); const tblSubsBody = $("#tblSubs tbody"), tblBeatsBody = $("#tblBeats tbody");
const tblWinBody = $("#tblWinSlots tbody"); const tblWinBody = $("#tblWinSlots tbody");
function ecatPill(id, cls, text) {
const n = $(id); n.className = "pill" + (cls ? " " + cls : "");
n.innerHTML = '<span class="led"></span>'; n.appendChild(el("span", null, text));
}
/** 节拍源:隐藏标签页会把主线程 setTimeout 节流到 ≥1s(实测被压到 1Hz, /** 节拍源:隐藏标签页会把主线程 setTimeout 节流到 ≥1s(实测被压到 1Hz,
* 采样密度一掉,所有"按相邻两拍差分"的判据就跟着失真)。电机页同款问题 * 采样密度一掉,所有"按相邻两拍差分"的判据就跟着失真)。电机页同款问题
* 用 js/motor/tick.worker.js 解决,Worker 的定时器不受此限;本页要保住 * 用 js/motor/tick.worker.js 解决,Worker 的定时器不受此限;本页要保住
@@ -997,7 +988,7 @@ async function handleTick(st) {
for (const k in E.seqAt) if (!(k in seq)) delete E.seqAt[k]; for (const k in E.seqAt) if (!(k in seq)) delete E.seqAt[k];
E.win.push({ E.win.push({
t, cycles: isNum(st.cycles) ? st.cycles : null, gseq: isNum(head.global_seq) ? head.global_seq : null, t, cycles: isNum(st.cycles) ? st.cycles : null, gseq: isNum(head.global_seq) ? head.global_seq : null,
drop: b.valid && isNum(b.drop) ? b.drop : null, rx: b.valid && isNum(b.rx_count) ? b.rx_count : null, drop: b.valid && isNum(b.drop) ? b.drop : null,
skip: dbg && isNum(dbg.skip_total) ? dbg.skip_total : null, seq, skip: dbg && isNum(dbg.skip_total) ? dbg.skip_total : null, seq,
rxbLost: (st.rx_batch || {}).lost, rxbPending: (st.rx_batch || {}).pending, rxbLost: (st.rx_batch || {}).lost, rxbPending: (st.rx_batch || {}).pending,
}); });
@@ -1020,7 +1011,7 @@ function startEcatIfIdle() {
E.live = true; E.everStarted = true; E.wantLive = true; tickPrev = null; E.pollDts = []; E.live = true; E.everStarted = true; E.wantLive = true; 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 = "暂停观测";
ecatPill("#ecatState", "warn", "观测中"); setPill("#ecatState", "warn", "观测中");
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; });
@@ -1031,7 +1022,7 @@ function stopEcat() {
E.live = false; E.wantLive = false; E.live = false; E.wantLive = false;
ticker.stop(); ticker.stop();
$("#btnLive").textContent = "开始实时观测"; $("#btnLive").textContent = "开始实时观测";
ecatPill("#ecatState", "", "已暂停"); setPill("#ecatState", "", "已暂停");
} }
$("#btnLive").onclick = () => { E.live ? stopEcat() : startEcatIfIdle(); }; $("#btnLive").onclick = () => { E.live ? stopEcat() : startEcatIfIdle(); };
$("#btnRing").onclick = () => { $("#btnRing").onclick = () => {
@@ -1084,18 +1075,28 @@ function renderMetrics(st, load, dbg, r, rtt) {
isNum(r.lambda) ? (r.lambda > 0 ? "good" : "bad") : ""); isNum(r.lambda) ? (r.lambda > 0 ? "good" : "bad") : "");
/* drop 是个会自证的数:段1/段3 只有一个"最近一帧"格子,每拍最多发布 1 帧, /* drop 是个会自证的数:段1/段3 只有一个"最近一帧"格子,每拍最多发布 1 帧,
* 于是它恒等于 λ − 实测Hz,拿它判好坏没有灵敏度(连"视野百分比"这种换算都不必做, * 于是它恒等于 λ − 实测Hz,拿它判好坏没有灵敏度(连"视野百分比"这种换算都不必做,
* 那是同一个数的另一种写法)。卡面只报真丢帧:主站 seq 空洞(rxbLostMax) 与从站 * 那是同一个数的另一种写法)。卡面只报真丢帧:主站 seq 空洞(rxbLostMax) 与 GD32
* OVF(rxbPendingMax) 取抽样单拍最大值(两者独立、正常应同步增长),颜色叠上撕裂累计。 */ * 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) + " · 从站OVF " + (r.rxbPendingMax || 0) + "主站空洞 " + (r.rxbLostMax || 0) + " · GD32 OVF " + (r.rxbPendingMax || 0) +
" · 撕裂 " + tornN + " · 另有段1/段3 单格 drop " + f0(r.drop) + "/s(≡λ−Hz,不算丢帧" + " · 撕裂 " + tornN + " · 另有段1/段3 单格 drop " + f0(r.drop) + "/s(≡λ−Hz,不算丢帧" +
(r.untrusted ? " · " + r.untrusted + " 点作废(采样率不足)" : "") + ")", (r.untrusted ? " · " + r.untrusted + " 点作废(采样率不足)" : "") + ")",
(r.drop === null && !holes && !tornN) ? "" : (holes > 0 || tornN > 0 ? "bad" : "good")); (r.drop === null && !holes && !tornN) ? "" : (holes > 0 || tornN > 0 ? "bad" : "good"));
metric("cSkip", f1(r.skip) + "<small>/s</small>", /* 超期/跳拍:cyc_us.late/lost 随 state 每拍就有(最近 n 拍窗口),不必开逐拍环。
isNum(r.skip) ? "累计 " + ((dbg && dbg.skip_total) ?? "—") : "需开「逐拍环」才有 skip_total", * 口径是**窗口计数**不是自启动累计;且只有 2026-09-11 之后的主站构建接了真值,
r.skip === null ? "" : (r.skip < 0.5 ? "good" : "bad")); * 旧构建恒报 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 loadFresh = load && load.valid && isNum(load.age_ms) && load.age_ms < 1000;
const loadPct = loadFresh ? load.load_perm / 10 : null; const loadPct = loadFresh ? load.load_perm / 10 : null;
@@ -1127,19 +1128,19 @@ function renderMetrics(st, load, dbg, r, rtt) {
rtt === null ? "bad" : rtt < 30 ? "good" : "warn"); rtt === null ? "bad" : rtt < 30 ? "good" : "warn");
if (st && st.ok === false) { if (st && st.ok === false) {
ecatPill("#ecatLink", "bad", "后端未交换:" + ((st.log || []).join(" / ") || "未知")); setPill("#ecatLink", "bad", "后端未交换:" + ((st.log || []).join(" / ") || "未知"));
} else if (!exchanging) { } else if (!exchanging) {
ecatPill("#ecatLink", "warn", "PDO 循环没在跑(wkc " + (st ? st.wkc : "—") + "/" + (st ? st.expected_wkc : "—") + setPill("#ecatLink", "warn", "PDO 循环没在跑(wkc " + (st ? st.wkc : "—") + "/" + (st ? st.expected_wkc : "—") +
"," + (((st && st.motors) || []).length - mv.length) + " 槽 seq 停更)→ 去电机页/EtherCAT 页连一次"); "," + (((st && st.motors) || []).length - mv.length) + " 槽 seq 停更)→ 去电机页/EtherCAT 页连一次");
} else { } else {
ecatPill("#ecatLink", "ok", "交换中 · " + f0(r.hz) + "Hz · " + mv.length + " 槽在刷新"); setPill("#ecatLink", "ok", "交换中 · " + f0(r.hz) + "Hz · " + mv.length + " 槽在刷新");
} }
const s = E.status && E.status.slave; const s = E.status && E.status.slave;
if (s) { if (s) {
const al = AL_NAME[s.state] || ("0x" + (s.state || 0).toString(16)); const al = AL_NAME[s.state] || ("0x" + (s.state || 0).toString(16));
ecatPill("#ecatAl", s.state === 8 ? "ok" : "warn", "AL " + al + (s.al_status ? " code 0x" + s.al_status.toString(16).toUpperCase() : "")); setPill("#ecatAl", s.state === 8 ? "ok" : "warn", "AL " + al + (s.al_status ? " code 0x" + s.al_status.toString(16).toUpperCase() : ""));
} else { } else {
ecatPill("#ecatAl", "", "AL —"); setPill("#ecatAl", "", "AL —");
} }
} }
@@ -1182,10 +1183,9 @@ function renderBridge(st) {
["rx_valid / truncated", b.valid ? b.rx_valid + " / " + b.truncated : null, (b.valid && b.truncated) ? "warn" : 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"], ["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"], ["tx_result", b.valid ? (b.tx_result + " " + (b.tx_result_text || "")) : null, (b.valid && b.tx_result === 0) ? "good" : "warn"],
["rx_batch count / lost(主站空洞) / pending(从站OVF)", rb.count === undefined ? null : rb.count + " / " + rb.lost + " / " + rb.pending, ["rx_batch count / lost(主站空洞) / pending(GD32 OVF)", rb.count === undefined ? null : rb.count + " / " + rb.lost + " / " + rb.pending,
(rb.lost || rb.pending) ? "warn" : "good"], (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], ["xrx 长帧", (st && st.xrx && st.xrx.data) ? "0x" + st.xrx.id.toString(16).toUpperCase() + " · " + st.xrx.dlc + "B" : null],
["TEC / REC", null, "dim"],
]); ]);
} }
@@ -1240,7 +1240,6 @@ function renderMotors(st, r) {
const hx = m.data || ""; const hx = m.data || "";
for (let i = 0; i < 96; i += 32) mcell(" " + (hx.substr(i, 32) ? hexGrp(hx.substr(i, 32)) : "—")); for (let i = 0; i < 96; i += 32) mcell(" " + (hx.substr(i, 32) ? hexGrp(hx.substr(i, 32)) : "—"));
} }
if (!motors.length) mcell("无槽", "hdr");
} }
/* ═══════════════════ 渲染:批量接收区(按 cycle 去重) ═══════════════════ */ /* ═══════════════════ 渲染:批量接收区(按 cycle 去重) ═══════════════════ */
@@ -1251,7 +1250,7 @@ function renderFrames(st, raw) {
const frames = (raw && raw.parsed) || []; const frames = (raw && raw.parsed) || [];
const rb = (st && st.rx_batch) || {}; const rb = (st && st.rx_batch) || {};
$("#rxbPill").textContent = "rxraw 本拍新帧 " + frames.length + " · state 侧计数 新帧 " + rb.count + $("#rxbPill").textContent = "rxraw 本拍新帧 " + frames.length + " · state 侧计数 新帧 " + rb.count +
" / seq 空洞 " + rb.lost + " / 从站 OVF " + rb.pending + " / seq 空洞 " + rb.lost + " / GD32 OVF " + rb.pending +
" · layout " + (raw && raw.layout) + " · cycle " + (raw && raw.cycle); " · layout " + (raw && raw.layout) + " · cycle " + (raw && raw.cycle);
/* 轮询只看得到"这一拍的覆盖窗口",全量帧永远采不完 —— 必须把采样占空比摊开说, /* 轮询只看得到"这一拍的覆盖窗口",全量帧永远采不完 —— 必须把采样占空比摊开说,
* 否则展示时这串数字会被当成总帧数。 */ * 否则展示时这串数字会被当成总帧数。 */
@@ -1282,7 +1281,10 @@ function renderFrames(st, raw) {
const k = (s.id >>> 0) + ":" + s.data; const k = (s.id >>> 0) + ":" + s.data;
if (!best.has(k) || s.seq > best.get(k).seq) best.set(k, s); if (!best.has(k) || s.seq > best.get(k).seq) best.set(k, s);
} }
if (!frames.length) body.appendChild(el("tr")).appendChild(el("td", "dim", "这一拍没有新到的批量帧(STATUS 走状态窗口,不进这里)")); if (!frames.length) body.appendChild(el("tr")).appendChild(el("td", "dim",
raw && raw.layout === "dense"
? "本拍无长帧变更(STATUS 在上方状态槽 + 下方整轮槽表;CONFIG/版本变了才会进这里)"
: "这一拍没有新到的批量帧(STATUS 走状态窗口,不进这里)"));
for (const f of frames) { for (const f of frames) {
const node = (f.id >> 16) & 0xF, sub = f.id & 0xFFFF; 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 src = best.get((f.id >>> 0) + ":" + String(f.data).slice(0, 16).toUpperCase());
@@ -1317,6 +1319,32 @@ function renderFrames(st, raw) {
/* 覆盖窗口单槽布局:44B = ch@0 dlc@1 flags@2 (pad@3) id@4..7 seq@8..11 data@12..43 */ /* 覆盖窗口单槽布局: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; 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) { function decodeWinSlots(hex) {
if (!hex) return []; if (!hex) return [];
const b = new Uint8Array(hex.length / 2); const b = new Uint8Array(hex.length / 2);
@@ -1335,8 +1363,11 @@ function decodeWinSlots(hex) {
} }
function renderWinSlots(raw, slots, best) { function renderWinSlots(raw, slots, best) {
const body = tblWinBody; const body = tblWinBody;
slots = slots || ((raw && raw.layout === "win") ? decodeWinSlots(raw.raw) : []);
body.textContent = ""; body.textContent = "";
if (raw && raw.layout === "dense") {
renderDenseRound(raw, body);
return;
}
if (!slots.length) { if (!slots.length) {
const tr = el("tr"); const tr = el("tr");
tr.appendChild(el("td", "dim", raw && raw.layout !== "win" tr.appendChild(el("td", "dim", raw && raw.layout !== "win"
@@ -1384,20 +1415,67 @@ function renderWinSlots(raw, slots, best) {
} }
} }
/* 1.6.21 密排整轮槽表:同一拍的线级原样,与上方状态槽解码结果对账用 */
function renderDenseRound(raw, body) {
const round = decodeDenseRound(raw.raw);
if (!round || !round.slots.length) {
const tr = el("tr");
tr.appendChild(el("td", "dim", "密排整轮 raw 为空或 count=0(这一拍没有电机槽)"));
tr.children[0].colSpan = 10;
body.appendChild(tr);
return;
}
if (!E.densLast) E.densLast = new Map();
const cap = document.getElementById("winSlotCap");
if (cap) cap.innerHTML =
"密排整轮槽 · count=" + round.head.count + " active=" + round.head.active +
" boot=" + round.head.boot + " magic=0x" + round.head.magic.toString(16).toUpperCase() +
" gseq=" + round.head.gseq.toLocaleString("en-US") +
"(node ch online dlc seq + 48B STATUS,空槽不发送)。" +
" 末列「本拍新数据」:<b>是</b> = seq 比上一拍大(差分命中,状态真变了);" +
"<b>否</b> = seq 未变,固件本拍跳过填充。";
for (const s of round.slots) {
const prev = E.densLast.get(s.node + ":" + s.ch);
const dseq = (prev === undefined) ? null : (s.seq - prev);
const isNew = dseq !== null && dseq !== 0;
E.densLast.set(s.node + ":" + s.ch, s.seq);
/* STATUS CAN ID:0x180+node(DBC v0.74,默认扩展帧在 MotorState 侧,
* 这里只标语义,真 ID 以状态槽/DBC 为准) */
const tr = el("tr");
const td = (v, cls) => tr.appendChild(el("td", cls, v));
td(s.slot, "num");
td(s.online ? "在线" : "离线", s.online ? "good" : "warn");
td(s.seq.toLocaleString("en-US"), "num");
td(dseq === null ? "—" : (dseq === 0 ? "0(未变)" : "+" + dseq), "num " + (dseq ? "good" : "dim"));
td(s.ch, "num");
td(s.dlc, "num");
td("0x" + (0x180 + s.node).toString(16).toUpperCase(), "num");
td("N" + s.node + " · STATUS");
td(hexShort(s.data, 16), "hex");
td(prev === undefined ? "首见" : (isNew ? "是(seq 前进)" : "否(差分跳过)"),
isNew || prev === undefined ? "good" : "dim");
tr.title = "48B STATUS: " + (s.full || s.data);
body.appendChild(tr);
}
}
/* ═══════════════════ 渲染:原始接收窗口 ═══════════════════ */ /* ═══════════════════ 渲染:原始接收窗口 ═══════════════════ */
function renderRaw(st, raw) { function renderRaw(st, raw) {
const x = (st && st.xrx) || null; const x = (st && st.xrx) || null;
/* rxraw 的三个头字节是"原样 dump 不翻译":win 布局下它们是 /* rxraw 的三个头字节随布局变义:win=slot_count/wr_idx/ovf;
* slot_count / wr_idx(写游标) / ovf(从站侧未取先覆计数),沿用旧名会把写游标读成丢帧数 */ * dense=count/active/boot(lost/pending 对 STATUS 不适用) */
const isWin = raw && raw.layout === "win"; const isWin = raw && raw.layout === "win";
const hLbl = isWin ? ["窗口槽数 slot_count", "写游标 wr_idx(下一格覆盖谁)", "从站 OVF 未取先覆"] const isDense = raw && raw.layout === "dense";
: ["count", "lost", "pending"]; const hLbl = isWin ? ["窗口槽数 slot_count", "写游标 wr_idx(下一格覆盖谁)", "GD32 OVF 未取先覆"]
: isDense ? ["count 密排电机数", "active", "boot"]
: ["count", "lost", "pending"];
kv("rawKv", [ kv("rawKv", [
["rxraw cycle", isNum(raw && raw.cycle) ? raw.cycle.toLocaleString("en-US") : null], ["rxraw cycle", isNum(raw && raw.cycle) ? raw.cycle.toLocaleString("en-US") : null],
["layout", raw ? raw.layout : null], ["layout", raw ? raw.layout : null],
[hLbl[0], raw ? raw.count : null], [hLbl[0], raw ? raw.count : null],
[hLbl[1], raw ? raw.lost : null], [hLbl[1], raw ? raw.lost : null],
[hLbl[2], raw ? raw.pending : null, (isWin && raw.pending) ? "warn" : null], [hLbl[2], raw ? raw.pending : null, (isWin && raw.pending) ? "warn" : null],
["gseq", isDense && isNum(raw.gseq) ? raw.gseq.toLocaleString("en-US") : null],
["len (字节)", raw ? raw.len : null], ["len (字节)", raw ? raw.len : null],
["parsed 帧数", raw && raw.parsed ? raw.parsed.length : null], ["parsed 帧数", raw && raw.parsed ? raw.parsed.length : null],
["xrx ch/dlc/id", x ? x.ch + " / " + x.dlc + " / 0x" + (x.id >>> 0).toString(16).toUpperCase() : null], ["xrx ch/dlc/id", x ? x.ch + " / " + x.dlc + " / 0x" + (x.id >>> 0).toString(16).toUpperCase() : null],
@@ -1416,6 +1494,20 @@ function renderRaw(st, raw) {
grid.textContent = ""; grid.textContent = "";
const cell = (t, cls) => grid.appendChild(el("i", cls, t)); const cell = (t, cls) => grid.appendChild(el("i", cls, t));
if (!raw || !raw.raw) { cell(raw ? "原始窗口为空(这一拍没有批量帧)" : "—", "hdr"); return; } if (!raw || !raw.raw) { cell(raw ? "原始窗口为空(这一拍没有批量帧)" : "—", "hdr"); return; }
if (raw.layout === "dense") {
/* 密排:8B 头 + count×56B 槽。头单独一行,每槽 56B=112 hex 切成 2 行 */
const hex = raw.raw;
if (hex.length >= 16) cell("头 " + hexGrp(hex.substr(0, 16)), "hdr");
for (let s = 0; s * DENSE_SLOT * 2 < hex.length - 16; s++) {
const off = 16 + s * DENSE_SLOT * 2, slotHex = hex.substr(off, DENSE_SLOT * 2);
if (!slotHex) break;
cell("槽" + s + " " + hexGrp(slotHex.substr(0, 16)), "slot");
cell(" " + hexGrp(slotHex.substr(16, 32)));
if (slotHex.length > 48) cell(" " + hexGrp(slotHex.substr(48, 32)));
if (slotHex.length > 80) cell(" " + hexGrp(slotHex.substr(80)));
}
return;
}
if (raw.layout !== "win") { if (raw.layout !== "win") {
for (let i = 0; i < raw.raw.length; i += 32) for (let i = 0; i < raw.raw.length; i += 32)
cell(String(i / 2).padStart(4, "0") + " " + hexGrp(raw.raw.substr(i, 32))); cell(String(i / 2).padStart(4, "0") + " " + hexGrp(raw.raw.substr(i, 32)));
@@ -1524,11 +1616,8 @@ function drawChart() {
window.addEventListener("resize", layoutChart); window.addEventListener("resize", layoutChart);
/* ═══════════════════ 热改 PDO 周期 ═══════════════════ */ /* ═══════════════════ 热改 PDO 周期 ═══════════════════ */
const runPill = $("#runPill"), runProg = $("#runProg"), btnEcatAbort = $("#btnEcatAbort"); const runProg = $("#runProg"), btnEcatAbort = $("#btnEcatAbort");
function runBusy(cls, text) { function runBusy(cls, text) { setPill("#runPill", cls, text); }
runPill.className = "pill" + (cls ? " " + cls : "");
runPill.innerHTML = '<span class="led"></span>'; runPill.appendChild(el("span", null, text));
}
$("#btnPeriod").onclick = async () => { $("#btnPeriod").onclick = async () => {
const v = parseFloat($("#periodPick").value); const v = parseFloat($("#periodPick").value);
if (!(v > 0)) { runBusy("warn", "先在「PDO 周期档位」里选一档"); return; } if (!(v > 0)) { runBusy("warn", "先在「PDO 周期档位」里选一档"); return; }
@@ -1607,7 +1696,7 @@ async function measureOnce(kind, seconds, hz, extraNote) {
} }
win.push({ win.push({
t, cycles: isNum(st.cycles) ? st.cycles : null, gseq: isNum(head.global_seq) ? head.global_seq : null, t, cycles: isNum(st.cycles) ? st.cycles : null, gseq: isNum(head.global_seq) ? head.global_seq : null,
drop: b.valid && isNum(b.drop) ? b.drop : null, rx: b.valid && isNum(b.rx_count) ? b.rx_count : null, drop: b.valid && isNum(b.drop) ? b.drop : null,
seq, rxbLost: (st.rx_batch || {}).lost, rxbPending: (st.rx_batch || {}).pending, 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); } if (cyc.total) { p50s.push(cyc.total.p50); p90s.push(cyc.total.p90); pmaxs.push(cyc.total.max); }
@@ -1689,7 +1778,7 @@ async function withLivePaused(fn) {
finally { finally {
for (const b of [$("#btnEcatRun"), $("#btnSweep"), $("#btnPeriod")]) b.disabled = false; for (const b of [$("#btnEcatRun"), $("#btnSweep"), $("#btnPeriod")]) b.disabled = false;
btnEcatAbort.hidden = true; btnEcatAbort.hidden = true;
if (wasLive) { E.live = true; tickPrev = null; ecatPill("#ecatState", "warn", "观测中"); ticker.start(1000 / pollHz()); requestTick(); } if (wasLive) { E.live = true; tickPrev = null; setPill("#ecatState", "warn", "观测中"); ticker.start(1000 / pollHz()); requestTick(); }
else runBusy("", "空闲"); else runBusy("", "空闲");
} }
} }
@@ -1761,6 +1850,7 @@ $("#btnSweep").onclick = () => withLivePaused(async () => {
$("#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.densLast = null;
E.seenT0 = performance.now(); E.seenT0 = performance.now();
/* 暂停观测时不会再渲染,标签必须就地归零,否则点了像没生效 */ /* 暂停观测时不会再渲染,标签必须就地归零,否则点了像没生效 */
$("#rxbSeen").textContent = "本页轮询采到的帧样本 0 条"; $("#rxbSeen").textContent = "本页轮询采到的帧样本 0 条";
+30 -2
View File
@@ -225,7 +225,35 @@
</div> </div>
<div class="nodeid-result" id="nodeIdResult">连接设备后读取节点 ID</div> <div class="nodeid-result" id="nodeIdResult">连接设备后读取节点 ID</div>
<p style="margin-top:var(--sp-3);font-size:.72rem;color:var(--fg-dim);line-height:1.6"> <p style="margin-top:var(--sp-3);font-size:.72rem;color:var(--fg-dim);line-height:1.6">
SET_NODE_ID (0x000N0400 DLC2) / GET_NODE_ID (0x000N0401 DLC2)。写入后 CAN ID 前缀变为 0x000Nxxxx。 SET_NODE_ID (0x000N0400 DLC2) / GET_NODE_ID (0x000N0401 DLC2,选择器 0x0000)。写入后 CAN ID 前缀变为 0x000Nxxxx。
</p>
<!-- 反馈刷新率:界面单位 Hz,协议值是周期 ms(=1000÷Hz,电机 1ms 节拍分频)。
配置值以「读取」回读电机为准(0x0401),「写入」经 0x0400 下发并掉电保存 -->
<div class="nodeid-row" style="margin-top:var(--sp-4)">
<span class="field-label">反馈刷新率 <span class="field-unit">Hz · 0=停发 · 写入掉电保存</span></span>
</div>
<div class="nodeid-row">
<div class="field" style="min-width:140px">
<span class="field-label">STATUS 帧 <span class="field-unit">默认 1000 Hz</span></span>
<input type="number" id="periodStatusInput" min="0" max="1000" value="1000" aria-label="STATUS 帧刷新率(赫兹,0=停发)" />
</div>
<button class="btn btn-sm" id="getPeriodStatusBtn">读取</button>
<button class="btn btn-primary btn-sm" id="setPeriodStatusBtn">写入</button>
</div>
<div class="nodeid-row">
<div class="field" style="min-width:140px">
<span class="field-label">CONFIG 帧 <span class="field-unit">默认 250 Hz</span></span>
<input type="number" id="periodConfigInput" min="0" max="1000" value="250" aria-label="CONFIG 帧刷新率(赫兹,0=停发)" />
</div>
<button class="btn btn-sm" id="getPeriodConfigBtn">读取</button>
<button class="btn btn-primary btn-sm" id="setPeriodConfigBtn">写入</button>
</div>
<div class="nodeid-result" id="periodResult">连接设备后读取当前刷新率</div>
<p style="margin-top:var(--sp-3);font-size:.72rem;color:var(--fg-dim);line-height:1.6">
输入为频率(Hz),协议按 周期(ms) = 1000 ÷ Hz 换算下发:0x000N0400 DLC4 [选择器][周期] /
0x000N0401 DLC2 [选择器](选择器 0x0100=STATUS · 0x0101=CONFIG)。当前值以「读取」回读为准。
停发 STATUS 后状态面板会显示离线,电机仍可控可刷写。
</p> </p>
</div> </div>
<!-- OTA TAB --> <!-- OTA TAB -->
@@ -402,7 +430,7 @@
</div> </div>
</div> </div>
<script type="module" src="../js/motor/main.js?v=20260908a"></script> <script type="module" src="../js/motor/main.js?v=20260912b"></script>
<!-- 单独打开本页(不在标签外壳里)时, 由本页自己持有 app 租约 --> <!-- 单独打开本页(不在标签外壳里)时, 由本页自己持有 app 租约 -->
<script src="../js/common/app-lease.js"></script> <script src="../js/common/app-lease.js"></script>
</body> </body>
+1 -1
View File
@@ -600,7 +600,7 @@ def main():
'<code>sys/cpu_load.h</code> 定义收窄为"中断+非延迟处理段",' '<code>sys/cpu_load.h</code> 定义收窄为"中断+非延迟处理段",'
'<b>排除</b> SSC MainLoop 轮询;当前 free-run(<code>dc=0</code>)下 PDO ' '<b>排除</b> SSC MainLoop 轮询;当前 free-run(<code>dc=0</code>)下 PDO '
'应用走主循环轮询路径、<b>不计入</b>。所以不能把"load→100%"读成"CPU 耗尽"。</li>' '应用走主循环轮询路径、<b>不计入</b>。所以不能把"load→100%"读成"CPU 耗尽"。</li>'
'<li><b><code>OVF</code> 每拍被 <code>s_rxWinOvf=0</code> 清零</b> —— 从站侧' '<li><b><code>OVF</code> 每拍被 <code>s_rxWinOvf=0</code> 清零</b> —— GD32 侧'
'没有可用的累计丢帧计数。读它恒 0 <b>不能</b>当"无丢帧"证据' '没有可用的累计丢帧计数。读它恒 0 <b>不能</b>当"无丢帧"证据'
'(我据此误判 <code>bridge.drop</code> 是死字段,结论已收回)。</li>' '(我据此误判 <code>bridge.drop</code> 是死字段,结论已收回)。</li>'
'<li><b><code>/api/can/cadence</code> 只存最近 512 拍</b> —— 0.25ms 档 ' '<li><b><code>/api/can/cadence</code> 只存最近 512 拍</b> —— 0.25ms 档 '
+3 -3
View File
@@ -9,7 +9,7 @@
L2 CAN 软件队列 rx_drop_count(深度 16 满则丢) —— 只在 ESP32 侧有,过程数据里没有 L2 CAN 软件队列 rx_drop_count(深度 16 满则丢) —— 只在 ESP32 侧有,过程数据里没有
L3 ECAT 桥 bridge.rx_count(进桥帧数) L3 ECAT 桥 bridge.rx_count(进桥帧数)
L4 段4 覆盖窗口 s_rxWinSeq 每入窗帧 +2 → Δmax_seq/2 是**精确**的入窗帧数 L4 段4 覆盖窗口 s_rxWinSeq 每入窗帧 +2 → Δmax_seq/2 是**精确**的入窗帧数
L5 主站取帧 rx_batch.lost(主站 seq 空洞) / pending(从站 OVF) —— 单拍值, L5 主站取帧 rx_batch.lost(主站 seq 空洞) / pending(GD32 OVF) —— 单拍值,
由本脚本按 cycle 去重后统计,仍是**下界**(轮询看不到每一拍) 由本脚本按 cycle 去重后统计,仍是**下界**(轮询看不到每一拍)
L3−L4 就是 STATUS 帧数(STATUS 在入窗之前就 return,见固件 L3−L4 就是 STATUS 帧数(STATUS 在入窗之前就 return,见固件
@@ -154,7 +154,7 @@ def main():
print(f"段4 布局是 {rr.get('layout')!r},不是 1.6.15 覆盖窗口(win) —— 本脚本的解析器不适用") print(f"段4 布局是 {rr.get('layout')!r},不是 1.6.15 覆盖窗口(win) —— 本脚本的解析器不适用")
return 2 return 2
# 注意:rxraw 的 count/lost/pending 是**旧布局偏移**(360/361/362)的原始字节, # 注意:rxraw 的 count/lost/pending 是**旧布局偏移**(360/361/362)的原始字节,
# 在 win 布局下分别是"窗口槽数常量""环形写游标 0..4""从站 OVF"。只有第三个 # 在 win 布局下分别是"窗口槽数常量""环形写游标 0..4""GD32 OVF"。只有第三个
# 有意义,前两个拿来当丢帧数会得出"lost 最大 4、84% 的拍非 0"这种假结论。 # 有意义,前两个拿来当丢帧数会得出"lost 最大 4、84% 的拍非 0"这种假结论。
if rr and rr.get("cycle") != seen_cycle: # 按拍去重:同一拍重复读会重复计数 if rr and rr.get("cycle") != seen_cycle: # 按拍去重:同一拍重复读会重复计数
seen_cycle = rr.get("cycle") seen_cycle = rr.get("cycle")
@@ -212,7 +212,7 @@ def main():
f"STATUS 在入窗前就 return,固件 ethercat_can_bridge.cpp:435") f"STATUS 在入窗前就 return,固件 ethercat_can_bridge.cpp:435")
print(f" 本脚本见到的唯一入窗帧 {got:10.0f} 下界:轮询只覆盖 {n_state / max(1e-9, hz * dt):.1%} 的拍") print(f" 本脚本见到的唯一入窗帧 {got:10.0f} 下界:轮询只覆盖 {n_state / max(1e-9, hz * dt):.1%} 的拍")
print(f"L5 单拍值 主站空洞 lost 最大 {lost_max:10.0f} 每拍回填清零,只能当下界") print(f"L5 单拍值 主站空洞 lost 最大 {lost_max:10.0f} 每拍回填清零,只能当下界")
print(f" 从站 OVF pending 最大 {pend_max:10.0f} 同上") print(f" GD32 OVF pending 最大 {pend_max:10.0f} 同上")
print(f" 主站上报 count 最大 {cnt_max:10.0f} 单拍上报帧数(窗口 5 槽)") print(f" 主站上报 count 最大 {cnt_max:10.0f} 单拍上报帧数(窗口 5 槽)")
print(f"-- 段1/段3 单格 drop {drop_single:10.0f} " print(f"-- 段1/段3 单格 drop {drop_single:10.0f} "
f"恒等于 λ−Hz = {lam - hz:.0f},不参与判丢帧") f"恒等于 λ−Hz = {lam - hz:.0f},不参与判丢帧")
+23 -2
View File
@@ -9,7 +9,10 @@ ota_relay_trigger_handler 契约一致):
3. POST /ota/relay,请求体 = {"sha256":"<该 BIN 的 sha256>"};设备侧还会做 3. POST /ota/relay,请求体 = {"sha256":"<该 BIN 的 sha256>"};设备侧还会做
包身份防呆(复位向量必须落在 APP 区,误传 BOOT 会被拒); 包身份防呆(复位向量必须落在 APP 区,误传 BOOT 会被拒);
4. 轮询 /system/status 直到 ota.sys_state=GD32_COMPLETE 且 ota.gd32_app 是 4. 轮询 /system/status 直到 ota.sys_state=GD32_COMPLETE 且 ota.gd32_app 是
期望版本、gd32_adc.uptime_sec 已归零; 期望版本;**并且必须拿到"GD32 真的在跑新代码"的证据**:版本号相对刷前变了,
或 gd32_adc.uptime_sec 归零(复位过)。两者都没有 → 判"未生效",不写台账、
非零退出。(实测踩过:01:44 刷进 1.6.22 后板子又跑了 2 小时老镜像,
布局/耗时全程不变,台账却已更新 —— 写入 ≠ 在跑。)
5. 把该 BIN 的 sha256 写进 lab/_last_gd32_sha.txt(板上镜像台账)。 5. 把该 BIN 的 sha256 写进 lab/_last_gd32_sha.txt(板上镜像台账)。
用法: python scripts/flash_gd32.py [期望版本 如 1.6.5.0] 用法: python scripts/flash_gd32.py [期望版本 如 1.6.5.0]
@@ -93,6 +96,14 @@ def main():
print(f"镜像 {HEX}\n 展平 {len(blob)}B @0x{APP_BASE:08X} sha256={sha}") print(f"镜像 {HEX}\n 展平 {len(blob)}B @0x{APP_BASE:08X} sha256={sha}")
hdr = json.dumps({"total_size": len(blob)}).encode("ascii") hdr = json.dumps({"total_size": len(blob)}).encode("ascii")
b_ver, b_up = "", None
try:
b = get("/system/status")
b_ver = str((b.get("ota") or {}).get("gd32_app") or "").lstrip("v")
b_up = (b.get("gd32_adc") or {}).get("uptime_sec")
except Exception as e:
print(f"刷前 /system/status 读失败({type(e).__name__}):生效判定只能靠 uptime")
print(f"刷前: gd32_app=v{b_ver or '?'} gd32 uptime={b_up}")
t0 = time.time() t0 = time.time()
st, body = post("/ota/upload", hdr + blob, timeout=120) st, body = post("/ota/upload", hdr + blob, timeout=120)
print(f"upload -> {st} {body[:200]} ({time.time()-t0:.1f}s)") print(f"upload -> {st} {body[:200]} ({time.time()-t0:.1f}s)")
@@ -120,11 +131,21 @@ def main():
last = line last = line
if o.get("sys_state") == "GD32_COMPLETE" and o.get("gd32_app"): if o.get("sys_state") == "GD32_COMPLETE" and o.get("gd32_app"):
ver = str(o["gd32_app"]).lstrip("v") ver = str(o["gd32_app"]).lstrip("v")
a_up = (s.get("gd32_adc") or {}).get("uptime_sec")
if want and ver != want: if want and ver != want:
raise SystemExit(f"板上版本 {o['gd32_app']} 与期望 {want} 不符 —— 刷写未生效") raise SystemExit(f"板上版本 {o['gd32_app']} 与期望 {want} 不符 —— 刷写未生效")
changed = bool(b_ver) and ver != b_ver
rebooted = a_up is not None and (b_up is None or a_up < b_up)
if not changed and not rebooted:
raise SystemExit(
f"写入完成,但没有 GD32 换跑新代码的证据:板上仍是 v{ver}、"
f"uptime={a_up}(刷前 {b_up})—— 老镜像还在跑,台账不更新。"
"断电重启(或复位 APP)后重跑本脚本再确认")
with open(LEDGER, "w", encoding="ascii") as f: with open(LEDGER, "w", encoding="ascii") as f:
f.write(sha) f.write(sha)
print(f"完成: 板上 gd32_app={o['gd32_app']},台账已写 {os.path.normpath(LEDGER)}") print(f"完成: 板上 gd32_app=v{ver}(依据: "
f"{'版本 ' + b_ver + '→' + ver if changed else 'uptime 归零 ' + str(b_up) + '→' + str(a_up)}"
f"),台账已写 {os.path.normpath(LEDGER)}")
return return
raise SystemExit("90s 内未见 GD32_COMPLETE —— 刷写未完成,别继续测试") raise SystemExit("90s 内未见 GD32_COMPLETE —— 刷写未完成,别继续测试")
+24 -1
View File
@@ -15,11 +15,13 @@
* 4 取值:缺失/脏值回落该链路默认,不回落成另一条链路的值 * 4 取值:缺失/脏值回落该链路默认,不回落成另一条链路的值
* 5 隔离:改一条链路不影响另一条(这就是本次改动的全部意义) * 5 隔离:改一条链路不影响另一条(这就是本次改动的全部意义)
* 6 不限速谓词:6ms 档要关掉卡顿黄标与理论 Hz 显示(6ms ≠ 166Hz) * 6 不限速谓词:6ms 档要关掉卡顿黄标与理论 Hz 显示(6ms ≠ 166Hz)
* 7 判活阈值 staleLimitMs:随实测 seq 间隔自适应,钳在 [2s, 30s]
*/ */
import { import {
REFRESH_DEFAULTS, REFRESH_MIN_MS, REFRESH_MAX_MS, REFRESH_MIN_BY_TP, REFRESH_DEFAULTS, REFRESH_MIN_MS, REFRESH_MAX_MS, REFRESH_MIN_BY_TP,
UNTHROTTLED_MAX_MS, minRefreshFor, isUnthrottled, UNTHROTTLED_MAX_MS, minRefreshFor, isUnthrottled,
defaultRefreshMap, normalizeRefreshMap, pickRefresh defaultRefreshMap, normalizeRefreshMap, pickRefresh,
staleLimitMs, STALE_OFFLINE_MS, STALE_IV_FACTOR, STALE_MAX_MS
} from "../js/motor/refresh-rate.js"; } from "../js/motor/refresh-rate.js";
let pass = 0, fail = 0; let pass = 0, fail = 0;
@@ -143,6 +145,27 @@ check("WiFi 下限 50ms 永远不进不限速区(该链路无此档)",
check("ECAT 下限 6ms 一定在不限速区", isUnthrottled(REFRESH_MIN_BY_TP.ecat) === true); check("ECAT 下限 6ms 一定在不限速区", isUnthrottled(REFRESH_MIN_BY_TP.ecat) === true);
check("非数字不炸且判 false", isUnthrottled(undefined) === false && isUnthrottled(NaN) === false); check("非数字不炸且判 false", isUnthrottled(undefined) === false && isUnthrottled(NaN) === false);
/* ── 7. ECAT 判活阈值自适应 ──
* 固定 2s 阈值的问题:电机 CAN 回报周期调到秒级后,两次**正常**回报之间
* 就超过 2s,节点反复判离线(LED 闪/波形虚线/面板切离线层)。
* 生效阈值 = clamp(3×EWMA(实测间隔), 2s, 30s)。
* 注:STATUS 面板的 Hz 刻意不按 seq 实测电机回报率 —— 面板显示的是网页
* 实际刷新率(数据到达口径),WiFi 慢于电机时按电机档位显示就是谎报。 */
section("[7] 判活阈值 staleLimitMs(随实测 seq 间隔自适应)");
check("下限 2s / 系数 3 / 上限 30s(契约常量)",
STALE_OFFLINE_MS === 2000 && STALE_IV_FACTOR === 3 && STALE_MAX_MS === 30000);
check("还没观察到第二次变化(iv=0)→ 下限 2s,与旧固定阈值行为一致",
staleLimitMs(0) === 2000, `实际 ${staleLimitMs(0)}`);
check("undefined 同样落到下限(防御)", staleLimitMs(undefined) === 2000);
check("快周期 20ms → 仍取下限 2s(兜底快周期,阈值不小于扫表+抖动)",
staleLimitMs(20) === 2000, `实际 ${staleLimitMs(20)}`);
check("回报周期 1s → 阈值 3s(连丢 3 拍才判离线,2s 不再误判)",
staleLimitMs(1000) === 3000, `实际 ${staleLimitMs(1000)}`);
check("回报周期 5s → 阈值 15s", staleLimitMs(5000) === 15000,
`实际 ${staleLimitMs(5000)}`);
check("回报周期 20s → 被 30s 上限压住(长停顿后不能迟迟不判离线)",
staleLimitMs(20000) === 30000, `实际 ${staleLimitMs(20000)}`);
console.log(out.join("\n")); console.log(out.join("\n"));
console.log("\n" + "=".repeat(56)); console.log("\n" + "=".repeat(56));
console.log(`通过 ${pass} 项,失败 ${fail} 项`); console.log(`通过 ${pass} 项,失败 ${fail} 项`);
+28 -1
View File
@@ -19,6 +19,7 @@
* 8 节点 ID 应答取走即清 * 8 节点 ID 应答取走即清
* 9 fetchFeedback:无数据的字段必须是 null 而不是 0(监控 UI 约定) * 9 fetchFeedback:无数据的字段必须是 null 而不是 0(监控 UI 约定)
* 10 代理不可用时报错信封可读(不是裸 500/CORS 报错) * 10 代理不可用时报错信封可读(不是裸 500/CORS 报错)
* 11 判活阈值随实测回报周期自适应(电机可变 CAN 刷新率,慢速不误判离线)
*/ */
import { readFileSync } from "node:fs"; import { readFileSync } from "node:fs";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
@@ -312,10 +313,36 @@ section("[8] 快照→契约翻译(构造快照,跑生产代码)");
await new Promise((r) => setTimeout(r, 2100)); await new Promise((r) => setTimeout(r, 2100));
snap = mkSnap([stale]); snap = mkSnap([stale]);
st = await tp.fetchMotorState(); st = await tp.fetchMotorState();
check("(h) seq 停超 2s → online 转 false(快照不删历史槽,必须靠 seq 判活)", check("(h) seq 停超 2s → online 转 false(快周期兜底下限,快照不删历史槽)",
st.motors.find((x) => x.node === 3).online === false, st.motors.find((x) => x.node === 3).online === false,
`online=${st.motors.find((x) => x.node === 3).online}`); `online=${st.motors.find((x) => x.node === 3).online}`);
/* (h2) 判活阈值随实测回报周期自适应(电机可变 CAN 刷新率)。
* 用另一台"慢速电机"(N8@CH0,回报周期 ~1.2s)验证:固定 2s 阈值会在
* 两次正常回报之间把它判离线(LED 闪/波形虚线/面板切离线层);
* 新阈值 = clamp(3×EWMA, 2s, 30s) ≈ 3.6s —— 2.5s 静默必须仍算在线,
* 静默超 ~3.6s 才判离线。间隔用真睡(与 (h) 同理,判活用本地时间)。 */
const slow = (seq) => [{ slot: 21, node: 8, ch: 0, online: true, dlc: 48, seq, data: STATUS_HEX }];
snap = mkSnap(slow(900));
await tp.fetchMotorState(); /* 首见(不计间隔) */
await new Promise((r) => setTimeout(r, 1200));
snap = mkSnap(slow(901));
await tp.fetchMotorState(); /* 间隔#1 ≈1.2s */
await new Promise((r) => setTimeout(r, 1200));
snap = mkSnap(slow(902));
await tp.fetchMotorState(); /* 间隔#2 ≈1.2s → EWMA≈1.2s,阈值≈3.6s */
snap = mkSnap(slow(902)); /* seq 停住:两次回报之间的静默 */
await new Promise((r) => setTimeout(r, 2500));
st = await tp.fetchMotorState();
check("(h2) 慢速电机(1.2s/帧)静默 2.5s 仍在线(固定 2s 阈值会误判)",
st.motors.find((x) => x.node === 8).online === true,
`online=${st.motors.find((x) => x.node === 8).online}`);
await new Promise((r) => setTimeout(r, 1400)); /* 累计静默 ≥3.9s > 阈值 ≈3.6s */
st = await tp.fetchMotorState();
check("(h2) 静默超自适应阈值(≈3.6s)后才判离线(拔线仍能查出)",
st.motors.find((x) => x.node === 8).online === false,
`online=${st.motors.find((x) => x.node === 8).online}`);
/* (i) 通信异常(wkc != expected)→ feedback 全部 null,而不是 0 */ /* (i) 通信异常(wkc != expected)→ feedback 全部 null,而不是 0 */
snap = mkSnap([stale], { wkc: 0, expected_wkc: 3 }); snap = mkSnap([stale], { wkc: 0, expected_wkc: 3 });
await tp.fetchMotorState(); await tp.fetchMotorState();
+2 -2
View File
@@ -69,8 +69,8 @@
{ page: 'pages/can.html', label: 'CAN 通信', ico: '⇄', color: 'var(--can)' }, { page: 'pages/can.html', label: 'CAN 通信', ico: '⇄', color: 'var(--can)' },
{ page: 'pages/can_monitor.html', label: 'CAN 监控', ico: '👁', color: 'var(--can)' }, { page: 'pages/can_monitor.html', label: 'CAN 监控', ico: '👁', color: 'var(--can)' },
{ page: 'pages/motor.html', label: '电机控制', ico: '⚙', color: 'var(--motor)' }, { page: 'pages/motor.html', label: '电机控制', ico: '⚙', color: 'var(--motor)' },
{ page: 'lab/continuity_test.html', label: '通断测试', ico: '🧰', color: 'var(--gd32)' }, { page: 'lab/lan9252_pm.html', label: 'LAN9252 电源', ico: '⏻', color: '#a88bfa' },
{ page: 'lab/spi_test.html', label: 'SPI 测速', ico: '⚡', color: 'var(--ble)' } { page: 'lab/spi_test.html', label: 'SPI/ECAT 通信测速', ico: '⚡', color: 'var(--ble)' }
]; ];
var bar = document.getElementById('tabbar'); var bar = document.getElementById('tabbar');
var view = document.getElementById('view'); var view = document.getElementById('view');