feat(motor): 支持 WiFi 与 EtherCAT 双链路传输

- 抽象电机传输层,WiFi 与 EtherCAT 实现分离,解码、状态面板、波形和命令序列共用
- 新增 EtherCAT 后端反向代理与自动拉起,解决跨端口 CORS 与预检失败问题
- 刷新速度按链路独立存储,支持 EtherCAT 低延迟读取与 WiFi 稳定轮询
- 更新电机页链路选择、不支持页签置灰样式、README 说明及相关测试脚本
This commit is contained in:
cbb
2026-09-04 12:18:06 +08:00
parent 31b90f68b3
commit 2d1743ca4f
15 changed files with 2889 additions and 211 deletions
+229 -3
View File
@@ -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()