feat(motor): 反馈刷新率 Hz 适配(0x0400/0x0401 扩展格式)+ ECAT 密排槽与判活自适应
反馈刷新率(配套电机固件 v0.4.1:0x0400 DLC4/0x0401 DLC2 扩展格式, 选择器 0x0100=STATUS · 0x0101=CONFIG 帧,协议值为周期 ms): - 「节点 ID」页签新增 STATUS/CONFIG 刷新率读取/写入;界面单位一律 Hz (写入按 周期ms=round(1000/Hz) 换算下发,读取反算 Hz 并附原始周期, 取整有损如实展示),0=停发,写入掉电保存;配置值以「读取」回读电机为准 - WiFi 应答回执走 0xC1 per-ID 快照(/can/feedback 全量 entries): GD32 0xC0 ACK 仅 1 字节状态、不携带电机回帧;同 ID 回帧快照只存最新 一条,故一次快照只配一次请求(STATUS/CONFIG 分开读写) - ECAT 回执走批量接收区 takeNodeIdReport,按长度分派 2B 旧格式/4B GET/5B SET ACK - GET_NODE_ID 迁移到扩展选择器 0x0000,兼容旧固件 2B 节点 ID 应答 (SET DLC2 旧格式行为不变) - transport-wifi 新增 fetchFeedbackFull(全量 CAN 反馈快照) EtherCAT/基础设施(密排槽固件 1.6.21 配套): - 快照槽位 5→9 一拍整轮(扫描周期数 6→1);判活阈值随实测 seq 间隔 自适应 clamp(3×EWMA, 2s, 30s),秒级回报档不再被固定 2s 误判离线; STATUS 面板 Hz 明确为网页到达口径(按槽 seq 实测电机回报率的方案已否掉) - esp32_server.py:ecat_native 单实例收紧(后起独占 + taskkill 残留实例, 双实例并存会抢 Npcap 致后起者抓不到从站)+ native stdout 落盘 - flash_gd32.py:刷写后校验「GD32 真的在跑新代码」(版本变化或 uptime 归零), 防"写入≠在跑"假台账 - lab:新增 LAN9252 电源诊断台、PDO 布局页;SPI 测速并入 EtherCAT 接收看板 并更名「SPI/ECAT 通信测速」;移除 continuity_test.html;新增 ECAT 单拍耗时 优化报告;/_local/ 排除入库;测试同步更新(62+58 项全过)
This commit is contained in:
+169
-59
@@ -142,16 +142,17 @@ g_ecat_port = None # 记住上次探到的端口, 避免每次请求都从 809
|
||||
# 换成原生守护进程 build_native\
|
||||
# ecat_native.exe (C/SOEM, 由 build_native.bat 编译)。路由/JSON 契约与 Python
|
||||
# 逐字段对齐, 见 tools/ecat/MIGRATION.md。三条必须小心的地方(native 原样镜像了这套契约):
|
||||
# 1) **必须先探测再拉起**。native 启动时**无条件清场**(后起的独占): 对
|
||||
# 8090..8095 的旧实例逐个调 /api/shutdown(从站回 INIT)再兜底强杀。
|
||||
# 已有实例时仍只复用不重复拉起 —— 清场是给"并发拉起撞上"兜底的,
|
||||
# 不该被用来每次踢掉用户手开的实例。
|
||||
# 1) **单实例、后起独占**(2026-09-12 收紧): 只有本进程拉起且仍存活的子进程才复用;
|
||||
# 起新进程前 taskkill 全部残留 ecat_native.exe。两个实例并存会抢 Npcap
|
||||
# (后起的抓不到从站、HTTP 超时、PDO 空转)。native 启动时也会 kill_stale_instances,
|
||||
# 与本处强杀双重保险。
|
||||
# 2) 拉起时**必须带 --parent-pid**: 让 native 在本服务退出后自我了断,
|
||||
# 不留孤儿进程占着 Npcap 适配器和端口。(与清场无关, 清场总是执行)
|
||||
# 不留孤儿进程占着 Npcap 适配器和端口。
|
||||
# 3) 不再需要 venv/pythonw —— 单文件 exe, 无 Python 运行时依赖。
|
||||
ECAT_DIR = os.path.normpath(os.path.join(BASE_DIR, "..", "ecat"))
|
||||
ECAT_NATIVE_EXE = os.path.join(ECAT_DIR, "build_native", "ecat_native.exe")
|
||||
g_ecat_child = None # 由本服务拉起的 ecat 服务子进程(复用已有实例时为 None)
|
||||
g_ecat_log_fp = None # native stdout 落盘句柄(见 _ecat_autostart 内注释)
|
||||
g_ecat_lock = threading.Lock() # 串行化自动启动, 避免并发请求拉起多个进程
|
||||
|
||||
|
||||
@@ -201,56 +202,128 @@ def _ecat_find_port():
|
||||
return None
|
||||
|
||||
|
||||
def _kill_all_ecat_native():
|
||||
"""强杀所有 ecat_native.exe,返回杀掉的个数。
|
||||
|
||||
两个 native 并存会抢 Npcap 适配器:后起的抓不到从站、HTTP 超时、
|
||||
PDO 空转。启动新实例前必须保证旧的死透。只按进程名杀,不碰 python。
|
||||
"""
|
||||
try:
|
||||
out = subprocess.check_output(
|
||||
["tasklist", "/FI", "IMAGENAME eq ecat_native.exe",
|
||||
"/FO", "CSV", "/NH"],
|
||||
text=True, stderr=subprocess.DEVNULL, timeout=8,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
) or ""
|
||||
except Exception:
|
||||
return 0
|
||||
pids = []
|
||||
for line in (out or "").splitlines():
|
||||
parts = [p.strip().strip('"') for p in line.split(",")]
|
||||
if len(parts) >= 2 and parts[0].lower() == "ecat_native.exe":
|
||||
try:
|
||||
pids.append(int(parts[1]))
|
||||
except ValueError:
|
||||
pass
|
||||
if not pids:
|
||||
return 0
|
||||
for pid in pids:
|
||||
try:
|
||||
subprocess.run(
|
||||
["taskkill", "/F", "/PID", str(pid)],
|
||||
capture_output=True, timeout=5,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
# 等 Npcap 句柄随进程退出由内核回收
|
||||
deadline = time.time() + 3.0
|
||||
while time.time() < deadline:
|
||||
try:
|
||||
again = subprocess.check_output(
|
||||
["tasklist", "/FI", "IMAGENAME eq ecat_native.exe",
|
||||
"/FO", "CSV", "/NH"],
|
||||
text=True, stderr=subprocess.DEVNULL, timeout=4,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
) or ""
|
||||
if "ecat_native.exe" not in (again or "").lower():
|
||||
break
|
||||
except Exception:
|
||||
break
|
||||
time.sleep(0.2)
|
||||
else:
|
||||
time.sleep(0.3)
|
||||
return len(pids)
|
||||
|
||||
|
||||
def _ecat_autostart():
|
||||
"""按需拉起 tools/ecat 的服务进程。返回 (port, log_lines)。
|
||||
|
||||
port 为 None 表示拉不起来(缺 exe / 起了但没监听)。
|
||||
已有实例时**只复用不重启** —— 详见文件头 ECAT_DIR 附近的注释:
|
||||
native 独立启动会清场(优雅关掉旧实例), 不能重复拉起。
|
||||
单实例策略(2026-09-12):
|
||||
- 本进程子进程仍存活 → 只等它听口,**绝不 kill_all**(否则启动中的实例
|
||||
会被自己下一次请求杀掉,形成 8s kill_stale 死循环)。
|
||||
- 子进程已死 / 从未拉起 → 先 taskkill 全部残留 ecat_native,再起唯一实例。
|
||||
port 为 None 表示拉不起来。
|
||||
"""
|
||||
global g_ecat_child, g_ecat_port
|
||||
global g_ecat_child, g_ecat_port, g_ecat_log_fp
|
||||
with g_ecat_lock:
|
||||
port = _ecat_find_port()
|
||||
if port: # 已经有人在跑(用户手开的, 或我们上次拉起的)
|
||||
g_ecat_port = port
|
||||
return port, []
|
||||
|
||||
logs = []
|
||||
|
||||
# ── 我们自己拉起的还活着:只复用/等它,不杀 ──
|
||||
if g_ecat_child is not None and g_ecat_child.poll() is None:
|
||||
port = _ecat_find_port()
|
||||
if port:
|
||||
g_ecat_port = port
|
||||
return port, []
|
||||
# 仍在启动(kill_stale/绑口最多约 10s):等完再判
|
||||
deadline = time.time() + 10
|
||||
while time.time() < deadline:
|
||||
if g_ecat_child.poll() is not None:
|
||||
break
|
||||
port = _ecat_find_port()
|
||||
if port:
|
||||
g_ecat_port = port
|
||||
return port, []
|
||||
time.sleep(0.3)
|
||||
if g_ecat_child.poll() is None:
|
||||
return None, logs + ["EtherCAT 服务仍在启动中(未监听端口),请稍候再试"]
|
||||
g_ecat_child = None
|
||||
|
||||
if g_ecat_child is not None and g_ecat_child.poll() is not None:
|
||||
g_ecat_child = None
|
||||
|
||||
if not os.path.isfile(ECAT_NATIVE_EXE):
|
||||
return None, [
|
||||
f"找不到 EtherCAT 原生守护进程: {ECAT_NATIVE_EXE}",
|
||||
"请先运行 tools\\ecat\\build_native.bat 编译(需 VS2022 + Npcap)。",
|
||||
]
|
||||
|
||||
# 复用已被本进程拉起但已退出的槽位
|
||||
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: 让 native 在本服务退出后自我了断(不留孤儿占
|
||||
# Npcap/端口)。native 侧的清场与它无关, 启动时无条件执行。
|
||||
# --mask 0x10: PDO 自旋线程钉单个 LP4(i7-14700K P核2), 避开 DPC
|
||||
# 集中的核0 与 E 核, 与 ecat_web.bat 同值, 换机需按拓扑改。
|
||||
# 掩码**只能给单比特**: 多比特(如 0x50=LP4|LP6)不是"两个核一起
|
||||
# 自旋", 而是授权调度器在这一个线程上来回迁移, 迁移期间的迟到拍
|
||||
# 由 skip_total 计入、直接压低周期达成率。不钉的话随调度器漂移,
|
||||
# 1ms 档周期抖动明显放大。
|
||||
# cwd=tools/ecat: --web 的相对路径(src/web)按此解析。
|
||||
g_ecat_child = subprocess.Popen(
|
||||
[ECAT_NATIVE_EXE, "--substr", "I210", "--mask", "0x10",
|
||||
"--web", "src/web", "--parent-pid", str(os.getpid())],
|
||||
cwd=ECAT_DIR,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
|
||||
stdin=subprocess.DEVNULL,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
)
|
||||
logs.append(f"已自动启动 EtherCAT 原生守护进程(pid {g_ecat_child.pid})")
|
||||
except OSError as e:
|
||||
return None, [f"启动 EtherCAT 服务失败: {type(e).__name__}: {e}"]
|
||||
# 起新进程前清光旧的:禁止双实例抢网卡
|
||||
killed = _kill_all_ecat_native()
|
||||
if killed:
|
||||
logs.append(f"已清掉 {killed} 个残留 ecat_native(单实例,防抢网卡)")
|
||||
|
||||
# 等它把端口监听起来。native 启动即绑口(ec_init 是懒的, 在 open 路由里),
|
||||
# 给 10s 足够; 老 Python 版要等 pysoem 导入 + Npcap 枚举, 才需要 20s。
|
||||
deadline = time.time() + 10
|
||||
try:
|
||||
# 日志落文件而不是 DEVNULL:native 的 stdout 是崩溃/楔死/自动重拉的
|
||||
# 唯一现场(WEDGE、kill_stale、进 OP 全打这里),DEVNULL 吞掉后只能靠
|
||||
# Windows 事件日志反推(2026-09-12 教训)。追加写,重启服务不清零。
|
||||
if g_ecat_log_fp is None:
|
||||
g_ecat_log_fp = open(
|
||||
os.path.join(ECAT_DIR, "build_native", "native_stdout.log"),
|
||||
"ab", buffering=0)
|
||||
g_ecat_child = subprocess.Popen(
|
||||
[ECAT_NATIVE_EXE, "--substr", "I210", "--mask", "0x50",
|
||||
"--web", "src/web", "--parent-pid", str(os.getpid())],
|
||||
cwd=ECAT_DIR,
|
||||
stdout=g_ecat_log_fp, stderr=subprocess.STDOUT,
|
||||
stdin=subprocess.DEVNULL,
|
||||
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
|
||||
)
|
||||
logs.append(f"已自动启动 EtherCAT 原生守护进程(pid {g_ecat_child.pid})")
|
||||
except OSError as e:
|
||||
return None, logs + [f"启动 EtherCAT 服务失败: {type(e).__name__}: {e}"]
|
||||
|
||||
deadline = time.time() + 12
|
||||
while time.time() < deadline:
|
||||
if g_ecat_child.poll() is not None:
|
||||
return None, logs + [
|
||||
@@ -263,10 +336,10 @@ def _ecat_autostart():
|
||||
logs.append(f"EtherCAT 服务已就绪(端口 {port})")
|
||||
return port, logs
|
||||
time.sleep(0.3)
|
||||
return None, logs + ["EtherCAT 服务启动超时(10s 内未监听端口)"]
|
||||
return None, logs + ["EtherCAT 服务启动超时(12s 内未监听端口)"]
|
||||
|
||||
|
||||
def _ecat_post(port, path, body, timeout):
|
||||
def _ecat_post(port, path, body, timeout, connect_timeout=None):
|
||||
"""向 EtherCAT 后端(native)转发一次 POST, 返回 (status, body_bytes)。
|
||||
|
||||
**不要改回 urllib/http.client 并给它传 timeout。** 那样每次请求会多花约 10ms:
|
||||
@@ -281,6 +354,11 @@ def _ecat_post(port, path, body, timeout):
|
||||
|
||||
所以这里手写最小 HTTP/1.0 客户端: 连接阶段**不设** timeout(本机回环连接
|
||||
要么立刻成功要么立刻 ECONNREFUSED, 不会挂住), 连上之后才设读写超时。
|
||||
|
||||
connect_timeout: 只给冷路径(逐个试漂移端口)用的连接超时。**热路径(缓存
|
||||
端口)必须保持 None(阻塞连接)**——本机对死端口无论阻塞还是 settimeout 都
|
||||
不是秒拒(阻塞 ~2s, settimeout(0.3) 吃满 0.3s 才回 WOULDBLOCK), 但活端口
|
||||
的阻塞 connect 只要 ~0.6ms, 两害相权取其轻。
|
||||
"""
|
||||
payload = (
|
||||
f"POST {path} HTTP/1.0\r\n"
|
||||
@@ -291,9 +369,9 @@ def _ecat_post(port, path, body, timeout):
|
||||
"\r\n"
|
||||
).encode("ascii") + (body or b"")
|
||||
|
||||
sock = socket.create_connection(("127.0.0.1", port)) # 注意: 不传 timeout
|
||||
sock = socket.create_connection(("127.0.0.1", port), timeout=connect_timeout)
|
||||
try:
|
||||
sock.settimeout(timeout) # 连上之后再设: 只作用于收发, 不影响 connect
|
||||
sock.settimeout(timeout) # 连上之后再设: 只作用于收发, 不影响已完成的 connect
|
||||
sock.sendall(payload)
|
||||
chunks = []
|
||||
while True:
|
||||
@@ -323,16 +401,28 @@ def _ecat_proxy(path, body, timeout=6.0):
|
||||
|
||||
连不上时返回 503 + 与后端同构的 {"ok":false,"log":[...]} 信封,
|
||||
这样前端只有一条错误处理路径 —— 它不需要知道中间还隔着一层代理。
|
||||
|
||||
2026-09-12: 先用 _port_taken 快探,全灭则立刻 _ecat_autostart;
|
||||
不再对 6 个死端口各等 timeout 秒(那样要 30s+ 才会拉起服务)。
|
||||
"""
|
||||
global g_ecat_port
|
||||
# /ecat/api/motor/state → /api/motor/state (只剥掉 "/ecat" 前缀, 保留 /api)
|
||||
target = "/api/" + path[len(ECAT_PROXY_PREFIX):]
|
||||
# 端口尝试顺序: 先缓存口, 失败再试其余漂移口(带短连接超时), 全灭才拉起。
|
||||
# **健康路径上绝不能对死端口做预探测** —— 本机 Windows 对回环死端口的
|
||||
# connect 不是秒拒(阻塞 ~2s, settimeout(0.3) 吃满 0.3s 回 WOULDBLOCK),
|
||||
# 2026-09-12 实测每请求预探 5 个死端口 = 恒定 1.5s, 电机页"本页请求耗时
|
||||
# 1517ms"、刷新被按到 0.66Hz 皆此。缓存口命中的代价只有 ~0.6ms。
|
||||
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)
|
||||
# 缓存口连不上大概率是后端挪了/死了, 阻塞 connect ~2s 可接受;
|
||||
# 其余候选口用 0.3s 连接超时, 避免逐口 2s(全灭时 12s+)。
|
||||
status, data = _ecat_post(
|
||||
port, target, body, timeout,
|
||||
connect_timeout=None if port == g_ecat_port else 0.3)
|
||||
g_ecat_port = port
|
||||
return status, data
|
||||
except Exception as e: # 连不上/超时 → 试下一个端口
|
||||
@@ -340,10 +430,13 @@ def _ecat_proxy(path, body, timeout=6.0):
|
||||
if g_ecat_port == port:
|
||||
g_ecat_port = None # 缓存端口失效, 下轮重新全量探测
|
||||
|
||||
# 一个端口都不通 → 服务没在跑, 按需自动拉起后重试一次。
|
||||
# 放在这里而不是每次请求前探测: 正常情况(服务在跑)一次都不会走到,
|
||||
# 不给高频的 /motor/state 轮询增加任何开销。
|
||||
port, start_logs = _ecat_autostart()
|
||||
# 无存活端口或全失败 → 单实例拉起后重试一次
|
||||
try:
|
||||
port, start_logs = _ecat_autostart()
|
||||
except Exception as e:
|
||||
last = e
|
||||
port = None
|
||||
start_logs = [f"autostart 异常: {type(e).__name__}: {e}"]
|
||||
if port:
|
||||
try:
|
||||
status, data = _ecat_post(port, target, body, timeout)
|
||||
@@ -522,6 +615,10 @@ class Handler(SimpleHTTPRequestHandler):
|
||||
self.send_response(200)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
# WebView2 无 Cache-Control 时按启发式缓存 HTML,而注入刷新只是
|
||||
# load_url(同 URL) 不回源——改了页面窗口里永远还是旧版(实测)。
|
||||
# no-cache = 可缓存但每次必须 revalidate,配合 Last-Modified 变更即回源。
|
||||
self.send_header("Cache-Control", "no-cache")
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
else:
|
||||
@@ -552,8 +649,14 @@ class Handler(SimpleHTTPRequestHandler):
|
||||
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)
|
||||
_note_ecat_loop(path, status, body)
|
||||
try:
|
||||
status, body = _ecat_proxy(path, raw, timeout)
|
||||
_note_ecat_loop(path, status, body)
|
||||
except Exception as e:
|
||||
# 窗口模式 stderr 是父进程 PIPE,写满会冻死服务 —— 不要 traceback
|
||||
status, body = 503, json.dumps(
|
||||
{"ok": False, "log": [f"代理异常: {type(e).__name__}: {e}"]},
|
||||
ensure_ascii=False).encode("utf-8")
|
||||
self._post_json(body, status)
|
||||
return
|
||||
self.send_error(404)
|
||||
@@ -900,7 +1003,10 @@ def _run_window(webview, args):
|
||||
"--parent-pid", str(os.getpid())], "ESP32 调试控制台启动失败")
|
||||
g_child = child
|
||||
g_port = port
|
||||
url = f"http://127.0.0.1:{port}/tabs.html"
|
||||
# 启动 URL 带启动时间戳: 每次开窗必然缓存 miss。老缓存条目没有 no-cache
|
||||
# 指令时 WebView2 仍可能直接复用,时间戳强制绕开——配合 HTML 响应的
|
||||
# Cache-Control: no-cache,此后改页面窗口刷新即可见。
|
||||
url = f"http://127.0.0.1:{port}/tabs.html?v={int(time.time())}"
|
||||
for _ in range(50): # 等 HTTP 就绪(最多 ~10s)
|
||||
try:
|
||||
urllib.request.urlopen(url, timeout=1)
|
||||
@@ -911,6 +1017,10 @@ def _run_window(webview, args):
|
||||
os._exit(1)
|
||||
time.sleep(0.2)
|
||||
|
||||
# 保留 GPU 加速(页面依赖)。曾默认 --disable-gpu,用户要求恢复。
|
||||
# 白屏现象:首帧可见后立刻全白,更像 renderer/GPU 在 loaded 后崩溃,
|
||||
# 或 watchdog 误 load_url 二次导航 —— 此处不再自动二次 load。
|
||||
|
||||
def _monitor():
|
||||
while True:
|
||||
if focus_evt.is_set():
|
||||
@@ -923,17 +1033,17 @@ def _run_window(webview, args):
|
||||
threading.Thread(target=_monitor, daemon=True).start()
|
||||
g_win = webview.create_window("ESP32 调试控制台 — DCU", url,
|
||||
width=1600, height=1000, min_size=(1100, 720),
|
||||
js_api=_GuiApi(), hidden=True, text_select=True)
|
||||
js_api=_GuiApi(), hidden=False, text_select=True)
|
||||
print(f"桌面窗口模式: {url} (关窗即退出)")
|
||||
|
||||
def _show_watchdog():
|
||||
# 页面加载后由 JS 调 page_loaded → show; 若 20s 仍未显示则强制 show
|
||||
deadline = time.time() + 20
|
||||
# 只负责前台,不再二次 load_url(二次导航会造成「闪一下再白」)
|
||||
deadline = time.time() + 15
|
||||
while time.time() < deadline:
|
||||
if g_win.events.loaded.is_set():
|
||||
time.sleep(1)
|
||||
time.sleep(0.3)
|
||||
break
|
||||
time.sleep(0.5)
|
||||
time.sleep(0.4)
|
||||
try:
|
||||
g_win.show()
|
||||
except Exception:
|
||||
|
||||
Reference in New Issue
Block a user