289 lines
11 KiB
Python
289 lines
11 KiB
Python
#!/usr/bin/env python3
|
||||
|
|
# -*- coding: utf-8 -*-
|
|||
|
|
"""模拟网页全链路自测(改 native/前端后必跑)。
|
|||
|
|
|
|||
|
|
覆盖浏览器实际路径(同源 :2625 /ecat/api/* + 静态资源),
|
|||
|
|
以及 GD32 出口(bridge.tx_count/ack)与 WiFi 对照。
|
|||
|
|
|
|||
|
|
用法:
|
|||
|
|
python scripts/e2e_web_send_latency.py
|
|||
|
|
python scripts/e2e_web_send_latency.py --n 8
|
|||
|
|
退出码: 0=PASS, 1=FAIL
|
|||
|
|
"""
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import argparse
|
|||
|
|
import json
|
|||
|
|
import os
|
|||
|
|
import re
|
|||
|
|
import sys
|
|||
|
|
import time
|
|||
|
|
import urllib.error
|
|||
|
|
import urllib.request
|
|||
|
|
|
|||
|
|
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
|||
|
|
PAGE = os.environ.get("DCU_PAGE_BASE", "http://127.0.0.1:2625")
|
|||
|
|
ECAT = PAGE + "/ecat/api"
|
|||
|
|
ESP = os.environ.get("DCU_ESP_IP", "192.168.31.127")
|
|||
|
|
|
|||
|
|
PASS: list[str] = []
|
|||
|
|
FAIL: list[str] = []
|
|||
|
|
|
|||
|
|
|
|||
|
|
def ok(msg: str) -> None:
|
|||
|
|
PASS.append(msg)
|
|||
|
|
print(f" ✓ {msg}")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def bad(msg: str) -> None:
|
|||
|
|
FAIL.append(msg)
|
|||
|
|
print(f" ✗ {msg}")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def http_json(url: str, obj=None, timeout=8.0):
|
|||
|
|
data = b"{}" if obj is None else json.dumps(obj, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
|||
|
|
req = urllib.request.Request(
|
|||
|
|
url, data=data,
|
|||
|
|
headers={"Content-Type": "application/json", "Accept": "application/json"},
|
|||
|
|
method="POST" if obj is not None else "GET",
|
|||
|
|
)
|
|||
|
|
t0 = time.perf_counter()
|
|||
|
|
try:
|
|||
|
|
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|||
|
|
body = r.read()
|
|||
|
|
ms = (time.perf_counter() - t0) * 1000.0
|
|||
|
|
try:
|
|||
|
|
return ms, r.status, json.loads(body.decode("utf-8", "replace") or "{}")
|
|||
|
|
except Exception:
|
|||
|
|
return ms, r.status, {"_raw_len": len(body)}
|
|||
|
|
except Exception as e:
|
|||
|
|
ms = (time.perf_counter() - t0) * 1000.0
|
|||
|
|
return ms, None, {"ok": False, "error": str(e)}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def http_get(url: str, timeout=5.0):
|
|||
|
|
t0 = time.perf_counter()
|
|||
|
|
try:
|
|||
|
|
with urllib.request.urlopen(url, timeout=timeout) as r:
|
|||
|
|
body = r.read()
|
|||
|
|
return (time.perf_counter() - t0) * 1000.0, r.status, body
|
|||
|
|
except Exception as e:
|
|||
|
|
return (time.perf_counter() - t0) * 1000.0, None, str(e).encode()
|
|||
|
|
|
|||
|
|
|
|||
|
|
def main() -> int:
|
|||
|
|
ap = argparse.ArgumentParser()
|
|||
|
|
ap.add_argument("--n", type=int, default=6)
|
|||
|
|
args = ap.parse_args()
|
|||
|
|
|
|||
|
|
print("=== 1) 页面与静态资源(模拟浏览器打开电机页) ===")
|
|||
|
|
ms, st, body = http_get(PAGE + "/pages/motor.html")
|
|||
|
|
html = body.decode("utf-8", "replace") if isinstance(body, (bytes, bytearray)) else str(body)
|
|||
|
|
if st == 200 and "motor.html" or st == 200:
|
|||
|
|
ok(f"motor.html HTTP {st} ({ms:.1f}ms)")
|
|||
|
|
else:
|
|||
|
|
bad(f"motor.html 失败 st={st} {body[:80]!r}")
|
|||
|
|
css_ref = re.search(r'motor\.css\?v=([0-9a-z]+)', html)
|
|||
|
|
js_ref = re.search(r'main\.js\?v=([0-9a-z]+)', html)
|
|||
|
|
if css_ref:
|
|||
|
|
ok(f"CSS 引用 motor.css?v={css_ref.group(1)}")
|
|||
|
|
else:
|
|||
|
|
bad("motor.html 无 motor.css 引用")
|
|||
|
|
if js_ref:
|
|||
|
|
ok(f"JS 引用 main.js?v={js_ref.group(1)}")
|
|||
|
|
else:
|
|||
|
|
bad("motor.html 无 main.js 引用")
|
|||
|
|
if "sendLatency" in html or "send-trace" in html:
|
|||
|
|
bad("页面仍含延迟埋点(应已删除)")
|
|||
|
|
else:
|
|||
|
|
ok("页面无延迟埋点残留")
|
|||
|
|
|
|||
|
|
for rel in (
|
|||
|
|
"/css/motor.css",
|
|||
|
|
"/js/motor/main.js",
|
|||
|
|
"/js/motor/transport.js",
|
|||
|
|
"/js/motor/transport-ecat.js",
|
|||
|
|
"/js/motor/transport-wifi.js",
|
|||
|
|
"/js/common/desktop-bridge.js",
|
|||
|
|
"/js/common/device-link.js",
|
|||
|
|
"/js/common/app-lease.js",
|
|||
|
|
):
|
|||
|
|
ms, st, b = http_get(PAGE + rel)
|
|||
|
|
if st == 200 and isinstance(b, (bytes, bytearray)) and len(b) > 100:
|
|||
|
|
ok(f"{rel} {st} {len(b)}B ({ms:.1f}ms)")
|
|||
|
|
else:
|
|||
|
|
bad(f"{rel} 资源异常 st={st} {b[:60]!r}")
|
|||
|
|
|
|||
|
|
print("\n=== 2) EtherCAT 控制面(网页同源 /ecat/api/*) ===")
|
|||
|
|
ms, st, h = http_json(ECAT + "/native/health", {})
|
|||
|
|
if h.get("ok") and h.get("bus_open") == 1:
|
|||
|
|
ok(f"native/health {ms:.1f}ms dc={h.get('dc')} dc_us={h.get('dc_cycle_us')} loop={h.get('loop')} wkc={h.get('wkc')}")
|
|||
|
|
else:
|
|||
|
|
bad(f"native/health 异常 {h}")
|
|||
|
|
if h.get("dc") == 1 and h.get("dc_cycle_us") == 250:
|
|||
|
|
ok("DC/周期为 250µs(改动生效)")
|
|||
|
|
else:
|
|||
|
|
bad(f"DC/周期未钉死 250µs: dc={h.get('dc')} us={h.get('dc_cycle_us')}")
|
|||
|
|
|
|||
|
|
ms, st, r = http_json(ECAT + "/motor/start",
|
|||
|
|
{"period_ms": 0.25, "dc_cycle_us": 250, "dc_shift_us": 0,
|
|||
|
|
"force_reopen": True}, 30)
|
|||
|
|
log = r.get("log") if isinstance(r, dict) else None
|
|||
|
|
if r.get("ok"):
|
|||
|
|
ok(f"motor/start ok period={r.get('period_ms')} ({ms:.0f}ms) log={log}")
|
|||
|
|
else:
|
|||
|
|
bad(f"motor/start 失败 {r}")
|
|||
|
|
ms, st, h = http_json(ECAT + "/native/health", {})
|
|||
|
|
if h.get("dc") == 1 and h.get("dc_cycle_us") == 250 and h.get("loop") == 1:
|
|||
|
|
ok(f"start 后 health: dc=250 loop=1 wkc={h.get('wkc')} ({ms:.1f}ms)")
|
|||
|
|
else:
|
|||
|
|
bad(f"start 后 health 不健康: {h}")
|
|||
|
|
|
|||
|
|
ms, st, snap = http_json(ECAT + "/motor/state", {})
|
|||
|
|
if snap.get("ok"):
|
|||
|
|
br = snap.get("bridge") or {}
|
|||
|
|
ok(f"motor/state {ms:.1f}ms period={snap.get('period_ms')} wkc={snap.get('wkc')} "
|
|||
|
|
f"tx={br.get('tx_count')} ack={br.get('ack')} online="
|
|||
|
|
f"{[m.get('node') for m in (snap.get('motors') or []) if m.get('online')]}")
|
|||
|
|
else:
|
|||
|
|
bad(f"motor/state 失败 {snap}")
|
|||
|
|
|
|||
|
|
print("\n=== 3) 模拟网页点击「发送 CMD」×N(POST motor/send + 看 GD32 出口) ===")
|
|||
|
|
frame = {"frames": [{"ch": 2, "id": 0x00030200, "dlc": 32, "data": "03" * 32}]}
|
|||
|
|
|
|||
|
|
def bridge_snap():
|
|||
|
|
_, _, s = http_json(ECAT + "/motor/state", {})
|
|||
|
|
if not isinstance(s, dict) or not s.get("ok"):
|
|||
|
|
return None
|
|||
|
|
br = s.get("bridge") or {}
|
|||
|
|
return {
|
|||
|
|
"ms_tx": br.get("tx_count"),
|
|||
|
|
"ack": br.get("ack"),
|
|||
|
|
"sent_seq": s.get("sent_seq"),
|
|||
|
|
"tx_pack": (s.get("tx_pack") or {}).get("seq"),
|
|||
|
|
"txres": br.get("tx_result"),
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
send_lats = []
|
|||
|
|
outlet_lats = []
|
|||
|
|
for i in range(args.n):
|
|||
|
|
pre = bridge_snap()
|
|||
|
|
ms, st, r = http_json(ECAT + "/motor/send", frame, 6.0)
|
|||
|
|
send_lats.append(ms)
|
|||
|
|
send_ok = isinstance(r, dict) and r.get("ok") is True
|
|||
|
|
outlet = None
|
|||
|
|
t0 = time.perf_counter()
|
|||
|
|
while (time.perf_counter() - t0) < 2.0:
|
|||
|
|
post = bridge_snap()
|
|||
|
|
if pre and post and (
|
|||
|
|
post["ms_tx"] != pre["ms_tx"]
|
|||
|
|
or post["ack"] != pre["ack"]
|
|||
|
|
):
|
|||
|
|
outlet = (time.perf_counter() - t0) * 1000.0
|
|||
|
|
outlet_lats.append(outlet)
|
|||
|
|
break
|
|||
|
|
time.sleep(0.001)
|
|||
|
|
if send_ok and outlet is not None:
|
|||
|
|
ok(f"send[{i}] HTTP {ms:.1f}ms → GD32 出口 {outlet:.1f}ms "
|
|||
|
|
f"tx {pre['ms_tx']}→{bridge_snap() and bridge_snap()['ms_tx']} "
|
|||
|
|
f"ack {pre['ack']}→{'?'}")
|
|||
|
|
elif send_ok and outlet is None:
|
|||
|
|
post = bridge_snap()
|
|||
|
|
bad(f"send[{i}] HTTP ok {ms:.1f}ms,但 2s 内 GD32 出口无变化 "
|
|||
|
|
f"pre={pre} post={post} resp={r}")
|
|||
|
|
else:
|
|||
|
|
bad(f"send[{i}] HTTP 失败 {ms:.1f}ms {r}")
|
|||
|
|
time.sleep(0.05)
|
|||
|
|
|
|||
|
|
def pct(xs, q):
|
|||
|
|
if not xs:
|
|||
|
|
return None
|
|||
|
|
xs = sorted(xs)
|
|||
|
|
return xs[min(len(xs) - 1, int(q * (len(xs) - 1)))]
|
|||
|
|
|
|||
|
|
if send_lats:
|
|||
|
|
s_p50 = pct(send_lats, 0.5)
|
|||
|
|
if s_p50 is not None and s_p50 < 50:
|
|||
|
|
ok(f"控制面 HTTP send p50={s_p50:.1f}ms(网页路径)")
|
|||
|
|
elif s_p50 is not None:
|
|||
|
|
bad(f"控制面 HTTP send 过慢 p50={s_p50:.1f}ms")
|
|||
|
|
if outlet_lats:
|
|||
|
|
o_p50 = pct(outlet_lats, 0.5)
|
|||
|
|
o_max = max(outlet_lats)
|
|||
|
|
if o_p50 is not None and o_p50 < 50 and o_max < 200:
|
|||
|
|
ok(f"GD32 CAN 出口 p50={o_p50:.1f}ms max={o_max:.1f}ms")
|
|||
|
|
else:
|
|||
|
|
bad(f"GD32 CAN 出口过慢 p50={o_p50} max={o_max:.1f}ms(或仅有部分命中)")
|
|||
|
|
else:
|
|||
|
|
bad("没有任何 send 观测到 GD32 bridge.tx/ack 变化 — 电机侧未消费输出 PDO")
|
|||
|
|
|
|||
|
|
print("\n=== 4) WiFi 对照(ESP32 /can/send_batch) ===")
|
|||
|
|
try:
|
|||
|
|
body = {"ch": 2, "frames": [{"id": 0x00030200, "dlc": 32, "data": "03" * 32}]}
|
|||
|
|
data = json.dumps(body).encode()
|
|||
|
|
req = urllib.request.Request(
|
|||
|
|
f"http://{ESP}/can/send_batch", data=data,
|
|||
|
|
headers={"Content-Type": "application/json"}, method="POST")
|
|||
|
|
t0 = time.perf_counter()
|
|||
|
|
with urllib.request.urlopen(req, timeout=5) as resp:
|
|||
|
|
raw = resp.read()
|
|||
|
|
wms = (time.perf_counter() - t0) * 1000.0
|
|||
|
|
j = json.loads(raw.decode("utf-8", "replace") or "{}")
|
|||
|
|
if j.get("status") == "ok":
|
|||
|
|
ok(f"WiFi send_batch {wms:.0f}ms ok(链路可用)")
|
|||
|
|
else:
|
|||
|
|
bad(f"WiFi send_batch 回执异常 {wms:.0f}ms {j}")
|
|||
|
|
except Exception as e:
|
|||
|
|
bad(f"WiFi send_batch 失败: {e}")
|
|||
|
|
|
|||
|
|
print("\n=== 5) 点击→电机 STATUS 开始动(软件端到端) ===")
|
|||
|
|
print(" 调用 check_click_to_motion.py(会发一小段速度指令后自动 Disable)")
|
|||
|
|
import subprocess
|
|||
|
|
py = sys.executable
|
|||
|
|
motion = os.path.join(ROOT, "scripts", "check_click_to_motion.py")
|
|||
|
|
rc = subprocess.call([py, motion, "--node", "3", "--ch", "2", "--vel", "40", "--timeout", "3"], cwd=ROOT)
|
|||
|
|
if rc == 0:
|
|||
|
|
ok("点击→STATUS 开始动 已测通(详见上表)")
|
|||
|
|
else:
|
|||
|
|
bad("点击→STATUS 开始动 测量失败/超时")
|
|||
|
|
|
|||
|
|
print("\n=== 6) tx 冻结守卫可观测面(改 native 后必查) ===")
|
|||
|
|
# 这一段只回答"守卫还活着吗",不测触发(触发要 8s+60s 冷却,见
|
|||
|
|
# scripts/check_guard_idle_silence.py 与 tools/ecat/check_guard_regression.py)。
|
|||
|
|
# 本脚本每次发送只等 2s,结构上看不到 8s 判据,所以以前"守卫被改死"与
|
|||
|
|
# "守卫健康"在这里完全同像——必须显式读 guard 块。
|
|||
|
|
try:
|
|||
|
|
_, h = post(PAGE + "/ecat/api/native/health", {}, 5)
|
|||
|
|
g = h.get("guard")
|
|||
|
|
if not isinstance(g, dict):
|
|||
|
|
bad("health 无 guard 块 ⇒ 跑的是旧 exe(不含守卫可观测面);"
|
|||
|
|
"跑 tools/ecat/build_native.bat 换二进制")
|
|||
|
|
else:
|
|||
|
|
t0 = g.get("alive_ticks", 0)
|
|||
|
|
time.sleep(1.0)
|
|||
|
|
_, h2 = post(PAGE + "/ecat/api/native/health", {}, 5)
|
|||
|
|
t1 = (h2.get("guard") or {}).get("alive_ticks", 0)
|
|||
|
|
if t1 - t0 >= 3:
|
|||
|
|
ok(f"守卫心跳 +{t1 - t0}/1s,armed={g.get('armed')} fires={g.get('fires')} "
|
|||
|
|
f"wr={h2.get('wr')} build={h2.get('guard_build')}")
|
|||
|
|
else:
|
|||
|
|
bad(f"守卫心跳仅 +{t1 - t0}/1s ⇒ wedge_worker 没在跑(守卫死了而页面仍全绿)")
|
|||
|
|
if h2.get("recovering"):
|
|||
|
|
ok("health.recovering=1:此刻正在重拉,绿灯不作数(可见面工作正常)")
|
|||
|
|
except Exception as e:
|
|||
|
|
bad(f"guard 可见面读取失败: {e}")
|
|||
|
|
|
|||
|
|
print("\n=== 汇总 ===")
|
|||
|
|
print(f"PASS {len(PASS)} FAIL {len(FAIL)}")
|
|||
|
|
if FAIL:
|
|||
|
|
print("失败项:")
|
|||
|
|
for x in FAIL:
|
|||
|
|
print(" -", x)
|
|||
|
|
return 1
|
|||
|
|
print("全链路自测通过:网页资源 + ECAT 控制面 + 发送出口 + 点击→STATUS 动作延迟")
|
|||
|
|
return 0
|
|||
|
|
|
|||
|
|
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
sys.exit(main())
|