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:
cbb
2026-09-12 20:10:47 +08:00
parent 81096d0425
commit 209a869f96
21 changed files with 2550 additions and 981 deletions
+169 -59
View File
@@ -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: