Files
DCU_DesktopController/scripts/check_recover_after_reset.py
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

135 lines
5.4 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 -*-
"""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)