feat(motor): ESP32 侧适配 EtherCAT native 后端, 清理 Python 时代残留
tools/ecat 后端已从 Python(ecat_server.py, 已删除)切到原生守护进程 build_native/ecat_native.exe。本仓(ESP32 电机控制台)的 EtherCAT 传输经 /ecat/api/* 代理打它, 这次把代理实现与全部相关措辞对齐到 native。 代理与自启(esp32_server.py): - ECAT_ENTRY/ECAT_PYW(src/ecat_server.py + venv/pythonw) → ECAT_NATIVE_EXE (build_native/ecat_native.exe); autostart 直接 Popen 单文件 exe, 不再经 venv/pythonw; 清场/--parent-pid/端口漂移三条契约 native 原样镜像, 注释同步 - 文件头与代理段里 ecat_server.py 措辞订正为 native 传输层注释(js/motor/*): - main/transport/transport-ecat/refresh-rate 里"后端是 ecat_server.py / 用 venv 拉起"等 Python 时代说法统一改为 ecat_native.exe - MOT_SLOTS_PER_PDO/MOTOR_SLOT_COUNT 引用改指 ecat_native.c(同名存在); MOTOR_SCAN_CYCLES native 无此常量, 改按真实来源写 ⌈27/7⌉=4 - main.js 环境错误分类正则 /venv|pysoem|Npcap/ → /Npcap|build_native| 守护进程|全被占/: 前两者 native 永不吐给前端(死分支), 后者对齐 autostart 真实文案, 让"exe 没编译"这类错误不被 140 字截断 README/motor.html 同步; 新增 scripts/browser_check_period.mjs(内核级 PDO 周期热调的浏览器实测, 与既有 browser_check_*.mjs 同族)。 验证: py_compile esp32_server.py 通过; 6 个改动 JS + 新脚本 node --check (ESM)全过; 正则对三类 native 错误命中、对废弃 venv/pysoem 不命中; js/ 与 scripts/ 重扫 ecat_server|venv|pysoem 已空。
This commit is contained in:
+42
-47
@@ -9,10 +9,10 @@
|
||||
GET / → 302 到 /tabs.html (标签外壳, 工具主页)
|
||||
GET /<file> → 本目录静态文件(tabs.html / pages/ / lab/ / css/ / js/)
|
||||
POST /api/shutdown → 结束本服务进程(窗口模式父进程用它通知子进程退出)
|
||||
POST /ecat/api/<路径> → 反向代理到 tools/ecat 的 ecat_server.py 的 /api/<路径>
|
||||
POST /ecat/api/<路径> → 反向代理到 tools/ecat 的 ecat_native.exe 的 /api/<路径>
|
||||
|
||||
为什么要代理 EtherCAT: 电机页(pages/motor.html)支持 WiFi 与 EtherCAT 两种传输,
|
||||
后者的后端 tools/ecat/src/ecat_server.py 只绑 127.0.0.1 且完全没有 CORS
|
||||
后者的后端 tools/ecat 的 EtherCAT 服务只绑 127.0.0.1 且完全没有 CORS
|
||||
(既无 Access-Control-Allow-Origin, 也没有 do_OPTIONS —— 预检直接 501)。
|
||||
我们的 API 是 POST + application/json, 属非简单请求, 浏览器必发预检,
|
||||
因此页面无法直接跨端口调它。改那边要动另一个 git 仓库, 把页面搬过去又会让
|
||||
@@ -26,7 +26,7 @@ WiFi/ECAT 分裂成两份页面, 所以在本服务做同源转发, 两个仓库
|
||||
|
||||
窗口模式为何要分两个进程: 实测 pythonw + pythonnet(pywebview 依赖) 环境下,
|
||||
Python 后台线程会被静默杀死(py-spy 可见 serve 线程凭空消失, 页面随即失联)。
|
||||
把 HTTP 服务放进独立子进程可彻底规避, 见 tools/ecat/ecat_server.py 同款结构。
|
||||
把 HTTP 服务放进独立子进程可彻底规避, 见 tools/ecat 窗口模式同款结构。
|
||||
|
||||
pywebview 窗口没有浏览器刷新按钮/地址栏, 因此本服务器给 HTML 响应注入
|
||||
_reload.html: 右下角 ⟳ 悬浮按钮 + F5/Ctrl+R 键盘拦截(WebView2 默认不响应)。
|
||||
@@ -115,36 +115,37 @@ g_win = None
|
||||
g_job = None # Job Object 句柄: 父进程一死内核连坐杀服务子进程
|
||||
|
||||
# ── EtherCAT 后端反向代理 ──────────────────────────────────────────────
|
||||
# 电机页的 EtherCAT 传输要打 tools/ecat/src/ecat_server.py 的 /api/motor/*,
|
||||
# 电机页的 EtherCAT 传输要打 tools/ecat 的 EtherCAT 服务(native)的 /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 —— 那是硬白名单, 且会让
|
||||
# 1) 改 EtherCAT 后端加 CORS —— 那是另一个仓库, 不该为本页面改;
|
||||
# 2) 把电机页搬进 EtherCAT 后端的静态白名单 —— 且会让
|
||||
# WiFi 版和 ECAT 版分裂成两份页面, 正好违背"所有组件复用"的要求;
|
||||
# 3) 本服务代理 —— 页面永远同源(:8080), 两个仓库都不用动。
|
||||
# 端口自动探测: ecat_server 的 _bind_server 会在 8090..8095 间漂移。
|
||||
# 端口自动探测: EtherCAT 服务(含 native)会在 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。
|
||||
# 电机页切到 EtherCAT 链路时, 不该要求用户先手动起 EtherCAT 后端。
|
||||
# 本服务在第一次代理请求时按需拉起 tools/ecat 的服务进程。
|
||||
#
|
||||
# 三条必须小心的地方(都在 ecat_server.py 里读出来的, 不是猜的):
|
||||
# 1) **必须先探测再拉起**。ecat_server 的 _run_browser 在没有 --parent-pid 时
|
||||
# 会调 _kill_stale_instances(), 那会把用户已经开着的 EtherCAT 窗口
|
||||
# 优雅关掉(还会把从站带回 INIT)。所以已有实例时只复用, 绝不重复拉起。
|
||||
# 阶段 4 切换(2026-09-04): 后端从 Python(ecat_server.py, 已彻底删除含备份)
|
||||
# 换成原生守护进程 build_native\
|
||||
# ecat_native.exe (C/SOEM, 由 build_native.bat 编译)。路由/JSON 契约与 Python
|
||||
# 逐字段对齐, 见 tools/ecat/MIGRATION.md。三条必须小心的地方(native 原样镜像了这套契约):
|
||||
# 1) **必须先探测再拉起**。native 独立启动(无 --parent-pid)会清场: 对
|
||||
# 8090..8095 的旧实例逐个调 /api/shutdown(从站回 INIT)再兜底强杀。
|
||||
# 所以已有实例时只复用, 绝不重复拉起。
|
||||
# 2) 拉起时**必须带 --parent-pid**: 一是跳过上面那个清场逻辑, 二是让它在
|
||||
# 本服务退出后自我了断, 不留孤儿进程占着 Npcap 适配器和端口。
|
||||
# 3) 用 pythonw.exe + venv: pysoem/pywebview 都装在 tools/ecat\venv 里,
|
||||
# 用系统 python 会 ImportError; 用 python.exe 会弹黑框。
|
||||
# 3) 不再需要 venv/pythonw —— 单文件 exe, 无 Python 运行时依赖。
|
||||
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")
|
||||
ECAT_NATIVE_EXE = os.path.join(ECAT_DIR, "build_native", "ecat_native.exe")
|
||||
g_ecat_child = None # 由本服务拉起的 ecat 服务子进程(复用已有实例时为 None)
|
||||
g_ecat_lock = threading.Lock() # 串行化自动启动, 避免并发请求拉起多个进程
|
||||
|
||||
@@ -198,9 +199,9 @@ def _ecat_find_port():
|
||||
def _ecat_autostart():
|
||||
"""按需拉起 tools/ecat 的服务进程。返回 (port, log_lines)。
|
||||
|
||||
port 为 None 表示拉不起来(缺 venv / 缺文件 / 起了但没监听)。
|
||||
port 为 None 表示拉不起来(缺 exe / 起了但没监听)。
|
||||
已有实例时**只复用不重启** —— 详见文件头 ECAT_DIR 附近的注释:
|
||||
ecat_server 独立启动会 _kill_stale_instances(), 把用户开着的窗口关掉。
|
||||
native 独立启动会清场(优雅关掉旧实例), 不能重复拉起。
|
||||
"""
|
||||
global g_ecat_child, g_ecat_port
|
||||
with g_ecat_lock:
|
||||
@@ -210,13 +211,10 @@ def _ecat_autostart():
|
||||
return port, []
|
||||
|
||||
logs = []
|
||||
if not os.path.isfile(ECAT_ENTRY):
|
||||
return None, [f"找不到 EtherCAT 服务入口: {ECAT_ENTRY}"]
|
||||
if not os.path.isfile(ECAT_PYW):
|
||||
if not os.path.isfile(ECAT_NATIVE_EXE):
|
||||
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",
|
||||
f"找不到 EtherCAT 原生守护进程: {ECAT_NATIVE_EXE}",
|
||||
"请先运行 tools\\ecat\\build_native.bat 编译(需 VS2022 + Npcap)。",
|
||||
]
|
||||
|
||||
# 复用已被本进程拉起但已退出的槽位
|
||||
@@ -224,32 +222,29 @@ def _ecat_autostart():
|
||||
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 解析。
|
||||
# --parent-pid: 跳过 native 侧的清场逻辑, 并让它在本服务退出后
|
||||
# 自我了断(不留孤儿占 Npcap/端口)。
|
||||
# cwd=tools/ecat: --web 的相对路径(src/web)按此解析。
|
||||
g_ecat_child = subprocess.Popen(
|
||||
[ECAT_PYW, ECAT_ENTRY, "--no-browser",
|
||||
[ECAT_NATIVE_EXE, "--substr", "I226", "--web", "src/web",
|
||||
"--parent-pid", str(os.getpid())],
|
||||
cwd=os.path.join(ECAT_DIR, "src"),
|
||||
cwd=ECAT_DIR,
|
||||
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})")
|
||||
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
|
||||
# 等它把端口监听起来。native 启动即绑口(ec_init 是懒的, 在 open 路由里),
|
||||
# 给 10s 足够; 老 Python 版要等 pysoem 导入 + Npcap 枚举, 才需要 20s。
|
||||
deadline = time.time() + 10
|
||||
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; "
|
||||
f"EtherCAT 守护进程启动后立刻退出(exit={g_ecat_child.returncode}) —— "
|
||||
"多为 Npcap 未安装或 8090..8095 全被占; "
|
||||
"可双击 tools/ecat/ecat_web.bat 看具体报错"]
|
||||
port = _ecat_find_port()
|
||||
if port:
|
||||
@@ -257,11 +252,11 @@ def _ecat_autostart():
|
||||
logs.append(f"EtherCAT 服务已就绪(端口 {port})")
|
||||
return port, logs
|
||||
time.sleep(0.3)
|
||||
return None, logs + ["EtherCAT 服务启动超时(20s 内未监听端口)"]
|
||||
return None, logs + ["EtherCAT 服务启动超时(10s 内未监听端口)"]
|
||||
|
||||
|
||||
def _ecat_post(port, path, body, timeout):
|
||||
"""向 ecat_server 转发一次 POST, 返回 (status, body_bytes)。
|
||||
"""向 EtherCAT 后端(native)转发一次 POST, 返回 (status, body_bytes)。
|
||||
|
||||
**不要改回 urllib/http.client 并给它传 timeout。** 那样每次请求会多花约 10ms:
|
||||
给 socket 设 timeout 会让 Python 把它切成非阻塞模式, 而 Windows 上非阻塞
|
||||
@@ -270,7 +265,7 @@ def _ecat_post(port, path, body, timeout):
|
||||
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 全是这一层白交的过路费 ——
|
||||
后端自己只用 0.58ms, 那 10ms 全是这一层白交的过路费 ——
|
||||
电机页 EtherCAT 链路的刷新上限直接被它按在 ~76Hz。
|
||||
|
||||
所以这里手写最小 HTTP/1.0 客户端: 连接阶段**不设** timeout(本机回环连接
|
||||
@@ -302,20 +297,20 @@ def _ecat_post(port, path, body, timeout):
|
||||
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 体里带
|
||||
raise OSError(f"EtherCAT 后端回了无法解析的响应头: {head[:120]!r}")
|
||||
# 404 等业务错误要原样透传: 后端的 404 体里带
|
||||
# "服务在改动之后没重启" 的排查提示, 吞掉它会让人白排查半天。
|
||||
return int(first[1]), data
|
||||
|
||||
|
||||
def _ecat_proxy(path, body, timeout=6.0):
|
||||
"""把 /ecat/api/xxx 转发到 ecat_server 的 /api/xxx。
|
||||
"""把 /ecat/api/xxx 转发到 EtherCAT 后端的 /api/xxx。
|
||||
|
||||
返回 (status, body_bytes)。端口在 ECAT_PORTS 里探测并缓存到 g_ecat_port:
|
||||
ecat_server 的 _bind_server 会在 8090..8095 之间漂移(端口被占就挪),
|
||||
EtherCAT 后端会在 8090..8095 之间漂移(端口被占就挪),
|
||||
写死 8090 会在它挪了之后表现为"页面点了没反应"。
|
||||
|
||||
连不上时返回 503 + 与 ecat_server 同构的 {"ok":false,"log":[...]} 信封,
|
||||
连不上时返回 503 + 与后端同构的 {"ok":false,"log":[...]} 信封,
|
||||
这样前端只有一条错误处理路径 —— 它不需要知道中间还隔着一层代理。
|
||||
"""
|
||||
global g_ecat_port
|
||||
@@ -411,7 +406,7 @@ class Handler(SimpleHTTPRequestHandler):
|
||||
threading.Thread(target=_shutdown_self, daemon=True).start()
|
||||
return
|
||||
if path.startswith(ECAT_PROXY_PREFIX):
|
||||
# 电机页的 EtherCAT 传输: 同源转发到 ecat_server(见 _ecat_proxy 注释)。
|
||||
# 电机页的 EtherCAT 传输: 同源转发到 EtherCAT 后端(见 _ecat_proxy 注释)。
|
||||
# 超时给足: /api/op/start 要走 PREOP→SAFEOP→OP, 内部还会自动重试一次,
|
||||
# 实测最坏 ~24s, 用默认 6s 会在正常进 OP 的过程中假失败。
|
||||
try:
|
||||
|
||||
Reference in New Issue
Block a user