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