#!/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())