feat: 添加局域网设备发现功能和链路胶囊优化
- 实现局域网设备发现面板,支持探测ESP32中继板并自动切换端点 - 添加POST /api/lan/probe接口用于设备探测,返回三态判决结果 - 实现快扫和全段扫描两种模式,支持种子地址优先探测 - 添加端点唯一写手机制,防止多处同时修改设备IP - 优化链路胶囊UI显示WiFi/EtherCAT/BLE连接状态和设备名称 - 修复EtherCAT链路OTA刷写期间PDO循环暂停和AL状态处理问题 - 调整PDO周期设置范围为0.125-4ms,支持亚毫秒显示为微秒 - 添加ESP32 WiFi使能开关控件样式和数据来源角标显示
This commit is contained in:
@@ -26,20 +26,13 @@ import time
|
||||
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, HERE)
|
||||
from guard_probe import Blocked, bridge_of, guard_of, open_link, preflight, verdict # noqa: E402
|
||||
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 ask(prompt):
|
||||
if not sys.stdin or not hasattr(sys.stdin, "isatty") or not sys.stdin.isatty():
|
||||
raise Blocked("这一步要人在场,但 stdin 不是终端:%s" % prompt)
|
||||
raw = input(prompt + "(回车继续 / 输入 s 放弃本测试): ").strip().lower()
|
||||
if raw == "s":
|
||||
raise Blocked("用户放弃")
|
||||
|
||||
|
||||
def ck(fails, cond, msg):
|
||||
print((" OK " if cond else " FAIL ") + msg)
|
||||
if not cond:
|
||||
|
||||
@@ -0,0 +1,279 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""check_lan_discovery.py — 设备发现的真机门槛(PASS / FAIL / BLOCKED / NEEDS-HUMAN)。
|
||||
|
||||
与 scripts/test_lan_probe.py 的分工:那个在离线用假板服务把**判据**钉死;
|
||||
这个在真网段上量**实测**,前置不满足一律 BLOCKED(exit 2),绝不当 PASS。
|
||||
|
||||
用法:
|
||||
python scripts/check_lan_discovery.py --print-net
|
||||
python scripts/check_lan_discovery.py --expect 1 # 一块板在线
|
||||
python scripts/check_lan_discovery.py --expect none --server-port 2625
|
||||
退出码: 0=PASS 1=FAIL 2=BLOCKED(前置缺失,不代表通过)
|
||||
|
||||
判据设计上有三条"必须有灵敏度"的硬要求:
|
||||
A) RFC 5737 TEST-NET 段(路由不到任何机器)必须回 board=0 且全部 unknown;
|
||||
没有这条,"0 台设备"可能只是实现根本没发包。
|
||||
B) 反向对照:--expect none 时板子必须**断电**;此时 .1 要出现在 rejected/unknown
|
||||
并打印它到底回了什么(路由器回 HTML 还是 JSON 只有人能判断)。
|
||||
C) 干扰是实测不是外推:同一段轮询耗时,基线与"扫描期间"各采一遍,两张数一起报。
|
||||
"""
|
||||
import argparse
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, ROOT)
|
||||
import lan_probe as lp # noqa: E402
|
||||
|
||||
FW_VERSION_H = os.path.normpath(os.path.join(
|
||||
ROOT, "..", "..", "ESP32_COMM", "main", "include", "version.h"))
|
||||
|
||||
RESULTS = [] # (状态, 名称, 详情)
|
||||
|
||||
|
||||
def add(status, name, detail=""):
|
||||
RESULTS.append((status, name, detail))
|
||||
print("%-9s %s%s" % (status, name, (" :: " + str(detail)) if detail else ""))
|
||||
|
||||
|
||||
def _opener():
|
||||
# 本机 HTTP_PROXY=mihomo 7897 会把局域网请求吃掉(NO_PROXY 只白名单了几个地址),
|
||||
# 一律显式禁代理,否则测出来的"设备不响应"是代理不是设备。
|
||||
return urllib.request.build_opener(urllib.request.ProxyHandler({}))
|
||||
|
||||
|
||||
def parse_cidr(cidr):
|
||||
"""CIDR → 主机地址列表(网络位/广播位不生成)。只支持 mask<=24,够本脚本用。"""
|
||||
bits = str(cidr).strip().split("/")
|
||||
q = bits[0].split(".")
|
||||
if len(q) != 4 or not all(x.isdigit() and 0 <= int(x) <= 255 for x in q):
|
||||
return None
|
||||
mask = int(bits[1]) if len(bits) > 1 else 24
|
||||
if mask > 30 or mask < 8:
|
||||
return None
|
||||
hostbits = 32 - mask
|
||||
base_int = (int(q[0]) << 24) | (int(q[1]) << 16) | (int(q[2]) << 8) | int(q[3])
|
||||
base_int &= ~((1 << hostbits) - 1) # 抹平主机位,取网络地址
|
||||
total = (1 << hostbits) - 2 # 去掉网络地址与广播地址
|
||||
return [str_ip(base_int + i) for i in range(1, max(1, min(total, 1024)) + 1)]
|
||||
|
||||
|
||||
def str_ip(n):
|
||||
return "%d.%d.%d.%d" % ((n >> 24) & 255, (n >> 16) & 255, (n >> 8) & 255, n & 255)
|
||||
|
||||
|
||||
def run_batch(ips, server_port=None, timeout_ms=750):
|
||||
"""优先走服务的 /api/lan/probe(顺带把路由也验了);服务没起就进程内直调。"""
|
||||
if server_port:
|
||||
try:
|
||||
req = urllib.request.Request(
|
||||
"http://127.0.0.1:%d/api/lan/probe" % server_port,
|
||||
data=json.dumps({"ips": ips, "ports": [80], "timeout_ms": timeout_ms,
|
||||
"local": True}).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"})
|
||||
with _opener().open(req, timeout=60) as r:
|
||||
return json.loads(r.read().decode("utf-8")), "http"
|
||||
except Exception as e:
|
||||
add("WARN", "服务侧路由不可用,改进程内直调", "%s: %s" % (type(e).__name__, e))
|
||||
return lp.probe_batch(ips, ports=[80], timeout_ms=timeout_ms), "inproc"
|
||||
|
||||
|
||||
def run_all(ips, server_port=None, timeout_ms=750):
|
||||
"""按服务的批上限切头发(和网页做的一样),再把各批结果并起来。
|
||||
|
||||
一次性把 254 个地址丢给一个请求是不行的:服务侧一批最多
|
||||
lp.BATCH_MAX_IPS 个,多出来的会进 pending —— 那正是"截断了却假装探完"的坑。
|
||||
"""
|
||||
merged = {"results": [], "scan": {}, "errors": [], "local": None}
|
||||
via = None
|
||||
for i in range(0, len(ips), lp.BATCH_MAX_IPS):
|
||||
r, v = run_batch(ips[i:i + lp.BATCH_MAX_IPS], server_port, timeout_ms)
|
||||
via = v
|
||||
merged["results"] += r.get("results", [])
|
||||
s = r.get("scan", {})
|
||||
for k in ("candidates", "tcpOpen", "httpProbed", "board",
|
||||
"rejected", "unknown", "durationMs"):
|
||||
merged["scan"][k] = merged["scan"].get(k, 0) + (s.get(k) or 0)
|
||||
if s.get("pending"):
|
||||
merged["scan"]["pending"] = merged["scan"].get("pending", []) + list(s["pending"])
|
||||
merged["errors"] += r.get("errors", [])
|
||||
if r.get("local"):
|
||||
merged["local"] = r["local"]
|
||||
merged["scan"]["truncated"] = bool(merged["scan"].get("pending"))
|
||||
return merged, via
|
||||
|
||||
|
||||
def fw_expected():
|
||||
if not os.path.isfile(FW_VERSION_H):
|
||||
return None, "找不到 " + FW_VERSION_H
|
||||
txt = io.open(FW_VERSION_H, encoding="utf-8", errors="replace").read()
|
||||
|
||||
def num(name):
|
||||
m = re.search(r"^#define\s+" + name + r"\s+(\d+)", txt, re.M)
|
||||
return int(m.group(1)) if m else None
|
||||
triplet = None
|
||||
ma, mi, pa = num("FW_VERSION_MAJOR"), num("FW_VERSION_MINOR"), num("FW_VERSION_PATCH")
|
||||
if None not in (ma, mi, pa):
|
||||
triplet = "%d.%d.%d" % (ma, mi, pa)
|
||||
m = re.search(r'^#define\s+FW_VERSION_STRING\s+"([^"]+)"', txt, re.M)
|
||||
return triplet, (m.group(1) if m else None)
|
||||
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--expect", default="", help="none|1|2 —— 本轮局域网上应有的板子数;不填则相关步骤 BLOCKED")
|
||||
ap.add_argument("--cidr", default="", help="要扫的段,缺省用本机默认路由派生的 /24")
|
||||
ap.add_argument("--server-port", type=int, default=2625, help="本地服务端口(0=不走 HTTP 路由)")
|
||||
ap.add_argument("--print-net", action="store_true", help="只打印本机网络判定")
|
||||
ap.add_argument("--interfere", action="store_true", help="做扫描期干扰 A/B 实测(需一块板在线)")
|
||||
args = ap.parse_args()
|
||||
|
||||
net = lp.local_net()
|
||||
print("本机: %s" % json.dumps(net, ensure_ascii=False))
|
||||
if args.print_net:
|
||||
return 0
|
||||
if not net.get("ok"):
|
||||
add("BLOCKED", "取不到本机默认路由", net.get("err"))
|
||||
return summary()
|
||||
|
||||
ip, dev_cidr = net.get("default_src_ip"), net.get("cidr")
|
||||
if ip and not ip.startswith("192.168.31."):
|
||||
add("WARN", "本机地址不是 192.168.31.x,确认 PC 与板子同网段?", ip)
|
||||
|
||||
# ── A) 灵敏度对照:TEST-NET 段(RFC 5737,路由不到任何机器)──
|
||||
tn = parse_cidr("192.0.2.0/28")
|
||||
if not tn:
|
||||
add("BLOCKED", "TEST-NET 对照段解析失败", "192.0.2.0/28")
|
||||
return summary()
|
||||
res, via = run_all(tn, args.server_port or None)
|
||||
if via == "inproc":
|
||||
add("WARN", "本轮没打到 /api/lan/probe(本地服务未启动或还是旧版)——"
|
||||
"路由这条本轮未验证,下面的数字只证明探测逻辑本身")
|
||||
sc = res.get("scan", {})
|
||||
boards = [d for d in res.get("results", []) if d.get("verdict") == "board"]
|
||||
unknowns = [d for d in res.get("results", []) if d.get("verdict") == "unknown"]
|
||||
if sc.get("candidates") == len(tn) and not boards and len(unknowns) == len(tn):
|
||||
add("PASS", "A 灵敏度对照:192.0.2.0/28 → board=0 且 %d 条全 unknown(探测真的在跑,也真的没瞎报)"
|
||||
% len(tn), "via=" + via)
|
||||
else:
|
||||
add("FAIL", "A 灵敏度对照:期望 candidates=%d board=0 unknown=%d,实际 %s"
|
||||
% (len(tn), len(tn), {k: sc.get(k) for k in ("candidates", "board", "unknown")}),
|
||||
"via=" + via)
|
||||
|
||||
# ── B) 真实网段 ──
|
||||
cidr = args.cidr or dev_cidr
|
||||
ips = parse_cidr(cidr)
|
||||
if not ips:
|
||||
add("BLOCKED", "CIDR 解析失败(只支持 /24 及更小段)", cidr)
|
||||
return summary()
|
||||
t0 = time.time()
|
||||
res2, via2 = run_all(ips, args.server_port or None)
|
||||
dur = int((time.time() - t0) * 1000)
|
||||
sc2 = res2.get("scan", {})
|
||||
rows = res2.get("results", [])
|
||||
boards2 = [r for r in rows if r.get("verdict") == "board"]
|
||||
rej2 = [r for r in rows if r.get("verdict") == "rejected"]
|
||||
unk2 = [r for r in rows if r.get("verdict") == "unknown"]
|
||||
print("\n扫描 %s (%s):%d 候选 / 端口开 %d / 板 %d / 反证 %d / 不可判定 %d / %dms (via %s)"
|
||||
% (cidr, ",".join(net.get("local_ips", [])), sc2.get("candidates", 0),
|
||||
sc2.get("tcpOpen", 0), len(boards2), len(rej2), len(unk2), dur, via2))
|
||||
for b in boards2:
|
||||
print(" board %-16s kind=%-4s schema=%s" % (b["ip"], b.get("kind"), b.get("schema")))
|
||||
for r in rej2[:12]:
|
||||
print(" reject %-16s %s 首包 %r" % (r["ip"], r.get("reason"), (r.get("head") or "")[:48]))
|
||||
for u in unk2:
|
||||
if u.get("tcp"):
|
||||
print(" unknown(tcp开) %-16s %s" % (u["ip"], u.get("reason")))
|
||||
|
||||
exp = args.expect.strip().lower()
|
||||
if exp in ("none", "0"):
|
||||
ok = len(boards2) == 0
|
||||
add("PASS" if ok else "FAIL",
|
||||
"B 板子断电时 board 必须为 0(实测 %d)" % len(boards2),
|
||||
"" if ok else "板子没断电?还是判据把非板子说成板子了?")
|
||||
gw = [r for r in rows if r["ip"].endswith(".1")]
|
||||
add("NEEDS-HUMAN", "B2 人读一遍 .1 的判决与首包,确认路由器是被**反证**排除而不是碰巧没答",
|
||||
gw[0] if gw else "本轮 .1 未出现在结果里")
|
||||
elif exp in ("1", "2"):
|
||||
want = int(exp)
|
||||
ok = len(boards2) == want
|
||||
add("PASS" if ok else "FAIL", "B 期望 %d 块板,实测 %d 台" % (want, len(boards2)))
|
||||
if boards2:
|
||||
exp_ver, str_ver = fw_expected()
|
||||
for b in boards2:
|
||||
add("NEEDS-HUMAN", "B2 这块板是不是你手上那块?看 uptime:按复位键后重扫,新上电那台 uptime<30s",
|
||||
b["ip"])
|
||||
add("INFO", "源码版本 %s / FW_VERSION_STRING %s(板子的 version 要在网页点「详情」才取到,"
|
||||
"lite 不带版本;不一致只是板子跑旧固件,不算发现功能失败)" % (exp_ver, str_ver))
|
||||
else:
|
||||
add("BLOCKED", "B 未给 --expect none|1|2,板子在线数量无法判定(上面数字仅作观测)")
|
||||
|
||||
# ── C) 干扰 A/B 实测 ──
|
||||
if args.interfere:
|
||||
if not boards2:
|
||||
add("BLOCKED", "C 干扰实测需要先有一块在线板(--expect 1)")
|
||||
else:
|
||||
tgt = boards2[0]["ip"]
|
||||
op = _opener()
|
||||
|
||||
def sample(n=20):
|
||||
lat, fails = [], 0
|
||||
for _ in range(n):
|
||||
t = time.time()
|
||||
try:
|
||||
with op.open("http://%s/system/status?lite=1" % tgt, timeout=4) as r:
|
||||
r.read()
|
||||
lat.append(int((time.time() - t) * 1000))
|
||||
except Exception:
|
||||
fails += 1
|
||||
lat.sort()
|
||||
return {"n": n, "fails": fails,
|
||||
"p50": lat[n // 2] if lat else None,
|
||||
"max": lat[-1] if lat else None}
|
||||
|
||||
base = sample()
|
||||
stop = threading.Event()
|
||||
|
||||
def sweep():
|
||||
while not stop.is_set():
|
||||
run_all(parse_cidr(cidr), 0) # 进程内直调,别和采样抢同一个 handler 线程
|
||||
|
||||
th = threading.Thread(target=sweep, daemon=True)
|
||||
th.start()
|
||||
during = sample()
|
||||
stop.set()
|
||||
add("INFO", "C 干扰实测(两张数,不做外推):基线 %s / 扫描期间 %s" % (base, during))
|
||||
add("PASS" if during["fails"] <= base["fails"] + 1 else "FAIL",
|
||||
"C 扫描期间对在线板的请求失败数不得明显上升(基线 %d → 期间 %d)"
|
||||
% (base["fails"], during["fails"]))
|
||||
|
||||
add("NEEDS-HUMAN", "D 桌面 APP(esp32_app.vbs)里连开 20 批「全段」并在中途关面板/刷新,"
|
||||
"确认服务不卡死、顶栏 pill 仍能刷新——句柄泄漏与 stderr 冻结只能这么观察")
|
||||
add("NEEDS-HUMAN", "E AP 回退路径:真人连 ESP32_OTA_RELAY 后重跑本脚本,期望 kind=ap 的那台;"
|
||||
"PC 连着路由器 WLAN 时它自称 192.168.31.1 但三层不可达,扫不到是正确行为")
|
||||
add("NEEDS-HUMAN", "F 「本页直测」按钮:在 APP 窗口里对扫到的候选点一次,"
|
||||
"确认浏览器/WebView2 没有拦 127.0.0.1→私有子网(拦了会显示 A✓B✗ 并给原因)")
|
||||
return summary()
|
||||
|
||||
|
||||
def summary():
|
||||
n = {k: sum(1 for s, _, _ in RESULTS if s == k) for k in
|
||||
("PASS", "FAIL", "BLOCKED", "NEEDS-HUMAN", "WARN", "INFO")}
|
||||
print("\n合计 PASS=%(PASS)d FAIL=%(FAIL)d BLOCKED=%(BLOCKED)d NEEDS-HUMAN=%(NEEDS-HUMAN)d" % n)
|
||||
if n["FAIL"]:
|
||||
return 1
|
||||
if n["BLOCKED"] or not n["PASS"]:
|
||||
return 2
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
+43
-1
@@ -144,8 +144,50 @@ def verdict(ok, 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", "GUARD_KEYS", "PAGE_BASE"]
|
||||
"verdict", "ask", "GUARD_KEYS", "PAGE_BASE"]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -200,12 +200,15 @@ section("[2c] DC 自愈:证明配错才动总线,证明不了/正在恢复
|
||||
return { delta: after - before, reasons: st.startReasons };
|
||||
};
|
||||
const bad = await run({ dc: 0, dc_cycle_us: 0, period_ms: 4 });
|
||||
check("DC 被证明配错 ⇒ 恰又多一次 motor/start(拉回 250µs)",
|
||||
check("DC 被证明配错(free-run)⇒ 恰又多一次 motor/start(按 period 对齐 DC)",
|
||||
bad.delta === 1, `多 ${bad.delta} 次`);
|
||||
check("DC 自愈带 reason=auto_dc_repair(代理侧据此统一节流)",
|
||||
bad.reasons.some((r) => r === "auto_dc_repair"), bad.reasons.join(","));
|
||||
const good = await run({ dc: 1, dc_cycle_us: 250, period_ms: 0.25 });
|
||||
check("DC 正确 ⇒ 零次 motor/start", good.delta === 0, `多 ${good.delta} 次`);
|
||||
const goodHot = await run({ dc: 1, dc_cycle_us: 500, period_ms: 0.5 });
|
||||
check("热改档 0.5ms/500us 同值 ⇒ 零次 motor/start(不再被当成配错)",
|
||||
goodHot.delta === 0, `多 ${goodHot.delta} 次`);
|
||||
const busy = await run({ dc: 0, dc_cycle_us: 0, period_ms: 4, recovering: 1 });
|
||||
check("native 正在全量重连 ⇒ 即使 DC 配错也不插第二刀", busy.delta === 0, `多 ${busy.delta} 次`);
|
||||
const blind = await run({});
|
||||
@@ -238,8 +241,14 @@ section("[2d] proveEcatLoop / proveEcatDc 三态矩阵");
|
||||
ebus.proveEcatDc({ ok: true }) === "unknown");
|
||||
check("proveEcatDc: dc=1/250/0.25 ⇒ ok",
|
||||
ebus.proveEcatDc({ ok: true, dc: 1, dc_cycle_us: 250, period_ms: 0.25 }) === "ok");
|
||||
check("proveEcatDc: period 掉到 4ms ⇒ misconfigured",
|
||||
ebus.proveEcatDc({ ok: true, dc: 1, dc_cycle_us: 4000, period_ms: 4 }) === "misconfigured");
|
||||
check("proveEcatDc: 热改档 dc=1/500/0.5 ⇒ ok(period 与 DC 同值即合法)",
|
||||
ebus.proveEcatDc({ ok: true, dc: 1, dc_cycle_us: 500, period_ms: 0.5 }) === "ok");
|
||||
check("proveEcatDc: period=4ms 且 DC=4000us ⇒ ok(UI 档位内)",
|
||||
ebus.proveEcatDc({ ok: true, dc: 1, dc_cycle_us: 4000, period_ms: 4 }) === "ok");
|
||||
check("proveEcatDc: period=4ms 但 DC 仍 250us ⇒ misconfigured(不同值)",
|
||||
ebus.proveEcatDc({ ok: true, dc: 1, dc_cycle_us: 250, period_ms: 4 }) === "misconfigured");
|
||||
check("proveEcatDc: free-run ⇒ misconfigured",
|
||||
ebus.proveEcatDc({ ok: true, dc: 0, dc_cycle_us: 0, period_ms: 0.25 }) === "misconfigured");
|
||||
}
|
||||
|
||||
/* ── 2e. OTA 负载页的决策:读数不新鲜 ≠ 循环停了 ── */
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""test_lan_probe.py — 设备发现的离线自测(不需要真板子,可 CI 跑)。
|
||||
|
||||
覆盖三件事,缺一不可:
|
||||
1) 判据本身有灵敏度:同一套代码必须把"板子的 lite 响应"和"路由器的 HTML/
|
||||
缺字段 JSON"分开,也要把"旧固件(无 gd32_features)"收下而不是当反证扔掉。
|
||||
2) 真 socket 路径走真代码:假板服务绑在**本机 WLAN 网卡地址的高端口**上
|
||||
(不是 127.0.0.1 —— 用回环测不到 selectors/截止/refused 的真实语义)。
|
||||
3) 不自证:起/关假板服务,同一请求的判决必须翻转;种子故意指错地址,
|
||||
真在那儿的假板仍要被找到。
|
||||
|
||||
用法: python scripts/test_lan_probe.py 退出码 0=PASS 1=FAIL
|
||||
"""
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
import sys
|
||||
import threading
|
||||
import urllib.request
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
|
||||
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
sys.path.insert(0, ROOT)
|
||||
|
||||
import lan_probe as lp # noqa: E402
|
||||
|
||||
FAILS = []
|
||||
PASSES = []
|
||||
|
||||
|
||||
def check(name, cond, detail=""):
|
||||
if cond:
|
||||
PASSES.append(name)
|
||||
print(" PASS " + name)
|
||||
else:
|
||||
FAILS.append(name)
|
||||
print(" FAIL " + name + (" :: " + str(detail) if detail else ""))
|
||||
|
||||
|
||||
def lite_body(mode="STA", ip="192.168.31.127", connected=True, with_feat=True):
|
||||
b = {"wifi": {"mode": mode, "ip": ip, "connected": connected}}
|
||||
if with_feat:
|
||||
b["gd32_features"] = {"known": True, "flags": 15, "can_ch0": True,
|
||||
"can_ch1": True, "can_ch2": True, "can_ch_mask": 7}
|
||||
return json.dumps(b)
|
||||
|
||||
|
||||
# ───────────────────────── 1) 判决表(含反向对照) ─────────────────────────
|
||||
def test_classify():
|
||||
print("[1] classify 判决表")
|
||||
ip = "192.168.31.127"
|
||||
r = lp.classify(ip, 200, "application/json", lite_body(ip=ip))
|
||||
check("正对照:真 lite 响应 → board/full",
|
||||
r["verdict"] == "board" and r["schema"] == "full" and r["kind"] == "sta", r)
|
||||
|
||||
# 反向对照 1:路由器登录页
|
||||
r = lp.classify("192.168.31.1", 200, "text/html; charset=utf-8",
|
||||
"<!DOCTYPE html><title>miwifi</title>")
|
||||
check("反对照1:HTML → rejected/not-json",
|
||||
r["verdict"] == "rejected" and r["reason"] == "not-json", r)
|
||||
|
||||
# 反向对照 2:形状不对的 JSON
|
||||
r = lp.classify("192.168.31.9", 200, "application/json", '{"error":"unauthorized"}')
|
||||
check("反对照2:无 wifi 对象 → rejected/no-wifi-object",
|
||||
r["verdict"] == "rejected" and r["reason"] == "no-wifi-object", r)
|
||||
|
||||
# 反向对照 3:STA 自称在别处(端口转发/捕获门户)
|
||||
r = lp.classify(ip, 200, "application/json", lite_body(ip="10.0.0.5"))
|
||||
check("反对照3:STA 自报 IP 不符 → rejected/ip-mismatch",
|
||||
r["verdict"] == "rejected" and r["reason"].startswith("ip-mismatch"), r)
|
||||
|
||||
# 反向对照 4:HTTP 不是 200
|
||||
r = lp.classify(ip, 404, "text/plain", "not found")
|
||||
check("反对照4:404 → rejected/http-404",
|
||||
r["verdict"] == "rejected" and r["reason"] == "http-404", r)
|
||||
|
||||
# 旧固件:有 wifi 无 gd32_features 必须**收下**(标 core-only),不能当反证丢
|
||||
r = lp.classify(ip, 200, "application/json", lite_body(ip=ip, with_feat=False))
|
||||
check("旧固件无 gd32_features → board/core-only(不丢弃)",
|
||||
r["verdict"] == "board" and r["schema"] == "core-only", r)
|
||||
|
||||
# 关键坑:lite 在 NOT(STA&&connected) 时一律报 AP,所以 AP+connected:false
|
||||
# 多半是"STA 掉租约的板子",既不能当在线 AP 板,也不能当反证 → unknown
|
||||
r = lp.classify(ip, 200, "application/json",
|
||||
lite_body(mode="AP", ip="0.0.0.0", connected=False))
|
||||
check("AP+connected:false → unknown/ap-not-connected",
|
||||
r["verdict"] == "unknown" and r["reason"] == "ap-not-connected", r)
|
||||
|
||||
# 真 AP 回退(PC 连了板子热点):connected=true 才认
|
||||
r = lp.classify("192.168.31.1", 200, "application/json",
|
||||
lite_body(mode="AP", ip="0.0.0.0", connected=True))
|
||||
check("AP+connected:true → board/kind=ap",
|
||||
r["verdict"] == "board" and r["kind"] == "ap", r)
|
||||
|
||||
# busy/degraded 桩:完整 status 抢不到 mutex 时只回 version+ota.sys_state
|
||||
r = lp.classify(ip, 200, "application/json",
|
||||
'{"version":"0.5.0","ota":{"sys_state":"busy"},"cpu":{}}')
|
||||
check("busy 桩(无 wifi) → rejected/no-wifi-object( lite 不受此影响)",
|
||||
r["verdict"] == "rejected", r)
|
||||
|
||||
# 兜底不许有未定义分支:mode 值不认识 → unknown
|
||||
r = lp.classify(ip, 200, "application/json", '{"wifi":{"mode":"NA","ip":null}}')
|
||||
check("mode 非法值 → unknown(兜底,不落 rejected)",
|
||||
r["verdict"] == "unknown", r)
|
||||
|
||||
|
||||
# ───────────────────────── 2) IP 校验 ─────────────────────────
|
||||
def test_ip_validation():
|
||||
print("[2] IPv4 校验(候选表与端点写入都靠它,判据在服务侧)")
|
||||
for s in ["192.168.31.127", "10.0.0.1", "1.2.3.4"]:
|
||||
check("收合法地址 " + s, lp.valid_ipv4(s))
|
||||
for s in ["not-an-ip", "1.2.3.4.5", "256.1.1.1", "", "192.168.31", "1.2.3.04x"]:
|
||||
check("拒 " + repr(s), not lp.valid_ipv4(s))
|
||||
|
||||
|
||||
# ───────────────────────── 3) 真 socket + 真路由 ─────────────────────────
|
||||
class _Fake(BaseHTTPRequestHandler):
|
||||
BODY = ""
|
||||
CTYPE = "application/json"
|
||||
CODE = 200
|
||||
|
||||
def do_GET(self):
|
||||
b = self.BODY.encode("utf-8")
|
||||
self.send_response(self.CODE)
|
||||
self.send_header("Content-Type", self.CTYPE)
|
||||
self.send_header("Content-Length", str(len(b)))
|
||||
self.end_headers()
|
||||
self.wfile.write(b)
|
||||
|
||||
def log_message(self, *a):
|
||||
pass
|
||||
|
||||
|
||||
def _serve(ip, body, ctype="application/json", code=200):
|
||||
cls = type("F", (_Fake,), {"BODY": body, "CTYPE": ctype, "CODE": code})
|
||||
srv = HTTPServer((ip, 0), cls)
|
||||
threading.Thread(target=srv.serve_forever, daemon=True).start()
|
||||
return srv, srv.server_address[1]
|
||||
|
||||
|
||||
def local_ip():
|
||||
n = lp.local_net()
|
||||
return n.get("default_src_ip"), n
|
||||
|
||||
|
||||
def _free_port(host_ip):
|
||||
"""要一个"确实没人监听"的端口:绑 0 拿到号→关掉→再 connect 确认被拒。
|
||||
别拿 bp+1 猜:Windows 会把 ephemeral 端口连着发,猜中的就是隔壁那个服务。"""
|
||||
for _ in range(20):
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
s.bind((host_ip, 0))
|
||||
p = s.getsockname()[1]
|
||||
s.close()
|
||||
t = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
|
||||
t.settimeout(0.3)
|
||||
try:
|
||||
refused = t.connect_ex((host_ip, p)) != 0
|
||||
finally:
|
||||
t.close()
|
||||
if refused:
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def test_probe_route():
|
||||
print("[3] 真 socket:/api/lan/probe 走真代码路径")
|
||||
ip, net = local_ip()
|
||||
check("本机默认路由取到 WLAN 地址(期望 192.168.31.x)",
|
||||
bool(ip) and ip.startswith("192.168.31."), net)
|
||||
if not ip:
|
||||
return
|
||||
|
||||
board_body = lite_body(ip=ip) # 自报 IP 必须等于被探地址
|
||||
board, bp = _serve(ip, board_body)
|
||||
router, rp = _serve(ip, "<!DOCTYPE html><p>miwifi</p>", "text/html")
|
||||
closed = _free_port(ip) # 实测被拒的端口
|
||||
check("拿到一个确实没人监听的端口", closed is not None, closed)
|
||||
if closed is None:
|
||||
return
|
||||
|
||||
httpd, _p = __import__("esp32_server")._bind_server(0)
|
||||
port = httpd.server_address[1]
|
||||
threading.Thread(target=httpd.serve_forever, daemon=True).start()
|
||||
|
||||
def call(body, timeout=20):
|
||||
req = urllib.request.Request(
|
||||
"http://127.0.0.1:%d/api/lan/probe" % port,
|
||||
data=json.dumps(body).encode("utf-8"),
|
||||
headers={"Content-Type": "application/json"})
|
||||
return json.loads(urllib.request.urlopen(req, timeout=timeout).read().decode("utf-8"))
|
||||
|
||||
r = call({"ips": [ip], "ports": [bp, rp, closed], "local": True, "timeout_ms": 900})
|
||||
by = {(x["ip"], x["port"]): x for x in r["results"]}
|
||||
check("假板(真 socket,绑本机网卡)→ board",
|
||||
by[(ip, bp)]["verdict"] == "board", by[(ip, bp)])
|
||||
check("假路由器 → rejected/not-json",
|
||||
by[(ip, rp)]["verdict"] == "rejected" and by[(ip, rp)]["reason"] == "not-json",
|
||||
by[(ip, rp)])
|
||||
check("没人监听的端口 → unknown(不是 rejected:没有反证,只是没答)",
|
||||
by[(ip, closed)]["verdict"] == "unknown", by[(ip, closed)])
|
||||
check("tcpOpen 不把 refused 算成开着",
|
||||
r["scan"]["tcpOpen"] == 2, r["scan"])
|
||||
check("local 段随响应返回", bool(r.get("local", {}).get("cidr")), r.get("local"))
|
||||
|
||||
# 灵敏度对照:关掉假板,同一请求的判决必须翻
|
||||
board.shutdown()
|
||||
r2 = call({"ips": [ip], "ports": [bp], "timeout_ms": 600})
|
||||
check("灵敏度对照:假板下电后 board → unknown",
|
||||
r2["results"][0]["verdict"] != "board", r2["results"][0])
|
||||
|
||||
# 配置自证对照:种子故意指一个错地址,真在那儿的假路由器地址仍要如实分类
|
||||
r3 = call({"ips": [ip], "ports": [rp], "timeout_ms": 600})
|
||||
check("种子指错也不会把非板子说成板子", r3["results"][0]["verdict"] == "rejected",
|
||||
r3["results"][0])
|
||||
|
||||
# 非法 IP / 路径白名单
|
||||
r4 = call({"ips": ["bogus", "1.2.3.4.5"], "ports": [80]})
|
||||
check("非法 IP 被剔且带原因", r4["scan"]["candidates"] == 0
|
||||
and len(r4["scan"]["skipped"]) == 2, r4["scan"])
|
||||
r5 = call({"ips": [ip], "ports": [rp], "path": "/logs"})
|
||||
check("危险路径不进白名单(/logs 会排空固件日志环)",
|
||||
"/system/status?lite=1" == r5["path"] and any(
|
||||
"path-not-allowed" in e for e in r5["errors"]), r5)
|
||||
|
||||
# 连续 10 批不泄漏状态:每批结果数必须齐(3 个相同 IP 去重 × 2 个端口 = 2 条)
|
||||
ok, detail = True, ""
|
||||
for _ in range(10):
|
||||
rr = call({"ips": [ip] * 3, "ports": [rp, closed], "timeout_ms": 400})
|
||||
if len(rr["results"]) != 2:
|
||||
ok = False
|
||||
detail = "期望 2 条,实际 %d 条" % len(rr["results"])
|
||||
break
|
||||
check("连续多批结果数稳定(批间无残留状态、去重按 ip:port)", ok, detail)
|
||||
|
||||
httpd.shutdown()
|
||||
router.shutdown()
|
||||
|
||||
|
||||
# ───────────────────────── 4) 结构回归 ─────────────────────────
|
||||
def test_structure():
|
||||
print("[4] 结构回归(把已经踩过的坑钉住)")
|
||||
src = io.open(os.path.join(ROOT, "lan_probe.py"), encoding="utf-8").read()
|
||||
check("lan_probe.py 不写 stdout/stderr(--window 下子进程 stderr 是父进程管道)",
|
||||
"print(" not in src and "sys.stderr" not in src)
|
||||
m = re.search(r"^ALLOWED_PATHS = \((.*?)\)", src, re.M)
|
||||
allowed = m.group(1) if m else ""
|
||||
check("探测白名单只有安全路径(/logs 会排空固件日志环,/system/probe 会发真实 SPI 事务)",
|
||||
m is not None and "/logs" not in allowed and "/system/probe" not in allowed
|
||||
and "PROBE_PATH" in allowed and '"/system/status"' in allowed, allowed)
|
||||
|
||||
srv = io.open(os.path.join(ROOT, "esp32_server.py"), encoding="utf-8").read()
|
||||
check("路由挂在 do_POST(GET 会被无扩展名当 HTML 的分支吃掉)",
|
||||
srv.count('"/api/lan/probe"') >= 1
|
||||
and "_handle_lan_probe" in srv)
|
||||
check("QuietServer.handle_error 仍静默(异常灌进父进程 4KB 管道会冻住服务)",
|
||||
re.search(r"def handle_error\(self[^)]*\):\s*\n\s+pass", srv) is not None)
|
||||
|
||||
js = {}
|
||||
for rel in ["js/common/base-url.js", "js/common/device-link.js", "js/can.js",
|
||||
"js/can/dbc.js", "js/common/lan-picker.js", "js/app.js"]:
|
||||
js[rel] = io.open(os.path.join(ROOT, rel), encoding="utf-8").read()
|
||||
|
||||
writers = [k for k, v in js.items() if "saveDeviceIp(" in v.replace("function saveDeviceIp(", "")]
|
||||
check("写端点的只有 device-link(+ base-url 自身实现)",
|
||||
set(writers) <= {"js/common/base-url.js", "js/common/device-link.js"}, writers)
|
||||
check("can.js 不再把设备自报 IP 回写成端点(0.0.0.0 污染源)",
|
||||
"setDeviceIp" not in js["js/can.js"] and "setDeviceIp" not in js["js/can/dbc.js"])
|
||||
check("can.js 改为只提示不符", "与当前端点" in js["js/can.js"])
|
||||
check("device-link 里端点唯一写手 + 刷写门",
|
||||
"selectDevice: function" in js["js/common/device-link.js"]
|
||||
and "flashBusyNow()" in js["js/common/device-link.js"])
|
||||
check("自动补扫门齐(failStreak + 刷写 + ECAT 环跑 + 冷却)",
|
||||
"maybeAutoScan" in js["js/common/device-link.js"]
|
||||
and "snap.links.ecat.loop" in js["js/common/device-link.js"])
|
||||
check("面板不重算判据(只取服务侧 verdict 字段,不自己解析 gd32_features)",
|
||||
re.search(r"gd32_features\s*[\[.]", js["js/common/lan-picker.js"]) is None
|
||||
and ".verdict" in js["js/common/lan-picker.js"])
|
||||
check("当前端点不进候选表(不重复占设备 httpd 的 socket)",
|
||||
"当前端点" in js["js/common/lan-picker.js"])
|
||||
|
||||
|
||||
def main():
|
||||
print("== lan_probe 自测 ==")
|
||||
test_classify()
|
||||
test_ip_validation()
|
||||
test_probe_route()
|
||||
test_structure()
|
||||
print("\n合计 PASS=%d FAIL=%d" % (len(PASSES), len(FAILS)))
|
||||
if FAILS:
|
||||
print("FAILED: " + "; ".join(FAILS))
|
||||
return 1
|
||||
print("ALL PASS")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -70,6 +70,9 @@ const REQUIRED = [
|
||||
"probe", "fetchStatus", "describeStatus", "reportedIp",
|
||||
"fetchMotorState", "fetchFeedback", "sendFrames", "sendBurst",
|
||||
"fetchVersionNode", "setPeriod", "teardown",
|
||||
/* 电机 OTA 整套:少一个就是"点开始才崩",流程只在 ota.js 写一遍,两条链路都得给 */
|
||||
"otaHostSendsBootFrame", "otaIntegrity",
|
||||
"otaUploadAttempt", "otaStartRelay", "otaStatus", "otaCancel",
|
||||
];
|
||||
const wifi = createWifiTransport(CTX);
|
||||
const ecat = createEcatTransport(CTX);
|
||||
@@ -120,7 +123,17 @@ section("[2] WiFi /motor/state?bin=1 二进制解析");
|
||||
/* ── 3. 能力位 ── */
|
||||
section("[3] 链路能力位");
|
||||
check("WiFi 支持 OTA", wifi.supportsOta === true);
|
||||
check("EtherCAT 不支持 OTA(没有固件上传+relay 通路)", ecat.supportsOta === false);
|
||||
/* EtherCAT 的料走 CoE 邮箱进 GD32 暂存区(DCU_APP comm/ecat_motor_ota.cpp),
|
||||
* 中继引擎与 WiFi 同一个 ⇒ 能力位必须为真。曾经这里是 false,
|
||||
* 因为那条链路只有 ESP32 上传这一条进料路(纯网线打不通设备 IP)。 */
|
||||
check("EtherCAT 支持 OTA(邮箱进料 + 同一个 GD32 relay)", ecat.supportsOta === true);
|
||||
/* 两处真实差异必须被钉住,别被"看着一样"糊过去:
|
||||
* - 进 BOOT 帧谁发(EtherCAT 必须 GD32 在已加锁通道上发,否则运动帧抢邮箱)
|
||||
* - 完整性口径(WiFi 回读 SHA-256,EtherCAT 从站核 CRC32,不许互相冒充) */
|
||||
check("WiFi 由上位机先进 BOOT", wifi.otaHostSendsBootFrame === true);
|
||||
check("EtherCAT 由 GD32 在已加锁通道上先进 BOOT", ecat.otaHostSendsBootFrame === false);
|
||||
check("WiFi 完整性口径 = ESP32 flash 回读 SHA-256", wifi.otaIntegrity === "sha256-esp32-flash");
|
||||
check("EtherCAT 完整性口径 = 从站核 CRC32(进 BOOT 前)", ecat.otaIntegrity === "crc32-verify-in-slave");
|
||||
check("WiFi 支持 0xC5 周期同步", wifi.supportsPeriodSync === true);
|
||||
check("EtherCAT 不做周期同步(PDO 周期由后台循环维持)", ecat.supportsPeriodSync === false);
|
||||
check("EtherCAT 不回报设备 IP", ecat.reportedIp({}) === null);
|
||||
@@ -499,6 +512,13 @@ section("[8] 快照→契约翻译(构造快照,跑生产代码)");
|
||||
JSON.stringify({ periodHits, startHits2 }));
|
||||
check("(i4) 周期值持久化到 localStorage(服务重启后不用重敲)",
|
||||
globalThis.localStorage.getItem("dcu_ecat_period_ms") === "0.25");
|
||||
/* 放开热改:0.5ms 档必须原样下发,不得被前端钳回 0.25 */
|
||||
const got05 = await tp.setPdoPeriod(0.5);
|
||||
check("(i4) setPdoPeriod(0.5) 不钉死 0.25:原样下发并持久化",
|
||||
got05 === 0.5 && periodBody.period_ms === 0.5
|
||||
&& globalThis.localStorage.getItem("dcu_ecat_period_ms") === "0.5",
|
||||
JSON.stringify({ got05, periodBody }));
|
||||
await tp.setPdoPeriod(0.25); /* 回落到默认档,后续 ensureStarted 用例口径不变 */
|
||||
await tp.probe();
|
||||
check("(i4) 循环重启(ensureStarted)自动带上持久化的周期",
|
||||
startHits2 === 1 && startBody2.period_ms === 0.25, JSON.stringify(startBody2));
|
||||
|
||||
Reference in New Issue
Block a user