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