Files

280 lines
13 KiB
Python
Raw Permalink Normal View History

#!/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())