feat(motor): 反馈刷新率 Hz 适配(0x0400/0x0401 扩展格式)+ ECAT 密排槽与判活自适应
反馈刷新率(配套电机固件 v0.4.1:0x0400 DLC4/0x0401 DLC2 扩展格式, 选择器 0x0100=STATUS · 0x0101=CONFIG 帧,协议值为周期 ms): - 「节点 ID」页签新增 STATUS/CONFIG 刷新率读取/写入;界面单位一律 Hz (写入按 周期ms=round(1000/Hz) 换算下发,读取反算 Hz 并附原始周期, 取整有损如实展示),0=停发,写入掉电保存;配置值以「读取」回读电机为准 - WiFi 应答回执走 0xC1 per-ID 快照(/can/feedback 全量 entries): GD32 0xC0 ACK 仅 1 字节状态、不携带电机回帧;同 ID 回帧快照只存最新 一条,故一次快照只配一次请求(STATUS/CONFIG 分开读写) - ECAT 回执走批量接收区 takeNodeIdReport,按长度分派 2B 旧格式/4B GET/5B SET ACK - GET_NODE_ID 迁移到扩展选择器 0x0000,兼容旧固件 2B 节点 ID 应答 (SET DLC2 旧格式行为不变) - transport-wifi 新增 fetchFeedbackFull(全量 CAN 反馈快照) EtherCAT/基础设施(密排槽固件 1.6.21 配套): - 快照槽位 5→9 一拍整轮(扫描周期数 6→1);判活阈值随实测 seq 间隔 自适应 clamp(3×EWMA, 2s, 30s),秒级回报档不再被固定 2s 误判离线; STATUS 面板 Hz 明确为网页到达口径(按槽 seq 实测电机回报率的方案已否掉) - esp32_server.py:ecat_native 单实例收紧(后起独占 + taskkill 残留实例, 双实例并存会抢 Npcap 致后起者抓不到从站)+ native stdout 落盘 - flash_gd32.py:刷写后校验「GD32 真的在跑新代码」(版本变化或 uptime 归零), 防"写入≠在跑"假台账 - lab:新增 LAN9252 电源诊断台、PDO 布局页;SPI 测速并入 EtherCAT 接收看板 并更名「SPI/ECAT 通信测速」;移除 continuity_test.html;新增 ECAT 单拍耗时 优化报告;/_local/ 排除入库;测试同步更新(62+58 项全过)
This commit is contained in:
@@ -600,7 +600,7 @@ def main():
|
||||
'<code>sys/cpu_load.h</code> 定义收窄为"中断+非延迟处理段",'
|
||||
'<b>排除</b> SSC MainLoop 轮询;当前 free-run(<code>dc=0</code>)下 PDO '
|
||||
'应用走主循环轮询路径、<b>不计入</b>。所以不能把"load→100%"读成"CPU 耗尽"。</li>'
|
||||
'<li><b><code>OVF</code> 每拍被 <code>s_rxWinOvf=0</code> 清零</b> —— 从站侧'
|
||||
'<li><b><code>OVF</code> 每拍被 <code>s_rxWinOvf=0</code> 清零</b> —— GD32 侧'
|
||||
'没有可用的累计丢帧计数。读它恒 0 <b>不能</b>当"无丢帧"证据'
|
||||
'(我据此误判 <code>bridge.drop</code> 是死字段,结论已收回)。</li>'
|
||||
'<li><b><code>/api/can/cadence</code> 只存最近 512 拍</b> —— 0.25ms 档 '
|
||||
|
||||
@@ -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},不参与判丢帧")
|
||||
|
||||
+23
-2
@@ -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 —— 刷写未完成,别继续测试")
|
||||
|
||||
|
||||
@@ -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} 项`);
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user