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

159 lines
5.9 KiB
Python
Raw 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.
"""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)