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