feat(motor): 支持 WiFi 与 EtherCAT 双链路传输
- 抽象电机传输层,WiFi 与 EtherCAT 实现分离,解码、状态面板、波形和命令序列共用 - 新增 EtherCAT 后端反向代理与自动拉起,解决跨端口 CORS 与预检失败问题 - 刷新速度按链路独立存储,支持 EtherCAT 低延迟读取与 WiFi 稳定轮询 - 更新电机页链路选择、不支持页签置灰样式、README 说明及相关测试脚本
This commit is contained in:
+229
-3
@@ -3,11 +3,20 @@
|
||||
|
||||
本目录的 HTML/CSS/JS 是纯前端: 页面本体由本服务器提供(同源相对路径),
|
||||
而所有业务 API 由 ESP32 固件的 80 端口跨域提供(js/common/base-url.js 里的
|
||||
设备 IP, 存 localStorage 的 dcu_esp_ip)。因此本脚本不碰任何硬件, 只做静态服务。
|
||||
设备 IP, 存 localStorage 的 dcu_esp_ip)。因此本脚本不碰任何硬件, 只做静态服务
|
||||
—— 唯一例外是 /ecat/api/* 反向代理(见下)。
|
||||
|
||||
GET / → 302 到 /tabs.html (标签外壳, 工具主页)
|
||||
GET /<file> → 本目录静态文件(tabs.html / pages/ / lab/ / css/ / js/)
|
||||
POST /api/shutdown → 结束本服务进程(窗口模式父进程用它通知子进程退出)
|
||||
POST /ecat/api/<路径> → 反向代理到 tools/ecat 的 ecat_server.py 的 /api/<路径>
|
||||
|
||||
为什么要代理 EtherCAT: 电机页(pages/motor.html)支持 WiFi 与 EtherCAT 两种传输,
|
||||
后者的后端 tools/ecat/src/ecat_server.py 只绑 127.0.0.1 且完全没有 CORS
|
||||
(既无 Access-Control-Allow-Origin, 也没有 do_OPTIONS —— 预检直接 501)。
|
||||
我们的 API 是 POST + application/json, 属非简单请求, 浏览器必发预检,
|
||||
因此页面无法直接跨端口调它。改那边要动另一个 git 仓库, 把页面搬过去又会让
|
||||
WiFi/ECAT 分裂成两份页面, 所以在本服务做同源转发, 两个仓库都不用改。
|
||||
|
||||
目录约定: tabs.html 在根(工具入口), 功能页在 pages/, 自包含实验页在 lab/。
|
||||
|
||||
@@ -25,6 +34,7 @@ _reload.html: 右下角 ⟳ 悬浮按钮 + F5/Ctrl+R 键盘拦截(WebView2 默
|
||||
import argparse
|
||||
import collections
|
||||
import ctypes
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import socket
|
||||
@@ -104,6 +114,40 @@ g_port = None
|
||||
g_win = None
|
||||
g_job = None # Job Object 句柄: 父进程一死内核连坐杀服务子进程
|
||||
|
||||
# ── EtherCAT 后端反向代理 ──────────────────────────────────────────────
|
||||
# 电机页的 EtherCAT 传输要打 tools/ecat/src/ecat_server.py 的 /api/motor/*,
|
||||
# 但那个服务**只绑 127.0.0.1 且完全没有 CORS**(无 Access-Control-Allow-Origin,
|
||||
# 也没有 do_OPTIONS —— 预检直接 501)。而我们的 API 是 POST + JSON,
|
||||
# 属非简单请求, 浏览器必发预检 → 从本页面(:8080)直接 fetch 它(:8090)必然失败。
|
||||
#
|
||||
# 三种解法里选反向代理:
|
||||
# 1) 改 ecat_server.py 加 CORS —— 它是**独立 git 仓库**, 不该为本页面改;
|
||||
# 2) 把电机页搬进 ecat_server 的 _STATIC_MAP —— 那是硬白名单, 且会让
|
||||
# WiFi 版和 ECAT 版分裂成两份页面, 正好违背"所有组件复用"的要求;
|
||||
# 3) 本服务代理 —— 页面永远同源(:8080), 两个仓库都不用动。
|
||||
# 端口自动探测: ecat_server 的 _bind_server 会在 8090..8095 间漂移。
|
||||
ECAT_PROXY_PREFIX = "/ecat/api/"
|
||||
ECAT_PORTS = (8090, 8091, 8092, 8093, 8094, 8095)
|
||||
g_ecat_port = None # 记住上次探到的端口, 避免每次请求都从 8090 试起
|
||||
|
||||
# ── EtherCAT 服务自动启动 ─────────────────────────────────────────────
|
||||
# 电机页切到 EtherCAT 链路时, 不该要求用户先去双击 ecat_app.vbs。
|
||||
# 本服务在第一次代理请求时按需拉起 tools/ecat 的服务进程。
|
||||
#
|
||||
# 三条必须小心的地方(都在 ecat_server.py 里读出来的, 不是猜的):
|
||||
# 1) **必须先探测再拉起**。ecat_server 的 _run_browser 在没有 --parent-pid 时
|
||||
# 会调 _kill_stale_instances(), 那会把用户已经开着的 EtherCAT 窗口
|
||||
# 优雅关掉(还会把从站带回 INIT)。所以已有实例时只复用, 绝不重复拉起。
|
||||
# 2) 拉起时**必须带 --parent-pid**: 一是跳过上面那个清场逻辑, 二是让它在
|
||||
# 本服务退出后自我了断, 不留孤儿进程占着 Npcap 适配器和端口。
|
||||
# 3) 用 pythonw.exe + venv: pysoem/pywebview 都装在 tools/ecat\venv 里,
|
||||
# 用系统 python 会 ImportError; 用 python.exe 会弹黑框。
|
||||
ECAT_DIR = os.path.normpath(os.path.join(BASE_DIR, "..", "ecat"))
|
||||
ECAT_ENTRY = os.path.join(ECAT_DIR, "src", "ecat_server.py")
|
||||
ECAT_PYW = os.path.join(ECAT_DIR, "venv", "Scripts", "pythonw.exe")
|
||||
g_ecat_child = None # 由本服务拉起的 ecat 服务子进程(复用已有实例时为 None)
|
||||
g_ecat_lock = threading.Lock() # 串行化自动启动, 避免并发请求拉起多个进程
|
||||
|
||||
|
||||
class _GuiApi:
|
||||
"""暴露给窗口内 JS (window.pywebview.api) 的 GUI 控制接口。"""
|
||||
@@ -141,6 +185,173 @@ def _focus_existing(port):
|
||||
return False
|
||||
|
||||
|
||||
def _ecat_find_port():
|
||||
"""返回正在监听的 ecat 服务端口, 没有则 None。"""
|
||||
ports = ([g_ecat_port] if g_ecat_port else []) + \
|
||||
[p for p in ECAT_PORTS if p != g_ecat_port]
|
||||
for p in ports:
|
||||
if _port_taken(p):
|
||||
return p
|
||||
return None
|
||||
|
||||
|
||||
def _ecat_autostart():
|
||||
"""按需拉起 tools/ecat 的服务进程。返回 (port, log_lines)。
|
||||
|
||||
port 为 None 表示拉不起来(缺 venv / 缺文件 / 起了但没监听)。
|
||||
已有实例时**只复用不重启** —— 详见文件头 ECAT_DIR 附近的注释:
|
||||
ecat_server 独立启动会 _kill_stale_instances(), 把用户开着的窗口关掉。
|
||||
"""
|
||||
global g_ecat_child, g_ecat_port
|
||||
with g_ecat_lock:
|
||||
port = _ecat_find_port()
|
||||
if port: # 已经有人在跑(用户手开的, 或我们上次拉起的)
|
||||
g_ecat_port = port
|
||||
return port, []
|
||||
|
||||
logs = []
|
||||
if not os.path.isfile(ECAT_ENTRY):
|
||||
return None, [f"找不到 EtherCAT 服务入口: {ECAT_ENTRY}"]
|
||||
if not os.path.isfile(ECAT_PYW):
|
||||
return None, [
|
||||
f"找不到 EtherCAT 服务的 venv: {ECAT_PYW}",
|
||||
"请在 tools/ecat 下重建: py -3.12 -m venv venv 后 "
|
||||
"venv\\Scripts\\python.exe -m pip install -r requirements.txt",
|
||||
]
|
||||
|
||||
# 复用已被本进程拉起但已退出的槽位
|
||||
if g_ecat_child is not None and g_ecat_child.poll() is not None:
|
||||
g_ecat_child = None
|
||||
if g_ecat_child is None:
|
||||
try:
|
||||
# --parent-pid: 跳过 ecat 侧的 _kill_stale_instances 清场,
|
||||
# 并让它在本服务退出后自我了断(不留孤儿占 Npcap/端口)。
|
||||
# --no-browser: 我们只要后台服务, 不要它再弹一个浏览器标签。
|
||||
# cwd=src: ecat_server.py 与同目录 py 之间靠相对 import 解析。
|
||||
g_ecat_child = subprocess.Popen(
|
||||
[ECAT_PYW, ECAT_ENTRY, "--no-browser",
|
||||
"--parent-pid", str(os.getpid())],
|
||||
cwd=os.path.join(ECAT_DIR, "src"),
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
stdin=subprocess.DEVNULL,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
env=dict(os.environ, PYTHONUTF8="1"),
|
||||
)
|
||||
logs.append(f"已自动启动 EtherCAT 服务(pid {g_ecat_child.pid})")
|
||||
except OSError as e:
|
||||
return None, [f"启动 EtherCAT 服务失败: {type(e).__name__}: {e}"]
|
||||
|
||||
# 等它把端口监听起来。pysoem 首次导入 + find_adapters 要一两秒,
|
||||
# 给到 20s: 慢机器上 Npcap 枚举适配器偶发很久, 提前放弃会让用户
|
||||
# 看到"启动失败"而其实几秒后就好了。
|
||||
deadline = time.time() + 20
|
||||
while time.time() < deadline:
|
||||
if g_ecat_child.poll() is not None:
|
||||
return None, logs + [
|
||||
f"EtherCAT 服务启动后立刻退出(exit={g_ecat_child.returncode}) —— "
|
||||
"多为 venv 缺 pysoem 或未安装 Npcap; "
|
||||
"可双击 tools/ecat/ecat_web.bat 看具体报错"]
|
||||
port = _ecat_find_port()
|
||||
if port:
|
||||
g_ecat_port = port
|
||||
logs.append(f"EtherCAT 服务已就绪(端口 {port})")
|
||||
return port, logs
|
||||
time.sleep(0.3)
|
||||
return None, logs + ["EtherCAT 服务启动超时(20s 内未监听端口)"]
|
||||
|
||||
|
||||
def _ecat_post(port, path, body, timeout):
|
||||
"""向 ecat_server 转发一次 POST, 返回 (status, body_bytes)。
|
||||
|
||||
**不要改回 urllib/http.client 并给它传 timeout。** 那样每次请求会多花约 10ms:
|
||||
给 socket 设 timeout 会让 Python 把它切成非阻塞模式, 而 Windows 上非阻塞
|
||||
connect 要靠 select 轮询完成, 于是撞上约 10ms 的系统调度粒度。实测
|
||||
(本机回环, /api/motor/state):
|
||||
urllib.urlopen(req, timeout=5) p50 11.0 ms
|
||||
http.client + timeout p50 10.7 ms
|
||||
裸 socket 连接阶段不设 timeout p50 0.58 ms
|
||||
ecat_server 自己只用 0.58ms, 那 10ms 全是这一层白交的过路费 ——
|
||||
电机页 EtherCAT 链路的刷新上限直接被它按在 ~76Hz。
|
||||
|
||||
所以这里手写最小 HTTP/1.0 客户端: 连接阶段**不设** timeout(本机回环连接
|
||||
要么立刻成功要么立刻 ECONNREFUSED, 不会挂住), 连上之后才设读写超时。
|
||||
"""
|
||||
payload = (
|
||||
f"POST {path} HTTP/1.0\r\n"
|
||||
f"Host: 127.0.0.1:{port}\r\n"
|
||||
"Content-Type: application/json\r\n"
|
||||
f"Content-Length: {len(body or b'')}\r\n"
|
||||
"Connection: close\r\n"
|
||||
"\r\n"
|
||||
).encode("ascii") + (body or b"")
|
||||
|
||||
sock = socket.create_connection(("127.0.0.1", port)) # 注意: 不传 timeout
|
||||
try:
|
||||
sock.settimeout(timeout) # 连上之后再设: 只作用于收发, 不影响 connect
|
||||
sock.sendall(payload)
|
||||
chunks = []
|
||||
while True:
|
||||
chunk = sock.recv(65536)
|
||||
if not chunk:
|
||||
break
|
||||
chunks.append(chunk)
|
||||
finally:
|
||||
sock.close()
|
||||
|
||||
raw = b"".join(chunks)
|
||||
head, _, data = raw.partition(b"\r\n\r\n")
|
||||
first = head.split(b"\r\n", 1)[0].split(b" ", 2)
|
||||
if len(first) < 2 or not first[1].isdigit():
|
||||
raise OSError(f"ecat_server 回了无法解析的响应头: {head[:120]!r}")
|
||||
# 404 等业务错误要原样透传: ecat_server 的 404 体里带
|
||||
# "服务在改动之后没重启" 的排查提示, 吞掉它会让人白排查半天。
|
||||
return int(first[1]), data
|
||||
|
||||
|
||||
def _ecat_proxy(path, body, timeout=6.0):
|
||||
"""把 /ecat/api/xxx 转发到 ecat_server 的 /api/xxx。
|
||||
|
||||
返回 (status, body_bytes)。端口在 ECAT_PORTS 里探测并缓存到 g_ecat_port:
|
||||
ecat_server 的 _bind_server 会在 8090..8095 之间漂移(端口被占就挪),
|
||||
写死 8090 会在它挪了之后表现为"页面点了没反应"。
|
||||
|
||||
连不上时返回 503 + 与 ecat_server 同构的 {"ok":false,"log":[...]} 信封,
|
||||
这样前端只有一条错误处理路径 —— 它不需要知道中间还隔着一层代理。
|
||||
"""
|
||||
global g_ecat_port
|
||||
# /ecat/api/motor/state → /api/motor/state (只剥掉 "/ecat" 前缀, 保留 /api)
|
||||
target = "/api/" + path[len(ECAT_PROXY_PREFIX):]
|
||||
ports = ([g_ecat_port] if g_ecat_port else []) + \
|
||||
[p for p in ECAT_PORTS if p != g_ecat_port]
|
||||
last = None
|
||||
for port in ports:
|
||||
try:
|
||||
status, data = _ecat_post(port, target, body, timeout)
|
||||
g_ecat_port = port
|
||||
return status, data
|
||||
except Exception as e: # 连不上/超时 → 试下一个端口
|
||||
last = e
|
||||
if g_ecat_port == port:
|
||||
g_ecat_port = None # 缓存端口失效, 下轮重新全量探测
|
||||
|
||||
# 一个端口都不通 → 服务没在跑, 按需自动拉起后重试一次。
|
||||
# 放在这里而不是每次请求前探测: 正常情况(服务在跑)一次都不会走到,
|
||||
# 不给高频的 /motor/state 轮询增加任何开销。
|
||||
port, start_logs = _ecat_autostart()
|
||||
if port:
|
||||
try:
|
||||
status, data = _ecat_post(port, target, body, timeout)
|
||||
g_ecat_port = port
|
||||
return status, data
|
||||
except Exception as e:
|
||||
last = e
|
||||
|
||||
msg = (f"EtherCAT 服务不可用: {type(last).__name__ if last else 'unknown'}")
|
||||
body = json.dumps({"ok": False, "log": start_logs + [msg]},
|
||||
ensure_ascii=False).encode("utf-8")
|
||||
return 503, body
|
||||
|
||||
|
||||
class Handler(SimpleHTTPRequestHandler):
|
||||
server_version = "Esp32Tools/1.0"
|
||||
|
||||
@@ -199,10 +410,25 @@ class Handler(SimpleHTTPRequestHandler):
|
||||
self._post_json(body)
|
||||
threading.Thread(target=_shutdown_self, daemon=True).start()
|
||||
return
|
||||
if path.startswith(ECAT_PROXY_PREFIX):
|
||||
# 电机页的 EtherCAT 传输: 同源转发到 ecat_server(见 _ecat_proxy 注释)。
|
||||
# 超时给足: /api/op/start 要走 PREOP→SAFEOP→OP, 内部还会自动重试一次,
|
||||
# 实测最坏 ~24s, 用默认 6s 会在正常进 OP 的过程中假失败。
|
||||
try:
|
||||
length = int(self.headers.get("Content-Length") or 0)
|
||||
except ValueError:
|
||||
length = 0
|
||||
raw = self.rfile.read(length) if length > 0 else b"{}"
|
||||
timeout = 30.0 if path.endswith(("/op/start", "/motor/start",
|
||||
"/flash", "/eeprom/write",
|
||||
"/eeprom/read", "/eeprom/verify")) else 6.0
|
||||
status, body = _ecat_proxy(path, raw, timeout)
|
||||
self._post_json(body, status)
|
||||
return
|
||||
self.send_error(404)
|
||||
|
||||
def _post_json(self, body):
|
||||
self.send_response(200)
|
||||
def _post_json(self, body, status=200):
|
||||
self.send_response(status)
|
||||
self.send_header("Content-Type", "application/json; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
|
||||
Reference in New Issue
Block a user