- 实现局域网设备发现面板,支持探测ESP32中继板并自动切换端点 - 添加POST /api/lan/probe接口用于设备探测,返回三态判决结果 - 实现快扫和全段扫描两种模式,支持种子地址优先探测 - 添加端点唯一写手机制,防止多处同时修改设备IP - 优化链路胶囊UI显示WiFi/EtherCAT/BLE连接状态和设备名称 - 修复EtherCAT链路OTA刷写期间PDO循环暂停和AL状态处理问题 - 调整PDO周期设置范围为0.125-4ms,支持亚毫秒显示为微秒 - 添加ESP32 WiFi使能开关控件样式和数据来源角标显示
203 lines
8.7 KiB
Python
203 lines
8.7 KiB
Python
"""真机正向验证:主动制造输出冻结,证明守卫**真的**会触发,且 60s 内不第二次触发。
|
||
|
||
这套判据历史上"修过一次又复发",而复发时页面全绿、wr 恒 0,没有任何测试断言它
|
||
必须触发。纯逻辑门槛(test_guard_logic)已经能离线判死,但它证明的是"函数",
|
||
这条证明的是"函数确实挂在这台机器的总线上工作"。
|
||
|
||
流程(两处物理动作都要人在场,脚本会停下来等):
|
||
1. 前置:bus/op/loop=1、dc=250us、wkc>=3、pm_hold=0、recovering=0、心跳在涨;
|
||
并要求 since_full_s>61(60s 冷却是进程全局的,早先任何一次恢复都会吃掉预算)
|
||
2. 证明通路活着:发一帧,出口 tx_count/ack 必须动
|
||
3. → 请按 GD32 的 RESET:主站继续打包(cycles 涨)而出口冻住 = 现场症状复现
|
||
4. 只发一次(就是"点一下"),然后盯 guard:armed 必须在 1s 内起来,
|
||
tx_stale_s 必须爬过 8s,fires 必须 +1(预算 25s:按 GD32 复位会连输入一起冻,
|
||
输入侧判据可能先做一次 OP-only 恢复,其 10s 冷却会把本判据的补触发往后推)
|
||
5. → 请让 GD32 重新跑起 APP:出口必须重新前进、dc/wkc/loop 恢复、再发一帧拿回显
|
||
6. 冷却:从触发那一刻起每 5s 一帧共 70s,fires 增量必须仍为 1
|
||
7.(可选 --with-pm-hold)再来一轮,但先 /api/pm/hold=1:要求 fires 零增量**且心跳
|
||
在涨** —— 证明"静音"是设计而不是线程死亡
|
||
|
||
用法:python scripts/check_guard_fires_hw.py [--with-pm-hold]
|
||
退出码:0=PASS 1=FAIL 2=BLOCKED(前置/不在场)。末行恒为 VERDICT。
|
||
"""
|
||
import os
|
||
import sys
|
||
import time
|
||
|
||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||
sys.path.insert(0, HERE)
|
||
from guard_probe import (Blocked, ask, bridge_of, guard_of, open_link, # noqa: E402
|
||
preflight, verdict)
|
||
|
||
NATIVE_LOG = os.path.abspath(os.path.join(HERE, os.pardir, os.pardir, "ecat",
|
||
"build_native", "native_stdout.log"))
|
||
|
||
|
||
def ck(fails, cond, msg):
|
||
print((" OK " if cond else " FAIL ") + msg)
|
||
if not cond:
|
||
fails.append(msg)
|
||
|
||
|
||
def log_tail(n=400):
|
||
try:
|
||
with open(NATIVE_LOG, "rb") as f:
|
||
f.seek(max(0, os.path.getsize(NATIVE_LOG) - 65536))
|
||
return f.read().decode("utf-8", "replace").splitlines()[-n:]
|
||
except OSError:
|
||
return []
|
||
|
||
|
||
def wait_freeze(lk, fails, budget=12.0):
|
||
"""等"主站还在打包、出口冻住"——现场症状的定义。"""
|
||
t0 = time.time()
|
||
seen = None
|
||
while time.time() - t0 < budget:
|
||
h = lk.health()
|
||
st = lk.state()
|
||
br = bridge_of(st)
|
||
now = (br.get("tx_count"), br.get("ack"), h.get("cycles"))
|
||
if seen and seen[0] == now[0] and seen[1] == now[1] and now[2] > seen[2]:
|
||
print(" 出口已冻 %s(cycles 仍在涨 %s→%s),耗时 %.1fs"
|
||
% (now[0], seen[2], now[2], time.time() - t0))
|
||
return True
|
||
seen = now
|
||
time.sleep(0.4)
|
||
ck(fails, False, "%.0fs 内没出现「cycles 涨而出口不动」——冻结没制造成功" % budget)
|
||
return False
|
||
|
||
|
||
def run_round(lk, fails, tag, pm_hold=False):
|
||
g0 = guard_of(lk.health())
|
||
st0 = lk.state()
|
||
fires0 = g0["fires"]
|
||
wr0 = st0.get("wr", 0)
|
||
if pm_hold:
|
||
lk.api("/api/pm/hold", {"hold": 1})
|
||
print(" pm_hold=1(守卫按设计静音;心跳必须仍在涨)")
|
||
ask("请按 GD32 板上 RESET(让 APP 停止消费输出 PDO)")
|
||
if not wait_freeze(lk, fails):
|
||
ask("请让 GD32 重新跑起 APP,然后继续")
|
||
return
|
||
_r, ms = lk.send()
|
||
print(" 单次发送完成(HTTP %.1fms);开始盯 guard" % ms)
|
||
t_fire = None
|
||
armed_at = None
|
||
stale_max = 0.0
|
||
t0 = time.time()
|
||
while time.time() - t0 < 25.0:
|
||
g = guard_of(lk.health())
|
||
if g["armed"] and armed_at is None:
|
||
armed_at = time.time() - t0
|
||
stale_max = max(stale_max, g.get("tx_stale_s") or 0)
|
||
if g["fires"] > fires0:
|
||
t_fire = time.time() - t0
|
||
break
|
||
time.sleep(0.5)
|
||
hit = [l for l in log_tail() if "GUARD" in l and "全量重连" in l]
|
||
if pm_hold:
|
||
ck(fails, t_fire is None, "pm_hold 期间不得触发(%s)"
|
||
% ("没触发 ✔" if t_fire is None else "%.1fs 就触发了 ✗" % t_fire))
|
||
g1 = guard_of(lk.health())
|
||
ticks0 = g0["alive_ticks"]
|
||
time.sleep(1.2)
|
||
g2 = guard_of(lk.health())
|
||
ck(fails, g2["alive_ticks"] - g1["alive_ticks"] >= 3,
|
||
"静音中心跳仍在涨(+%d/1.2s)—— 证明是设计静音而不是线程死了"
|
||
% (g2["alive_ticks"] - g1["alive_ticks"]))
|
||
lk.api("/api/pm/hold", {"hold": 0})
|
||
ask("请让 GD32 重新跑起 APP,然后继续")
|
||
return
|
||
ck(fails, armed_at is not None and armed_at <= 1.5,
|
||
"armed 在 %.2fs 内起来(旧判据在这里就已经断了)" % (armed_at if armed_at is not None else -1))
|
||
ck(fails, stale_max >= 8.0, "tx_stale_s 爬过 8s(峰值 %.1fs)" % stale_max)
|
||
ck(fails, t_fire is not None,
|
||
"fires %d→%d:守卫在 %.1fs 时触发(预算 25s)"
|
||
% (fires0, guard_of(lk.health())["fires"], t_fire if t_fire is not None else -1))
|
||
ck(fails, bool(hit), "native_stdout.log 里有 [GUARD] … 全量重连 这一行(最近命中 %d 条)"
|
||
% len(hit))
|
||
if t_fire is None:
|
||
print(" ⇒ 判据没触发:先 curl /api/native/health 看 guard 块,"
|
||
"再确认跑的是新 exe(tools/ecat/check_build_provenance.py)")
|
||
ask("请让 GD32 重新跑起 APP(守卫已做过 close→open,恢复应该很快)")
|
||
t1 = time.time()
|
||
resumed = False
|
||
while time.time() - t1 < 30.0:
|
||
h = lk.health()
|
||
if h.get("loop") == 1 and (h.get("wkc") or 0) >= 3 and h.get("dc_cycle_us") == 250:
|
||
pre = bridge_of(lk.state())
|
||
_r, ms2 = lk.send()
|
||
lag, ok = None, False
|
||
t2 = time.perf_counter()
|
||
while time.perf_counter() - t2 < 2.0:
|
||
bb = bridge_of(lk.state())
|
||
if (bb.get("tx_count") != pre.get("tx_count")
|
||
or bb.get("ack") != pre.get("ack")):
|
||
ok, lag = True, (time.perf_counter() - t2) * 1000.0
|
||
break
|
||
time.sleep(0.002)
|
||
if ok:
|
||
resumed = True
|
||
print(" 恢复:出口重新前进(发送→跟上 %.1fms),probe HTTP %.1fms" % (lag, ms2))
|
||
break
|
||
time.sleep(0.5)
|
||
ck(fails, resumed, "松手后出口重新前进且总线回到 250us/OP")
|
||
st1 = lk.state()
|
||
ck(fails, st1.get("wr", 0) > wr0, "wr %d→%d(全量重连计一次;wr 汇总所有恢复路径,"
|
||
"不能单独当本判据的凭据)" % (wr0, st1.get("wr", 0)))
|
||
|
||
print(" 冷却观察:每 5s 一帧共 70s,期间 fires 不得再涨")
|
||
f_now = guard_of(lk.health())["fires"]
|
||
t3 = time.time()
|
||
extra = 0
|
||
while time.time() - t3 < 70.0:
|
||
lk.send()
|
||
time.sleep(5.0)
|
||
g = guard_of(lk.health())
|
||
if g["fires"] > f_now and time.time() - t3 < 60.0:
|
||
extra += 1
|
||
f_now = g["fires"]
|
||
g_end = guard_of(lk.health())
|
||
ck(fails, extra == 0 and g_end["fires"] <= fires0 + 2,
|
||
"冷却守住:本轮 fires %d→%d,60s 内额外触发 %d 次" % (fires0, g_end["fires"], extra))
|
||
|
||
|
||
def main():
|
||
fails = []
|
||
rounds_pm = "--with-pm-hold" in sys.argv
|
||
lk = open_link()
|
||
preflight(lk)
|
||
|
||
g = guard_of(lk.health())
|
||
since_full = g.get("since_full_s", -1)
|
||
if since_full is not None and since_full >= 0 and since_full < 61:
|
||
wait = 62 - since_full
|
||
print("上次全量重连在 %.0fs 前,等 %.0fs 让 60s 冷却走完" % (since_full, wait))
|
||
time.sleep(wait)
|
||
st = lk.state()
|
||
act = [m for m in (st.get("motors") or []) if m.get("online")]
|
||
if act:
|
||
print(" 注意:有 %d 个电机槽位 online。本测试会让总线断流并强制重连," % len(act))
|
||
ask("请确认电机已停止/未使能(否则输入 s 放弃)")
|
||
|
||
print("\n=== 主轮:制造冻结,要求守卫触发 ===")
|
||
run_round(lk, fails, "main")
|
||
if rounds_pm:
|
||
print("\n=== 对照轮:pm_hold=1 时同样的冻结不得触发,但心跳必须仍在涨 ===")
|
||
run_round(lk, fails, "pm_hold", pm_hold=True)
|
||
|
||
print("\n日志:%s" % NATIVE_LOG)
|
||
return verdict(not fails, "; ".join(fails[:4]))
|
||
|
||
|
||
if __name__ == "__main__":
|
||
try:
|
||
sys.exit(main())
|
||
except Blocked as e:
|
||
print("BLOCKED %s" % e)
|
||
print("VERDICT BLOCKED")
|
||
sys.exit(2)
|
||
except KeyboardInterrupt:
|
||
print("VERDICT BLOCKED 中断(别忘了 GD32 可能还在 RESET 状态)")
|
||
sys.exit(2)
|