Files
cbb 83a035c6d6 test(guard): 验收链改名入库 + 补真退出码;tx 冻结守卫真机门槛两段
之前 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 时判据
  按设计是静音的,那种"绿了"毫无意义,所以判据只放一个执行点。
2026-09-21 23:30:07 +08:00

289 lines
11 KiB
Python
Raw Permalink 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 -*-
"""模拟网页全链路自测(改 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())