#!/usr/bin/env python3 # -*- coding: utf-8 -*- """pdo-fast-send 端到端分段延迟(模式切换代理)。 电机启动/转动有惯性,rtAngle/rtVelocity 阈值难踩准。改用 **CONFIG 回报的 Target_Control_Mode(0x000N0101 B0)** 作为「命令已被电机接收并应用」的可见 代理:CMD 的 mode 字节一旦被接受,下一拍 CONFIG 就会以新 mode 回报。 路径: json — HTTP POST /ecat/api/motor/send(浏览器同款页面代理) fast — ecat_send_raw_frames(桌面桥同构 ESF1 TCP,不含 pywebview hop) 分段: t_click = 发请求前 t_sent = 控制面返回(入队完成) t_can = bridge.tx_count/ack 变化(EtherCAT→GD32 CAN 出口) t_mode = 观测到目标节点 CONFIG B0 == 期望 mode(xrx 或 motor/rx) 用法: python scripts/check_e2e_click_motion.py python scripts/check_e2e_click_motion.py --node 3 --ch 2 --trials 5 python scripts/check_e2e_click_motion.py --proxy motion # 旧:角度/速度阈值 """ from __future__ import annotations import argparse import json import os import statistics import struct import sys import time import urllib.request sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) from esp32_server import ecat_send_raw_frames # noqa: E402 PAGE = os.environ.get("DCU_PAGE_BASE", "http://127.0.0.1:2625") ECAT = PAGE + "/ecat/api" # 观测默认打 native 直连:页面代理 p50~2ms×多次 + xrx 单帧槽会把 CONFIG 观测抬到 ~12ms NATIVE = os.environ.get("DCU_NATIVE_API", "http://127.0.0.1:8090/api") OBS = NATIVE # 观测环(motor/state、motor/rx) # 安全模式对:0=Disable(输出关闭),2=Velocity 但目标速度 0(不驱动转动) SAFE_TOGGLE = (0, 2) def post(url, obj=None, raw=None, ctype="application/json", timeout=8.0): if raw is not None: data = raw headers = {"Content-Type": ctype} else: data = json.dumps(obj or {}, ensure_ascii=False, separators=(",", ":")).encode("utf-8") headers = {"Content-Type": ctype} req = urllib.request.Request(url, data=data, headers=headers, method="POST") t0 = time.perf_counter() with urllib.request.urlopen(req, timeout=timeout) as r: body = r.read() ms = (time.perf_counter() - t0) * 1000.0 try: return ms, json.loads(body.decode("utf-8", "replace") or "{}") except Exception: return ms, {"_raw_len": len(body)} def decode_status(data_hex: str): if not data_hex or len(data_hex) < 36: return None raw = bytes.fromhex(data_hex[:72] if len(data_hex) >= 72 else data_hex) if len(raw) < 21: return None def f32(off): if off + 4 > len(raw): return 0.0 v = struct.unpack_from(" dict: st0, outlet0, _ = motor_sample(node, ch) # 基线 mode:SIG 必须相对它变化,避免 CONFIG 持续回报同一 mode 造成假阳性 base_mode, base_src = observe_config_mode(node) if proxy == "mode": # 强制与基线不同 if base_mode == next_mode: next_mode = SAFE_TOGGLE[1] if base_mode == SAFE_TOGGLE[0] else SAFE_TOGGLE[0] data = build_cmd(mode=next_mode, voltage=24.0, current=0.0, velocity=0.0, angle=0.0) expect = next_mode else: data = build_cmd(mode=2, voltage=24.0, current=0.0, velocity=40.0, angle=0.0) expect = 2 base_mode = None t_click = time.perf_counter() sent_ms, resp = send_path(path, ch, node, data) t_sent = time.perf_counter() t_can = None t_mode = None t_motion = None can_detail = mode_detail = motion_detail = "" ang0 = st0["rtAngle"] if st0 else 0.0 vel0 = st0["rtVelocity"] if st0 else 0.0 while time.perf_counter() - t_click < timeout: st, outlet, snap = motor_sample(node, ch) now = time.perf_counter() if t_can is None and outlet_changed(outlet0, outlet): t_can = now can_detail = f"{outlet0} → {outlet}" if t_mode is None and proxy == "mode": # 观测优先 drain rx:CONFIG 约 ~200Hz/节点,xrx 只有单槽会被其它节点挤掉 drained = drain_rx_modes(node) if expect in drained and expect != base_mode: t_mode = now mode_detail = f"mode {base_mode}→{expect} via rx" else: hits = scan_config_mode(node, snap) for src, m, raw in reversed(hits): if m == expect and m != base_mode: t_mode = now mode_detail = f"mode {base_mode}→{m} via {src} data={(raw or '')[:16]}" break if st and t_motion is None and proxy == "motion": d_ang = abs(st["rtAngle"] - ang0) d_vel = abs(st["rtVelocity"] - vel0) if d_ang > 0.3 or abs(d_vel) > 5.0: t_motion = now motion_detail = f"Δang={d_ang:.3f} Δvel={d_vel:.2f}" done_mode = (proxy == "mode" and t_mode is not None) or (proxy == "motion" and t_motion is not None) if t_can is not None and done_mode: break # 直连 native 时观测环约 0.6–1ms;页面代理则 ≥2ms×N,会主导 p50 time.sleep(0) send_path(path, ch, node, build_cmd(mode=0, voltage=24.0, current=0.0, velocity=0.0, angle=0.0)) time.sleep(0.08) def ms(dt): return None if dt is None else dt * 1000.0 t_sig = t_mode if proxy == "mode" else t_motion return { "ok": True, "path": path, "proxy": proxy, "base_mode": base_mode, "base_src": base_src, "expect_mode": expect if proxy == "mode" else None, "resp_ok": bool(resp.get("ok")), "resp_path": resp.get("path"), "sent_ms": sent_ms, "click_to_sent": ms(t_sent - t_click), "click_to_can": ms(None if t_can is None else t_can - t_click), "click_to_sig": ms(None if t_sig is None else t_sig - t_click), "sent_to_can": ms(None if t_can is None else t_can - t_sent), "sent_to_sig": ms(None if t_sig is None else t_sig - t_sent), "can_to_sig": ms(None if (t_can is None or t_sig is None) else t_sig - t_can), "can_detail": can_detail, "sig_detail": mode_detail if proxy == "mode" else motion_detail, "base_vel": vel0, "sig_ok": t_sig is not None, } def summarize(rows, key): xs = [r[key] for r in rows if r.get(key) is not None] if not xs: return "n/a" return ( f"n={len(xs)} mean={statistics.mean(xs):.2f}ms " f"p50={statistics.median(xs):.2f} min={min(xs):.2f} max={max(xs):.2f}" ) def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--node", type=int, default=3) ap.add_argument("--ch", type=int, default=2) ap.add_argument("--timeout", type=float, default=2.0) ap.add_argument("--trials", type=int, default=4) ap.add_argument("--paths", type=str, default="json,fast") ap.add_argument("--proxy", type=str, default="mode", choices=["mode", "motion"]) args = ap.parse_args() paths = [p.strip() for p in args.paths.split(",") if p.strip()] _, health = post(OBS + "/native/health", {}) print("health:", json.dumps(health, ensure_ascii=False)[:220]) print(f"观测环直连: {OBS}(发送仍按 path 走 page/fast)") post(ECAT + "/motor/start", {"period_ms": 0.25, "dc_cycle_us": 250, "dc_shift_us": 0}, timeout=30) time.sleep(0.25) cur_mode, src = observe_config_mode(args.node) print(f"基线 CONFIG mode node={args.node}: {cur_mode} (via {src})") print(f"代理={args.proxy} 模式对={SAFE_TOGGLE} 每次在两者间切换,目标速度恒 0") all_rows: dict[str, list] = {} for path in paths: print(f"\n======== path={path} proxy={args.proxy} node={args.node} ch={args.ch} ========") rows = [] for i in range(args.trials): # 每轮现读基线再选「与基线不同」的 mode cur_mode, _src = observe_config_mode(args.node) if args.proxy == "mode": if cur_mode == SAFE_TOGGLE[0]: next_mode = SAFE_TOGGLE[1] elif cur_mode == SAFE_TOGGLE[1]: next_mode = SAFE_TOGGLE[0] else: next_mode = SAFE_TOGGLE[0] else: next_mode = 2 print(f"--- trial {i+1}/{args.trials} base_mode={cur_mode} → send mode={next_mode} ---") r = run_once(path, args.node, args.ch, args.proxy, args.timeout, next_mode) rows.append(r) if not r.get("resp_ok") or not r.get("sig_ok", True): print(" FAIL/NOSIG", {k: r.get(k) for k in ("resp_ok", "sig_ok", "base_mode", "expect_mode", "resp_path", "sig_detail")}) else: print( f" via={r['resp_path']} mode {r.get('base_mode')}→{r.get('expect_mode')} " f"sent={r['sent_ms']:.2f}ms " f"click→sent={r['click_to_sent']:.2f} " f"click→can={r['click_to_can'] if r['click_to_can'] is not None else 'n/a'} " f"click→sig={r['click_to_sig'] if r['click_to_sig'] is not None else 'n/a'} " f"sent→can={r['sent_to_can'] if r['sent_to_can'] is not None else 'n/a'} " f"sent→sig={r['sent_to_sig'] if r['sent_to_sig'] is not None else 'n/a'} " f"can→sig={r['can_to_sig'] if r['can_to_sig'] is not None else 'n/a'}" ) if r.get("can_detail"): print(" CAN", r["can_detail"]) if r.get("sig_detail"): print(" SIG", r["sig_detail"]) time.sleep(0.35) all_rows[path] = rows good = [r for r in rows if r.get("ok") and r.get("resp_ok") and r.get("sig_ok", True)] print(f"\n=== {path} 汇总 ({len(good)}/{len(rows)}) ===") for key in ("sent_ms", "click_to_sent", "click_to_can", "click_to_sig", "sent_to_can", "sent_to_sig", "can_to_sig"): print(f" {key:14s} {summarize(good, key)}") print("\n======== 口径 ========") if args.proxy == "mode": print("主信号: CONFIG Target_Control_Mode (0x000N0101 B0) 变为期望值") print(" = 电机已接收并回报新控制模式(不依赖转动/角度阈值)") else: print("主信号: STATUS rtAngle/rtVelocity 阈值(旧口径,启动惯性会漏测)") print("t_click 不含真实鼠标与 pywebview hop;观测分辨率受 motor/state/rx 轮询限制。") print("fast 路径为 GUI 桥同构 Python 调用。") return 0 if __name__ == "__main__": sys.exit(main())