Files
DCU_DesktopController/lan_probe.py
T
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

406 lines
16 KiB
Python
Raw 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.
"""lan_probe.py — 局域网设备探测原语(无状态:一次请求跑一批,跑完就返回)。
职责切分:候选表、分批、进度、取消、去重、渲染、结果缓存全在页面
(js/common/lan-picker.js);本模块只做两件事——
probe_batch(ips, ports, path, timeout_ms) 逐 IP 一次 HTTP GET + 判决
local_net() 本机默认路由源 IP / 派生 /24
为什么不用 urllib/requests:Windows 上它们的 connect 超时和 read 超时共用一个
预算,冷 ARP + WiFi 省电唤醒能让一台活板子整批假死;同仓实测记录见
ecat_client.py:319-335。这里用单个 selectors 循环驱动全部非阻塞 socket,
chunk 内所有目标共用一个绝对截止,最坏耗时 = ceil(n/concurrency) * timeout。
判决三态(不许有未定义分支,兜底一律 unknown):
board — 实现了 /system/status?lite=1 且 wifi/gd32_features 形状对得上
rejected — 有**反证**(非 200 / 非 JSON / 无 wifi 对象 / STA 自报 IP 与探测地址不符)
unknown — 连不上、读超时、或形状合但落不进上面两类
把"没测出来"塞进 rejected 就是自证判据;本模块宁可留灰也不猜。
"""
import json
import selectors
import socket
import time
# 探测路径固定 lite:256 B 栈缓冲、不抢 httpJsonWorkMutex、不发 SPI 事务
# (ESP32_COMM/main/server/http_handlers.cpp:1769-1806)。
PROBE_PATH = "/system/status?lite=1"
# 白名单而不是随便传:/system/probe 会发起真实 SPI 事务,/logs 会排空固件的
# 日志环形缓冲——扫 254 台时这些都会污染正在调试的东西。要加路径先去固件仓确认副作用。
ALLOWED_PATHS = (PROBE_PATH, "/system/status")
BATCH_MAX_IPS = 64 # 一次请求最多探多少个 IP(页面按此分批)
# 并发是"同时探多少个不同地址",不是对同一个地址开多条连接——所以一块板子
# 在整场扫描里最多只会被我们占 1 个 socket(它 httpd 只有 6 个且开 lru_purge)。
# 整段耗时由"不存在的地址等满截止"主导:254 个地址在 8 并发下要 24s,48 并发约 4s。
CONCURRENCY = 48
TIMEOUT_MS_MAX = 3000 # 单目标硬截止上限
BUDGET_MS_MAX = 8000 # 一批的最长总耗时(超出部分回 pending,不谎报探过)
HEAD_BYTES = 96 # rejected 里带回的响应片段长度(给人念出来用)
def valid_ipv4(s):
"""严格 IPv4 点分十进制校验:四段 0-255、纯数字、不允许前导多余字符。"""
parts = str(s or "").strip().split(".")
if len(parts) != 4:
return False
for p in parts:
if not p.isdigit() or len(p) > 3 or not 0 <= int(p) <= 255:
return False
return True
def local_net():
"""默认路由源 IP + 派生 /24 + 本机全部 IPv4(供页面过滤自己的地址)。
UDP connect 只做内核路由查询,不发一个包。取不到默认路由就返回 ok=False,
页面必须要求人填 CIDR —— 不回退成"那就打 .127 试一把",那是配置自证。
"""
for dst in (("8.8.8.8", 53), ("223.5.5.5", 53), ("1.1.1.1", 53)):
ip = None
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
s.connect(dst)
ip = s.getsockname()[0]
finally:
s.close()
except OSError:
continue
if not ip or ip.startswith("169.254.") or ip.startswith("127."):
continue
return {
"ok": True,
"default_src_ip": ip,
"cidr": ".".join(ip.split(".")[:3]) + ".0/24",
"source": "udp-route",
"local_ips": _all_local_ipv4(),
}
return {"ok": False, "err": "no-default-route", "local_ips": _all_local_ipv4()}
def _all_local_ipv4():
out = []
try:
for ai in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET):
ip = ai[4][0]
if ip not in out:
out.append(ip)
except OSError:
pass
return out
def classify(ip, status, ctype, body):
"""纯判决函数:给一次 HTTP 响应,返回该地址是不是我们的板子。
单独拎出来是为了能被离线单测打(scripts/test_lan_probe.py),不碰 socket。
"""
if status is None:
return {"verdict": "unknown", "reason": "no-response"}
if status != 200:
return {"verdict": "rejected", "reason": "http-%d" % status}
if "json" not in (ctype or "").lower():
# 路由器/NAS 登录页落这里
return {"verdict": "rejected", "reason": "not-json"}
try:
j = json.loads(body)
except (ValueError, TypeError):
return {"verdict": "rejected", "reason": "bad-json"}
if not isinstance(j, dict):
return {"verdict": "rejected", "reason": "not-object"}
wifi = j.get("wifi")
if not isinstance(wifi, dict):
return {"verdict": "rejected", "reason": "no-wifi-object"}
mode = wifi.get("mode")
if mode not in ("STA", "AP"):
# 形状不对但不是反证:固件字段变了/别的 JSON 服务都落这里
return {"verdict": "unknown", "reason": "bad-mode"}
gf = j.get("gd32_features")
schema = "core-only"
if isinstance(gf, dict) and isinstance(gf.get("known"), bool) \
and isinstance(gf.get("flags"), int):
schema = "full"
self_ip = wifi.get("ip")
connected = wifi.get("connected")
if mode == "AP" and connected is not True:
# 关键坑:lite 在 NOT(STA && connected) 时一律报 mode:"AP"
# (http_handlers.cpp:1776-1789)。所以 AP+connected:false 多半是
# "STA 掉租约的板子",既不能当在线 AP 板收下,也不能当反证扔掉。
return {"verdict": "unknown", "reason": "ap-not-connected",
"schema": schema, "mode": mode, "self_ip": self_ip}
if mode == "STA" and self_ip not in (ip, None, ""):
# 反证:它自称在别处。NAT/端口转发/捕获门户落这里。
return {"verdict": "rejected", "reason": "ip-mismatch:%s" % self_ip,
"schema": schema}
return {
"verdict": "board",
"kind": "ap" if mode == "AP" else "sta",
"schema": schema,
"mode": mode,
"connected": bool(connected),
"self_ip": self_ip,
}
def _parse_http(buf):
"""从原始字节里取 (status, content_type, body)。不引 http.client:只要这三样。"""
sep = buf.find(b"\r\n\r\n")
head = buf[:sep if sep >= 0 else len(buf)]
body = buf[sep + 4:] if sep >= 0 else b""
lines = head.split(b"\r\n")
status = None
if lines and lines[0]:
bits = lines[0].split()
if len(bits) >= 2 and bits[1].isdigit():
status = int(bits[1])
ctype = ""
for ln in lines[1:]:
k, _, v = ln.partition(b":")
if k.lower() == b"content-type":
ctype = v.decode("latin-1", "replace").strip()
break
return status, ctype, body.decode("utf-8", "replace")
def _content_length(head_bytes_buf):
i = head_bytes_buf.find(b"\r\n\r\n")
if i < 0:
return None
for ln in head_bytes_buf[:i].split(b"\r\n")[1:]:
k, _, v = ln.partition(b":")
if k.lower() == b"content-length":
try:
return int(v.strip())
except ValueError:
return None
return None
def _probe_chunk(targets, path, deadline, errors):
"""并发探完一小批(≤CONCURRENCY)。返回与 targets 同序的结果列表。
每个 socket 都登记在 active 里,退出路径只有一条(close + pop),所以句柄
不会随"页面中途关标签、这批没人收"而泄漏。
"""
sel = selectors.DefaultSelector()
states = []
active = {} # sock -> state
def retire(sock, st):
if sock in active:
try:
sel.unregister(sock)
except (KeyError, ValueError):
pass
active.pop(sock, None)
try:
sock.close()
except OSError:
pass
if "verdict" not in st:
_finish(st)
try:
for ip, port in targets:
st = {"ip": ip, "port": port, "tcp": False, "status": None,
"ctype": "", "body": "", "err": None, "got": b"",
"req": ("GET %s HTTP/1.0\r\nHost: %s\r\n"
"Accept: application/json\r\nConnection: close\r\n\r\n"
% (path, ip)).encode("ascii")}
states.append(st)
sock = None
try:
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setblocking(False)
sock.connect((ip, port)) # 非阻塞:要么立刻 0,要么 BlockingIOError
st["tcp"] = True
except BlockingIOError:
pass # 在途:可写时看 SO_ERROR 才算数
except OSError as e: # 本机立刻就知道连不上(无路由等)
st["tcp"] = False
st["err"] = e.winerror or e.errno or type(e).__name__
if sock is not None:
try:
sock.close()
except OSError:
pass
sock = None
_finish(st)
continue
sel.register(sock, selectors.EVENT_WRITE, st)
active[sock] = st
while active:
left = deadline - time.monotonic()
if left <= 0:
break
try:
ready = sel.select(left)
except OSError as e:
errors.append("select:%s" % type(e).__name__)
break
for key, events in ready:
st = key.data
sock = key.fileobj
if st.get("done"):
retire(sock, st)
continue
try:
if events & selectors.EVENT_WRITE and not st.get("sent"):
if sock.getsockopt(socket.SOL_SOCKET, socket.SO_ERROR):
st["tcp"] = False
st["err"] = "connect-refused"
retire(sock, st)
continue
sock.sendall(st["req"])
st["tcp"] = True # 可写且 SO_ERROR==0:这次真连上了
st["sent"] = True
sel.modify(sock, selectors.EVENT_READ, st)
continue
chunk = sock.recv(4096)
if not chunk:
retire(sock, st) # EOF:对方关连接,够判了
continue
st["got"] += chunk
st["tcp"] = True
# 按 Content-Length 收完就走:路由器多半 keep-alive 不关连接,
# 死等 EOF 会把每个活非板子都拖满截止,整批白跑。
head_end = st["got"].find(b"\r\n\r\n")
if head_end >= 0:
cl = _content_length(st["got"])
have = len(st["got"]) - head_end - 4
if (cl is not None and have >= cl) or have > 65536:
retire(sock, st)
except OSError as e:
if (e.winerror or e.errno) in (10035, 11): # WSAEWOULDBLOCK/EAGAIN
continue
st["err"] = e.winerror or e.errno or type(e).__name__
retire(sock, st)
finally:
for st in states:
if "verdict" not in st:
_finish(st)
for sock in list(active):
try:
sel.unregister(sock)
except (KeyError, ValueError):
pass
try:
sock.close()
except OSError:
pass
active.clear()
sel.close()
return states
def _finish(st):
"""把已收字节解析成判决,并把大缓冲释放掉(结果要回给页面,不能揣 64 KB)。"""
status, ctype, body = _parse_http(st.get("got") or b"")
st["status"], st["ctype"] = status, ctype
st["head"] = body[:HEAD_BYTES].replace("\r", "").replace("\n", " ")
if st.get("tcp"):
v = classify(st["ip"], status, ctype, body)
else:
v = {"verdict": "unknown",
"reason": "tcp-failed:%s" % (st.get("err") or "no-connection")}
st.update(v)
st.pop("req", None)
st.pop("got", None)
st["done"] = True
def probe_batch(ips, ports=None, path=PROBE_PATH, timeout_ms=750):
"""逐 IP 探测。返回 {ok, results, scan, errors};异常只记账,绝不写 stderr。"""
t0 = time.monotonic()
errors = []
scan = {"candidates": 0, "tcpOpen": 0, "httpProbed": 0,
"board": 0, "rejected": 0, "unknown": 0,
"skipped": [], "durationMs": 0, "timeoutMs": 0, "concurrency": CONCURRENCY}
if path not in ALLOWED_PATHS:
errors.append("path-not-allowed:%s" % path)
path = PROBE_PATH
timeout_ms = max(80, min(int(timeout_ms or 750), TIMEOUT_MS_MAX))
scan["timeoutMs"] = timeout_ms
use_ports = [int(p) for p in (ports or [80]) if str(p).strip()]
ips_in = [str(x or "").strip() for x in (ips or [])]
targets = []
seen = set()
# 批大小由服务侧兜死:页面漏分批时也不会让一个 handler 线程在
# 254 个 socket 上挂 24 s。多出来的原样回 pending,由页面下一批接着探。
if len(ips_in) > BATCH_MAX_IPS:
pending_overflow = ["%s:overflow" % ip for ip in ips_in[BATCH_MAX_IPS:]]
errors.append("ips-truncated:%d" % len(pending_overflow))
else:
pending_overflow = []
for ip in ips_in[:BATCH_MAX_IPS]:
if not valid_ipv4(ip):
scan["skipped"].append([ip or "(empty)", "not-ipv4"])
continue
for port in use_ports:
key = (ip, port)
if key in seen:
continue
seen.add(key)
targets.append(key)
scan["candidates"] = len(targets)
scan["requested"] = len(ips_in)
raw = []
pending = list(pending_overflow)
# 一批的总预算 = 分批数 × 单目标截止 + 300ms 余量。页面自己控制批大小(≤24),
# 所以这里只是兜底:超出预算的尾巴回 pending,让页面继续下一批,绝不静默截断。
n_chunks = (len(targets) + CONCURRENCY - 1) // CONCURRENCY
budget_ms = min(max(timeout_ms * n_chunks + 300, timeout_ms), BUDGET_MS_MAX)
batch_deadline = t0 + budget_ms / 1000.0
scan["budgetMs"] = budget_ms
for i in range(0, len(targets), CONCURRENCY):
chunk = targets[i:i + CONCURRENCY]
now = time.monotonic()
if now >= batch_deadline:
# 批次预算用尽:剩下的一律记账交回页面,绝不伪装成"探过了"。
pending += ["%s:%d" % t for t in chunk]
continue
# 每个 chunk 各有自己的硬截止,但整批再套一个总预算(分批是限流,
# 不是把预算乘 N)。
chunk_deadline = min(batch_deadline, now + timeout_ms / 1000.0)
try:
raw += _probe_chunk(chunk, path, chunk_deadline, errors)
except Exception as e: # noqa: BLE001 - 兜住一切,不外泄
errors.append("chunk:%s:%s" % (type(e).__name__, e))
for ip, port in chunk:
raw.append({"ip": ip, "port": port, "tcp": False, "status": None,
"verdict": "unknown", "reason": "probe-error"})
results = []
for r in raw:
item = {"ip": r.get("ip"), "port": r.get("port"), "tcp": bool(r.get("tcp")),
"verdict": r.get("verdict") or "unknown",
"reason": r.get("reason") or "",
"kind": r.get("kind"), "schema": r.get("schema"),
"mode": r.get("mode"), "connected": r.get("connected"),
"self_ip": r.get("self_ip"), "status": r.get("status"),
"head": r.get("head") or ""}
results.append({k: v for k, v in item.items() if v is not None})
for st in results:
if st.get("tcp"):
scan["tcpOpen"] += 1
if st.get("status") is not None:
scan["httpProbed"] += 1
v = st["verdict"]
scan[v] = scan.get(v, 0) + 1
scan["pending"] = pending
scan["truncated"] = bool(pending)
scan["durationMs"] = int((time.monotonic() - t0) * 1000)
return {"ok": True, "path": path, "results": results,
"scan": scan, "errors": errors}