Files
DCU_DesktopController/scripts/can_bridge_reconcile.py
T
cbb 209a869f96 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 项全过)
2026-09-12 20:10:47 +08:00

233 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""逐层对账 CAN→ECAT:谁在丢帧、丢多少、哪一层丢的。
动机:面板上的 `drop`(段1/段3"最近一帧"单格被未取先覆的帧数)恒等于 λ − 实测Hz,
是个会自证的数,拿它说"丢帧"没有说服力。真丢帧要逐层各数一遍:
L1 CAN 控制器 err/TEC/REC —— ESP32 /can/feedback
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(GD32 OVF) —— 单拍值,
由本脚本按 cycle 去重后统计,仍是**下界**(轮询看不到每一拍)
L3−L4 就是 STATUS 帧数(STATUS 在入窗之前就 return,见固件
ethercat_can_bridge.cpp:435),每节点 STATUS 率另用段2 槽 seq 步长反推
(率 = λ/step,与轮询率无关)。
只读:全部走只读诊断路由,不发报、不改周期、不碰生命周期。
用法: python scripts/can_bridge_reconcile.py [秒数=20] [esp32_ip=192.168.31.127]
"""
import json
import socket
import sys
import time
import urllib.request
# 控制台默认 GBK:一个字符编不出来就整轮数据白采,所以先把输出流换成 UTF-8
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
HOST = "127.0.0.1"
ECAT_PORTS = range(8090, 8096)
DUR_S = float(sys.argv[1]) if len(sys.argv) > 1 else 20.0
ESP_IP = sys.argv[2] if len(sys.argv) > 2 else "192.168.31.127"
# 段4 覆盖窗口(固件 1.6.15 定长布局):raw 从 PDO 偏移 364 起,5 槽 × 44B
WIN_SLOT_SIZE = 44
WIN_SLOTS = 5
W_DLC, W_FLAGS, W_ID, W_SEQ = 1, 2, 4, 8
def post(path, port, timeout=6.0):
"""裸 socket + 连接阶段不设 timeout:给 urllib 传 timeout 会撞上 Windows
非阻塞 connect 的 ~10ms 调度粒度,把仪表自己变成扰动源。"""
body = b"{}"
req = (f"POST {path} HTTP/1.0\r\nHost: {HOST}:{port}\r\n"
"Content-Type: application/json\r\n"
f"Content-Length: {len(body)}\r\n\r\n").encode("ascii") + body
s = socket.create_connection((HOST, port))
try:
s.settimeout(timeout)
s.sendall(req)
buf = bytearray()
while True:
c = s.recv(65536)
if not c:
break
buf.extend(c)
finally:
s.close()
return json.loads(bytes(buf).partition(b"\r\n\r\n")[2].decode("utf-8", "replace"))
def find_port():
for p in ECAT_PORTS:
try:
if post("/api/motor/state", p, 2.0).get("ok"):
return p
except Exception:
continue
return None
def wrap(a, b, bits):
return (b - a) % (1 << bits)
def le32(bs, off):
return int.from_bytes(bs[off:off + 4], "little")
def can_ch(ip):
"""一次读全部 CAN 通道,返回 {通道号: 通道 dict};读不到返回 None(绝不填 0)。
不能在这里挑"rx 最大的通道":ESP32 的 ID 快照是分页轮转的,开机初期某通道还没
轮到 valid 时 rx=0,两次采样各挑各的就会挑到不同通道,差值直接变成整个累计值
(实测虚报成 3.0M 帧/s)。通道由调用方跨两次采样锁定。"""
try:
with urllib.request.urlopen(f"http://{ip}/can/feedback", timeout=3) as r:
d = json.loads(r.read().decode("utf-8"))
except Exception:
return None
chs = {c.get("ch"): c for c in (d.get("channels") or []) if c.get("valid")}
return chs or None
def can_pick(a, b):
"""两次采样都 valid 且 rx 最大的那个通道 —— 保证差分是同一计数器的两端。"""
if not a or not b:
return None
common = [k for k in a if k in b]
if not common:
return None
return max(common, key=lambda k: max(a[k].get("rx") or 0, b[k].get("rx") or 0))
def win_slots(raw_hex):
"""原始字节 → [(seq, id, dlc)],只收 flags.bit0=1 且 seq 为偶数(整槽一致)的槽。"""
bs = bytes.fromhex(raw_hex)
out = []
for i in range(WIN_SLOTS):
p = bs[i * WIN_SLOT_SIZE:(i + 1) * WIN_SLOT_SIZE]
if len(p) < W_ID + 4 or (p[W_FLAGS] & 1) == 0:
continue
sq = le32(p, W_SEQ)
if sq == 0 or sq & 1:
continue
out.append((sq, le32(p, W_ID), p[W_DLC]))
return out
def main():
port = find_port()
if not port:
print("找不到在监听的 ECAT native(8090..8095)—— 主站没起,先连一次总线")
return 1
print(f"native :{port} 窗口 {DUR_S:.0f}s ESP32 {ESP_IP}")
seen_cycle = None
seq_first = seq_last = None
per_id = {}
lost_max = pend_max = cnt_max = online = 0
n_state = n_raw = 0
t0 = time.perf_counter()
c0 = can_ch(ESP_IP)
s0 = post("/api/motor/state", port)
while time.perf_counter() - t0 < DUR_S:
st = post("/api/motor/state", port)
n_state += 1
rb = st.get("rx_batch") or {}
online = max(online, sum(1 for m in (st.get("motors") or []) if m.get("online")))
# 每拍回填清零的单拍值,只能取抽样最大值当下界
lost_max = max(lost_max, rb.get("lost") or 0)
pend_max = max(pend_max, rb.get("pending") or 0)
cnt_max = max(cnt_max, rb.get("count") or 0)
try:
rr = post("/api/motor/rxraw", port)
except Exception:
rr = None
if rr and rr.get("layout") != "win":
print(f"段4 布局是 {rr.get('layout')!r},不是 1.6.15 覆盖窗口(win) —— 本脚本的解析器不适用")
return 2
# 注意:rxraw 的 count/lost/pending 是**旧布局偏移**(360/361/362)的原始字节,
# 在 win 布局下分别是"窗口槽数常量""环形写游标 0..4""GD32 OVF"。只有第三个
# 有意义,前两个拿来当丢帧数会得出"lost 最大 4、84% 的拍非 0"这种假结论。
if rr and rr.get("cycle") != seen_cycle: # 按拍去重:同一拍重复读会重复计数
seen_cycle = rr.get("cycle")
n_raw += 1
for sq, _id, _dlc in win_slots(rr.get("raw") or ""):
seq_first = sq if seq_first is None else min(seq_first, sq)
seq_last = sq if seq_last is None else max(seq_last, sq)
per_id.setdefault(_id, set()).add(sq)
t1 = time.perf_counter()
time.sleep(0.05) # 约 20Hz:开环全速轮询会让仪表自己变成扰动源
t1 = time.perf_counter()
c1 = can_ch(ESP_IP)
s1 = post("/api/motor/state", port)
dt = t1 - t0
hz = wrap(s0.get("cycles", 0), s1.get("cycles", 0), 64) / dt
lam = wrap((s0.get("head") or {}).get("global_seq", 0),
(s1.get("head") or {}).get("global_seq", 0), 32) / dt
bridge = wrap((s0.get("bridge") or {}).get("rx_count", 0),
(s1.get("bridge") or {}).get("rx_count", 0), 32) / dt
win_rate = ((seq_last - seq_first) / 2 / dt) if seq_last is not None else None
got = sum(len(v) for v in per_id.values()) / dt
drop_single = wrap((s0.get("bridge") or {}).get("drop", 0),
(s1.get("bridge") or {}).get("drop", 0), 16) / dt
print(f"\n窗口 {dt:.2f}s 档位 {s1.get('period_ms')}ms PDO {hz:.0f}Hz "
f"轮询 state {n_state} 次 / rxraw 新拍 {n_raw} 次"
f"({n_raw / dt:.1f} 拍/s → 抽样比 {n_raw / max(1e-9, hz * dt):.1%})")
print("\n层 计数 帧/s 判读")
ck = can_pick(c0, c1)
can_rate = None
if ck is not None:
rx0 = c0[ck].get("rx") or 0
dr0 = c0[ck].get("drop") or 0
rx1 = c1[ck].get("rx") or 0
dr1 = c1[ck].get("drop") or 0
err, tec, rec, bus = (c1[ck].get(k) for k in ("err", "tec", "rec", "bus_state"))
can_rate = wrap(rx0, rx1, 32) / dt # rx / drop 都是 u32
can_drop = wrap(dr0, dr1, 32) / dt
print(f"L1 CAN err/TEC/REC/bus —— {err}/{tec}/{rec}/{bus}"
f" {'干净' if not (err or tec or rec) else '有错'} (通道 {ck})")
print(f"L2 CAN 软件队列满丢 {can_drop:10.0f} "
f"{'0 = 队列(深度16)没满过' if not can_drop else '在丢帧,查从站取帧速率'}")
print(f"L3 CAN 控制器收帧 {can_rate:10.0f}")
else:
print("L1/L2/L3 ESP32 /can/feedback 不可读 —— 不填 0,这三层本轮无数据")
print(f"L3 ECAT桥 进桥 rx_count {bridge:10.0f}")
if can_rate is not None:
gap = can_rate - bridge
print(f" CAN - 桥 {gap:10.0f} "
f"{'≈0 两层同域' if abs(gap) < bridge * 0.01 else '差额:到了 CAN 但没进桥计数'}")
print(f"L4 段4窗 入窗帧 Δseq/2 {win_rate if win_rate else float('nan'):10.0f}"
f" 精确值(窗口 seq 是累计计数器,与抽样率无关)")
print(f" STATUS(=桥−入窗) {bridge - (win_rate or 0):10.0f} "
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" 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},不参与判丢帧")
if online:
print(f"\n按节点拆(在线 {online} 个): STATUS ≈ {(bridge - (win_rate or 0)) / online:6.0f} 帧/s/节点,"
f"入窗 ≈ {(win_rate or 0) / online:6.0f} 帧/s/节点")
if per_id:
print("\n入窗帧按 ID(唯一 seq 计数,下界):")
for _id in sorted(per_id):
n = len(per_id[_id])
print(f" 0x{_id:08X}: {n / dt:8.0f} 帧/s 子码 0x{_id & 0xFFFF:04X}")
return 0
if __name__ == "__main__":
sys.exit(main())