From 209a869f966afafc531c422dc8ed5362d06b6111 Mon Sep 17 00:00:00 2001 From: cbb <2625274797@qq.com> Date: Sat, 12 Sep 2026 20:10:47 +0800 Subject: [PATCH] =?UTF-8?q?feat(motor):=20=E5=8F=8D=E9=A6=88=E5=88=B7?= =?UTF-8?q?=E6=96=B0=E7=8E=87=20Hz=20=E9=80=82=E9=85=8D=EF=BC=880x0400/0x0?= =?UTF-8?q?401=20=E6=89=A9=E5=B1=95=E6=A0=BC=E5=BC=8F=EF=BC=89+=20ECAT=20?= =?UTF-8?q?=E5=AF=86=E6=8E=92=E6=A7=BD=E4=B8=8E=E5=88=A4=E6=B4=BB=E8=87=AA?= =?UTF-8?q?=E9=80=82=E5=BA=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 反馈刷新率(配套电机固件 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 项全过) --- .gitignore | 3 + ECAT_BEAT_TAIL_REPORT.html | 289 ++++++++++ ECAT_RATE_TRADEOFF.html | 2 +- README.md | 15 +- esp32_server.py | 228 ++++++-- js/motor/main.js | 276 +++++++-- js/motor/refresh-rate.js | 33 ++ js/motor/transport-ecat.js | 53 +- js/motor/transport-wifi.js | 8 + lab/_last_gd32_sha.txt | 2 +- lab/continuity_test.html | 754 ------------------------- lab/lan9252_pm.html | 579 +++++++++++++++++++ lab/pdo_layout.html | 902 ++++++++++++++++++++++++++++++ lab/spi_test.html | 264 ++++++--- pages/motor.html | 32 +- scripts/build_ecat_report_html.py | 2 +- scripts/can_bridge_reconcile.py | 6 +- scripts/flash_gd32.py | 25 +- scripts/test_motor_refresh.mjs | 25 +- scripts/test_motor_transport.mjs | 29 +- tabs.html | 4 +- 21 files changed, 2550 insertions(+), 981 deletions(-) create mode 100644 ECAT_BEAT_TAIL_REPORT.html delete mode 100644 lab/continuity_test.html create mode 100644 lab/lan9252_pm.html create mode 100644 lab/pdo_layout.html diff --git a/.gitignore b/.gitignore index 308cb99..5e8ab46 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,9 @@ scripts/_*_out.txt # 一次性排障探针(跟着某次现场问题写的,问题闭环后即失效) scripts/probe_*.py scripts/selftest_*.py + +# 本地一次性排障产物(页面测试驱动 / 诊断脚本 / 截图 / 测试报告),不入库 +/_local/ # 例外:这两个已入库,是可复用的通用采样器 !scripts/probe_ecat_slot_atomicity.py !scripts/probe_ecat_vs_wifi.py diff --git a/ECAT_BEAT_TAIL_REPORT.html b/ECAT_BEAT_TAIL_REPORT.html new file mode 100644 index 0000000..a737198 --- /dev/null +++ b/ECAT_BEAT_TAIL_REPORT.html @@ -0,0 +1,289 @@ + + + +EtherCAT 单拍耗时 345µs→124µs:改动原理与验证 +
+

EtherCAT 单拍耗时 345µs→124µs:改动原理与验证

+
调查日期 2026-09-11 · 台子:i7-14700K + Intel I210(100 Mbps 全双工)+ DCU(GD32E508 + LAN9252)+ 3 台电机 · 源报告 tools/ecat/REPORT_ecat_beat_tail_20260911.md · 本页所有数字已对照源码与实机复测核验(layout=dense、rx p50=92.1µs、wkc 3/3、v1.6.22.0)
+ +
345 → 124µs9/11 当天 0.5ms 档整拍 p50 实测(cyc_us.work)
310 → 92µs主站等回包 rx p50 —— 变化都在这一段
124.6µs9/8 同为 win 布局的实测单拍(CSV)—— 345µs 并非 win 固有成本
0.44µs/BPRAM 写单价(DWT 实测)—— 600B≈265µs/拍,两布局共同成本
+ +

结论(2026-09-12 修订)

+
+

重大更正(2026-09-12 复核):220µs 的归因被动摇,两个变量被混在一起

+

用户质疑"5 个 STATUS 变 3 个,怎么会差 20 倍"——顺着查,发现两件原调查没做的事:

+ +

混淆的变量(刷板台账 git 史 + 主站 git 史):9/8 板上镜像 201832b5…(win)→ 9/10 前已刷 4e84a354…(win 期 1.6.15→1.6.20 演进);主站 9/9 03:05 提交 97fab23(三布局自适应、发现窗基址 {0,7,14,20}→{0,5,10,15,20,22}、覆盖窗按 seq 归一)。345µs 出现在这两个改动之后、9/8 之前——原归因把回归记在 1.6.21 密排头上,但密排也可能只是"绕开"了回归(dense 下主站强制 n_bases=1,不轮转窗口)。

+

钉死实验(一次刷板,未执行):① 刷回 959e2ae(win 固件)+ build_native/ecat_native.prev.exe(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,需专门开窗。

+ +

一、一拍的生命周期:210µs 差在哪一环

+

主站每一拍做的事:组帧 → pcap_sendpacket 发出 → 等帧从线路绕回来(pcap_next_ex)→ 解码。等回包(rx)的时长 = 线路传输时间 + 从站把这一拍的输入镜像备好的时间——LAN9252 的 SM2 事件触发 PDI 中断,从站要在 ESC 把输入区搬上线路之前把 600B 输入镜像写好,主站就得等这段活干完。

+
+
① 同一 0.5ms 档、同一静默判据下的一拍构成(µs)
+
主站自身(pre+post 合计 0.3µs)发送(组帧 0.2 + pcap_sendpacket ≈28.4)线路传输 81.3µs(两布局相同)从站每拍准备(PDI buildInput)
+ + + +100 +200 +300 +0 +µs +1.6.15 滑窗 +发送 ≈28.6µs + + + + + +线路 81.3 +从站每拍准备 ≈229µs +rx 往返 ≈310µs = 线路 81.3 + 从站准备 ≈229 · 整拍 p50 345µs · 达成 93.9% +1.6.21 密排 +整拍 p50 124µs · 达成 99.9% + + + + + +线路 81.3 +rx 92.1µs = 线路 81.3 + 从站准备 ≈10(差分填充命中,接近纯线时) + +虚线处线路段用完(28.6+81.3≈110µs),之后整段在等从站 + +
数字出处:整拍与 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(更重负载下所测)同量级。三段的逐层组成见下表。
+
+
+

三段逐层拆解(对应图中蓝 / 粉 / 红三个色块)

+ + + + + + +
段0.5ms 实测 p50从外到内的组成出处 / 尚未拆开的部分
发送(蓝)28.6µs组帧 ≈0.2µs(组 LRW + 填输出区)→ pcap_sendpacket ≈28.4µs:NPF 栈把 1016B 交给 I210 驱动并提交发送/api/motor/dbg 拆段(sd_us 列)。驱动/DMA 内部无仪表;B 臂曾见发送 p99 348.9µs 的偶发阻塞(单窗样本,未立论)
线路(粉)81.3µs帧 1016B = 过程数据 984B(输出 384 + 输入 600)+ 以太网头 14 + EtherType 2 + 数据报头 10 + WKC 2 + FCS 4;8128bit ÷ 100Mbps;再加前导 8B + 帧间隙 12B ≈ +1.6µs;从站 ESC 硬件转发 µs 级由帧构成计算,两布局逐字节同长——结构上不可能解释 220µs 差
从站准备(红)· win≈229µs1.6.15 每拍在 PDI 里:① 按 win_base 轮转准备 27 槽表 ② 段4 覆盖窗 5×44B 拷贝(fillRxWindow/RxWinSlot)③ MotorState 重读 + 逐槽 48B 拷贝 ④ 600B 经 HBI/PRAM 推入 ESC RAM实测差值 310−81.3。但 ①②③ 的纯 CPU 差按代码对比仅 1~3µs,④ 与整块 600B PRAM 写(≈265µs/拍)两布局同在且 A/B/A 证明不影响往返——229µs 的载体机制未闭环,且 9/8 同布局实测 124.6µs 表明"345µs=win 固有成本"的前提不成立(见"重大更正"卡)
从站准备(红)· dense≈10µs差分填充命中:节点集合与各 seq 未变 → 整段复用影子,①②③全免;只剩 count 个槽的更新推送 + ESC 取数rx 92.1 − 81.3。220µs 的差全部消失在这里
+

口径备注:win 时代插桩拆段亦记录到 pcap_sendpacket 181.1µs、其后续中位 1 次 poll 即得帧(§四表)——该口径下发送调用内部吸收了部分等待,发送/等回包两段的分界随仪表口径浮动;但整拍总量(345)与"扣除线路后的从站侧总延迟"(≈229)不受此影响。另 1ms 档每拍 next_ex 轮询中位 79 次(轮询间隔 ~1.2µs),0.5ms 档为紧轮询。

+

原理(按 9/11 当天的观测口径):SM 长度(600B)与线速两布局完全相同,所以"线路那一截"不变;rx 的差值实测全部落在"等从站"这一段。但这段差值的载体机制存在重大疑点——两版 buildInput 的纯 CPU 差仅 1~3µs,且 9/8 同布局实测 124.6µs 表明 345µs 并非 win 固有成本——见开头"重大更正"卡。图①对比的是 9/11 上午 win 异常态 vs 午后 dense 的实测,不代表两种布局的固有差距。

+ +

二、改动原理 A:从站输入映射 1.6.15 滑窗 → 1.6.21 密排整轮

+

两种布局的输入区都是 600B(SM 长度、SII 都不变),只靠输入区第 35 字节的魔数 0xA5 区分(ecat_native.c:74-84,固件 ethercat_can_bridge.h:154 同款注释):

+
+
② 输入 PDO 600B 布局对比(按字节偏移)
+ +1.6.15 滑窗 + + + + + + +段2 STATUS 5×56B(固定 5 槽,空槽占位) +段4 覆盖窗 5×44B(每拍维护) +fillRxWindow / RxWinSlot 整套 +桥计数 +1.6.21 密排 + + + + + + +密排槽×3(56B/台) +按 count 扩展,最多 9 台(空节点不占位) +1.6.22 稀疏写:这段不再推入 ESC RAM +count=3 时空闲 +桥计数 + +0 +100 +200 +300 +400 +500 +600(字节) +头区 32..39:win= active/boot/win_base/win_count/gseq@36 · 密排= count/active/boot/magic@35/gseq@36 —— 两种布局 gseq 同在偏移 36 + +
段3 长帧(CONFIG/版本应答):win 在 320..359,密排前移到 544..583;段5 诊断两者都在 584..599。主站 decode_input 每拍判 magic@35 自适应,两端可任意先后升级。
+
+
+

1.6.21 的三个子改动,各砍掉什么活

+

① 密排整轮:固定 5 槽 + win_base 轮转扫 27 槽 → count 个密排槽

+

旧方案段 2 固定 5 个 STATUS 槽(空槽 node_id=0 也占位),主站按 win_base 轮转窗口才能盖到 9 节点×3 通道=27 槽的表——接 3 台电机却会看到 5 个状态帧。新方案一拍直接带齐总线上全部电机(本台 3 台=3 槽×56B),槽内按 node_id 升序,主站按 count 迭代。砍掉的是主站轮转窗口带来的多拍刷新分频与从站"为凑满 5 槽而做的填充"。

+

② 删段 4 覆盖窗口(fillRxWindow / RxWinSlot 整套)

+

旧方案每拍要把非 STATUS 帧抄进 5×44B 的历史覆盖窗反复重发——这是纯粹的每拍 CPU 拷贝活。新方案非 STATUS 帧只走段 1/3 的"最近一帧",覆盖窗整套删除(图中 win 红段在密排侧不复存在)。

+

③ 差分填充:节点集合与各 seq 未变时整段复用影子

+

注意一个容易误解的点:电机"不动"≠数据不变——STATUS 帧按电机自己的节奏持续上报(本台 3 台合计 ~3750 帧/s,每台 ~1.25 帧/拍),所以大多数拍里槽 seq 都变了、重建路径照样走(实测槽 seq 每 ~1 拍刷新一次)。差分填充真正的收益是"没变的槽不重读不重拷、重建范围从无条件 5 槽缩到实际变化的槽"——量级是 µs 级的,不能用它解释 rx 310→92(见开头"重大更正"卡)。

+ +

三、改动原理 B:1.6.22 稀疏写为何对主站往返"无效"

+

1.6.22 的思路:PDI 往 ESC RAM 写输入镜像时按 count 只写「段1+段2 有效区」与「段3+段5」,跳过空槽区,省 HW_EscWriteIsr 的 PRAM 字节。方向是对的,但省错了地方——这里有一个关键的不对称:

+

不对称原理:差分填充有效、稀疏写无效

+
+ + + + + +
A/B/A 轮次(只改这一个开关)板上版本0.5ms 达成长拍%发送 p50/p99 µs等回包 rx p50/p99 µsload_perm
A:稀疏写=1(出厂)1.6.22.099.90%0.1028.6 / 45.992.2 / 190.9489
B:整包写=0(A/B 基线)1.6.22.199.42%0.8329.8 / 348.992.2 / 210.4441
A 回归1.6.22.099.85%0.1029.4 / 86.993.0 / 101.2—
+

判读:rx p50 三轮 92.2/92.2/93.0µs ⇒ 稀疏写对主站往返无可测影响;load_perm 反而在整包写侧更低(441 vs 489)⇒ 它不能当这项的验收口径(真正该看 ESC 对象 0x2000 sub10,需独占路由停循环才能读,未做)。B 臂发送 p99 348.9µs 与 A 臂不同量级,但单窗样本不足以立论。当前 config.h 已回置出厂 A 臂(CONFIG_ECAT_SPARSE_ESC_WRITE=1)。

+ +

四、配套改动:把"看不见的东西"变成仪表

+

排查过程暴露了四个观测缺口,对应的改动与各自原理:

+ + + + + + + + + +
改动解决什么问题原理验证
flash_gd32.py 生效守卫台账只能证明"写进去了",不能证明"板上在跑"(本次 10 小时空窗的根因)刷前记 ota.gd32_app 版本 + gd32_adc.uptime_sec;刷完必须"版本变了或 uptime 归零"才写台账,否则非零退出不写两轮真实刷写 1.6.22.0→.1→.0 通过
ecat_native.c cyc_us.late/lost 接真值两字段旧为硬编码 0,超期/跳拍完全不可见最近 STATS_N 拍窗口内统计:late=起跑即超期(k>0)的拍数,lost=这些拍跳过的周期数(累计仍在 skip_total)重载窗 late=21/lost=28,健康窗 0/0
/api/motor/dbg 尾部追加两列发送段是黑盒:不知 sendpacket 花多久、轮询了几次第 7/8 列 = 本拍 pcap_sendpacket 净耗时与 pcap_next_ex 次数;旧列序不变,按前 6 列下标读的消费方不受影响0.5ms 档 181µs 调用、每拍中位 1 次 poll
soem-src/oshw/win32/nicdrv.c pcap 调用计数主/从栈调用混在一起没法归因只在 stacknumber==0(主栈)累加 QPC 计数,冗余栈不计防双计A/B 与旧构建行为一致(中性)
watch_beat_tail.py(新)跨分钟现象无法回溯(布局翻转恰好发生在两次采样之间)每 10s 只读一轮 motor/state+rxraw+app/load,落 27 列 CSV(layout/work/send/rx/late/lost/λ/达成)--once 跑通
transport-ecat.js 常量适配前端仍按旧 5 槽/6 周期口径显示密排下一拍整轮:MOT_SLOTS_PER_PDO 5→9、MOTOR_SCAN_CYCLES 6→1;能否拿齐电机由后端 decode 决定,常量只影响展示口径电机页原样复用,无回归
lab/spi_test.html 跳拍卡旧版需开逐拍环才有超期数据直接读 cyc_us.late/lost(最近 400 拍窗),并注明旧后端恒报 0 不可当"无超期"真机复跑成功,node --check 通过
+ +

五、证据:dense 是真的,以及为什么今天才生效

+
+
③ 判据:槽 seq 距当前 gseq 的滞后分布(111 次连续采样 ×3 槽=333 样本)
+ + + +滑窗方案预期落点 +每槽每 ≥2 拍才轮到一次 +滞后应 ≈4~8 帧 —— 实测 0 个样本 + + + + + +110 +85 +77 +60 +1 + +0 +1 +2 +3 +4 +≥6(帧) +样本数 · λ≈3.75 帧/拍(3750 帧/s @ 1kHz) +槽 seq 滞后于当前 global_seq(帧数) + +
三台电机落在槽 2/5/8:滑窗方案盖住它们需 ≥2 个基址轮转 → 每槽每 ≥2 拍才刷新、滞后应集中在 4~8 帧。实测 1/3 样本滞后为 0、全部 ≤4 帧 ⇒ 只能是"每拍带齐整轮"的密排语义。seq 与 gseq 都是 GD32 自己盖的号,滞后能对上同时证明解码偏移没错。(原报告曾把"gseq≡rx_count"当最强证据,复核发现两种布局 gseq 同在偏移 36,该条不能区分布局,已撤换为本判据。)
+
+
④ 时间线:刷写 ≠ 生效
+ + + + + + +01:44 镜像刷入 1.6.22 +台账 sha=hex 展平 ✓ +白天:仍在跑老代码 +主站实测 win 布局 · 232B · 整拍 345µs +中午断电重启 +生效:dense · 124µs + +≈10 小时:写入 ≠ 生效 +三方计数器锚点(不依赖系统时钟):主站进程存活 2084s · GD32 桥计数归零 1914s(gseq÷λ)· ESP32 上电 1922s —— 后两者差 8s,GD32 与 ESP32 同次上电;板子比主站晚 ~2.8 分钟重启,且 uptime 单调增(只重启过一次) + +
主站在 02:3x 直接读到 layout="win" len=232 配 345µs——刷入后 10 小时仍是老代码在跑。台账 _last_gd32_sha.txt 当时就与 Release hex 展平一致,即"写进去了"为真,"在跑了"为假。(A/B/A 之后台账现为 dda92d11…,对应 12:46 刷回 A 臂的产物,本次复核独立展平重算一致。)
+
+
+

同机同日前后对照(观察性证据)

+ + + + +
从站布局rx 往返整拍 p50达成长拍>1.4×跳拍 k>0gap p99
win(1.6.15 滑窗)~310µs345µs93.9%(1879Hz)7.9%8.6%1144µs
dense(1.6.21/22 密排)92.2µs123µs99.9%(1998Hz)0.10%~0.6%=周期本身
+

0.5ms 档余量从 155µs 变为 377µs:旧方案任何调度抖动都会成片跳拍,新方案长拍基本消失。另 1ms 档现网 late/lost(k>0 口径,连 1µs 超期都算)为个位数/400 拍窗,与"长拍>1.4×≈0%"不矛盾——两套阈值灵敏度不同,并列引用时必须标注口径。

+ +

六、被实测排除的原因(别再重测)

+ + + + + + + + +
候选解释排除依据
线路变快 / 帧变小两种布局 Ibytes 同为 600B(ecat_native.c:74 与固件头文件双侧注释),帧长 ~1016B 不变,线速仍 100Mbps 全双工
收帧超时(EC_TIMEOUTRET)预算 2000µs(µs 单位),11,264 拍里仅 1 拍 EC_NOFRAME;win 时代 rx p50 7~9µs 起步,预算从不消耗——收紧它是自证式"变好"
主站构建差异 / 双实例插桩版与旧构建 A/B 一致;单进程单端口;旧 exe 留档 build_native/ecat_native.pre-instr.exe
观察者负载面板全量 10Hz×3、单 dbg 10Hz(≈1.5MB/s JSON)、零请求静默窗,三者结果在噪声内相同
CPU 抢占另开 4 / 12 个纯算线程压机,send/rx/work 一动不动
"PRAM 逐 DWORD 轮数 150→45"被 A/B/A 否:整包写 600B 的 B 臂同样 92µs——写侧省字节数不进入主站关键路径(§三不对称原理)
+ +

七、复核更正(本次排查自己错过又改掉的判断)

+

证据层的两处撤换

+
+

限制声明的一处更正(本次复核新增)

+
+

归因的置信度边界(2026-09-12 修订)

+
+ +

八、遗留与下一步

+

按优先级

+
+
本报告依据 tools/ecat/REPORT_ecat_beat_tail_20260911.md、当日调查会话与复核证据整理;所有代码引证(偏移定义、357µs、路由表、台账 sha)已于 2026-09-11 对照源码与实机独立核验,实时复测 rx p50=92.1µs / layout=dense / wkc 3/3 / v1.6.22.0。相关页面:ECAT_RATE_TRADEOFF.html(周期×故障率×CPU 调研)· lab/spi_test.html(SPI/ECAT 测速)。
+
diff --git a/ECAT_RATE_TRADEOFF.html b/ECAT_RATE_TRADEOFF.html index 71bd810..978e164 100644 --- a/ECAT_RATE_TRADEOFF.html +++ b/ECAT_RATE_TRADEOFF.html @@ -60,7 +60,7 @@ ul{padding-left:22px}

三、机理与上限推算

上限已实测:≈8.2kHz,墙是单拍 ≈121µs 的硬耗时

证据链:无 CAN 设定 0.25 → 0.0625ms 五档,实测循环率在 ≤0.125ms (8kHz)前跟随设定,再快就被钉死在 8196–8238Hz;与此同时主站单拍耗时 cyc p50 在所有档位(含 2ms 慢档)恒为 117–126µs,1/121µs ≈ 8.2kHz,与顶墙实测严丝合缝。即:每拍有一段不可压缩的「发送→从站处理→回读+主站处理」往返时间,周期设定小于它时就堵在这里。

排除项:不是主机 CPU 不够 —— 顶墙档主机 CPU 只有 ~85%(自旋等待占比变小);不是从站拒答 —— wkc 全程 3/3。load_perm 在 ≥4kHz 全饱和 99.x%,既定位不了拐点也喂不准模型,早先「两项模型 load=F/T+R → 2.7/3.5kHz」的外推已被满速补测证伪,仅留档。

未闭合的一处:121µs 里主站协议栈与从站 ESC/固件各占多少,本轮数据分不开 —— 需给固件 fillRxWindow 加直接计时(固件改动,需批准)或抓总线帧时序才能归因。

四、判据口径与踩过的坑

-

仪表陷阱(每条都实际踩过)

+

仪表陷阱(每条都实际踩过)

五、可信度边界

这份数据不能证明什么

六、下一步

diff --git a/README.md b/README.md index 4ea05a5..37fb37a 100644 --- a/README.md +++ b/README.md @@ -46,8 +46,7 @@ esp32_app.vbs esp32_web.vbs serve.bat 三个启动入口 | `pages/can.html` | CAN 通信(DBC 解析、消息编辑与批量发送) | `js/can.js`、`js/can/*` | | `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` | -| `lab/continuity_test.html` | LAN9252 通断测试(自包含,含 WebGL 液态玻璃效果) | 内联 | -| `lab/spi_test.html` | 【临时功能 SPI-SPEEDTEST】SPI 链路测速,完全自包含,删文件即可移除 | 内联 | +| `lab/spi_test.html` | 【临时功能 SPI-SPEEDTEST】SPI/ECAT 通信测速(标签名「SPI/ECAT 通信测速」;含 EtherCAT 接收看板,完全自包含,删文件即可移除) | 内联 | ## 目录约定 @@ -116,6 +115,18 @@ esp32_app.vbs esp32_web.vbs serve.bat 三个启动入口 2. 「≈N Hz」理论值 —— 6ms 硬算是 166Hz。这时只显示 `statusHzText` 按数据到达时间实测出来的频率(其滑动窗口在该档改用固定 2s, 否则 `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` 侧 写字节 ÷ 快照 1.6KB 反推),但画面最多只能显示 160 Hz —— 多出来的 390 次/s 在 renderer / 网络服务 / browser 进程 / Python 代理上各点一次,实测这条链 diff --git a/esp32_server.py b/esp32_server.py index ab0505c..87b0b65 100644 --- a/esp32_server.py +++ b/esp32_server.py @@ -142,16 +142,17 @@ g_ecat_port = None # 记住上次探到的端口, 避免每次请求都从 809 # 换成原生守护进程 build_native\ # ecat_native.exe (C/SOEM, 由 build_native.bat 编译)。路由/JSON 契约与 Python # 逐字段对齐, 见 tools/ecat/MIGRATION.md。三条必须小心的地方(native 原样镜像了这套契约): -# 1) **必须先探测再拉起**。native 启动时**无条件清场**(后起的独占): 对 -# 8090..8095 的旧实例逐个调 /api/shutdown(从站回 INIT)再兜底强杀。 -# 已有实例时仍只复用不重复拉起 —— 清场是给"并发拉起撞上"兜底的, -# 不该被用来每次踢掉用户手开的实例。 +# 1) **单实例、后起独占**(2026-09-12 收紧): 只有本进程拉起且仍存活的子进程才复用; +# 起新进程前 taskkill 全部残留 ecat_native.exe。两个实例并存会抢 Npcap +# (后起的抓不到从站、HTTP 超时、PDO 空转)。native 启动时也会 kill_stale_instances, +# 与本处强杀双重保险。 # 2) 拉起时**必须带 --parent-pid**: 让 native 在本服务退出后自我了断, -# 不留孤儿进程占着 Npcap 适配器和端口。(与清场无关, 清场总是执行) +# 不留孤儿进程占着 Npcap 适配器和端口。 # 3) 不再需要 venv/pythonw —— 单文件 exe, 无 Python 运行时依赖。 ECAT_DIR = os.path.normpath(os.path.join(BASE_DIR, "..", "ecat")) ECAT_NATIVE_EXE = os.path.join(ECAT_DIR, "build_native", "ecat_native.exe") g_ecat_child = None # 由本服务拉起的 ecat 服务子进程(复用已有实例时为 None) +g_ecat_log_fp = None # native stdout 落盘句柄(见 _ecat_autostart 内注释) g_ecat_lock = threading.Lock() # 串行化自动启动, 避免并发请求拉起多个进程 @@ -201,56 +202,128 @@ def _ecat_find_port(): 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(): """按需拉起 tools/ecat 的服务进程。返回 (port, log_lines)。 - port 为 None 表示拉不起来(缺 exe / 起了但没监听)。 - 已有实例时**只复用不重启** —— 详见文件头 ECAT_DIR 附近的注释: - native 独立启动会清场(优雅关掉旧实例), 不能重复拉起。 + 单实例策略(2026-09-12): + - 本进程子进程仍存活 → 只等它听口,**绝不 kill_all**(否则启动中的实例 + 会被自己下一次请求杀掉,形成 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: - port = _ecat_find_port() - if port: # 已经有人在跑(用户手开的, 或我们上次拉起的) - g_ecat_port = port - return port, [] - 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): return None, [ f"找不到 EtherCAT 原生守护进程: {ECAT_NATIVE_EXE}", "请先运行 tools\\ecat\\build_native.bat 编译(需 VS2022 + Npcap)。", ] - # 复用已被本进程拉起但已退出的槽位 - if g_ecat_child is not None and g_ecat_child.poll() is not None: - g_ecat_child = None - if g_ecat_child is None: - 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}"] + # 起新进程前清光旧的:禁止双实例抢网卡 + killed = _kill_all_ecat_native() + if killed: + logs.append(f"已清掉 {killed} 个残留 ecat_native(单实例,防抢网卡)") - # 等它把端口监听起来。native 启动即绑口(ec_init 是懒的, 在 open 路由里), - # 给 10s 足够; 老 Python 版要等 pysoem 导入 + Npcap 枚举, 才需要 20s。 - deadline = time.time() + 10 + try: + # 日志落文件而不是 DEVNULL:native 的 stdout 是崩溃/楔死/自动重拉的 + # 唯一现场(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: if g_ecat_child.poll() is not None: return None, logs + [ @@ -263,10 +336,10 @@ def _ecat_autostart(): logs.append(f"EtherCAT 服务已就绪(端口 {port})") return port, logs 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)。 **不要改回 urllib/http.client 并给它传 timeout。** 那样每次请求会多花约 10ms: @@ -281,6 +354,11 @@ def _ecat_post(port, path, body, timeout): 所以这里手写最小 HTTP/1.0 客户端: 连接阶段**不设** timeout(本机回环连接 要么立刻成功要么立刻 ECONNREFUSED, 不会挂住), 连上之后才设读写超时。 + + connect_timeout: 只给冷路径(逐个试漂移端口)用的连接超时。**热路径(缓存 + 端口)必须保持 None(阻塞连接)**——本机对死端口无论阻塞还是 settimeout 都 + 不是秒拒(阻塞 ~2s, settimeout(0.3) 吃满 0.3s 才回 WOULDBLOCK), 但活端口 + 的阻塞 connect 只要 ~0.6ms, 两害相权取其轻。 """ payload = ( f"POST {path} HTTP/1.0\r\n" @@ -291,9 +369,9 @@ def _ecat_post(port, path, body, timeout): "\r\n" ).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: - sock.settimeout(timeout) # 连上之后再设: 只作用于收发, 不影响 connect + sock.settimeout(timeout) # 连上之后再设: 只作用于收发, 不影响已完成的 connect sock.sendall(payload) chunks = [] while True: @@ -323,16 +401,28 @@ def _ecat_proxy(path, body, timeout=6.0): 连不上时返回 503 + 与后端同构的 {"ok":false,"log":[...]} 信封, 这样前端只有一条错误处理路径 —— 它不需要知道中间还隔着一层代理。 + + 2026-09-12: 先用 _port_taken 快探,全灭则立刻 _ecat_autostart; + 不再对 6 个死端口各等 timeout 秒(那样要 30s+ 才会拉起服务)。 """ global g_ecat_port # /ecat/api/motor/state → /api/motor/state (只剥掉 "/ecat" 前缀, 保留 /api) 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 []) + \ [p for p in ECAT_PORTS if p != g_ecat_port] last = None for port in ports: 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 return status, data except Exception as e: # 连不上/超时 → 试下一个端口 @@ -340,10 +430,13 @@ def _ecat_proxy(path, body, timeout=6.0): if g_ecat_port == port: g_ecat_port = None # 缓存端口失效, 下轮重新全量探测 - # 一个端口都不通 → 服务没在跑, 按需自动拉起后重试一次。 - # 放在这里而不是每次请求前探测: 正常情况(服务在跑)一次都不会走到, - # 不给高频的 /motor/state 轮询增加任何开销。 - port, start_logs = _ecat_autostart() + # 无存活端口或全失败 → 单实例拉起后重试一次 + try: + port, start_logs = _ecat_autostart() + except Exception as e: + last = e + port = None + start_logs = [f"autostart 异常: {type(e).__name__}: {e}"] if port: try: status, data = _ecat_post(port, target, body, timeout) @@ -522,6 +615,10 @@ class Handler(SimpleHTTPRequestHandler): self.send_response(200) self.send_header("Content-Type", "text/html; charset=utf-8") 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.wfile.write(body) else: @@ -552,8 +649,14 @@ class Handler(SimpleHTTPRequestHandler): timeout = 30.0 if path.endswith(("/op/start", "/motor/start", "/flash", "/eeprom/write", "/eeprom/read", "/eeprom/verify")) else 6.0 - status, body = _ecat_proxy(path, raw, timeout) - _note_ecat_loop(path, status, body) + try: + 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) return self.send_error(404) @@ -900,7 +1003,10 @@ def _run_window(webview, args): "--parent-pid", str(os.getpid())], "ESP32 调试控制台启动失败") g_child = child 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) try: urllib.request.urlopen(url, timeout=1) @@ -911,6 +1017,10 @@ def _run_window(webview, args): os._exit(1) time.sleep(0.2) + # 保留 GPU 加速(页面依赖)。曾默认 --disable-gpu,用户要求恢复。 + # 白屏现象:首帧可见后立刻全白,更像 renderer/GPU 在 loaded 后崩溃, + # 或 watchdog 误 load_url 二次导航 —— 此处不再自动二次 load。 + def _monitor(): while True: if focus_evt.is_set(): @@ -923,17 +1033,17 @@ def _run_window(webview, args): threading.Thread(target=_monitor, daemon=True).start() g_win = webview.create_window("ESP32 调试控制台 — DCU", url, 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} (关窗即退出)") def _show_watchdog(): - # 页面加载后由 JS 调 page_loaded → show; 若 20s 仍未显示则强制 show - deadline = time.time() + 20 + # 只负责前台,不再二次 load_url(二次导航会造成「闪一下再白」) + deadline = time.time() + 15 while time.time() < deadline: if g_win.events.loaded.is_set(): - time.sleep(1) + time.sleep(0.3) break - time.sleep(0.5) + time.sleep(0.4) try: g_win.show() except Exception: diff --git a/js/motor/main.js b/js/motor/main.js index 515ac34..12ba0d9 100644 --- a/js/motor/main.js +++ b/js/motor/main.js @@ -29,6 +29,7 @@ STATUS 0x000N0100 · CMD 0x000N0200(DLC32) · CMD_CONFIG 0x000N0301(DLC24) GET_VERSION 请求/回报共用 0x000N00FF(同 ID, DLC24) CANFD_BOOT 0x000N0000 · NODE_ID SET/GET 0x000N0400/0401 + (扩展格式 DLC4/2:选择器 0x0000=节点ID · 0x0100/0x0101=STATUS/CONFIG 刷新周期 ms) ═══════════════════════════════════════════════════════════════ */ "use strict"; @@ -180,7 +181,7 @@ function getSharedDeviceIp() { const PERSIST_FIELDS = [ "modeSelect", "inVoltage", "inVelocity", "inAngle", "pidCurP", "pidVelP", "pidAngP", "repeatPeriod", "chSelect", - "nodeIdInput", + "nodeIdInput", "periodStatusInput", "periodConfigInput", ]; function saveUiState() { @@ -1610,8 +1611,14 @@ nodeListEl.addEventListener("keydown", (e) => { const RECV_HZ_WINDOW = 10; /* STATUS 面板右上角频率:基于实际数据到达时间计算,不用用户设定的数值做死算。 - * 保持近 RECV_HZ_WINDOW 次的到达时间戳滑动窗口,平均间隔 → Hz。 + * 要显示的就是**网页实际刷新率**(数据每秒真到几次):电机 CAN 回报率可能高于 + * 链路承载(WiFi 慢于电机时界面拿不到中间帧),按电机回报率显示会谎报; + * 按到达时间实测则快链路自动跑出高值、慢链路如实反映卡顿。 + * 保持近 RECV_HZ_WINDOW 次的到达时间戳滑动窗口,平均间隔 → Hz; * 窗口未满时回退到按 refreshIntervalMs 理论值(首次显示前不空窗)。 */ +function hzText(hz) { + return (hz >= 10 ? hz.toFixed(0) : hz.toFixed(1)) + " Hz"; +} function statusHzText() { const ts = state.recvTs; /* 过滤掉旧窗口残留(与最近一次间隔超过 2× 设定间隔的视为旧数据丢弃)。 @@ -1626,16 +1633,12 @@ function statusHzText() { if (ts.length >= 2) { const span = ts[ts.length - 1] - ts[0]; const avg = span / (ts.length - 1); - if (avg > 0 && avg < 60000) { - const hz = 1000 / avg; - return (hz >= 10 ? hz.toFixed(0) : hz.toFixed(1)) + " Hz"; - } + if (avg > 0 && avg < 60000) return hzText(1000 / avg); } /* 窗口数据不足时回退到理论值,不让面板空着。 * 不限速档没有有意义的理论值(6ms≠166Hz),显示 "…" 等实测数据到齐。 */ if (isUnthrottled(iv)) return "\u2026 Hz"; - const hz = 1000 / iv; - return (hz >= 10 ? hz.toFixed(0) : hz.toFixed(1)) + " Hz"; + return hzText(1000 / iv); } function updateNodeLabels() { 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 () => { if (!state.connected) { toast("设备未连接", "err"); return; } if (!requireNode()) return; @@ -2739,7 +2952,7 @@ $("#setNodeIdBtn").addEventListener("click", async () => { const cur = state.selectedNode; const ok = await showModal("设置节点 ID", `将节点 ${cur} 的地址修改为 ${newId},CAN ID 前缀将变为 0x000${nodeHex(newId)}xxxx。修改立即生效并写入 EEPROM。`); 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); bytes[0] = newId & 0xFF; bytes[1] = (newId >> 8) & 0xFF; @@ -2763,47 +2976,16 @@ $("#getNodeIdBtn").addEventListener("click", async () => { if (!requireNode()) return; if (inBusCooldown()) return; const cur = state.selectedNode; - /* CANFD_GET_NODE_ID 0x000N0401 DLC2:NodeID_Request 0|16@1+ */ - const bytes = new Uint8Array(2); - bytes[0] = cur & 0xFF; - bytes[1] = 0; + /* 扩展格式:选择器 0x0000=节点 ID。电机 v0.4.1 回 4B [选择器][值]; + * 旧固件忽略载荷回 2B 节点号,queryParam 按长度分派,两者都兼容。 */ 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 }); - $("#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) { state.txCount++; - let reported = null; - const r = res.results && res.results[0]; - 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"); + $("#nodeIdResult").textContent = `节点 ${cur} 回报 ID: ${res.val} · ${hexId(id)}`; + toast(`GET_NODE_ID → Node ${cur} 已读取: ${res.val}`, "ok"); } else if (!res.networkError && !res.busy) { $("#nodeIdResult").textContent = `读取失败: ${res.desc}`; toast(`GET_NODE_ID 失败: ${res.desc}`, "err"); diff --git a/js/motor/refresh-rate.js b/js/motor/refresh-rate.js index 74e1823..3748337 100644 --- a/js/motor/refresh-rate.js +++ b/js/motor/refresh-rate.js @@ -17,6 +17,10 @@ * 见 UNTHROTTLED_MAX_MS。 * 共用一个值的实际后果:ECAT 的快值带到 WiFi 上会让 HTTP 请求排队 * (tickStuck 常亮黄),WiFi 的 250ms 带到 ECAT 上又白白浪费了本地快照的低延迟。 + * + * 后半部分还住着一块与「实测数据率」相关的纯逻辑(同样为了脱离 DOM 自测): + * staleLimitMs —— EtherCAT 槽判活的离线阈值,随实测 seq 间隔自适应, + * transport-ecat.js 用它,慢速回报的电机不再被固定 2s 误判离线。 */ "use strict"; @@ -105,3 +109,32 @@ export function pickRefresh(map, tpId) { if (Number.isFinite(n)) return clampFor(tpId, n); 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)); +} diff --git a/js/motor/transport-ecat.js b/js/motor/transport-ecat.js index 3364a52..936a1ce 100644 --- a/js/motor/transport-ecat.js +++ b/js/motor/transport-ecat.js @@ -32,24 +32,31 @@ */ "use strict"; -/** 单个 PDO 周期回传的状态槽数(与 ecat_native.c 的 MOT_SLOTS_PER_PDO 一致; - * 固件 1.6.4 起槽长 56B = 8B 头 + 48B STATUS,段 2 的 280B 正好放 5 个) */ -const MOT_SLOTS_PER_PDO = 5; -/** 状态槽总数 = 9 节点 × 3 通道(ecat_native.c 的 MOTOR_SLOT_COUNT) */ +/* 判活阈值纯逻辑(随实测 seq 间隔自适应)住在 refresh-rate.js 的 + * 「ECAT 槽判活阈值」一节,纯函数以便脱离 DOM 自测。 */ +import { staleLimitMs } from "./refresh-rate.js"; + +/** 单个 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; -/** 扫完全表所需周期数(= ⌈MOTOR_SLOT_COUNT / MOT_SLOTS_PER_PDO⌉ = ⌈27/5⌉ = 6)。 - * 注意:这**只**是开机首圈全表扫描的窗口数,不是稳态下单槽的刷新分频 —— - * 后端会把窗口收缩到只盖有主的槽(窗口自适应),稳态下在线电机 - * 每个 PDO 周期都刷新。要显示"数据多新"请用快照里的 slot_period_ms。 */ -const MOTOR_SCAN_CYCLES = 6; +/** 扫完全表所需周期数。1.6.21 密排下恒 1(一拍整轮);旧滑窗为 ⌈27/5⌉=6。 + * 显示"数据多新"请用快照里的 slot_period_ms。 */ +const MOTOR_SCAN_CYCLES = 1; /** - * 槽 seq 停止增长多久算离线(ms)。 - * 为什么需要它:后端的快照把见过的槽**永久累积**,电机拔了也不会消失, - * 只能靠 seq 是否还在涨来判活。阈值要大于"扫完全表 + 一点抖动",否则 - * 慢周期下会把正常电机误判离线:period 10ms × 6 周期 = 60ms,取 2s 有充足余量。 + * 槽 seq 判活阈值:**不写死** —— 随该槽实测 seq 变化间隔自适应 + * clamp(3×EWMA, 2s, 30s),见 refresh-rate.js 的 staleLimitMs()。 + * 为什么需要判活:后端的快照把见过的槽**永久累积**,电机拔了也不会消失, + * 只能靠 seq 是否还在涨来判活。为什么必须自适应:电机 CAN 回报率可变, + * 回报周期调到秒级后,固定 2s 会在两次正常回报之间把正常电机判离线 + * (LED 闪烁、波形画虚线、面板切离线覆盖层);下限 2s 仍兜底快周期 + * (要大于"扫完全表 + 一点抖动",1.6.15 滑窗时代 period 10ms × 6 周期 = 60ms)。 */ -const STALE_OFFLINE_MS = 2000; /** 位掩码 → 逻辑处理器号列表。JS 移位只有 32 位,所以用除法逐位取。 */ function pcoreList(mask) { @@ -101,7 +108,7 @@ export function createEcatTransport(ctx) { const configs = new Map(); // node -> 24B CONFIG hex(0x000N0101) const versions = new Map(); // node -> {node, seq, version} 供 fetchVersionNode 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 回报) /* 单个电机状态的真实刷新周期(ms,来自快照的 slot_period_ms)。 * 与 periodMs 的区别是要点:PDO 周期是"总线跑多快",而某个电机的状态要等 @@ -278,7 +285,8 @@ export function createEcatTransport(ctx) { /** * 快照 → WiFi 的 /motor/state 契约。 - * 关键点:online 不能直接用从站给的标志位,还要叠一层 seq 判活(见 STALE_OFFLINE_MS)。 + * 关键点:online 不能直接用从站给的标志位,还要叠一层 seq 判活 + * (阈值随实测回报周期自适应,见 refresh-rate.js 的 staleLimitMs)。 */ function snapshotToMotorState(snap) { const now = Date.now(); @@ -288,12 +296,21 @@ export function createEcatTransport(ctx) { const prev = seqSeen.get(key); let rx = prev ? prev.rx : 0; let at = prev ? prev.at : now; + let iv = prev ? prev.iv : 0; 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 语义 at = now; } - seqSeen.set(key, { seq: m.seq, at, rx }); - const fresh = (now - at) <= STALE_OFFLINE_MS; + seqSeen.set(key, { seq: m.seq, at, rx, iv }); + /* 从站说在线 **且** seq 还在动才算在线:快照永不删除历史槽。 + * 阈值随实测回报周期放大(慢速电机不误判),下限 2s 兜底快周期。 */ + const fresh = (now - at) <= staleLimitMs(iv); motors.push({ node: m.node, ch: m.ch, diff --git a/js/motor/transport-wifi.js b/js/motor/transport-wifi.js index 820844b..ece252a 100644 --- a/js/motor/transport-wifi.js +++ b/js/motor/transport-wifi.js @@ -192,6 +192,14 @@ export function createWifiTransport(ctx) { 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 = 批级通道。 * @returns {Promise} 已按帧数补齐的 results[] diff --git a/lab/_last_gd32_sha.txt b/lab/_last_gd32_sha.txt index 07932a4..fa4a0ec 100644 --- a/lab/_last_gd32_sha.txt +++ b/lab/_last_gd32_sha.txt @@ -1 +1 @@ -4e84a35431a6435613f13d1374f4df8e60082257186562500a0bcf0662fd8b9e \ No newline at end of file +dda92d11876f059d27390232e88b5c9fa7c0eb57e442365f5247d4acdefb9b20 \ No newline at end of file diff --git a/lab/continuity_test.html b/lab/continuity_test.html deleted file mode 100644 index 22d621f..0000000 --- a/lab/continuity_test.html +++ /dev/null @@ -1,754 +0,0 @@ - - - - - -LAN9252 通断测试 - - - - - -
- -

LAN9252 通断测试

-
通过 ESP32 /spi/send → SPI 0xA2 → GD32 驱动 EXMC 引脚,万用表测 LAN9252 端电压
- -
- ⚠️ - 不点按钮时 GD32 引脚为高阻输入态(安全)。点击模式后先预览引脚图,点"确认应用"才实际驱动引脚。测量完毕请点"停止/恢复"释放。 -
- - -
-
- 设备 IP - 读取中... - 共用主页"设备连接"配置的 IP -
-
- - -
-

LAN9252 64-QFN 引脚图

-

俯视,Pin1 右上角。点击下方模式按钮预览引脚电压,点"确认应用"发送命令。绿色=3.3V,红色=0V,橙色=控制线测试中。

-
- - - - - - - - - - - - -
-
- HIGH 3.3V - LOW 0V - 控制线测试 - 数据线空闲 - 控制线空闲 - 电源/其他 - Pin1 -
-
- - -
-

模式选择

-

点击预览引脚图,再点"确认应用"发送命令

- - -
- - - - -
- - -
- - - - - -
- - -
- -
-

- 数据线切为高阻输入、CS#+RD# 持续拉低(读 IDX_0)。用万用表在 LAN9252 芯片引脚 上量数据线: - 芯片活着 → D2(Pin12)、D4(Pin49)、D5(Pin50)、D9(Pin19)、D12(Pin21) ≈ 3.3V,其余数据脚 ≈ 0V(全部确定电平,即 0x1234); - 全部中电平/漂移 → 芯片未驱动总线。图上预览色 = 芯片应答的期望值。 -

-
- - -
-

操作

-
- - - -
-

- 确认应用 · 发送当前选中的模式到 GD32
- 循环切换 · 每5s在 0x0000 ↔ 当前选中模式 之间切换(需先选数据线 pattern)
- 停止 / 恢复 · 恢复 EXMC 正常模式 -

-
- - -
-
- 操作日志 -
- - - - -
-
-
-
- 共 0 条 - SPI 0xA2 · UDP 6666 -
-
- -
- - - - diff --git a/lab/lan9252_pm.html b/lab/lan9252_pm.html new file mode 100644 index 0000000..e54421f --- /dev/null +++ b/lab/lan9252_pm.html @@ -0,0 +1,579 @@ + + + + + + +LAN9252 电源管理台 + + + + + + + + +
+ + +
+
+ +
+ LAN9252 电源管理台 + EtherCAT ESC · D1/D2/D3 休眠 · DCU_APP ≥1.6.23 +
+
+
+ 连接中… + IP -- +
+
+ + +
+ + +
+ +
+
🌙 进入低功耗档
+
+
+ + + +
+
+ D1 系统时钟关、晶振+PLL 保持(有自动唤醒路径)。
+ D2 PLL 请求关(两 PHY 已掉电才真关)。
+ D3 晶振/PLL 全关,最省电、只能手动唤醒(二次确认)。
+ 休眠期间 EtherCAT 中断、主站 wkc 报错属预期;ESC 寄存器配置全保留,唤醒后自清睡眠位。 + D3 进入前固件自动掉电两 PHY,唤醒后自动恢复(自协商需数秒)。 +
+
+
+ +
+
🛟 恢复链 —— 为什么睡不死
+
+
    +
  • action 34 软唤醒(正常路径,≤2ms)
  • +
  • NRST 硬复位:PB10 拉低 60ms 释放,等效上电复位,覆盖 D3 睡死/HBI 假死等一切软件状态
  • +
  • GD32 断电重启:复位期间 PB10 浮空由 R44 上拉维持,重启后固件 hardwareReset() 自动重走受控复位
  • +
+
+ +
+
+ 可靠性:ESP32↔GD32 的 SPI 链路与 LAN9252 完全解耦(独立外设/中断),芯片睡得再深 + 0xA2 照样送达 GD32——恢复失败的唯一前提是 ESP32 和 GD32 同时失能,那已不是休眠问题。
+ NRST 复位会清掉 SSC 已配置的芯片状态,EtherCAT 需主站重新走状态机。 +
+
+
+ +
+
📋 status 字节判读(recv 首字节)
+
+ + + + + + + + +
位/值含义
bit0READY:1=D0 且 PLL 稳定
bit1PM_SLEEP_EN:1=低功耗档
bit3:2PM_MODE 寄存器位(仅 SLEEP_EN=1 时有效;未睡眠可能残留旧档)
0xEE无效 action
0xE1HBI 未就绪(勿操作电源)
唤醒 1/0成功 / 10ms 超时
+
+
+ +
+ + +
+ +
+
🔋 芯片电源状态 2s 自动刷新
+
+
🌙 芯片处于低功耗档 —— EtherCAT 通信已中断,点「手动唤醒」恢复
+
+
READY--
+
SLEEP_EN--
+
电源档--
+
原始字节--
+
+
+ + +
+
唤醒轮询上限 10ms(手册标称 ≤2ms)。超时多半是芯片不在睡眠或 HBI 异常——以「读状态」为准。
+
+
+ +
+
📜 操作日志
+
+
+
+
+ +
+ +
+
+ + + + diff --git a/lab/pdo_layout.html b/lab/pdo_layout.html new file mode 100644 index 0000000..3354333 --- /dev/null +++ b/lab/pdo_layout.html @@ -0,0 +1,902 @@ + + + + + +DCU EtherCAT PDO · 字节布局 1.6.21 · 固件 1.6.24 + + + + +
+
+
+

DCU EtherCAT PDO 布局

+ 布局 1.6.21 + 固件 1.6.24 · 字段级 · 稀疏写 +
+
+
384 B输出 SM2
+
600 B输入 SM3
+
96–600 B稀疏写实占
+
40 BDPRAM 余量
+
+
+
+ +
+ +
+ 真源 ethercat_can_bridge.h + 尺寸 ethercat_pdo_size.h + PRAM 0.441 µs/B + 魔数 @35=0xA5 + 电机槽 ≤9 × 56 B + 稀疏写 CONFIG=1 +
+ +
+

布局 1.6.21 · 固件 1.6.24 · 写侧 1.6.22 起稀疏

+
    +
  • 字节布局仍是 1.6.21:段 2 密排、删段 4、魔数 @35=0xA5;out 384 + in 600 不变,SII/EEPROM 无需重刷。
  • +
  • 1.6.22 稀疏写 ESC(CONFIG_ECAT_SPARSE_ESC_WRITE=1):按 count 只写「段1+段2有效区」与「段3+段5」,跳过空槽区。主站解码不变,仍只按 count 迭代。
  • +
  • 1.6.23/1.6.24 为 LAN9252 电源管理诊断(pmDrive D1/D2/D3),不影响 PDO 布局。
  • +
  • 回退整包 600B 的条件:开关置 0 / magic≠0xA5 / count 越界 / count 撑满(9 电机两段相接)。
  • +
+
+ +
+ 桥状态 / 快照 + 段头 / 元数据 + 电机密排槽 + 批量发送区 + 诊断 + 保留 / 填充 +
+ + +
+
+ 1 +

输入 PDO · 600 B · 从站 → 主站

+ 稀疏写 96–600 B · 整包 ≈265 µs · 3 电机 ≈116 µs +
+ +
+
+

段总览

+ SM3 · 0x1880–0x1F67 · 3-buffer · 写侧稀疏 +
+
+
+
+
+ +
+
+

段内字段

+ 每段独占全宽 · 宽度 ∝ 字节数 +
+
+
+ +
+

字段明细

+ + + + + + + + + + + + + + + + + +
段 / 字段偏移宽类型内容属性
合计600逻辑总长;写侧按 count 稀疏(见下)96–600 B
+
+
+ + +
+
+ 2 +

输出 PDO · 384 B · 主站 → 从站

+ pramRead(384) · 96 DWORD · ≈169 µs 折算 +
+ +
+
+

段总览

+ SM2 · 0x1400–0x187F · WIN@8 忽略 +
+
+
+
+
+ +
+
+

段内字段

+ 每段独占全宽 · 宽度 ∝ 字节数 +
+
+
+ +
+

字段明细

+ + + + + + + + + + + + + + + + + +
段 / 字段偏移宽类型内容属性
合计384每拍一次 pramRead(384)(未实测)≈169 µs
+
+
+ + +
+
+ 3 +

电机槽内部 · 56 B × ≤9

+ PDO 地址 = 40 + i×56 · node_id 升序 +
+ +
+
+

密排槽区 @40..543

+ 空节点不占位 · count 决定有效槽数 +
+
+
+
单槽 56 B 字段
+
+
+
+ +
+

主站解码

+
+
count = in[32];                    // 0..9
+magic = in[35];                    // 必须 0xA5
+for (i = 0; i < 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
+}
+

+ 同号多通道取 seq 最新的一条;写侧 seqlock 奇数(写入中)的槽本拍省略、下拍自愈。 + 节点集合与各 seq/online 未变时整段复用影子区(CPU 差分),PRAM 写侧仍按 count 决定字节数。 + 1.6.22+ 稀疏写:ESC 中 40+count×56 .. 543 为上次残留, + 主站只许按 count 迭代,不得读空洞区。 +

+
+
+ +
+

单槽字段明细

+ + + + + + + + + + + +
字段槽内偏移宽类型内容
单槽合计568 B 头 + 48 B STATUS
+
+
+ + +
+
+ 4 +

每拍成本与 SM 预算

+ PRAM ≈0.441 µs/B · 稀疏写 1.6.22+ +
+ +
+
+
输入写侧 · 3 电机稀疏
+
116
+
µs/拍 · 264 B×0.441 · 11.6% @1ms
+
+
+
+
输入写侧 · 整包基线
+
265
+
µs/拍 · 600×0.441 · 26.5% @1ms
+
+
+
+
输出读侧 · 探针命中
+
12
+
B 探针 · 无沿时跳过 384B 全量读
+
+
+
+ +
+

1.6.22 稀疏写 ESC · 写侧字节随 count 缩

+

CONFIG_ECAT_SPARSE_ESC_WRITE=1(默认开)。HW_EscWriteIsr 只写两段:

+
段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(两段相接,回退)
+

+ APPL 仍整填 aPdInputData(对齐 SDO 0x6000);省的是 PRAM 搬运时间,不是 RAM 拷贝。 + 主站必须继续只按 count 迭代——空洞区内容是上次残留,不可读。 +

+
+ +
+

实测锚点:从站自报负载

+

1 ms 档 49.2% → 0.5 ms 档 81.4% → 0.25 ms 档 98.7%,同时 λ 从 6000 帧/s 塌到 317 帧/s —— 饱和发生在从站,不在主站的 Npcap 或钉核上。

+

+ 上表为 1.6.21 整包基线。1.6.22 稀疏写开启后写侧字节随 count 缩,同工况 load_perm 应显著下降; + 验收看 0x2000 sub10(EscWrite 周期)与 load_perm,同工况开关各测一轮。 +

+
+ +
+
+

DPRAM 预算 · out + in ≤ 1024 B

+ LAN9252 · 过程区 0x1400–0x1FFF · 3-buffer +
+
+
+

+ 邮箱 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,代价失去三缓冲并发保护)。 +

+
+
+
+ + +
+
+ 5 +

段 4 历史覆盖窗口 · 已删除

+ 1.6.15 引入 → 1.6.21 删除 +
+
+

为什么删

+

1.6.15 曾把批量接收区做成「定长覆盖窗口」:5 个固定格子(每格 44 B = 12 B 头 + 32 B 数据)+ 写游标绕圈。它是「最近 5 帧」而不是队列 —— 第 6 帧盖掉第 1 帧,未取走的计入 OVF。

+
    +
  • 覆盖窗口反复重发最近几条非 STATUS 帧,属于「历史帧 / 重复帧」,对电机状态视图是噪声;
  • +
  • 用户实测病理:接 3 个电机,网页却看到 5 个状态帧 —— 旧段 2 固定 5 槽里空槽 node_id=0 也占位,主站不滤空槽就多报。
  • +
+

本版语义:每一拍只携带「总线上已识别电机的完整一轮状态」。版本/CONFIG 仍走段 1/段 3「最近一帧」快照。对象字典 sub21 恒 0;596..599 保留空洞。

+
+
+ +
+ 布局真源 comm/ethercat_can_bridge.h(1.6.21)· 尺寸 comm/ethercat_pdo_size.h · + 固件版本 config.h APP_FW_VERSION = 1.6.24 · + 稀疏写 ethercat_pdo.cpp sparseEscWritePlan + ecatappl.c PDO_InputMapping · + 成本系数 0.441 µs/B · 布局魔数 kEcatMotLayoutMagic = 0xA5 +
+
+ + + + diff --git a/lab/spi_test.html b/lab/spi_test.html index 38d2a09..1eb541b 100644 --- a/lab/spi_test.html +++ b/lab/spi_test.html @@ -43,9 +43,8 @@ --border:#21304a; --border-strong:#2e4262; --border-subtle:#1a2740; --text:#e9f0fa; --text-dim:#93a8c4; --text-label:#617a99; --esp32:#2fd8c1; --esp32-hi:#6ff2e0; --esp32-dim:rgba(47,216,193,.14); - --gd32:#fcbf3f; --gd32-hi:#ffd97a; --gd32-dim:rgba(252,191,63,.14); - --can:#43d97b; --can-dim:rgba(67,217,123,.14); - --motor:#22d3ee; --motor-hi:#67e8f9; --motor-dim:rgba(34,211,238,.14); + --gd32:#fcbf3f; --gd32-dim:rgba(252,191,63,.14); + --can:#43d97b; --mem:#a88bfa; --mem-hi:#c7b3ff; --mem-dim:rgba(168,139,250,.14); --green:#3ddc8e; --amber:#fcbf3f; --red:#ff6b6b; --red-dim:rgba(255,107,107,.14); --spi:var(--esp32); --ecat:var(--mem); --ecat-hi:var(--mem-hi); --ecat-dim:var(--mem-dim); @@ -266,7 +265,7 @@
等效吞吐
—
KB/s
失败帧
—
必须为 0
-
+
单帧分段平均(固件五段拆解,占总往返比例)
跑一次测速后出现
@@ -333,18 +332,18 @@

刷新率 实测 = Δcycles/Δt;名义 = 1000/period_ms 仅作对照,两者差值就是软件调度开销

-
实测 PDO 刷新率
—Hz
cycles 差分
-
达成率
—
名义 — Hz
-
单拍耗时
—µs
p50 / p90 / max
-
主站占空比
—
单拍 p50 ÷ 周期
-
单电机状态刷新
—Hz
slot_period_ms —
-
CAN 到帧率 λ
—/s
Δglobal_seq (u32)
-
真丢帧
—帧/拍
主站 seq 空洞 · 从站 OVF(抽样单拍值)
-
跳拍
—/s
累计 —
-
从站 CPU 负载
—
load_perm · age —
-
WKC
—
期望 —
-
撕裂 / 重入
—
rx_torn / pdo_reentry
-
本页请求耗时
—ms
本页→代理→native 最慢一个请求的往返(≠节拍耗时)
+
实测 PDO 刷新率
—Hz
cycles 差分
+
达成率
—
名义 — Hz
+
单拍耗时
—µs
p50 / p90 / max
+
主站占空比
—
单拍 p50 ÷ 周期
+
单电机状态刷新
—Hz
slot_period_ms —
+
CAN 到帧率 λ
—/s
Δglobal_seq (u32)
+
真丢帧
—帧/拍
主站 seq 空洞 · GD32 OVF(抽样单拍值)
+
跳拍
—/s
累计 —
+
从站 CPU 负载
—
load_perm · age —
+
WKC
—
期望 —
+
撕裂 / 重入
—
rx_torn / pdo_reentry
+
本页请求耗时
—ms
本页→代理→native 最慢一个请求的往返(≠节拍耗时)
判读:达成率 < 100% = 节拍被软件拖住;λ ÷ 实测Hz = 每拍到达几帧 CAN(当前 1ms 档实测 6 帧/拍)。
@@ -356,7 +355,7 @@ 真丢帧逐层看:① CAN 控制器 err/TEC/REC;② CAN 软件 RX 队列满(深度 16)的 rx_drop_count —— 只在 ESP32 /can/feedback 有,过程数据里没有; ③ 段 4 覆盖窗口 主站侧按 seq 空洞算的 rx_batch.lost(step/2−1, - 发布值恒为偶数,按 step−1 会把每帧虚报成丢 1 个);④ 段 4 从站侧未取先覆 + 发布值恒为偶数,按 step−1 会把每帧虚报成丢 1 个);④ 段 4 GD32 侧未取先覆 rx_batch.pending(= OVF,与 ③ 是两个独立仪表,正常应当同步增长); ⑤ CAN 取帧撕裂 rx_torn。③④ 是本页 10Hz 抽样看到的单拍值,只能当下界。 撕裂 / 重入是饱和计数(到 65535 停),封顶时只能报下界。 @@ -405,9 +404,9 @@ B32-35 —— 补 0 会把"无数据"显示成"初始化正常"。
-

收到的东西 ② 批量接收区 rxraw.parsed(与下面槽表同一拍快照)· 段 4 覆盖窗口去重后的事件流

+

收到的东西 ② 密排整轮 + 长帧事件 rxraw.parsed · 1.6.21 密排:段2整轮槽 + 段3长帧变更

- 本拍新帧 / seq 空洞 / 从站 OVF — + 本拍新帧 / seq 空洞 / GD32 OVF — 本页帧样本 —
@@ -417,13 +416,12 @@
-
- 覆盖窗口 5 槽原样(从站每拍整块拷出,写游标绕圈覆盖 —— 槽下标 ≠ 到达顺序,seq 才是顺序)。 - 末列「本拍新上报」:是 = 这一拍第一次见到这帧,主站把它交给上层;否 = 它是上一拍就交过的旧帧 - (窗口只留"最近 5 帧",一帧会连续几拍都躺在里面,但不会被重复上报)。
+
+ 密排整轮槽(GD32 每拍原样拷出 count×56B:node ch online dlc seq + 48B STATUS)。 + 末列「本拍新数据」:是 = seq 比上一拍大(差分命中,状态真变了);否 = seq 未变,固件本拍跳过填充。
- +
槽状态seqΔseqCHDLCCAN ID节点/子码载荷前 8B本拍新上报
槽状态seqΔseqCHDLCCAN ID节点/子码载荷前 8B本拍新数据
@@ -433,18 +431,14 @@
- 同一 ID + 同一载荷出现两次不是重复显示:主站已按每槽 seq 去重,同一帧不会报第二次。 - 实测 120 个窗口快照里「同 ID 且 seq 相同」0 次、「同 ID 但 seq 不同」240 次 —— 那是两帧真的各到达一次, - 只是电机空闲时 CONFIG 回报的 24B 逐字节相同,肉眼看一模一样。看下面槽表的 seq 列即可分辨。 -
"前面像少了一帧"也不是丢帧:① 主站每拍只把 seq 比上次大的槽交给上层,上一拍已经交过的帧 - 仍躺在窗口里(就是末列的"否"),但不会再进上面的表;② 页面 10Hz 采样、PDO 1kHz,两次采样之间约 - 100 拍的窗口内容已被覆盖,看不见(所以"样本数"只占全量约 0.8%,全量看 λ 那张卡)。 - 本表能看到的丢帧判据只有两个:seq 空洞(主站侧)与 OVF(从站侧), - 上面 pill 里实时显示,实测同为 0;本表看不到的还有三层 —— CAN 控制器 err/TEC/REC、 - GD32 软件 RX 队列满丢(深度 16,只在 ESP32 /can/feedback 有)、CAN 取帧撕裂 - rx_torn。逐层对账跑 scripts/can_bridge_reconcile.py。 -
长帧(CONFIG 24B / 版本应答)在 8B 短快照里会被截断,所以后端另开 state.xrx 长帧槽, - 逐字节的真凭实据在下面第 ③ 区的原始窗口里。
+ 密排整轮(1.6.21):接 N 个电机本拍就发 N 个状态,空槽不发送。 + STATUS 全在上方状态槽解码;本表是同一拍的线级原样(seq / online / 前 8B 载荷), + 用来核对"差分是否真的跳过了未变电机"。
+ 长帧(CONFIG 24B / 版本应答):段 3「最近一帧」,内容变了才进上面的事件表 + (主站按 ch/dlc/id/data 逐字节比对,相同不重复上报)。逐字节的真凭实据在下面第 ③ 区。
+ 本表看不到的还有三层 —— CAN 控制器 err/TEC/REC、GD32 软件 RX 队列满丢(深度 16, + 只在 ESP32 /can/feedback 有)、CAN 取帧撕裂 rx_torn。 + 逐层对账跑 scripts/can_bridge_reconcile.py。
@@ -453,8 +447,8 @@
- layout:win=1.6.15 定长覆盖窗口 / slim=变长 / old=旧布局,解析器必须按 layout 选, - 否则拿变长游标解定长窗口会整片错位。
+ layout:dense=1.6.21 密排整轮 / win=1.6.15 定长覆盖窗口 / slim=变长 / old=旧布局, + 解析器必须按 layout 选,否则拿变长游标解定长窗口会整片错位。
xrx 长帧完整载荷(kv 里那行是截断的,这里不截)
—
@@ -469,7 +463,7 @@
-
覆盖窗口原始字节 —— 按 44B/槽分组:头 12B(ch dlc flags pad ‖ id LE ‖ seq LE)+ 数据 32B(两行)
+
接收区原始字节 —— dense:8B 整轮头 + count×56B 槽;win:44B/槽 覆盖窗口
—
@@ -545,6 +539,11 @@ function kv(boxId, rows) { box.appendChild(d); } } +/** 状态胶囊:三处(SPI / ECAT / 测速跑批)共用同一套 led + 文案结构 */ +function setPill(sel, cls, text) { + const n = $(sel); n.className = "pill" + (cls ? " " + cls : ""); + n.innerHTML = ''; n.appendChild(el("span", null, text)); +} const isNum = (v) => typeof v === "number" && isFinite(v); const f1 = (v) => (isNum(v) ? v.toFixed(1) : "—"); const f2 = (v) => (isNum(v) ? v.toFixed(2) : "—"); @@ -603,7 +602,7 @@ function showPane(name) { /* 切到 SPI 就把看板轮询停掉:SPI 测的是 µs 级帧周期,这边 10Hz×3 条代理请求 * 会把自己的测量抬高 —— 先排除自己造的假象。 */ 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); @@ -611,7 +610,7 @@ for (const b of $$(".tab")) b.onclick = () => showPane(b.dataset.pane); /* ═══════════════════ SPI 测速面板 ═══════════════════ * 设备端点无应答时原实现会把按钮永久卡在 disabled(fetch 没有超时),这就是"卡死"。 * 这里每次请求都带 AbortController + 超时,并额外给一个手动中止口。 */ -const spiOut = $("#out"), spiHist = $("#hist"), spiPill = $("#spiState"), btnAbort = $("#btnAbort"); +const spiOut = $("#out"), spiHist = $("#hist"), btnAbort = $("#btnAbort"); const spiBtns = [$("#btnRun"), $("#btnRtt"), $("#btnStress")]; let spiCtl = null; try { const saved = ls("spi_test_ip"); if (saved) $("#ip").value = saved; } catch (_) {} @@ -621,9 +620,7 @@ if (!ls("spi_test_ip")) { const shared = ls("dcu_esp_ip"); if (shared) $("#ip"). function spiBusy(busy, cls, text) { for (const b of spiBtns) b.disabled = busy; btnAbort.hidden = !busy; - spiPill.className = "pill" + (cls ? " " + cls : ""); - spiPill.innerHTML = ''; - spiPill.appendChild(el("span", null, text)); + setPill("#spiState", cls, text); } /** 固件返回是分行的中文纯文本;逐行取数,缺行就留 null(不能补 0 —— 会把"没这项数据"显示成"0µs") */ function spiParse(text) { @@ -804,21 +801,20 @@ class RateWindow { /** 返回窗口内的各类速率;无法判定的一律给 null,不猜 */ calc() { 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: {}, - rxbLostMax: 0, rxbPendingMax: 0, samples: s.length, + rxbLostMax: 0, rxbPendingMax: 0, }; 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 = {}; for (let i = 1; i < s.length; i++) { const a = s[i - 1], b = s[i]; const pair = (b.t - a.t) / 1000; if (!(pair > 0)) continue; - dt += pair; out.pairs++; + dt += pair; 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.rx) && isNum(b.rx)) dRx += wrapDelta(a.rx, b.rx, 32); if (isNum(a.drop) && isNum(b.drop)) { const d = wrapDelta(a.drop, b.drop, 16); /* 单拍增量超过 u16 半程 = 采样率不足以分辨回绕,该点对 drop 作废并计数 */ @@ -840,7 +836,7 @@ class RateWindow { } out.span = dt; 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; for (const k in steps) { out.motorStep[k] = quantile(steps[k], 0.5); @@ -882,7 +878,7 @@ const pcores = (mask) => { const out = []; let m = mask >>> 0; for (let b = 0; b < 32 && m; b++, m >>>= 1) if (m & 1) out.push(b); let hi = Math.floor(mask / 4294967296); - for (let b = 0; b < 32 && hi; b++, hi >>>= 1) if (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; }; 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, tornPrev: null, reentryPrev: null, tornCap: false, reentryCap: false, lastRawCycle: null, seenFrames: 0, seenT0: 0, subs: new Map(), - lastSeq: {}, seqAt: {}, + lastSeq: {}, seqAt: {}, densLast: null, chart: { t: [], hz: [], us: [] }, errors: 0, errText: "", }; /** 槽 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 tblWinBody = $("#tblWinSlots tbody"); -function ecatPill(id, cls, text) { - const n = $(id); n.className = "pill" + (cls ? " " + cls : ""); - n.innerHTML = ''; n.appendChild(el("span", null, text)); -} - /** 节拍源:隐藏标签页会把主线程 setTimeout 节流到 ≥1s(实测被压到 1Hz, * 采样密度一掉,所有"按相邻两拍差分"的判据就跟着失真)。电机页同款问题 * 用 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]; E.win.push({ t, cycles: isNum(st.cycles) ? st.cycles : null, gseq: isNum(head.global_seq) ? head.global_seq : null, - drop: b.valid && isNum(b.drop) ? b.drop : null, 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, 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.seenFrames = 0; E.seenT0 = performance.now(); E.lastRawCycle = null; $("#btnLive").textContent = "暂停观测"; - ecatPill("#ecatState", "warn", "观测中"); + setPill("#ecatState", "warn", "观测中"); eapi("status", {}, 6000).then((s) => { E.status = s; renderBus(E.lastState); }) .catch((e) => { E.status = null; E.errText = e.message; }); eapi("native/health", {}, 6000).then((h) => { E.health = h; renderBus(E.lastState); }).catch(() => { E.health = null; }); @@ -1031,7 +1022,7 @@ function stopEcat() { E.live = false; E.wantLive = false; ticker.stop(); $("#btnLive").textContent = "开始实时观测"; - ecatPill("#ecatState", "", "已暂停"); + setPill("#ecatState", "", "已暂停"); } $("#btnLive").onclick = () => { E.live ? stopEcat() : startEcatIfIdle(); }; $("#btnRing").onclick = () => { @@ -1084,18 +1075,28 @@ function renderMetrics(st, load, dbg, r, rtt) { isNum(r.lambda) ? (r.lambda > 0 ? "good" : "bad") : ""); /* drop 是个会自证的数:段1/段3 只有一个"最近一帧"格子,每拍最多发布 1 帧, * 于是它恒等于 λ − 实测Hz,拿它判好坏没有灵敏度(连"视野百分比"这种换算都不必做, - * 那是同一个数的另一种写法)。卡面只报真丢帧:主站 seq 空洞(rxbLostMax) 与从站 + * 那是同一个数的另一种写法)。卡面只报真丢帧:主站 seq 空洞(rxbLostMax) 与 GD32 * OVF(rxbPendingMax) 取抽样单拍最大值(两者独立、正常应同步增长),颜色叠上撕裂累计。 */ const holes = Math.max(r.rxbLostMax || 0, r.rxbPendingMax || 0); const tornN = E.tornAccum || 0; metric("cDrop", holes + "帧/拍", - "主站空洞 " + (r.rxbLostMax || 0) + " · 从站OVF " + (r.rxbPendingMax || 0) + + "主站空洞 " + (r.rxbLostMax || 0) + " · GD32 OVF " + (r.rxbPendingMax || 0) + " · 撕裂 " + tornN + " · 另有段1/段3 单格 drop " + f0(r.drop) + "/s(≡λ−Hz,不算丢帧" + (r.untrusted ? " · " + r.untrusted + " 点作废(采样率不足)" : "") + ")", (r.drop === null && !holes && !tornN) ? "" : (holes > 0 || tornN > 0 ? "bad" : "good")); - metric("cSkip", f1(r.skip) + "/s", - isNum(r.skip) ? "累计 " + ((dbg && dbg.skip_total) ?? "—") : "需开「逐拍环」才有 skip_total", - r.skip === null ? "" : (r.skip < 0.5 ? "good" : "bad")); + /* 超期/跳拍:cyc_us.late/lost 随 state 每拍就有(最近 n 拍窗口),不必开逐拍环。 + * 口径是**窗口计数**不是自启动累计;且只有 2026-09-11 之后的主站构建接了真值, + * 旧构建恒报 0 —— 看到 0 先确认后端版本,别当"没有超期"。 */ + const cu = (st && st.cyc_us) || {}; + const lateN = isNum(cu.late) ? cu.late : null, lostN = isNum(cu.lost) ? cu.lost : null; + const winN = isNum(cu.n) ? cu.n : 0; + const lateHz = (lateN !== null && winN > 1 && isNum(period)) ? lateN / (winN * period / 1000) : null; + metric("cSkip", (lateHz === null ? "—" : f1(lateHz)) + "/s", + lateN === null ? "cyc_us.late 未上报(旧后端恒 0 占位,不可当无超期)" + : "窗口 " + winN + " 拍:超期 " + lateN + " · 跳过 " + (lostN ?? "—") + + (isNum(r.skip) ? " · skip_total 累计 " + ((dbg && dbg.skip_total) ?? "—") + : " · 开逐拍环可看自启动累计"), + lateN === null ? "" : (lateHz < 0.5 ? "good" : "bad")); const loadFresh = load && load.valid && isNum(load.age_ms) && load.age_ms < 1000; const loadPct = loadFresh ? load.load_perm / 10 : null; @@ -1127,19 +1128,19 @@ function renderMetrics(st, load, dbg, r, rtt) { rtt === null ? "bad" : rtt < 30 ? "good" : "warn"); if (st && st.ok === false) { - ecatPill("#ecatLink", "bad", "后端未交换:" + ((st.log || []).join(" / ") || "未知")); + setPill("#ecatLink", "bad", "后端未交换:" + ((st.log || []).join(" / ") || "未知")); } 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 页连一次"); } 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; if (s) { 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 { - 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], ["backlog", isNum(b.backlog) ? b.backlog : null, b.backlog ? "warn" : "good"], ["tx_result", b.valid ? (b.tx_result + " " + (b.tx_result_text || "")) : null, (b.valid && b.tx_result === 0) ? "good" : "warn"], - ["rx_batch count / lost(主站空洞) / pending(从站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"], ["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 || ""; for (let i = 0; i < 96; i += 32) mcell(" " + (hx.substr(i, 32) ? hexGrp(hx.substr(i, 32)) : "—")); } - if (!motors.length) mcell("无槽", "hdr"); } /* ═══════════════════ 渲染:批量接收区(按 cycle 去重) ═══════════════════ */ @@ -1251,7 +1250,7 @@ function renderFrames(st, raw) { const frames = (raw && raw.parsed) || []; const rb = (st && st.rx_batch) || {}; $("#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); /* 轮询只看得到"这一拍的覆盖窗口",全量帧永远采不完 —— 必须把采样占空比摊开说, * 否则展示时这串数字会被当成总帧数。 */ @@ -1282,7 +1281,10 @@ function renderFrames(st, raw) { const k = (s.id >>> 0) + ":" + s.data; if (!best.has(k) || s.seq > best.get(k).seq) best.set(k, s); } - if (!frames.length) body.appendChild(el("tr")).appendChild(el("td", "dim", "这一拍没有新到的批量帧(STATUS 走状态窗口,不进这里)")); + if (!frames.length) body.appendChild(el("tr")).appendChild(el("td", "dim", + raw && raw.layout === "dense" + ? "本拍无长帧变更(STATUS 在上方状态槽 + 下方整轮槽表;CONFIG/版本变了才会进这里)" + : "这一拍没有新到的批量帧(STATUS 走状态窗口,不进这里)")); for (const f of frames) { const node = (f.id >> 16) & 0xF, sub = f.id & 0xFFFF; const src = best.get((f.id >>> 0) + ":" + String(f.data).slice(0, 16).toUpperCase()); @@ -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 */ const WIN_SLOT_SIZE = 44, WIN_SLOT_COUNT = 5; +/* 1.6.21 密排整轮 raw:8B 头(count active boot magic gseqLE) + count×56B 槽 + * 槽 = node@0 ch@1 online@2 dlc@3 seq@4..7 + 48B STATUS@8..55 */ +const DENSE_HDR = 8, DENSE_SLOT = 56, DENSE_STATUS = 48; +function decodeDenseRound(hex) { + if (!hex || hex.length < DENSE_HDR * 2) return null; + const b = new Uint8Array(hex.length / 2); + for (let i = 0; i < b.length; i++) b[i] = parseInt(hex.substr(i * 2, 2), 16); + const dv = new DataView(b.buffer); + const head = { count: b[0], active: b[1], boot: b[2], magic: b[3], gseq: dv.getUint32(4, true) }; + const slots = []; + for (let s = 0; s < head.count; s++) { + const o = DENSE_HDR + s * DENSE_SLOT; + if (o + DENSE_SLOT > b.length) break; + const dlc = b[o + 3]; + let data = ""; + const nd = Math.min(dlc || 0, 8); + for (let i = 0; i < nd; i++) data += b[o + 8 + i].toString(16).padStart(2, "0"); + /* 48B 全长 hex(详情用) */ + let full = ""; + const nf = Math.min(dlc || 0, DENSE_STATUS); + for (let i = 0; i < nf; i++) full += b[o + 8 + i].toString(16).padStart(2, "0"); + slots.push({ slot: s, node: b[o], ch: b[o + 1], online: b[o + 2] !== 0, dlc, + seq: dv.getUint32(o + 4, true), data: data.toUpperCase(), full: full.toUpperCase() }); + } + return { head, slots }; +} function decodeWinSlots(hex) { if (!hex) return []; const b = new Uint8Array(hex.length / 2); @@ -1335,8 +1363,11 @@ function decodeWinSlots(hex) { } function renderWinSlots(raw, slots, best) { const body = tblWinBody; - slots = slots || ((raw && raw.layout === "win") ? decodeWinSlots(raw.raw) : []); body.textContent = ""; + if (raw && raw.layout === "dense") { + renderDenseRound(raw, body); + return; + } if (!slots.length) { const tr = el("tr"); tr.appendChild(el("td", "dim", raw && raw.layout !== "win" @@ -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,空槽不发送)。" + + " 末列「本拍新数据」:是 = seq 比上一拍大(差分命中,状态真变了);" + + "否 = seq 未变,固件本拍跳过填充。"; + for (const s of round.slots) { + const prev = E.densLast.get(s.node + ":" + s.ch); + const dseq = (prev === undefined) ? null : (s.seq - prev); + const isNew = dseq !== null && dseq !== 0; + E.densLast.set(s.node + ":" + s.ch, s.seq); + /* STATUS CAN ID:0x180+node(DBC v0.74,默认扩展帧在 MotorState 侧, + * 这里只标语义,真 ID 以状态槽/DBC 为准) */ + const tr = el("tr"); + const td = (v, cls) => tr.appendChild(el("td", cls, v)); + td(s.slot, "num"); + td(s.online ? "在线" : "离线", s.online ? "good" : "warn"); + td(s.seq.toLocaleString("en-US"), "num"); + td(dseq === null ? "—" : (dseq === 0 ? "0(未变)" : "+" + dseq), "num " + (dseq ? "good" : "dim")); + td(s.ch, "num"); + td(s.dlc, "num"); + td("0x" + (0x180 + s.node).toString(16).toUpperCase(), "num"); + td("N" + s.node + " · STATUS"); + td(hexShort(s.data, 16), "hex"); + td(prev === undefined ? "首见" : (isNew ? "是(seq 前进)" : "否(差分跳过)"), + isNew || prev === undefined ? "good" : "dim"); + tr.title = "48B STATUS: " + (s.full || s.data); + body.appendChild(tr); + } +} + /* ═══════════════════ 渲染:原始接收窗口 ═══════════════════ */ function renderRaw(st, raw) { const x = (st && st.xrx) || null; - /* rxraw 的三个头字节是"原样 dump 不翻译":win 布局下它们是 - * slot_count / wr_idx(写游标) / ovf(从站侧未取先覆计数),沿用旧名会把写游标读成丢帧数 */ + /* rxraw 的三个头字节随布局变义:win=slot_count/wr_idx/ovf; + * dense=count/active/boot(lost/pending 对 STATUS 不适用) */ const isWin = raw && raw.layout === "win"; - const hLbl = isWin ? ["窗口槽数 slot_count", "写游标 wr_idx(下一格覆盖谁)", "从站 OVF 未取先覆"] - : ["count", "lost", "pending"]; + const isDense = raw && raw.layout === "dense"; + const hLbl = isWin ? ["窗口槽数 slot_count", "写游标 wr_idx(下一格覆盖谁)", "GD32 OVF 未取先覆"] + : isDense ? ["count 密排电机数", "active", "boot"] + : ["count", "lost", "pending"]; kv("rawKv", [ ["rxraw cycle", isNum(raw && raw.cycle) ? raw.cycle.toLocaleString("en-US") : null], ["layout", raw ? raw.layout : null], [hLbl[0], raw ? raw.count : null], [hLbl[1], raw ? raw.lost : null], [hLbl[2], raw ? raw.pending : null, (isWin && raw.pending) ? "warn" : null], + ["gseq", isDense && isNum(raw.gseq) ? raw.gseq.toLocaleString("en-US") : null], ["len (字节)", raw ? raw.len : null], ["parsed 帧数", raw && raw.parsed ? raw.parsed.length : null], ["xrx ch/dlc/id", x ? x.ch + " / " + x.dlc + " / 0x" + (x.id >>> 0).toString(16).toUpperCase() : null], @@ -1416,6 +1494,20 @@ function renderRaw(st, raw) { grid.textContent = ""; const cell = (t, cls) => grid.appendChild(el("i", cls, t)); if (!raw || !raw.raw) { cell(raw ? "原始窗口为空(这一拍没有批量帧)" : "—", "hdr"); return; } + if (raw.layout === "dense") { + /* 密排:8B 头 + count×56B 槽。头单独一行,每槽 56B=112 hex 切成 2 行 */ + const hex = raw.raw; + if (hex.length >= 16) cell("头 " + hexGrp(hex.substr(0, 16)), "hdr"); + for (let s = 0; s * DENSE_SLOT * 2 < hex.length - 16; s++) { + const off = 16 + s * DENSE_SLOT * 2, slotHex = hex.substr(off, DENSE_SLOT * 2); + if (!slotHex) break; + cell("槽" + s + " " + hexGrp(slotHex.substr(0, 16)), "slot"); + cell(" " + hexGrp(slotHex.substr(16, 32))); + if (slotHex.length > 48) cell(" " + hexGrp(slotHex.substr(48, 32))); + if (slotHex.length > 80) cell(" " + hexGrp(slotHex.substr(80))); + } + return; + } if (raw.layout !== "win") { for (let i = 0; i < raw.raw.length; i += 32) cell(String(i / 2).padStart(4, "0") + " " + hexGrp(raw.raw.substr(i, 32))); @@ -1524,11 +1616,8 @@ function drawChart() { window.addEventListener("resize", layoutChart); /* ═══════════════════ 热改 PDO 周期 ═══════════════════ */ -const runPill = $("#runPill"), runProg = $("#runProg"), btnEcatAbort = $("#btnEcatAbort"); -function runBusy(cls, text) { - runPill.className = "pill" + (cls ? " " + cls : ""); - runPill.innerHTML = ''; runPill.appendChild(el("span", null, text)); -} +const runProg = $("#runProg"), btnEcatAbort = $("#btnEcatAbort"); +function runBusy(cls, text) { setPill("#runPill", cls, text); } $("#btnPeriod").onclick = async () => { const v = parseFloat($("#periodPick").value); if (!(v > 0)) { runBusy("warn", "先在「PDO 周期档位」里选一档"); return; } @@ -1607,7 +1696,7 @@ async function measureOnce(kind, seconds, hz, extraNote) { } win.push({ t, cycles: isNum(st.cycles) ? st.cycles : null, gseq: isNum(head.global_seq) ? head.global_seq : null, - drop: b.valid && isNum(b.drop) ? b.drop : null, 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, }); 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 { for (const b of [$("#btnEcatRun"), $("#btnSweep"), $("#btnPeriod")]) b.disabled = false; 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("", "空闲"); } } @@ -1761,6 +1850,7 @@ $("#btnSweep").onclick = () => withLivePaused(async () => { $("#btnRxbClear").onclick = () => { E.seenFrames = 0; E.subs.clear(); E.lastRawCycle = null; E.tornPrev = null; E.reentryPrev = null; E.tornAccum = 0; E.reentryAccum = 0; E.tornCap = false; E.reentryCap = false; + E.densLast = null; E.seenT0 = performance.now(); /* 暂停观测时不会再渲染,标签必须就地归零,否则点了像没生效 */ $("#rxbSeen").textContent = "本页轮询采到的帧样本 0 条"; diff --git a/pages/motor.html b/pages/motor.html index e202f9d..4abbd4a 100644 --- a/pages/motor.html +++ b/pages/motor.html @@ -225,7 +225,35 @@
连接设备后读取节点 ID

- 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。 +

+ + +
+ 反馈刷新率 Hz · 0=停发 · 写入掉电保存 +
+
+
+ STATUS 帧 默认 1000 Hz + +
+ + +
+
+
+ CONFIG 帧 默认 250 Hz + +
+ + +
+
连接设备后读取当前刷新率
+

+ 输入为频率(Hz),协议按 周期(ms) = 1000 ÷ Hz 换算下发:0x000N0400 DLC4 [选择器][周期] / + 0x000N0401 DLC2 [选择器](选择器 0x0100=STATUS · 0x0101=CONFIG)。当前值以「读取」回读为准。 + 停发 STATUS 后状态面板会显示离线,电机仍可控可刷写。

@@ -402,7 +430,7 @@ - + diff --git a/scripts/build_ecat_report_html.py b/scripts/build_ecat_report_html.py index 301e4b0..9e20deb 100644 --- a/scripts/build_ecat_report_html.py +++ b/scripts/build_ecat_report_html.py @@ -600,7 +600,7 @@ def main(): 'sys/cpu_load.h 定义收窄为"中断+非延迟处理段",' '排除 SSC MainLoop 轮询;当前 free-run(dc=0)下 PDO ' '应用走主循环轮询路径、不计入。所以不能把"load→100%"读成"CPU 耗尽"。' - '
  • OVF 每拍被 s_rxWinOvf=0 清零 —— 从站侧' + '
  • OVF 每拍被 s_rxWinOvf=0 清零 —— GD32 侧' '没有可用的累计丢帧计数。读它恒 0 不能当"无丢帧"证据' '(我据此误判 bridge.drop 是死字段,结论已收回)。
  • ' '
  • /api/can/cadence 只存最近 512 拍 —— 0.25ms 档 ' diff --git a/scripts/can_bridge_reconcile.py b/scripts/can_bridge_reconcile.py index aba4aff..155ad1d 100644 --- a/scripts/can_bridge_reconcile.py +++ b/scripts/can_bridge_reconcile.py @@ -9,7 +9,7 @@ L2 CAN 软件队列 rx_drop_count(深度 16 满则丢) —— 只在 ESP32 侧有,过程数据里没有 L3 ECAT 桥 bridge.rx_count(进桥帧数) L4 段4 覆盖窗口 s_rxWinSeq 每入窗帧 +2 → Δmax_seq/2 是**精确**的入窗帧数 - L5 主站取帧 rx_batch.lost(主站 seq 空洞) / pending(从站 OVF) —— 单拍值, + L5 主站取帧 rx_batch.lost(主站 seq 空洞) / pending(GD32 OVF) —— 单拍值, 由本脚本按 cycle 去重后统计,仍是**下界**(轮询看不到每一拍) L3−L4 就是 STATUS 帧数(STATUS 在入窗之前就 return,见固件 @@ -154,7 +154,7 @@ def main(): print(f"段4 布局是 {rr.get('layout')!r},不是 1.6.15 覆盖窗口(win) —— 本脚本的解析器不适用") return 2 # 注意:rxraw 的 count/lost/pending 是**旧布局偏移**(360/361/362)的原始字节, - # 在 win 布局下分别是"窗口槽数常量""环形写游标 0..4""从站 OVF"。只有第三个 + # 在 win 布局下分别是"窗口槽数常量""环形写游标 0..4""GD32 OVF"。只有第三个 # 有意义,前两个拿来当丢帧数会得出"lost 最大 4、84% 的拍非 0"这种假结论。 if rr and rr.get("cycle") != seen_cycle: # 按拍去重:同一拍重复读会重复计数 seen_cycle = rr.get("cycle") @@ -212,7 +212,7 @@ def main(): f"STATUS 在入窗前就 return,固件 ethercat_can_bridge.cpp:435") print(f" 本脚本见到的唯一入窗帧 {got:10.0f} 下界:轮询只覆盖 {n_state / max(1e-9, hz * dt):.1%} 的拍") 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"-- 段1/段3 单格 drop {drop_single:10.0f} " f"恒等于 λ−Hz = {lam - hz:.0f},不参与判丢帧") diff --git a/scripts/flash_gd32.py b/scripts/flash_gd32.py index af88f8b..9f45310 100644 --- a/scripts/flash_gd32.py +++ b/scripts/flash_gd32.py @@ -9,7 +9,10 @@ ota_relay_trigger_handler 契约一致): 3. POST /ota/relay,请求体 = {"sha256":"<该 BIN 的 sha256>"};设备侧还会做 包身份防呆(复位向量必须落在 APP 区,误传 BOOT 会被拒); 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(板上镜像台账)。 用法: 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}") 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() st, body = post("/ota/upload", hdr + blob, timeout=120) print(f"upload -> {st} {body[:200]} ({time.time()-t0:.1f}s)") @@ -120,11 +131,21 @@ def main(): last = line if o.get("sys_state") == "GD32_COMPLETE" and o.get("gd32_app"): ver = str(o["gd32_app"]).lstrip("v") + a_up = (s.get("gd32_adc") or {}).get("uptime_sec") if want and ver != 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: 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 raise SystemExit("90s 内未见 GD32_COMPLETE —— 刷写未完成,别继续测试") diff --git a/scripts/test_motor_refresh.mjs b/scripts/test_motor_refresh.mjs index dde18b7..d5af873 100644 --- a/scripts/test_motor_refresh.mjs +++ b/scripts/test_motor_refresh.mjs @@ -15,11 +15,13 @@ * 4 取值:缺失/脏值回落该链路默认,不回落成另一条链路的值 * 5 隔离:改一条链路不影响另一条(这就是本次改动的全部意义) * 6 不限速谓词:6ms 档要关掉卡顿黄标与理论 Hz 显示(6ms ≠ 166Hz) + * 7 判活阈值 staleLimitMs:随实测 seq 间隔自适应,钳在 [2s, 30s] */ import { REFRESH_DEFAULTS, REFRESH_MIN_MS, REFRESH_MAX_MS, REFRESH_MIN_BY_TP, 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"; let pass = 0, fail = 0; @@ -143,6 +145,27 @@ check("WiFi 下限 50ms 永远不进不限速区(该链路无此档)", check("ECAT 下限 6ms 一定在不限速区", isUnthrottled(REFRESH_MIN_BY_TP.ecat) === true); 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("\n" + "=".repeat(56)); console.log(`通过 ${pass} 项,失败 ${fail} 项`); diff --git a/scripts/test_motor_transport.mjs b/scripts/test_motor_transport.mjs index 5ef2951..ba88760 100644 --- a/scripts/test_motor_transport.mjs +++ b/scripts/test_motor_transport.mjs @@ -19,6 +19,7 @@ * 8 节点 ID 应答取走即清 * 9 fetchFeedback:无数据的字段必须是 null 而不是 0(监控 UI 约定) * 10 代理不可用时报错信封可读(不是裸 500/CORS 报错) + * 11 判活阈值随实测回报周期自适应(电机可变 CAN 刷新率,慢速不误判离线) */ import { readFileSync } from "node:fs"; import { fileURLToPath } from "node:url"; @@ -312,10 +313,36 @@ section("[8] 快照→契约翻译(构造快照,跑生产代码)"); await new Promise((r) => setTimeout(r, 2100)); snap = mkSnap([stale]); 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, `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 */ snap = mkSnap([stale], { wkc: 0, expected_wkc: 3 }); await tp.fetchMotorState(); diff --git a/tabs.html b/tabs.html index f2bfb3f..f059360 100644 --- a/tabs.html +++ b/tabs.html @@ -69,8 +69,8 @@ { page: 'pages/can.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: 'lab/continuity_test.html', label: '通断测试', ico: '🧰', color: 'var(--gd32)' }, - { page: 'lab/spi_test.html', label: 'SPI 测速', ico: '⚡', color: 'var(--ble)' } + { page: 'lab/lan9252_pm.html', label: 'LAN9252 电源', ico: '⏻', color: '#a88bfa' }, + { page: 'lab/spi_test.html', label: 'SPI/ECAT 通信测速', ico: '⚡', color: 'var(--ble)' } ]; var bar = document.getElementById('tabbar'); var view = document.getElementById('view');