之前 e2e_web_send_latency.py / recover_ecat_after_reset.py / measure_click_to_motion.py 全在 .gitignore 里(e2e_/measure_ 通配),换台机器 clone 就跑不了验收;且 recover 脚本零失败路径(--skip-e2e 无条件退 0)。现在统一到已跟踪的 check_ 前缀, .gitignore 一行例外都不用加,并把"跑过了"和"恢复了"绑成一件事。 - check_recover_after_reset.py:force_reopen 后改为轮询到 dc=1/250us/loop=1/op=1/ wkc>=3(上限 15s,替掉原来单点采样那次假 loop=0),并断言守卫心跳在涨、 发一帧 3s 内出口必须跟上;退出码 0/1/2(前置不满足=BLOCKED=2)。 - check_web_send_latency.py:新增第 6 段读 health.guard(旧 exe 无 guard 块 / alive_ticks 不涨 都算 FAIL),第 5 段改调已入库的 check_click_to_motion.py。 它每次发送只等 2s,结构上看不到 8s 判据,所以只报"守卫当时是死是活", 不拿它冒充触发验证。 - check_guard_idle_silence.py:健康总线的"另一极"——90s 静默 + 25s 1Hz 发送, 要求心跳在涨、armed 恒 0、fires/wr 零增量、每次发送都被消费。这是防 "把守卫修成重连风暴"的真机版(对应离线 T1b/T4/T5/T6)。 - check_guard_fires_hw.py:需人在场按 GD32 RESET 制造冻结,证明 armed 秒起、 tx_stale_s 爬过 8s、fires +1、日志有 [GUARD] 全量重连、松手后 20s 内出口 重新前进,再验证 60s 冷却内不第二次触发;--with-pm-hold 另证"静音"时心跳仍在涨。 只认 guard.fires,不认 wr(wr 汇总所有恢复路径,会以错误的理由通过)。 - guard_probe.py:两个真机脚本共用同一份前置判据与通路选择(先 :2625 页面代理, 再直连 native 8090..8095)。前置没满足一律 BLOCKED——loop=0 或 pm_hold=1 时判据 按设计是静音的,那种"绿了"毫无意义,所以判据只放一个执行点。
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())
|