feat: 添加局域网设备发现功能和链路胶囊优化

- 实现局域网设备发现面板,支持探测ESP32中继板并自动切换端点
- 添加POST /api/lan/probe接口用于设备探测,返回三态判决结果
- 实现快扫和全段扫描两种模式,支持种子地址优先探测
- 添加端点唯一写手机制,防止多处同时修改设备IP
- 优化链路胶囊UI显示WiFi/EtherCAT/BLE连接状态和设备名称
- 修复EtherCAT链路OTA刷写期间PDO循环暂停和AL状态处理问题
- 调整PDO周期设置范围为0.125-4ms,支持亚毫秒显示为微秒
- 添加ESP32 WiFi使能开关控件样式和数据来源角标显示
This commit is contained in:
cbb
2026-10-13 00:48:34 +08:00
parent 514070b36d
commit eac3717f9a
41 changed files with 4229 additions and 934 deletions
+58 -2
View File
@@ -69,6 +69,7 @@ BASE_DIR = os.path.dirname(os.path.abspath(__file__))
# EtherCAT 客户端 / 进程治理模块(R8)
import ecat_client
import ecat_proc
import lan_probe
ecat_proc.init(BASE_DIR)
@@ -495,6 +496,11 @@ class Handler(SimpleHTTPRequestHandler):
p, rel = UI_STATE_ROUTES[path_l]
self._handle_ui_state_post(p, rel)
return
if path_l == "/api/lan/probe":
# 局域网设备发现: 无状态, 一次请求探一批(≤24), 探完就回。
# 编排(候选表/分批/进度/取消/缓存)全在页面 js/common/lan-picker.js。
self._handle_lan_probe()
return
if path.startswith(ECAT_PROXY_PREFIX):
# 电机页的 EtherCAT 传输: 同源转发到 EtherCAT 后端(见 ecat_client)。
# 超时给足: /api/op/start 要走 PREOP→SAFEOP→OP, 内部还会自动重试一次,
@@ -504,9 +510,14 @@ class Handler(SimpleHTTPRequestHandler):
except ValueError:
length = 0
raw = self.rfile.read(length) if length > 0 else b"{}"
# /api/open 正常只要 ~0.1s(实测 107ms),但刷写 commit 后那一拍不算正常:
# 新 APP 起板看到 warm 残值会自己补一发 LAN9252 RST#(esc_lan9252.cpp:441),
# 芯片+PHY 整颗复位 → I210 链路重协商 1~2s,open 的广播/statecheck 全撞上
# 去,实测能跨过 6s 档 → 代理假超时 → 非幂等只回 503(2026-10-09 21:49 现场)。
timeout = 30.0 if path.endswith(("/op/start", "/motor/start",
"/flash", "/eeprom/write",
"/eeprom/read", "/eeprom/verify")) else 6.0
"/eeprom/read", "/eeprom/verify",
"/open")) else 6.0
# EtherCAT 直灌 BOOT: run 要走 519 个邮箱帧 + 整区预擦, 几十秒量级。
# 默认 6s 会在"正在正常刷写"的过程中假失败 —— 浏览器报错离场, native
# 却还在往 flash 里写, 比不刷更糟。同一路由下的 open/push 都是纯内存
@@ -637,6 +648,51 @@ class Handler(SimpleHTTPRequestHandler):
return
self._post_json(json.dumps(result, ensure_ascii=False).encode("utf-8"))
def _handle_lan_probe(self):
"""POST /api/lan/probe — 局域网设备探测原语(一批 ≤24 个 IP,探完就回)。
刻意不建 job 表 / 不常驻线程 / 不落缓存:编排全在页面
(js/common/lan-picker.js),这里由每连接一线程的 ThreadingHTTPServer
自然限流。页面中途关标签最坏是让本线程跑满这一批的截止时长。
"""
try:
length = int(self.headers.get("Content-Length") or 0)
except ValueError:
length = 0
raw = self.rfile.read(min(length, 65536)) if length > 0 else b""
if length == 0 or length > 65536:
self._post_json(json.dumps({"ok": False, "errors": ["bad-body"]},
ensure_ascii=False).encode("utf-8"), 400)
return
try:
obj = json.loads(raw.decode("utf-8", "replace"))
except ValueError:
obj = {}
if not isinstance(obj, dict):
obj = {}
ips = obj.get("ips") if isinstance(obj.get("ips"), list) else []
ports = obj.get("ports") if isinstance(obj.get("ports"), list) else None
if not ports:
ports = [80]
try:
tmo = int(obj.get("timeout_ms") or 750)
except (TypeError, ValueError):
tmo = 750
try:
out = lan_probe.probe_batch(
ips, ports=ports, path=obj.get("path") or lan_probe.PROBE_PATH,
timeout_ms=tmo)
except Exception as e: # noqa: BLE001
out = {"ok": False, "results": [], "scan": {"candidates": len(ips)},
"errors": ["%s: %s" % (type(e).__name__, e)]}
if obj.get("local"):
# 同一次往返带回"本机在哪段",页面才说得出扫的是哪段、依据是什么。
try:
out["local"] = lan_probe.local_net()
except Exception as e: # noqa: BLE001
out["local"] = {"ok": False, "err": type(e).__name__}
self._post_json(json.dumps(out, ensure_ascii=False).encode("utf-8"))
def _handle_ui_state_post(self, path, rel):
"""配置快照 POST(R11:motor/lab 共用,表驱动)。"""
self._write_ui_state_route(path, rel)
@@ -735,7 +791,7 @@ def _run_browser(args):
url = f"http://127.0.0.1:{port}/tabs.html"
print(f"PORT={port}", flush=True) # 窗口模式父进程据此得知端口
print(f"ESP32 调试控制台: {url} (Ctrl+C 退出)")
print("页面里的设备 IP 决定 API 目标(默认 192.168.31.127), 本服务只提供页面")
print("设备端点由页面决定(顶栏『发现』选择或 IP 输入框手填); 本服务另有 /api/lan/probe 探测原语")
if not args.no_browser:
threading.Timer(0.5, lambda: webbrowser.open(url)).start()
try: