Files
cbb eac3717f9a feat: 添加局域网设备发现功能和链路胶囊优化
- 实现局域网设备发现面板,支持探测ESP32中继板并自动切换端点
- 添加POST /api/lan/probe接口用于设备探测,返回三态判决结果
- 实现快扫和全段扫描两种模式,支持种子地址优先探测
- 添加端点唯一写手机制,防止多处同时修改设备IP
- 优化链路胶囊UI显示WiFi/EtherCAT/BLE连接状态和设备名称
- 修复EtherCAT链路OTA刷写期间PDO循环暂停和AL状态处理问题
- 调整PDO周期设置范围为0.125-4ms,支持亚毫秒显示为微秒
- 添加ESP32 WiFi使能开关控件样式和数据来源角标显示
2026-10-13 00:48:34 +08:00

203 lines
8.7 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.
"""真机正向验证:主动制造输出冻结,证明守卫**真的**会触发,且 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)