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

280 lines
13 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.
#!/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())