From 81096d04257084e5eb1cf5c83cef4b23f97f2683 Mon Sep 17 00:00:00 2001
From: cbb <2625274797@qq.com>
Date: Thu, 10 Sep 2026 03:02:14 +0800
Subject: [PATCH] =?UTF-8?q?feat(motor):=20=E6=96=B0=E5=A2=9E=20EtherCAT=20?=
=?UTF-8?q?=E5=91=A8=E6=9C=9F=E9=80=9F=E7=8E=87=E6=89=AB=E6=8F=8F=E5=88=86?=
=?UTF-8?q?=E6=9E=90=E5=B7=A5=E5=85=B7=E9=93=BE=EF=BC=8C=E6=94=AF=E6=8C=81?=
=?UTF-8?q?=20sweep=20=E8=84=9A=E6=9C=AC=E3=80=81=E6=8C=87=E6=A0=87?=
=?UTF-8?q?=E6=A0=A1=E5=87=86=E3=80=81=E7=BB=98=E5=9B=BE=E4=B8=8E=20HTML?=
=?UTF-8?q?=20=E6=8A=A5=E5=91=8A=E7=94=9F=E6=88=90?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
ECAT_RATE_TRADEOFF.html | 69 +
ECAT_RATE_TRADEOFF.md | 291 +++++
css/motor.css | 32 +
esp32_server.py | 22 +-
js/motor/main.js | 178 ++-
lab/_last_gd32_sha.txt | 2 +-
lab/ecat_calib.json | 139 ++
lab/ecat_rate_sweep.png | Bin 0 -> 205525 bytes
lab/ecat_rate_sweep_nocan.png | Bin 0 -> 196659 bytes
lab/ecat_rate_sweep_nocan_vs.png | Bin 0 -> 94777 bytes
lab/spi_test.html | 1954 ++++++++++++++++++++++++++---
pages/motor.html | 5 +-
scripts/build_ecat_report_html.py | 677 ++++++++++
scripts/calibrate_ecat_metrics.py | 418 ++++++
scripts/can_bridge_reconcile.py | 232 ++++
scripts/ecat_sweep_common.py | 54 +
scripts/plot_ecat_nocan_vs_can.py | 112 ++
scripts/plot_ecat_sweep.py | 188 +++
scripts/repro_cmdload.py | 55 +-
scripts/sweep_ecat_rate.py | 335 +++++
20 files changed, 4544 insertions(+), 219 deletions(-)
create mode 100644 ECAT_RATE_TRADEOFF.html
create mode 100644 ECAT_RATE_TRADEOFF.md
create mode 100644 lab/ecat_calib.json
create mode 100644 lab/ecat_rate_sweep.png
create mode 100644 lab/ecat_rate_sweep_nocan.png
create mode 100644 lab/ecat_rate_sweep_nocan_vs.png
create mode 100644 scripts/build_ecat_report_html.py
create mode 100644 scripts/calibrate_ecat_metrics.py
create mode 100644 scripts/can_bridge_reconcile.py
create mode 100644 scripts/ecat_sweep_common.py
create mode 100644 scripts/plot_ecat_nocan_vs_can.py
create mode 100644 scripts/plot_ecat_sweep.py
create mode 100644 scripts/sweep_ecat_rate.py
diff --git a/ECAT_RATE_TRADEOFF.html b/ECAT_RATE_TRADEOFF.html
new file mode 100644
index 0000000..71bd810
--- /dev/null
+++ b/ECAT_RATE_TRADEOFF.html
@@ -0,0 +1,69 @@
+
+
+EtherCAT 周期速度 × 故障率 × CPU 占用 调研
+调研日期 2026-09-08 · 本机 I210 + 1 个 GD32E508 从站 · 两轮扫描:带 CAN / 拔掉全部 CAN · 数据 lab/ecat_rate_sweep_*.csv · 生成器 scripts/build_ecat_report_html.py
+0.5ms 实测可用上限(2kHz,带 CAN 工况)
≈9.0pp CAN 每拍恒定负担(3 档配对,饱和档除外)
4000 帧/s 电机 STATUS 实测刷新(1/0.5ms)
≈8.2kHz 纯 ECAT 实测硬上限(设 10/12.5/16kHz 实测均不涨)
+结论 带 CAN 工况的可用上限在 0.5ms,瓶颈在从站 GD32 不在主站。 实测可用到 0.5ms;0.25ms 起电机数据刷新率从 4000 帧/s 掉到 88 帧/s(带 CAN 那轮当场确认 online=True,是真失效)。可用上限只夹在 (0.25ms, 0.5ms] 内,具体位置没测到 。纯 ECAT 硬上限 ≈8.2kHz,这次测到了墙本身。 断电重启后无 CAN 补测:0.25/0.125ms 满速通过(99.7% / 98.0%);再往下设 0.1 / 0.08 / 0.0625ms,实测循环率全部顶在 8196–8238Hz(达成 82.3% / 65.9% / 51.2%),wkc 全程 3/3、从站始终应答。墙的来历:主站单拍硬耗时 cyc p50 各档恒 117–126µs,1/121µs ≈ 8.2kHz —— 是"每拍必须花的往返+处理时间",不是主机 CPU 不够(顶墙时主机 CPU 反而降到 ~85%)。原「3.5kHz 外推」被证伪 ;首轮无 CAN 0.25ms 停报为残状态偶发,不复现。注意"跑得快"≠"能用":带 CAN 的可用上限仍由电机数据刷新决定(下一条)。拔 CAN 对照解开了混淆 :未饱和三档 Δ 稳定 8.7–9.3pp,不随频率张开 → 快档塌陷不是 CAN 挤出来的;饱和两档 Δ 收窄到 ≤0.5pp,正是读数被夹紧的特征。主机 native CPU 恒约 100% 单核、与周期无关 (自旋恒转,/api/motor/spin 已是空操作)。唯一的例外是带 CAN 轮的 0.125ms 档降到 87.5% —— 因为从站已停答。无 CAN 补测里从站活着,同档就回到 99.7%。主机 CPU 掉下来那一刻,正是从站死掉那一刻。 建议:要吃到余量就停在 0.5ms 。0.25ms 不是"更快",是功能损坏。
+一、曲线
+
+
① 循环率:设定 vs 实测(主站侧)
200 500 1,000 2,000 5,000 10,000 20,000 200 500 1,000 2,000 5,000 10,000 20,000 97.2% 98.0% 51.2% 82.3% 65.9% 理想 1:1(设定率) 实测循环率 · 带 CAN 实测循环率 · 无 CAN 标注为达成率%(<99.5% 才标)。无 CAN 设定 >8kHz 后实测全顶在 ≈8.2kHz —— 硬上限 设定循环率 (Hz,对数) 实测循环率 (Hz,对数)
+
② 从站每拍负担:带 CAN vs 无 CAN
0 25.0 50.0 75.0 100 200 500 1,000 2,000 5,000 10,000 100% Δ9.3pp Δ9.1pp Δ8.7pp 带 CAN(电机在报 STATUS/CONFIG) 拔掉全部 CAN 设备 纵轴是固件 load_perm(只计 ISR+非延迟段,排除 SSC 轮询)—— 不是整机 CPU 占用率 无 CAN 补测:0.25/0.125ms 满速;设定再快实测全顶在 ≈8.2kHz。≥4kHz 的负载读数全是饱和值,不能定位拐点 带 CAN 的 0.25/0.125ms 两点同理停在 ~99.x% —— 饱和读数不等于"已测到上限" 实测循环率 (Hz,对数) 从站 load_perm (%)
+
③ CAN 流量分层:STATUS 刷新与 CONFIG 事件流
10.0 20.0 50.0 100 200 500 1,000 2,000 5,000 10,000 20,000 200 500 1,000 2,000 5,000 10,000 带 CAN 轮:STATUS 全流量 塌陷 CAN 总接收 rx_count ≡ STATUS 刷新 gseq(实测同为 ~4000/s) CONFIG 事件流独立实测仅约 92 帧/s(去重后),不随周期放大 >2kHz 处 STATUS 与 CONFIG 一同塌到 ~150/s 实测循环率 (Hz,对数) 帧/s(对数)
+
④ 主站错拍率
0.10 0.20 0.50 1 2 5 10.0 20.0 50.0 100 200 500 1,000 2,000 5,000 10,000 20,000 500 1,000 2,000 5,000 10,000 带 CAN 无 CAN ▽ = skip ≤0.05 次/s(真 0 档,不在对数刻度上) skip=错过的网格拍。0.0625ms 设定档 15720 次/s:实测率被钉在 8.2kHz,错拍数随设定率线性涨 实测循环率 (Hz,对数) 主站错拍 skip_total (次/s,对数)
+
+名词解释(表里这几个计数器到底是什么) WKC(working counter,工作计数器) :EtherCAT 报文自带一个计数器,每有一个从站硬件处理了该报文就 +1。主站知道自己发出了几个报文,这个应得值就是 expected_wkc(本台子 1 个从站、每拍 3 个报文 → 期望 3)。所以 wkc 3/3 = 从站每拍都完整处理了;wkc -1 = 整帧没回来(从站停答/掉 OP);wr(watchdog reset)0→1 = 看门狗把总线重拉过一次。主站错拍 skip :主站按周期给自己排了一个"节拍网格"(ecat_native.c:776-791)。每拍做完 EtherCAT 收发后,若发现已经错过了下一个节拍点(甚至错过多个),就把错过的个数累进 skip_total。它量的是"主机软件迟到",不是从站故障 —— 1ms 档 1.1 次/s 只是偶发 OS 调度抖动;0.125ms 档 5107 次/s 说明单拍耗时已超过两个周期,主机也在勉强。它不区分"从站慢"和"主机慢"。在线 :电机槽位的 online 标志(槽位数据在超时窗内持续刷新才为 true)。带 CAN 轮显示"未记录"是因为当时脚本还没有这列,不是电机掉线 ;该轮的电机在线状况另有 8s/档定点实测佐证。
+二、数据全表
+带 CAN(主结论轮) 周期 实测Hz 达成率 从站load CAN接收/s skip/s wkc 在线 4ms 249 99.7% 20.2% 3,987 0.02 3/3 未记录* 2ms 499 99.7% 28.3% 3,990 0.08 3/3 未记录* 1ms 998 99.8% 44.5% 3,992 1.10 3/3 未记录* 0.5ms 1,999 100.0% 76.7% 3,995 11.86 3/3 未记录* 0.25ms 3,989 99.7% 99.7% 159 98.61 3/3 未记录* 0.125ms 7,780 97.2% 99.3% 190 5,106.62 3/3 未记录*
+拔掉全部 CAN 设备(补测轮) 周期 实测Hz 达成率 从站load CAN接收/s skip/s wkc 在线 2ms 499 99.7% 19.0% 0 0.08 3/3 0.00 1ms 998 99.8% 35.4% 0 0.58 3/3 0.00 0.5ms 2,000 100.0% 68.0% 0 17.08 3/3 0.00 0.25ms 3,988 99.7% 99.4% 0 39.72 3/3 0.00 0.125ms 7,839 98.0% 98.8% 0 4,672.07 3/3 0.00 0.0625ms 8,196 51.2% 99.0% 0 15,720.46 3/3 0.00 0.1ms 8,232 82.3% 98.9% 0 10,039.19 3/3 0.00 0.08ms 8,238 65.9% 98.9% 0 12,282.87 3/3 0.00
+* 「在线」列是扫描脚本后来才加的(motors_online/online_frac),带 CAN 那轮跑在加列之前 ,逐档数据没采 —— 不是全部掉线 。该轮电机在线另有 8s/档定点实测(「电机数据刷新率」表)佐证。
+电机数据刷新率(带 CAN 轮,8s/档定点实测) 周期 刷新率 帧/s 判定 1ms 4,000.2 正常 0.5ms 4,000.2 正常 0.25ms 87.9 塌陷 0.125ms 147.5 塌陷
这是唯一直接回答"用户还能不能用"的指标 —— λ 与 drop 都是中间量。塌陷两档当场确认 online=True,故为真失效而非掉线。
+三、机理与上限推算
+上限已实测:≈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 加直接计时(固件改动,需批准)或抓总线帧时序才能归因。
+四、判据口径与踩过的坑
+仪表陷阱(每条都实际踩过) 【本次真正翻车处】把两个同速计数器相除当成比率 —— drop/rx_count 都约 4000/s,相除恒得 100%,看着像"全丢",其实只是同速量之比 。判丢帧必须有独立 的分子与分母:本次真正的独立证据是「STATUS 实测 4001 帧/s 正常刷新」+「CONFIG 事件流去重后仅约 92 帧/s」,两者一起否掉了 100% 丢帧。任何"A/B 比率"指标,先问 A 与 B 是否独立计数。 从站停报后 load_perm 保留残值 —— 实测 age_ms=214338 时仍报 99.2%。任何取 load_perm 的脚本必须先过 age_ms 闸。扫描脚本已加该闸 并新增 gd32_load_samples/gd32_load_stale 两列。load_perm 不是整机 CPU 占用率 —— 固件 sys/cpu_load.h 定义收窄为"中断+非延迟处理段",排除 SSC MainLoop 轮询;当前 free-run(dc=0)下 PDO 应用走主循环轮询路径、不计入 。所以不能把"load→100%"读成"CPU 耗尽"。OVF 每拍被 s_rxWinOvf=0 清零 —— 从站侧没有可用的累计丢帧计数。读它恒 0 不能 当"无丢帧"证据(我据此误判 bridge.drop 是死字段,结论已收回)。/api/can/cadence 只存最近 512 拍 —— 0.25ms 档 =128ms、0.125ms=64ms,环没装满时取到的 count/lost 是行数 不是每拍帧数。对应 CSV 列已从结论排除。u16 回绕 :drop 在 4000/s 下 16 秒绕一圈 → 10Hz 逐次累加 + 取模,单次增量>32768 记为作废。本次作废点数=0 。「seq 值域 − distinct」不是丢帧率 :报 99.686%,方向与 drop≈λ 一致但分母含 STATUS 全流量,口径不干净 → 只作旁证。周期可被 localStorage 劫持 (transport-ecat.js:227-228)→ 每档下发后回读校验。本次六档 hijacked=no、native PID 全程 29284 未变。cyc_us.late/lost 恒 0 (遗留占位,未接线),未使用。
+五、可信度边界
+这份数据不能证明什么 板载固件版本无法确认 :烧写台账 201832b5a8e258d6… 与在盘 .hex 展平哈希(63d6ab9b…、e6a6e75f…)均不匹配 ,且产物时间戳晚于台账。→ 本报告不复用任何历史固件结论。主站在测量期间又重编过(源码 02:39 / exe 02:40,PID 起于 02:32),无法证明运行中的进程就是盘上那份。 rx_torn 与 pdo_reentry 整段恒零 = 无检出功效 ,不是"无撕裂"。二者分别需固件 1.6.6/1.6.7+,版本未知 → 无法区分"没发生"与"字段根本没实现、恒读 0"。λ≈3993/s 是本次 CAN 工况。λ 变则负担变 ,0.5ms 这个数不可外搬到别流量。 单台单次,未做重复性与温漂验证。
+六、下一步
+按优先级 查明 drop 口径(当前最高优先,唯一未决的承重项) 。必须用一对独立可比 的计数,不能再拿同速量相除:从 WiFi 侧读固件 u32 rx_drop_count(app_spi_cmd_can_feedback.cpp:99)对速率,同时用 CAN 监控页统计实收 CONFIG 帧数作分母。只有「独立到达计数 − 独立交付计数」才是丢帧数。台子现状 :断电重启后重新 open+start 成功(OP、wkc=3),无 CAN 的 0.25→0.0625ms 五档补测完成,周期已自动恢复 1ms。CAN 设备当前处于拔除状态 ,电机数据相关指标要插回 CAN 才能再测。插回 CAN 后补测 0.35 / 0.4 / 0.5ms 三档的电机数据刷新率 ,把带 CAN 工况的可用上限定位出来。load_perm 已被证明在快档饱和,不能再用它定位拐点 ;扫描脚本的 motors_online/online_frac/motor_refresh_per_s 三列才是可用性判据。 给 fillRxWindow 加直接计时,把 121µs 硬耗时里的主/从占比拆开(属固件改动,需批准)。 0.5ms 档实跑电机页完整命令序列 —— 本调研只测了计数器,没测界面实际可用性。
+
+
diff --git a/ECAT_RATE_TRADEOFF.md b/ECAT_RATE_TRADEOFF.md
new file mode 100644
index 0000000..b6c2c28
--- /dev/null
+++ b/ECAT_RATE_TRADEOFF.md
@@ -0,0 +1,291 @@
+# EtherCAT 周期速度 × 故障率 × CPU 占用 调研
+
+> **⚠ 本 Markdown 版含一条已作废的结论,权威版本是 `ECAT_RATE_TRADEOFF.html`。**
+> 作废内容:第一节「`drop` ≡ λ → 丢帧率 100%、与周期无关」。**错因**:`drop`、
+> `bridge.rx_count`、`head.global_seq` **各自都以约 4000/s 增长**,两两相除必然得到 100%,
+> 那只是同速量之比、不是丢帧。**反证**(后续实测):两台电机 STATUS 以 4001 帧/s 正常刷新,
+> 帧确实收到了;而 ECAT 批量窗口去重后的 CONFIG 事件流只有约 92 帧/s,与 `drop` 的 4000/s
+> 完全不成比例。`drop` 的口径至今未查明,**不得用它做任何丢帧判断**。
+> 仍然成立:速度上限、从站 CPU 负担、拔 CAN 对照、>2kHz 时 STATUS/CONFIG 一同塌陷。
+
+调研日期:2026-09-08 · 台子:本机 I210 + 1 个 GD32E508 从站(双电机在线)
+数据与脚本:`scripts/sweep_ecat_rate.py`(扫描)、`scripts/calibrate_ecat_metrics.py`(现场标定)、
+`scripts/plot_ecat_sweep.py`(出图)、`scripts/plot_ecat_nocan_vs_can.py`(带/无 CAN 对比);
+曲线 `lab/ecat_rate_sweep.png`、`lab/ecat_rate_sweep_nocan_vs.png`;原始数据
+`lab/ecat_rate_sweep_withcan.csv`、`lab/ecat_rate_sweep_nocan.csv`、`lab/ecat_calib.json`
+(`*.csv`/`*.jsonl` 按 `.gitignore` 约定不入库)。
+本轮共两轮扫描:**带 CAN**(第四~五节主结论)与**拔掉 CAN 的补测**(第四 b 节)。
+
+---
+
+## 一、结论
+
+**速度余量约 2×,且瓶颈在从站 GD32,不在主站。** 实测可用到 **0.5ms(2kHz)**;
+0.25ms 起电机数据刷新率从 4000 帧/s 掉到 88 帧/s(带 CAN 那轮 8s/档定点实测,当场确认
+`online=True`,故为真失效)。上限的**具体位置没测到** —— 只夹在 (0.25ms, 0.5ms] 之间。
+
+**「加快 ECAT 周期能减少 CAN 丢帧」这个前提被本次数据推翻。** 在 4ms→0.5ms 整个区间,
+桥接层丢帧 `bridge.drop` 与 CAN 到帧率 λ **完全相等**(丢帧率恒 100.0%),与周期无关。
+该字段属 GD32 段1 桥接层的接收计数,其消费者是 GD32 主循环而非 ECAT PDO 填充路径,
+所以 ECAT 周期快慢改不了它(字段的精确含义尚有一处未闭合矛盾,见第五节)。
+
+**提速的实际收益与代价**:
+
+| | 4ms | 2ms | 1ms(现行) | **0.5ms** | 0.25ms | 0.125ms |
+|---|---|---|---|---|---|---|
+| 实测循环率 | 249Hz | 499Hz | 998Hz | **1999Hz** | 3989Hz | 7780Hz |
+| 达成率 | 99.7% | 99.7% | 99.8% | **100.0%** | 99.7% | 97.2% |
+| GD32 负载 | 20.2% | 28.3% | 44.5% | **76.7%** | 99.7% | 99.3% |
+| 主站错拍 skip/s | 0.02 | 0.08 | 1.1 | **11.9** | 98.6 | 5106.6 |
+| 电机数据刷新 /s ¹ | 4000† | 4000† | 4000.2 | **4000.2** | 87.9 | 147.5 |
+| CAN 到帧 λ /s | 3987 | 3990 | 3992 | **3995** | 159 | 190 |
+| 桥接层丢帧 drop /s | 3987 | 3990 | 3992 | **3995** | 159 | 190 |
+| 丢帧率 | 100.0% | 100.0% | 100.0% | **99.99%** | 100.0% | 100.0% |
+| 主机 native CPU | 100.3% | 100.3% | 100.3% | **100.3%** | 100.3% | 87.5% |
+
+¹ 电机刷新率只在 **1 / 0.5 / 0.25 / 0.125ms** 四档定点实测(8s/档)。标 † 的 4ms、2ms 两列
+是按"λ 不随周期变化"推的,**未实测**。
+
+建议:**要吃到这份余量就停在 0.5ms**(功能与 1ms 完全同速、达成率满分、CPU 76.7% 仍有余量,
+代价是错拍从 1.1/s 升到 11.9/s)。0.25ms 不是"更快",是**功能损坏**。
+
+**补测(拔掉全部 CAN)后新增两点**:
+- CAN 的每拍负担是**常量 ≈9 个百分点**(2/1/0.5ms 三档 Δ=9.3/9.1/8.7pp),不随频率张开
+ → 带 CAN 那轮的快档塌陷不是 CAN 挤出来的,上限由纯 ECAT 的过程数据交换决定。
+- 纯 ECAT 到 2kHz 只需 68.0%,模型外推上限约 **3.5kHz**(带 CAN 约 2.7kHz)。
+ 但 **0.25ms 那档没测到数**:加压时从站停止应答(`wkc=-1`、`load_perm` 成残值),
+ 所以"3.5kHz"目前是外推而非实测,**不要据此设定期望值**。详见第四 b 节。
+
+---
+
+## 二、工况与数据身份(先声明,因为对账不上)
+
+| 项 | 值 | 能否确认 |
+|---|---|---|
+| GD32 烧写台账 | `lab/_last_gd32_sha.txt` = `201832b5a8e258d6…`(未提交状态) | 台账本身可读 |
+| 在盘固件产物 | 展平后哈希 `63d6ab9b…`(09-08 02:31)、`e6a6e75f…`(09-05) | **均不等于台账** |
+| 主站构建 | `ecat_native.c` 改于 02:39、`ecat_native.exe` 改于 02:40,均有未提交改动 | 运行中 PID 29284 起于 02:32 |
+| 板载固件版本 | — | **无法确认**(设备不回报版本字) |
+
+三点后果,均已在结论中按保守口径处理:
+
+1. 台账哈希与任何在盘产物都不匹配,且产物时间戳(02:31)**晚于**台账记录 —— 无法证明板上跑的
+ 就是当前源码编出的那一版。**本报告不复用任何历史固件结论**(含"1.6.5 已修 seqlock 撕裂")。
+2. 测量期间主站构建又更新过(02:39/02:40),无法证明 02:32 启动的进程内存里就是盘上那份 exe。
+3. `pdo_reentry` 字段要固件 1.6.7+ 才有、`rx_torn` 要 1.6.6+。两者本次恒零,**既可能是"没发生",
+ 也可能是"该字段根本没实现、恒读 0"** —— 因版本未知,二者无法区分。
+
+---
+
+## 三、判据口径(含三个当场失效/需修正的测法)
+
+先做 Phase 0 标定(`scripts/calibrate_ecat_metrics.py`)再扫描,因为固件改过很多版,
+不能假设任何历史判据仍然有效。标定过程中我自己有三个判据当场失效或被我误判,记录如下以免重犯:
+
+| 测法 | 结果 | 处置 |
+|---|---|---|
+| 拿「seq 值域 − 观测到的 distinct seq」当丢帧率 | 报出 **99.686%** 丢帧 | **方向与 `drop`≈λ 一致,不是假信号**;但分母是全局 seq 值域(含 STATUS 全流量),口径不干净 → 不单独引用,只作 `drop` 的旁证。曾据 `OVF=0` 判它"被证伪",该否定**收回**(见下节 OVF 说明) |
+| 用 HTTP 轮询看 `rx_batch.count` 当"每拍几帧" | 读出 cap=2,与源码 3 帧/拍不符 | 轮询欠采样。每拍口径必须取 native 的按拍环 `/api/can/cadence`(实测 count 众数=2,与轮询一致,但与 1.6.15 的 5 槽上限不是一回事) |
+| 用 `rxraw` 的环快照列 `rxb_count_mode_per_cycle` / `lost` / `pending` | 快档读出 5、4、2 | 该环只存最近 512 拍,0.25ms 档 = 128ms、0.125ms 档 = 64ms,环没装满 —— 读到的是**行数**不是帧数。**这几列不入结论** |
+
+最终采用的口径:
+
+- **速度达成**:`cycles`(u32)增量 / 墙钟,**cycles 与时刻同取同舍**(沿用 `tools/ecat/rate_probe.py` 的纪律,其注释记过一次因分子分母不同源把 99.2% 误报成 96% 的实踩)。
+- **CAN 到帧 λ**:`head.global_seq`(u32,固件 `ECAT_MOTW_OFF_GLOBAL_SEQ`「状态表全局刷新序号」)。
+ 与电机槽 `seq` 同速(实测均 4000/s)—— 两条独立字段互证,λ 口径可信。
+- **桥接层丢帧**:`bridge.drop`,固件出处 `ECAT_CAN_RX_OFF_DROP = 24`,注释明确为
+ 「**投递时上一帧还没被取走**」的丢帧数(u16)—— 即窗口被覆盖而未送达主站的帧,
+ 语义正对本题。自增另有 `canfd.h:53 rx_drop_count`(CAN RX 软件队列满,`canfd.cpp:1615`
+ `queue.count >= kRxQueueDepth`,队列深 `kRxQueueDepth=16`);两者都指向"消费不及"。
+ u16 在 4000/s 下 16 秒绕一圈 → 10Hz 逐次累加 + `(b-a) mod 65536`,单次增量 >32768 记为
+ 绕圈作废。**本次作废点数 = 0**,即采样率足够。
+- **不能当"无丢帧"证据的字段**:`rx_batch.lost` / cadence 的 `lost`/`pending`。它们对应固件
+ `OVF`,而 `fillRxWindow` 里 `in[...] = s_rxWinOvf; s_rxWinOvf = 0;` —— **每拍清零取增量**,
+ 实测恒 0 只说明单拍增量小于分辨率,不代表没有丢帧。曾据它判 `drop` 为死字段,此推断**收回**。
+- **功能存活(真故障轴)**:电机槽 `seq`(u32)前进速率。这是唯一直接回答"用户还能不能用"的指标。
+- **CPU(从站)**:`load_perm`(千分比,1s 滚动窗,`ECAT_DIAG_OFF_LOAD`)。**它不是整机 CPU 占用率**
+ —— 固件 `sys/cpu_load.h:9-27` 定义收窄为「中断 + 非延迟处理段」占墙钟之比,并**刻意排除**
+ SSC MainLoop 轮询与 `AppSpiCommandBus.poll()`;且当前是 free-run(`native/health.dc=0`),
+ 注释写明"free-run 调试模式下 PDO 应用跑在主循环轮询路径,**不计入**"。
+ 因此:① 不能把"load→100%"读成"CPU 耗尽";② 但它仍随循环率强增(20.2%→76.7%),
+ 这份增量只能来自**被计入的部分**(PDI/EXTI 等挂钩 ISR 每拍触发一次),
+ 所以作为"每拍中断负担"的代理量仍然成立 —— 第五节据此建模,并把结论限定在这个口径内。
+- **CPU(主机)**:`psutil` 按 PID 差分,同时报单核% 与整机%(`scripts/cpu_sample.ps1` 只报前者,
+ 28 核机器上会把 3.6% 误读成 100%)。
+- **撕裂/重入**:`rx_torn` / `pdo_reentry` 为 u16 **饱和**计数(到 65535 停),按
+ `delta_saturate` 处理,封顶即报"下界"而非精确值。
+
+---
+
+## 四、曲线与解读
+
+
+
+**① 主站循环率** 在 4ms→0.25ms 全程贴着 1:1(≥99.7%),0.125ms 才降到 97.2%。
+主站单拍耗时 `cyc_us.total.p50` 各档恒约 120–134µs、与设定周期无关(自旋钉核所致),
+所以**主站侧根本不是限制项**。附带修正一条历史记录:`rate_probe.py` 写着"真机一送一回
+≈165µs 物理墙",本次实测 0.125ms 档跑到 7780Hz,那道墙在当前 5 槽布局下已经不是限制。
+
+**② CPU 占用** 是唯一陡峭的分界线。GD32 负载 20.2 → 28.3 → 44.5 → **76.7 → 99.7**%,
+在 0.5ms 与 0.25ms 之间越顶。主机 native 全程 100.3%(自旋线程恒占一个逻辑核,
+`/api/motor/spin` 已是空操作,CPU 降不下来)—— 有意思的是 0.125ms 档反而**降到 87.5%**,
+因为从站已经不回数据、总线事务提前完成:**主机 CPU 掉下来的那一刻,恰恰是从站死掉的那一刻**。
+这条曲线说明"提速免费"的判断只在一半意义上成立:不花主机成本,但花从站成本,而从站才是预算。
+
+**③ 故障率** 是本次的核心图。λ 与 `drop` 两条线**重合到肉眼分不开**,横跨 4ms→0.5ms 全程,
+然后一起从 4000/s 断崖式塌到 ~150/s。读法:
+- 重合 = 到多少丢多少,**丢帧率 100% 且与周期无关** → 提速不改善它;
+- 一起塌 = 0.25ms 起连 CAN 收帧本身都停了 → 提速会**打断**它。
+电机数据刷新率(红)与 λ 同步塌陷,确认这不是测量假象而是功能失效。
+
+**④ 主站错拍 `skip_total`** 是次级上限:0.02 → 11.9(0.5ms)→ 98.6(0.25ms)→ 5107/s(0.125ms)。
+它在 GD32 越顶之前就已经指数上升,若只看主站视角,0.5ms 的 11.9 次/s 是可接受代价,
+但 0.25ms 的 98.6 次/s 已说明主站也在勉强。
+
+---
+
+## 四b、补测:拔掉全部 CAN 设备后的纯 EtherCAT 速度
+
+**动机**:带 CAN 的那轮里,快档同时出现「λ 塌陷」与「电机掉线」两种征象,二者在 λ/seq 上
+长得一模一样,无法区分是**每拍过程数据交换太重**还是**CAN 流量挤掉了主循环**。
+拔 CAN 是解这个混淆的判别性实验。
+
+**结果**(同脚本同参数,`lab/ecat_rate_sweep_nocan.csv`,对比图 `lab/ecat_rate_sweep_nocan_vs.png`):
+
+| 周期 | 循环率 | 带 CAN | 无 CAN | Δ |
+|---|---|---|---|---|
+| 2ms | 500Hz | 28.3% | **19.0%** | 9.3pp |
+| 1ms | 1000Hz | 44.5% | **35.4%** | 9.1pp |
+| 0.5ms | 2000Hz | 76.7% | **68.0%** | 8.7pp |
+| 0.25ms | 4000Hz | 99.7% | **不可引用** | — |
+
+**三条读数**:
+
+1. **CAN 的负担是常量、不是变量**:三档 Δ 稳定在 8.7–9.3pp。所以 CAN 既不是上限的成因,
+ 也不随频率张开 —— 带 CAN 那轮的快档塌陷**不是 CAN 挤出来的**。
+2. **纯 ECAT 到 2kHz 毫无问题**(68.0%,达成率 100.0%,错拍 17.1 次/s)。
+3. **上限仍没测到**:加压到 0.25ms 时**从站停止用有效数据应答** ——
+ `wkc=-1`(cadence 环里 512/512 拍全为 65535)、`bridge.valid=false`、`head.active=0`、
+ `app/load` 的 `age_ms=214338`,而主站 `cycles` 仍以 1004Hz 前进、`op=1`、`al_status=0`。
+ 即总线在空转、从站不应答。**0.25ms 档的 99.2% 是从站停报后的 load_perm 残值,
+ 本报告不引用。**
+
+**用无 CAN 数据重拟合第五节的两项模型**:F=(0.680−0.190)/(2.0−0.25)≈**272µs**、
+R≈4.0%,硬上限周期 F/(1−R)≈**284µs(3.5kHz)**。
+对比带 CAN 的 F≈323µs → 2.7kHz。**拔 CAN 大约把上限从 2.7kHz 抬到 3.5kHz,
+但 0.25ms=4kHz 按此模型仍不可行** —— 与实测"0.25ms 出事"方向一致,只是这次
+出事的表征是从站不应答而非负载封顶,机理未定。
+
+**扫描工具的两个缺陷由这轮暴露并已修**:
+- 原脚本会把从站停报后的 `load_perm` 残值当实测写入结论 → 已加 `age_ms<1000` 闸,
+ 并新增 `gd32_load_samples` / `gd32_load_stale` 两列,使"这个数有没有被测到"可见。
+- 原脚本未记电机在线标志 → 已加 `motors_online` / `online_frac` / `motor_refresh_per_s`。
+
+**台子现状**:从站仍停在 `wkc=-1`,恢复到 1ms 也不能自愈(native 的楔死看门狗未触发,`wr=0`)。
+继续测快档前需要先断电重启板子。
+
+
+---
+
+## 五、机理:为什么是 2 倍而不是更多
+
+从站每拍要写满输入 PDO。1.6.15 布局下输入 600B,`fillRxWindow` 在
+PDI_Isr/Sync0_Isr(preempt=0)里跑,**每拍都要跑一次**,与 CAN 是否有新帧无关。
+
+先否定一个想当然的模型:把「负载 × 周期」当作每拍固定开销,反推得到
+808 / 566 / 445 / 384µs(4ms→0.5ms),**单调下降**——说明它量的是每拍总工作量,
+里面还含一项与周期成正比的后台摊薄,不是常数。改成两项模型:
+
+```
+load(T) = F / T + R F = 每拍固定开销,R = 与周期无关的本底
+```
+
+用两端点(4ms/20.2% 与 0.5ms/76.7%)辨识:
+
+```
+F = (0.767 − 0.202) / (1/0.5 − 1/4) = 0.565 / 1.750 ≈ 323µs
+R = 0.202 − 323/4000 ≈ 11.7%
+```
+
+用**未参与拟合**的中间两点检验:
+
+| T | 模型 F/T+R | 实测 | 残差 |
+|---|---|---|---|
+| 2ms | 17.8% | 28.3% | +10.5 |
+| 1ms | 44.0% | 44.5% | +0.5 |
+| 0.25ms | **140.9%** | 99.7%(封顶) | 算术上 >100% → 不可行 |
+| 0.125ms | **269.9%** | 99.3%(封顶) | 同上 |
+
+模型在 1ms 处几乎精确、2ms 处偏低(低速档还有别的开销项),但关键预测成立:
+**存在硬上限周期 F/(1−R) ≈ 367µs ≈ 2.7kHz**,实测分界正好落在它两侧
+(0.5ms=2kHz 可跑、0.25ms=4kHz 不可跑)。这条是可证伪的:若下一次扫描在 0.4ms
+也能满速运行且负载 <100%,本模型即被推翻。
+
+用第四 b 节的**无 CAN** 数据独立重拟合,得 F≈272µs、R≈4.0%、上限 ≈284µs(3.5kHz)——
+同一模型形式、两组独立数据、F 相差 16%(正是 CAN 那份负担),说明 F/T+R 这个结构站得住。
+但**两组都没能直接测到上限本身**:带 CAN 时在 0.25–0.5ms 之间靠"功能塌陷"间接定位,
+无 CAN 时 0.25ms 直接从站停报。所以上限目前是被**夹在** (0.25ms, 0.5ms] 区间内,
+不是被测出来的。
+
+结论:从站侧的极限周期由**每拍固定开销 F**决定,要突破只能减小每拍要搬的字节数
+(当前 600B / 5 槽 / 56B 电机槽),**不是调主站周期能解决的**。主机侧完全有余量
+(native 恒约 100% 单核,与周期无关),所以预算全在从站。
+
+丢帧是另一条独立链路,与 ECAT 周期无关:`drop` 字段位于**段1(CAN RX 桥,偏移 24)**,
+属 GD32 桥接层自己的接收/投递计数,其消费者是主循环(`sys/task_runner.cpp:248`)而非
+ECAT PDO 填充路径 —— 所以 ECAT 周期快慢改不了它,实测四个档位恒等于 λ 也印证了这点。
+
+但这里有一处**尚未闭合的矛盾**,不能糊过去:固件 `config.h:125` 的 1.6.15 设计说明写着
+「STATUS 自 1.6.9 起不入窗,滤掉后入窗流量降到 CONFIG 量级」「5 槽=5000 帧/s 对 4000 帧/s
+留 25% 余量」—— 按此设计意图,丢帧应当接近 0。实测却是 `drop` 以 3992/s 持续增长、
+且恰好等于 λ(含 STATUS 的全流量)。两种解释都还活着:
+(a) 该计数统计的是入窗**之前**的 CAN RX 队列/桥接层丢弃,STATUS 在入队环节就被丢,与窗口
+ 容量无关(则 `drop`≈λ 完全合理,设计意图也没被违背);
+(b) 字段在当前固件里已改义或读错偏移,`drop`≈λ 是巧合。
+本次数据不足以区分二者,**列为待验证第 1 项**。无论哪种,都不影响主结论:
+`drop` 与 ECAT 周期无关 → 提速不改善它。
+
+---
+
+## 六、局限
+
+1. 单台单次,未做重复性与温漂验证。
+2. λ≈3993/s 是本次台子的 CAN 工况。λ 变则分界变,本报告给的是**相对关系**,
+ 0.5ms 这个具体数字不可直接搬到别的 CAN 流量下。
+3. 板载固件版本无法确认(见第二节),历史固件结论一律未采信;`rx_torn` / `pdo_reentry`
+ 恒零属**无检出功效**,只能表述为"本次测不到",**不得**读成"无撕裂/已修好"。
+4. `cyc_us.late` / `cyc_us.lost` 恒为 0(`ecat_native.c` 里是 Python 时代遗留占位),未接线,未使用。
+5. 扫描用 HTTP 以 10Hz 打 native,对被测对象有扰动;但主机 CPU 已恒 100% 单核,
+ 且 native 另有 HTTP 线程,扰动量级判断为小 —— **未单独做无负载对照实验**。
+6. `rxb_count_mode_per_cycle` / `rxb_lost_max` / `rxb_pending_max` 三列因环缓冲快照问题不可引用
+ (见第三节),CSV 中保留原值但结论未使用。
+6b. **快档塌陷这条结论的证据来源要分清**:电机刷新率与 λ 的塌陷,取自扫描后另做的 8s/档
+ 定点实测,那四次都当场确认 `online=True` 且 seq 在动,所以是**真功能失效**,不是电机
+ 掉线造成的假象。但扫描 CSV 本身**没记在线标志**,事后无法复核。补该列前不要引用
+ CSV 的快档数字。
+ (2026-09-08 复测时电机已处于 `online=False`、`global_seq` 冻结状态 —— 总线本身正常,
+ cycles 仍以 1000Hz 前进、CAN 侧仍以 ~3700 帧/s 收帧,停的是 STATUS 上报。
+ 该状态下的 0.4/0.35ms 快档读数一律不采信,也**未**写入本报告。)
+7. 每拍固定开销 F≈323µs 是从 4 个负载点**反推**的,不是直接计时测得;两项模型在
+ 2ms 档残差 +10.5 个百分点,说明低速档还有未建模的开销项。F 只能当量级用。
+
+## 七、待验证(下一步该做的判据)
+
+1. **`drop` 语义二选一**(第五节的 (a)/(b)):从 ESP32/WiFi 侧读固件那份 u32
+ `rx_drop_count`(`app_spi_cmd_can_feedback.cpp:99`,CAN 通道队列满丢弃的权威口径),
+ 与 ECAT 侧 u16 `bridge.drop` 对速率。若两侧同为 ~4000/s → (a) 成立,丢帧发生在入窗之前,
+ 与 ECAT 无关;若 WiFi 侧接近 0 而 ECAT 侧 4000/s → (b) 成立,字段读错偏移,
+ 本报告的故障率轴需要整体重做。**这是唯一还悬着的承重结论。**
+2. **从站侧健康度目前不可读**:`OVF` 在 `fillRxWindow` 里每拍清零,只能取到单拍增量,
+ 而单拍增量落在 u8 分辨率以下即恒 0 —— 等于从站没有可用的累计丢帧仪表。
+ 要判窗口是否真的健康,需要固件改成累计值或主站侧累加(属改动,需另行批准)。
+3. **每拍固定开销 F 直接计时**:给 `fillRxWindow` 加计时(属改动,需另行批准)。
+ 目前 F≈323µs 是从 4 个负载点反推的;直接量到 F 就能把「上限 2.7kHz」从拟合
+ 变成算式,也才能判断减字节能否真的换到速度。
+4. **模型已被证伪(保守方向)**:F/T+R 预测 0.4ms 约 92% 可行,但 0.4ms/0.35ms 的复测因
+ 电机掉线作废,**上限究竟在 0.4 还是 0.5ms 仍未定**。恢复电机后需重跑这两档;
+ 在此之前,建议值只写"0.5ms 已验证可跑",不要外推到 0.4ms。
+5. **重复性**:同一档跑 3 次看 λ / GD32 负载 / 电机刷新率 / **online 标志** 的散布,
+ 确认 0.5ms 与 0.25ms 的分界不是单次偶然,也不是电机掉线的副产物。
+6. **0.5ms 档实跑功能验证**:在 0.5ms 下开电机页跑一段完整命令序列,确认前端
+ `drainRx()` 与波形在 2kHz 下不积压(本调研只测了计数器,没测界面实际可用性)。
diff --git a/css/motor.css b/css/motor.css
index 389f110..fd0febb 100644
--- a/css/motor.css
+++ b/css/motor.css
@@ -762,6 +762,38 @@ body:has(.net-banner.show) .main-grid { padding-top: calc(var(--sp-3) + 29px); }
.seq-ops button.seq-send-one:hover:not(:disabled) { background: var(--ok-dim); color: var(--ok); }
.seq-ops button.seq-del:hover:not(:disabled) { background: var(--danger-dim); color: var(--danger); }
.seq-ops button:disabled { opacity: .25; cursor: default; }
+/* 拖动把手:按住上下换位(取代旧的上移/下移按钮),聚焦后 ↑↓ 方向键同样可移 */
+.seq-grip {
+ flex-shrink: 0; width: 18px; height: 26px; margin-left: -4px;
+ display: inline-flex; align-items: center; justify-content: center;
+ color: var(--fg-dim); border-radius: var(--radius-sm);
+ cursor: grab; user-select: none; -webkit-user-select: none; touch-action: none;
+ transition: background .15s, color .15s;
+}
+.seq-grip:hover { color: var(--primary); background: var(--primary-dim); }
+.seq-grip:active { cursor: grabbing; }
+.seq-grip:focus-visible { outline: 2px solid var(--primary); outline-offset: 1px; }
+.seq-grip svg { pointer-events: none; }
+/* 折叠:默认摘要不显示;折叠行收起动作组、只留头部 + 一行功能摘要 */
+.seq-fold svg { transition: transform .15s; }
+.seq-step:not(.collapsed) .seq-fold svg { transform: rotate(90deg); }
+.seq-summary {
+ display: none; font-family: var(--font-mono); font-size: 0.72rem; color: var(--fg-muted);
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
+}
+.seq-sum-sep { color: var(--border-strong); margin: 0 var(--sp-2); }
+.seq-sum-more { color: var(--fg-dim); margin-left: var(--sp-2); }
+.seq-step.collapsed { padding: 6px var(--sp-3); }
+.seq-step.collapsed .seq-step-head { margin-bottom: 0; }
+.seq-step.collapsed .seq-actions { display: none; }
+.seq-step.collapsed .seq-summary { display: block; margin-top: 2px; }
+/* 拖拽中:整表(含被拖行)临时收成单行,腾出空间便于看清插入位置 */
+.seq-list.dragging { gap: var(--sp-2); user-select: none; }
+.seq-list.dragging .seq-step { padding: 6px var(--sp-3); }
+.seq-list.dragging .seq-step-head { margin-bottom: 0; }
+.seq-list.dragging .seq-actions { display: none; }
+.seq-list.dragging .seq-summary { display: block; margin-top: 2px; }
+.seq-step.drag-src { opacity: .45; border-style: dashed; border-color: var(--primary); }
.seq-step-fields { display: grid; grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); gap: var(--sp-2); }
.seq-step-fields .field-label { font-size: 0.62rem; }
.seq-step-fields .seq-num { width: 100%; }
diff --git a/esp32_server.py b/esp32_server.py
index afde5cb..ab0505c 100644
--- a/esp32_server.py
+++ b/esp32_server.py
@@ -142,11 +142,12 @@ g_ecat_port = None # 记住上次探到的端口, 避免每次请求都从 809
# 换成原生守护进程 build_native\
# ecat_native.exe (C/SOEM, 由 build_native.bat 编译)。路由/JSON 契约与 Python
# 逐字段对齐, 见 tools/ecat/MIGRATION.md。三条必须小心的地方(native 原样镜像了这套契约):
-# 1) **必须先探测再拉起**。native 独立启动(无 --parent-pid)会清场: 对
+# 1) **必须先探测再拉起**。native 启动时**无条件清场**(后起的独占): 对
# 8090..8095 的旧实例逐个调 /api/shutdown(从站回 INIT)再兜底强杀。
-# 所以已有实例时只复用, 绝不重复拉起。
-# 2) 拉起时**必须带 --parent-pid**: 一是跳过上面那个清场逻辑, 二是让它在
-# 本服务退出后自我了断, 不留孤儿进程占着 Npcap 适配器和端口。
+# 已有实例时仍只复用不重复拉起 —— 清场是给"并发拉起撞上"兜底的,
+# 不该被用来每次踢掉用户手开的实例。
+# 2) 拉起时**必须带 --parent-pid**: 让 native 在本服务退出后自我了断,
+# 不留孤儿进程占着 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")
@@ -226,11 +227,14 @@ def _ecat_autostart():
g_ecat_child = None
if g_ecat_child is None:
try:
- # --parent-pid: 跳过 native 侧的清场逻辑, 并让它在本服务退出后
- # 自我了断(不留孤儿占 Npcap/端口)。
- # --mask 0x10: PDO 自旋线程钉 LP4(i7-14700K P核2), 避开 DPC 集中的
- # 核0 与 E 核, 与 ecat_web.bat 同值, 换机需按拓扑改。不钉的话
- # 自旋线程随调度器漂移, 1ms 档周期抖动明显放大。
+ # --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",
diff --git a/js/motor/main.js b/js/motor/main.js
index 048a632..515ac34 100644
--- a/js/motor/main.js
+++ b/js/motor/main.js
@@ -97,6 +97,7 @@ const state = {
selectedNode: null, // 当前选中节点号
motors: [], // 最近一次 /motor/state 的 motors 数组
decoded: new Map(), // node -> 解码后的 STATUS 对象
+ decodedConfig: new Map(),// node -> 解码后的 CONFIG 对象(电机实时回报的目标模式/目标值)
versions: new Map(), // node -> {soft,hard,boot} 查询到的版本号(持久化)
/* 用户可调的电机状态轮询间隔(ms)。由工具栏 #refreshRate 输入框驱动。
* 注意:仅影响 motor 链路;feedback(CAN 1Hz)/status(1Hz lite+5s full) 保持独立。
@@ -1456,6 +1457,17 @@ function refreshChannelViews() {
$("#nodeSummary").textContent = `${onlineCount}/9 在线 · CH${state.ch}`;
}
+/** 节点卡片模式标签:电机 CONFIG 帧 B0(= FOC.control_mode)每周期实时回报,是唯一权威来源;
+ * 仅在这条链路还没回过 CONFIG 时,退到前端记录的最近发送 CMD。
+ * 都不到就用 "--",不要写"待机"—— 电机没有这个状态,写了就是假仪表。 */
+function modeLabelOf(m) {
+ const cfg = state.decodedConfig.get(motorKey(m));
+ const mode = cfg ? cfg.targetMode : state.lastSentMode.get(m.node);
+ if (mode == null) return "--";
+ const def = MODES[mode];
+ return def ? def.zh : ("模式" + mode);
+}
+
function renderNodeList() {
const motors = getDisplayMotors();
@@ -1513,11 +1525,9 @@ function renderNodeList() {
const ledFull = "led " + ledCls;
if (ref.led.className !== ledFull) ref.led.className = ledFull;
- const sentMode = state.lastSentMode.get(m.node);
- const mode = (sentMode != null && MODES[sentMode]) ? MODES[sentMode] : null;
- /* motStatus 是报错位(1=故障),不是运行标志;无最近 CMD 时用 待机。
+ /* motStatus 是报错位(1=故障),不是运行标志;模式取电机 CONFIG 帧实时回报。
* otherCh:节点在其他通道在线,标注实际通道供用户切换。 */
- const modeLabel = m.otherCh != null ? ("CH" + m.otherCh) : (!m.online ? "离线" : (mode ? mode.zh : "待机"));
+ const modeLabel = m.otherCh != null ? ("CH" + m.otherCh) : (!m.online ? "离线" : modeLabelOf(m));
if (ref.modeEl.textContent !== modeLabel) ref.modeEl.textContent = modeLabel;
const modeCls = m.otherCh != null ? "nc-mode mode-other-ch"
: (!m.online ? "nc-mode mode-off" : (faultCount > 0 ? "nc-mode mode-fault" : "nc-mode"));
@@ -1556,9 +1566,7 @@ function buildNodeCardHtml(m) {
faultCount > 0 ? "fault" : "",
!m.online ? "offline" : "",
].join(" ").trim();
- const sentMode = state.lastSentMode.get(m.node);
- const mode = (sentMode != null && MODES[sentMode]) ? MODES[sentMode] : null;
- const modeLabel = m.otherCh != null ? ("CH" + m.otherCh) : (!m.online ? "离线" : (mode ? mode.zh : "待机"));
+ const modeLabel = m.otherCh != null ? ("CH" + m.otherCh) : (!m.online ? "离线" : modeLabelOf(m));
const modeCls = m.otherCh != null ? "mode-other-ch" : (!m.online ? "mode-off" : (faultCount > 0 ? "mode-fault" : ""));
const stTxt = !m.online ? "--" : (faultLabel || "OK");
const cardTitle = faultLabel ? ("故障: " + faultLabel)
@@ -2015,6 +2023,36 @@ function seqNodeOptions(selected) {
return FIXED_MOTOR_NODES.map((n) =>
`