之前 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 时判据 按设计是静音的,那种"绿了"毫无意义,所以判据只放一个执行点。
135 lines
5.4 KiB
Python
135 lines
5.4 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""GD32 断电/复位后一键恢复 EtherCAT,并**验收**恢复是否真生效。
|
||
|
||
用法(GD32 上电稳定后):
|
||
python scripts/check_recover_after_reset.py
|
||
python scripts/check_recover_after_reset.py --skip-e2e
|
||
|
||
与旧版的差别:旧版全程只 print、没有任何失败路径(--skip-e2e 时无条件退 0),
|
||
所以"跑过了"和"恢复了"是两件事。现在每一步都有断言,退出码 0/1/2 是真门槛:
|
||
0=全部通过 1=有断言失败(别去点页面,先修) 2=前置不满足(native/页面没起)
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import os
|
||
import subprocess
|
||
import sys
|
||
import time
|
||
import urllib.request
|
||
|
||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||
sys.path.insert(0, os.path.join(ROOT, "scripts"))
|
||
from guard_probe import Blocked, bridge_of, open_link, guard_of # noqa: E402
|
||
|
||
ESP = os.environ.get("DCU_ESP_IP", "192.168.31.127")
|
||
|
||
FAILS: list[str] = []
|
||
|
||
|
||
def ck(cond, msg):
|
||
print((" OK " if cond else " FAIL ") + msg)
|
||
if not cond:
|
||
FAILS.append(msg)
|
||
|
||
|
||
def main() -> int:
|
||
skip_e2e = "--skip-e2e" in sys.argv
|
||
print("=== 1) WiFi motReset(可选,拉一下 GD32 应用层) ===")
|
||
try:
|
||
cfg = bytearray(24)
|
||
cfg[0] = 1 # motReset bit0
|
||
body = json.dumps({"ch": 2, "frames": [{"id": 0x00030301, "dlc": 24,
|
||
"data": cfg.hex()}]}).encode()
|
||
req = urllib.request.Request("http://%s/can/send_batch" % ESP, data=body,
|
||
headers={"Content-Type": "application/json"},
|
||
method="POST")
|
||
t0 = time.perf_counter()
|
||
with urllib.request.urlopen(req, timeout=6) as r:
|
||
j = json.loads(r.read().decode("utf-8", "replace") or "{}")
|
||
print(" wifi motReset %.0fms %s" % ((time.perf_counter() - t0) * 1000, j.get("status")))
|
||
except Exception as e:
|
||
print(" wifi motReset skip: %s" % e) # 可选步骤,不作为门槛
|
||
time.sleep(1.5)
|
||
|
||
lk = open_link()
|
||
print("=== 2) force_reopen + DC250 motor/start ===")
|
||
try:
|
||
h = lk.health()
|
||
print(" before: dc=%s us=%s loop=%s op=%s wkc=%s"
|
||
% (h.get("dc"), h.get("dc_cycle_us"), h.get("loop"), h.get("op"), h.get("wkc")))
|
||
except Exception as e:
|
||
print(" health fail: %s" % e)
|
||
|
||
r = lk.api("/api/motor/start", {"period_ms": 0.25, "dc_cycle_us": 250,
|
||
"dc_shift_us": 0, "force_reopen": True})
|
||
print(" motor/start force_reopen ok=%s period=%s" % (r.get("ok"), r.get("period_ms")))
|
||
print(" log: %s" % (r.get("log"),))
|
||
ck(bool(r.get("ok")), "motor/start 回执 ok=true")
|
||
|
||
# force_reopen 之后 loop 需要一点时间起来(冷启动见过 loop=0 的采样竞态),
|
||
# 所以要"轮询到恢复"而不是只读一次;上限 15s。
|
||
good = None
|
||
t_end = time.time() + 15.0
|
||
while time.time() < t_end:
|
||
h = lk.health()
|
||
if (h.get("dc") == 1 and h.get("dc_cycle_us") == 250 and h.get("loop") == 1
|
||
and h.get("op") == 1 and (h.get("wkc") or 0) >= 3):
|
||
good = h
|
||
break
|
||
time.sleep(0.5)
|
||
ck(good is not None,
|
||
"15s 内 health 达到 dc=1/250us/loop=1/op=1/wkc>=3(现在 %s)"
|
||
% ({k: (good or h).get(k) for k in ("dc", "dc_cycle_us", "loop", "op", "wkc")}))
|
||
|
||
g = guard_of(lk.health())
|
||
t0 = g["alive_ticks"]
|
||
time.sleep(1.0)
|
||
g1 = guard_of(lk.health())
|
||
ck(g1["alive_ticks"] - t0 >= 3,
|
||
"守卫心跳 +%d/1s,armed=%s fires=%s(fires 若在本步之后才涨,说明是守卫救回来的)"
|
||
% (g1["alive_ticks"] - t0, g1["armed"], g1["fires"]))
|
||
|
||
print("=== 3) 发一帧看 GD32 出口 ===")
|
||
pre = bridge_of(lk.state())
|
||
br_pre = (pre.get("tx_count"), pre.get("ack"))
|
||
r, ms = lk.send()
|
||
print(" send %.1fms ok=%s queued=%s" % (ms, r.get("ok"), r.get("queued")))
|
||
t_sent = time.perf_counter()
|
||
outlet = None
|
||
while time.perf_counter() - t_sent < 3.0:
|
||
br = bridge_of(lk.state())
|
||
if (br.get("tx_count"), br.get("ack")) != br_pre and br.get("tx_count") is not None:
|
||
outlet = (time.perf_counter() - t_sent) * 1000.0
|
||
print(" 出口 %.0fms tx/ack %s → %s"
|
||
% (outlet, br_pre, (br.get("tx_count"), br.get("ack"))))
|
||
break
|
||
time.sleep(0.002)
|
||
ck(outlet is not None,
|
||
"3s 内 GD32 出口跟上(否则就是输出 PDO 没被消费,不是网页慢)")
|
||
if outlet is None:
|
||
print(" → 仍失败:先确认 GD32 已完整上电跑 APP;若 health 全绿而出口恒冻,"
|
||
"等 8s 看 guard.fires 是否 +1(守卫会自动 close→open),"
|
||
"或跑 tools/ecat/check_guard_regression.py 定位。")
|
||
|
||
if not skip_e2e:
|
||
print("\n=== 4) 网页全链路自测 ===")
|
||
py = os.environ.get("MIMO_PYTHON") or sys.executable
|
||
script = os.path.join(ROOT, "scripts", "check_web_send_latency.py")
|
||
rc = subprocess.call([py, script, "--n", "6"], cwd=ROOT)
|
||
ck(rc == 0, "check_web_send_latency.py 退出码 %d" % rc)
|
||
|
||
print("\nVERDICT %s%s" % ("PASS" if not FAILS else "FAIL",
|
||
"" if not FAILS else " " + "; ".join(FAILS[:3])))
|
||
return 0 if not FAILS else 1
|
||
|
||
|
||
if __name__ == "__main__":
|
||
try:
|
||
sys.exit(main())
|
||
except Blocked as e:
|
||
print("BLOCKED 前置不满足:%s" % e)
|
||
print("VERDICT BLOCKED")
|
||
sys.exit(2)
|