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 时判据
  按设计是静音的,那种"绿了"毫无意义,所以判据只放一个执行点。
This commit is contained in:
cbb
2026-09-21 23:30:07 +08:00
parent 8ec7b31539
commit 83a035c6d6
9 changed files with 1081 additions and 2 deletions
+158
View File
@@ -0,0 +1,158 @@
"""tx 冻结守卫真机验证的共用底座:一条通路 + 一份前置判据。
为什么要有这个文件:两个真机脚本(check_guard_idle_silence.py / check_guard_fires_hw.py)
必须用**同一份**前置条件。前置没满足就跑,等于什么都没测——比如 loop=0 或 pm_hold=1
时判据按设计是静音的,这种"跑绿了其实没测"正是本次加固要根除的缺陷类别,所以
判据只放一个执行点,两个脚本都从这里取。
通路优先级:先试页面代理 :2625/ecat/api/*(用户点发送走的就是这条,代理自己那套
"不给 connect 设 timeout"的约束在 esp32_server/ecat_client 里),失败再直连 native
8090..8095(APP 没起时也能跑门槛)。
"""
import json
import os
import sys
import time
import urllib.error
import urllib.request
PAGE_BASE = os.environ.get("DCU_PAGE_BASE", "http://127.0.0.1:2625")
NATIVE_PORTS = list(range(8090, 8096))
GUARD_KEYS = ("alive_ticks", "armed", "tx_stale_s", "since_send_s", "since_full_s",
"cooldown_left_s", "tx_count", "tx_base", "motor_send_hits", "fires",
"last_fire_age_s")
# 注:这里的 timeout 只作用于门槛工具自己的请求。esp32_server 的 ECAT 代理严禁设
# socket timeout(Windows 上会让 connect 走 select 轮询,每请求白加约 10ms),
# 那是另一条路径,见 ecat_client.py 的同名注释。
TIMEOUT = float(os.environ.get("DCU_GUARD_TIMEOUT", "30"))
class Blocked(Exception):
"""前置不满足:调用方必须以退出码 2 结束,绝不折算成 PASS。"""
def _post(url, obj):
data = json.dumps(obj).encode("utf-8") if obj is not None else b"{}"
req = urllib.request.Request(url, data=data, method="POST",
headers={"Content-Type": "application/json"})
with urllib.request.urlopen(req, timeout=TIMEOUT) as r:
return json.loads(r.read().decode("utf-8", "replace"))
class Link:
def __init__(self, base, how):
self.base = base
self.how = how
def api(self, path, obj=None):
return _post(self.base + path, obj if obj is not None else {})
def health(self):
return self.api("/api/native/health")
def state(self):
return self.api("/api/motor/state")
def send(self, ch=2, ident=0x00030200, dlc=32, payload=None):
body = {"frames": [{"ch": ch, "id": ident, "dlc": dlc,
"data": payload or "03" * dlc}]}
t0 = time.perf_counter()
r = self.api("/api/motor/send", body)
return r, (time.perf_counter() - t0) * 1000.0
def open_link(verbose=True):
last = None
for url in [PAGE_BASE + "/ecat/api"]:
try:
lk = Link(url, "页面代理")
h = lk.health()
if not h.get("ok"):
raise ValueError("health.ok 非真")
if verbose:
print("通路:%s(%s)" % (url, lk.how))
return lk
except Exception as e:
last = e
for p in NATIVE_PORTS:
try:
lk = Link("http://127.0.0.1:%d" % p, "直连 native")
h = lk.health()
if h.get("ok"):
if verbose:
print("通路:http://127.0.0.1:%d(%s;页面代理不可用:%s)" % (p, lk.how, last))
return lk
except Exception as e:
last = e
raise Blocked("native 连不上(试过 %s 与 %s):%s"
% (PAGE_BASE + "/ecat/api", NATIVE_PORTS, last))
def guard_of(health):
g = health.get("guard")
if not isinstance(g, dict):
raise Blocked("health 里没有 guard 块 ⇒ 跑的是旧 exe(不含可观测面),"
"先跑 tools/ecat/build_native.bat 换二进制")
missing = [k for k in GUARD_KEYS if k not in g]
if missing:
raise Blocked("guard 缺字段 %s ⇒ 二进制与脚本不同代" % missing)
return g
def preflight(lk, want_tx_alive=True):
"""总线必须真的在交换数据、且守卫没被静音,否则跑了也测不到东西。"""
h = lk.health()
g = guard_of(h)
need = {"bus_open": 1, "op": 1, "loop": 1}
bad = ["%s=%s(需 1)" % (k, h.get(k)) for k, v in need.items() if h.get(k) != v]
if h.get("dc") != 1 or h.get("dc_cycle_us") != 250:
bad.append("dc=%s/%sus(需 1/250)" % (h.get("dc"), h.get("dc_cycle_us")))
if (h.get("wkc") or 0) < 3:
bad.append("wkc=%s(需 >=3)" % h.get("wkc"))
if h.get("pm_hold"):
bad.append("pm_hold=1(休眠豁免期判据按设计静音,测不到)")
if h.get("recovering"):
bad.append("recovering=1(正在重拉,此刻的绿灯不算数)")
t0 = g["alive_ticks"]
time.sleep(1.0)
g1 = guard_of(lk.health())
if g1["alive_ticks"] - t0 < 3:
bad.append("guard.alive_ticks +%d/1s(需 >=3)⇒ 巡检线程没在跑"
% (g1["alive_ticks"] - t0))
if bad:
raise Blocked(";".join(bad))
if want_tx_alive:
s = lk.state()
tx = ((s.get("bridge") or {}).get("tx_count"), (s.get("head") or {}).get("global_seq"))
print("前置 OK:bus/op/loop=1 dc=250us wkc=%s ticks=+%d/1s 当前 tx_count=%s gseq=%s"
% (h.get("wkc"), g1["alive_ticks"] - t0, tx[0], tx[1]))
else:
print("前置 OK(未要求出口活动)")
return h, g1
def bridge_of(state):
return state.get("bridge") or {}
def verdict(ok, why=""):
"""末行恒为 VERDICT,包装脚本 grep 它;退出码 0/1/2 是真门槛。"""
if ok:
print("VERDICT PASS")
return 0
print("VERDICT FAIL " + why)
return 1
__all__ = ["Link", "Blocked", "open_link", "guard_of", "preflight", "bridge_of",
"verdict", "GUARD_KEYS", "PAGE_BASE"]
if __name__ == "__main__":
try:
lk = open_link()
preflight(lk)
except Blocked as e:
print("BLOCKED %s" % e)
sys.exit(2)
sys.exit(0)