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

201 lines
7.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.
"""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)