"""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 def ask(prompt, timeout_s=600): """物理动作确认:必须等到人做完才继续,绝不自动放行。 终端里就是回车;被自动化/远程跑时 stdin 不是终端,改为等一个 ack 文件 (脚本先删掉它,出现即视为确认并再次删除)。两种路径都是人驱动的,只是 不靠 tty —— 换成"超时自动继续"就失去意义了,所以超时按 BLOCKED 收。 """ import tempfile # 显式指定了 ack 文件 = 明确要"文件握手",绝不先碰 stdin:后台任务的 stdin 未必 # 给 EOF,一旦落在 input() 上就是无限期吊着(实测踩过)。 ack = os.environ.get("DCU_GUARD_ACK_FILE") if not ack: if sys.stdin and hasattr(sys.stdin, "isatty") and sys.stdin.isatty(): try: raw = input(prompt + "(回车继续 / 输入 s 放弃本测试): ").strip().lower() if raw == "s": raise Blocked("用户放弃") return except EOFError: pass # isatty 为真但无输入 → 落到 ack 文件 ack = os.path.join(tempfile.gettempdir(), "dcu_guard_ack") try: os.remove(ack) except FileNotFoundError: pass print(" ⏸ 需要人工:%s" % prompt) print(" 做完后创建这个文件即可继续:%s" % ack) print(" 等待上限 %ds,期间脚本不做任何总线动作" % timeout_s) t0 = time.time() while time.time() - t0 < timeout_s: if os.path.exists(ack): try: os.remove(ack) except OSError: pass print(" 收到确认(%.0fs)" % (time.time() - t0)) return time.sleep(0.5) raise Blocked("等待人工确认超时 %ds:%s" % (timeout_s, prompt)) __all__ = ["Link", "Blocked", "open_link", "guard_of", "preflight", "bridge_of", "verdict", "ask", "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)