feat(web): 桌面原生桥 + 配置落盘 + 无设备静默态;排除调试脚本

变更点:
1. esp32_server.py
   - /api/motor/ui-state、/api/lab/ui-state:配置原子落盘 data/*.json(WebView2 清存储仍在)
   - _GuiApi.ecat_api/bridge_info:桌面壳经 GUI 进程裸 socket 直连 ecat_native,不经浏览器 HTTP
2. js/common/desktop-bridge.js(新)
   - 探测 pywebview.api(含 iframe top/parent),transport-ecat 优先走原生桥
3. connection.js + responsive.css + components.css
   - setQuietMode:离线且无传输时 html[data-dcu-calm=1] 掐常驻动画
   - 进度条斜纹仅 .live 挂载,空闲不再按刷新率空转重绘
4. device-link.js
   - 先查 native/health,已健康跳过 motor/start,避免 loop_stop 重进 OP 假超时
   - 周期强制 0.25ms + DC SYNC0 对齐;轮询自愈被配错的 DC/周期
5. motor/main.js + transport-ecat.js
   - 配置双写 localStorage + 磁盘 ui-state;pagehide/sendBeacon 刷盘;导入导出
   - 探测代际 probeGen;ECAT 失败阈值 12;跟踪 bootActive/slaveAl/slaveOp
6. lab/spi_test.html、ota/boot_ecat.js、can_monitor 等跟随桥/静默态
7. .gitignore
   - 排除 scripts/_* 与 anal/collect/measure/trace 等调试脚本、lab 产物、data/ 运行态
This commit is contained in:
cbb
2026-09-20 22:58:00 +08:00
parent e91d53e0fc
commit 383535bd1d
23 changed files with 2060 additions and 283 deletions
+39
View File
@@ -30,6 +30,45 @@ scripts/selftest_*.py
!scripts/probe_ecat_slot_atomicity.py
!scripts/probe_ecat_vs_wifi.py
# 电机页配置落盘(本机 UI 状态,每人不同;页面服务自动读写)
/data/motor_ui_state.json
/data/motor_ui_state.json.tmp
# ── 调试/临时脚本与本地采样产物(不入库)──
# _ 前缀:现场排障探针、预览截图、一次性驱动
scripts/_*
# 一次性分析/采样/测量/追踪脚本
scripts/anal_*.py
scripts/collect_*.py
scripts/capture_*.py
scripts/measure_*.py
scripts/e2e_*.py
scripts/trace_*.py
scripts/flash_gd32_ecat.py
scripts/recover_ecat_after_reset.py
scripts/test_ecat_version_*.py
scripts/test_gd32_restart_version.py
scripts/test_motor_ui_state.*
scripts/test_ver_*.py
# lab 实验中间产物(图/表/hex/探针输出;已入库的 lab/*.html 保持跟踪)
/lab/cpu_probe/
/lab/skip_meter/
/lab/_flash_*.py
/lab/*.json
/lab/*.svg
/lab/*.hex
/lab/*probe*.txt
/lab/*summary*.json
/lab/*_report.json
/lab/*_hist.json
# 运行时页面状态、桥接采样、本机 dump
/data/
*.jsonl
/logs*.json
/st*.json
# 编辑器 / 系统
.vscode/
.idea/
+269
View File
@@ -0,0 +1,269 @@
<!DOCTYPE html>
<html lang="zh-CN"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>GD32 刷写流程对比:ESP32 中继(老)× EtherCAT 直灌(新)</title>
<style>
:root{--fg:#111827;--mut:#6b7280;--bd:#e5e7eb;--bg:#f8fafc;--acc:#1d4ed8;--warn:#b45309;--bad:#b91c1c;--ok:#15803d}
*{box-sizing:border-box}
body{margin:0;background:#fff;color:var(--fg);font:15.5px/1.72 -apple-system,"Segoe UI","Microsoft YaHei",system-ui,sans-serif}
main{max-width:1180px;margin:0 auto;padding:34px 28px 90px}
h1{font-size:28px;line-height:1.3;margin:0 0 6px;letter-spacing:-.2px}
h2{font-size:19px;margin:34px 0 11px;padding-bottom:7px;border-bottom:2px solid var(--bd)}
h3{font-size:15.5px;margin:24px 0 7px;color:#374151}
.meta{color:var(--mut);font-size:13.5px;margin-bottom:22px}
.card{background:var(--bg);border:1px solid var(--bd);border-left:4px solid var(--acc);border-radius:8px;padding:14px 19px;margin:16px 0}
.card.warn{border-left-color:var(--warn);background:#fffbeb}
.card.bad{border-left-color:var(--bad);background:#fef2f2}
.card.good{border-left-color:var(--ok);background:#f0fdf4}
.card h3{margin:0 0 7px;font-size:15px;color:inherit}
table{border-collapse:collapse;width:100%;font-size:13px;margin:13px 0}
th,td{border:1px solid var(--bd);padding:6px 9px;text-align:left;vertical-align:top}
th{background:#f1f5f9;font-weight:600;text-align:center}
td.n,th.n{text-align:right;font-variant-numeric:tabular-nums}
td.c{text-align:center}
tr.hi td{background:#ecfdf5}
tr.lo td{background:#fef2f2}
code{background:#f1f5f9;padding:1.5px 5px;border-radius:4px;font-size:12.5px;font-family:ui-monospace,"Cascadia Mono",Consolas,monospace}
.ev{color:var(--mut);font-size:11.5px;font-family:ui-monospace,Consolas,monospace}
ul{padding-left:21px}
.kpi{display:flex;gap:13px;flex-wrap:wrap;margin:20px 0 6px}
.kpi div{flex:1 1 172px;border:1px solid var(--bd);border-radius:8px;padding:12px 14px;background:#fff}
.kpi b{display:block;font-size:22px;line-height:1.25;color:var(--acc)}
.kpi span{font-size:12px;color:var(--mut)}
.kpi div.warn b{color:var(--warn)}
.tabs{position:sticky;top:0;z-index:9;background:#fff;padding:14px 0 0;margin:22px 0 4px;border-bottom:2px solid var(--bd);display:flex;gap:8px;flex-wrap:wrap}
.tab{appearance:none;border:1px solid var(--bd);border-bottom:none;background:#f1f5f9;color:#374151;padding:9px 17px;border-radius:8px 8px 0 0;font-size:14px;cursor:pointer;font-family:inherit;position:relative;top:2px}
.tab:hover{background:#e8eef6}
.tab.on{background:#fff;color:var(--acc);font-weight:600;box-shadow:0 2px 0 #fff}
.tab .cnt{font-size:11px;color:var(--mut);font-weight:400}
.page{display:none}.page.on{display:block}
.chain{display:flex;align-items:stretch;flex-wrap:wrap;margin:16px 0 6px}
.node{flex:1 1 128px;min-width:124px;border:1px solid var(--bd);border-radius:8px;background:#fff;padding:9px 11px;font-size:12.5px;line-height:1.5}
.node b{display:block;font-size:13px;margin-bottom:2px}
.node .t{color:var(--mut);font-size:11.5px}
.node.hi{border-color:var(--acc);background:#eff6ff}
.node.drop{border-color:#fca5a5;background:#fef2f2}
.arrow{flex:0 0 24px;display:flex;align-items:center;justify-content:center;color:#9aa3af;font-size:17px}
.byte{display:flex;gap:2px;flex-wrap:wrap;margin:9px 0}
.byte div{border:1px solid var(--bd);border-radius:4px;padding:5px 7px;font-size:11.5px;font-family:ui-monospace,Consolas,monospace;background:#fff;text-align:center;line-height:1.45}
.byte div small{display:block;color:var(--mut);font-size:10.5px;font-family:inherit}
.byte div.key{background:#eff6ff;border-color:#bfdbfe}
.byte div.meta2{background:#fffbeb;border-color:#fde68a}
.legend{font-size:12.5px;color:var(--mut);margin:6px 0 0}
.foot{margin-top:44px;padding-top:15px;border-top:1px solid var(--bd);font-size:12.5px;color:var(--mut)}
</style></head><body><main>
<h1>GD32 刷写流程对比:ESP32 中继(老)× EtherCAT 直灌(新)</h1>
<div class="meta">2026-09-13 · BOOT <code>v1.1.0.7</code>(<code>ER_ROM0 64,552 / 73,728 B</code>)· 主站 <code>ecat_native.exe</code> 22:18 构建 · 每条数字带 <span class="ev">文件:行号</span> · 镜像基准 = <code>DCU_APP</code> Debug <code>.hex</code> 展平后 <b>124,992 B</b>(本机实测)</div>
<div class="kpi">
<div><b>2 604 → 527</b><span>同张镜像的链路往返次数(结构硬数字)</span></div>
<div><b>48 → 241 B</b><span>每帧净落 flash 的字节数</span></div>
<div><b>252 KB → 无</b><span>中间存储:ESP32 缓存分区整个消失</span></div>
<div class="warn"><b>0 次</b><span>新流程的上板实测次数 —— 一条都没跑过</span></div>
</div>
<div class="card"><h3>一句话结论</h3>
新流程<b>不是"更快的刷法",是"少一层先决条件的刷法"</b>。省下的 5 倍往返是副产品,真正的收益是:出厂首刷不再要求板上先有一颗刷好并配好网的 ESP32。两条路并存互为备份,而且刻意复用了同一个 flash 写入者、同一个提交顺序、同一条掉电不变式 —— 新流程没有引入第二套"刷坏了怎么回来"的语义。</div>
<nav class="tabs">
<button class="tab on" data-p="cmp">对比</button>
<button class="tab" data-p="old">老流程 <span class="cnt">WiFi → ESP32 → SPI</span></button>
<button class="tab" data-p="new">新流程 <span class="cnt">EtherCAT CoE SDO</span></button>
</nav>
<section class="page on" id="p-cmp">
<h2>逐维度对照</h2>
<table>
<tr><th style="width:126px">维度</th><th>老:WiFi → ESP32 中继 → SPI</th><th>新:PC 主站 → EtherCAT → SDO</th></tr>
<tr class="hi"><td><b>先决条件</b></td><td>板上必须已有 ① 刷好固件的 ESP32 ② 配好网的 ESP32 ③ 上位机知道设备 IP。<span class="ev">esp32_server.py:830</span></td><td>总线上有电、从站在 PREOP 即可。<b>ESP32 可以完全没刷过。</b></td></tr>
<tr><td><b>参与部件</b></td><td>浏览器 → 本机 2625(只发静态页)→ ESP32:80 → GD32。<span class="ev">esp32_server.py:1-7</span></td><td>浏览器 → 同一个 <code>ecat_native.exe</code>(页面也它发的)→ Npcap/I210 → GD32。<b>ESP32 不在链路里。</b></td></tr>
<tr><td><b>镜像中间存储</b></td><td>ESP32 flash 的 <code>gd32_ota</code> 分区 <b>252 KB</b>@0x11000,两段式:先整份存下算 SHA,<code>/ota/relay</code> 才起中继。<span class="ev">partitions.csv:7-8;http_handlers.cpp:515;ota_relay.cpp:1128</span></td><td>PC 内存 216 KB 静态缓冲 <code>BOOT_DL_IMG_MAX 0x36000</code>。不落任何中间设备。</td></tr>
<tr><td><b>浏览器→服务端请求</b></td><td>1 次整文件 POST(<code>/ota/upload</code>)+ 1 次 <code>/ota/relay</code> + 轮询。<b>无断点续传</b>:每次从偏移 0 重写。<span class="ev">upload-esp32.js:238-247;http_handlers.cpp:440</span></td><td>6 次 push(每次 24,576 B → hex 49,152 字符,卡在 native 的 65,535 B body 上限内)+ 1 次 run。<span class="ev">已冒烟:received 24576→49152→73728 精确累加</span></td></tr>
<tr class="hi"><td><b>每帧净落 flash</b></td><td>线上一帧 = v2 头 16 B + DATA 64 B + CRC 4 B;64 B 里只有 <b>48 B</b> 是固件。<span class="ev">config.h:140-146;ota_relay.cpp:1135-1151</span></td><td>4 KB 块 = <b>17 个邮箱帧</b>(1 initiate 带 240 B + 16 段×247 B)⇒ 每帧 <b>241 B</b>,是老的 5.0 倍。<span class="ev">sdoserv.c:210,744</span></td></tr>
<tr class="hi"><td><b>链路往返次数</b></td><td>124,992 / 48 = <b>2 604 包</b>,每包一次「发 → 等 ACK(50 ms 上限,最多重发 5 次)」。<span class="ev">ota_relay.cpp:1181,1185</span></td><td>124,992 / 4096 = <b>31 次 SDO 传输</b> = 527 帧,外加总长 / begin / commit 三个控制 SDO。</td></tr>
<tr><td><b>鉴权</b></td><td>AES-128 三步双向握手,会话密钥作为前缀喂进每包 CRC32(防重放/篡改)。<span class="ev">OTA.cpp:976-1246,1414-1442</span></td><td><b>出厂形态零鉴权。</b>唯一裁决点 <code>ecatArmed()</code> = 配置页令牌 <b>或</b> A 区不可启动;commit 成功即收回令牌,用完即弃。<span class="ev">OTA.cpp:792-812,905-915</span></td></tr>
<tr><td><b>主站复判身份?</b></td><td>不存在该问题 —— ESP32 是唯一持有 AES 密钥的对端。</td><td><b>不复判。</b>读 <code>0x1008</code> 只当日志读数。APP 的对象字典里没有 <code>0x2500/0x2501</code>(实测只有 0x1600/0x1A00/0x1C12/0x1C13/0x2000/0x6000/0x7000),写上去只会 SDO abort,擦不到一个字节。</td></tr>
<tr class="hi"><td><b>掉电不变式</b></td><td colspan="2"><b>完全相同,且是刻意复用的。</b>擦除前先把 A 区三面标志(CHECK/AVAL/EN)全标 <code>NOT_AVAL</code> ⇒ 任何时刻掉电都表现为「AVAL 未置 = 不可启动 = 留守 BOOT 可重灌」。<span class="ev">OTA.cpp:635-637;config.h:37-43;main.cpp:229-402</span></td></tr>
<tr><td><b>提交顺序</b></td><td>向量表校验 → <code>calculateAppCrc</code>+<code>writeAppCrc</code> → CHECK → AVAL → EN;跳转延后到 <code>process()</code> 排空 <code>pending_app_jump</code>。<span class="ev">OTA.cpp:372-410</span></td><td><b>逐字对齐同一条</b>,只在 CRC 之后多插一步收回令牌。CRC 必须先于 AVAL —— <code>verifyAppCrc()</code> 对擦除态 0xFFFFFFFF 返回 true,真门控是三面标志。<span class="ev">OTA.cpp:900-915</span></td></tr>
<tr><td><b>对在跑业务的影响</b></td><td>SPI 中继是独立链路,不占 EtherCAT 环;但会话期间 BOOT 会丢弃 CAN OTA 帧。<span class="ev">OTA.cpp:303-309;Can.cpp:174-179</span></td><td><code>run</code> 全程持 <code>g_bus_lock</code> + <code>loop_stop()</code> ⇒ PDO 循环冻结数秒。但目标本来就得停在 BOOT(进不了 SAFEOP/OP),实际没有循环可冻。</td></tr>
<tr><td><b>可观测性</b></td><td>进度靠轮询 <code>/system/status</code> 的 <code>relay_seq/relay_total_packets</code>;每 32 包一次健康检查。<span class="ev">upload-gd32.js:58,194;app_config.h:86</span></td><td>从站 <code>0x2500:03</code> 实时报已落盘字节、<code>:04</code> 报 arm/会话位;主站 <code>probe</code> 一次读回,进度条两侧各一个独立读数。</td></tr>
<tr><td><b>刷 BOOT 自己</b></td><td colspan="2"><b>两条都不行。</b>BOOT 跑在 <code>0x08000000</code>,改不了自己正在执行的 flash ⇒ 永远只能 SWD/JTAG。新流程没改变这一点。</td></tr>
<tr class="lo"><td><b>刷"正在跑 APP 的机器"</b></td><td>可以 —— 这正是老流程的主用途(APP 收 OTA 请求、复位进 BOOT 再灌)。<span class="ev">main.cpp:249-272</span></td><td><b>还不行。</b>A 区可启动 ⇒ 令牌是硬门,而置令牌的入口没接(<code>ecatArmSet</code> 目前只有 commit 里那个"收回"调用者)。要么 BOOT 加密会话加命令码(要花余下的 9,176 B),要么 APP 侧复位进 BOOT 前自己写 word8。</td></tr>
<tr><td><b>BOOT flash 代价</b></td><td>基线 36,340 B</td><td>+28,212 B ⇒ 64,552 B,<b>余 9,176 B(用 87.6%)</b>;RAM 32,392 B(堆为分段下载放大到 16 KB,栈仍 1 KB)。<span class="ev">GD32E50X_BOOT.axf.map</span></td></tr>
<tr><td><b>成熟度</b></td><td>产线在用;有 CLI 与刷写台账 —— 只有 <code>ESP32/scripts/flash_gd32.py</code> 写 <code>lab/_last_gd32_sha.txt</code>,且要拿到"板上版本变了 / uptime 归零"的证据才写。<span class="ev">scripts/flash_gd32.py:36,132-148</span></td><td><b>代码接通、零上板。</b>主站软件路径已冒烟(路由分发 / 缓冲累加 / 边界值全过),但 ESC 起板、<code>0x1008</code> 可读、SDO 字节序三条只有源码级推导。</td></tr>
</table>
<div class="card warn"><h3>关于"快了多少" —— 不能说</h3>
老流程 ESP32 侧注释有实测:<b>正常 ~2 ms/包</b>、"777 包正常仅 ~1.6 s"。<span class="ev">ota_relay.cpp:356-368</span> 按这个常数换算,124,992 B = 2 604 包 ≈ <b>5.2 s</b> 纯传输。<br>
新流程<b>没有任何一次真机计时</b>,这里填不出可比的数字 —— 5 倍往返差是代码结构决定的硬事实,端到端耗时不是。第一次上板要记的就是三件事:begin 实际阻塞多久、单块实际耗时、整轮总耗时。</div>
<div class="card good"><h3>为什么值得两条都留</h3>
<ul>
<li><b>覆盖场景不重叠</b>:出厂首刷只有新流程能做(那时还没有 ESP32 固件);刷在跑的机器目前只有老流程能做(有 AES 通道)。</li>
<li><b>故障域独立</b>:WiFi 配不上 / ESP32 没刷 / 网卡拉不起 / 从站没进 PREOP —— 任意一边坏,另一边还是条路。</li>
<li><b>共用同一个落盘点</b>:两条最终都调 <code>eraseApplicationArea / writeApplicationData / validateApplication / OTA_SET</code>;BOOT 里始终只有一个 flash writer,<code>ecatBegin</code> 在 SPI/CAN 会话活跃时直接拒绝并发。</li>
</ul></div>
<h2>新流程的未证清单</h2>
<table>
<tr><th style="width:40px">#</th><th>待证</th><th>判据(一条就够)</th></tr>
<tr><td class="c">1</td><td>BOOT 侧 ESC 起不起得来(EXMC/HBI/RST#/IRQ 这套移植层从未在 BOOT 跑过)</td><td>串口出 <code>[BOOT][ESC] PDI ok DL=… 邮箱可用</code>;否则 <code>FAIL PDI 未就绪 DL=… EEP=…</code></td></tr>
<tr><td class="c">2</td><td>主站认不认出它在 BOOT</td><td><code>probe</code> 回 <code>name="GD32E508 DCU BOOT"</code> 且 <code>armed=1</code></td></tr>
<tr><td class="c">3</td><td>SDO 分段载荷字节序(源码推得原序,未实测)</td><td>用现成 <code>/api/sdo</code> 往 <code>0x2501</code> 写 <code>00 01 02 … 0F</code>,看落 flash 后回读是否逐字节同序;不对就只在 <code>ObjFoeDlWrite</code> 一处加转换</td></tr>
<tr><td class="c">4</td><td>begin 的整区预擦到底阻塞多久、8 s 超时够不够</td><td>串口时间戳与主站 <code>ec_SDOwrite</code> 往返计时对表</td></tr>
<tr><td class="c">5</td><td>刷完 APP 真能起来</td><td>重新连接后 <code>probe</code> 的设备名变回 <code>"GD32E508 DCU"</code> —— 从另一次启动读的,不自证</td></tr>
</table>
<div class="card bad"><h3>顺带查到的一个既有缺陷(老流程同样有)</h3>
<code>writeApplicationData()</code> 的循环条件是 <code>i + 4 &lt;= size</code> ⇒ <b>不足 4 字节的尾巴被静默丢弃</b>,注释里写的"用 0xFF 补齐一个 word"代码并没有做。<span class="ev">OTA.cpp:762-782</span><br>
老流程每包固定 48 B(4 对齐)所以碰不到,只有<b>非 4 对齐的镜像总长</b>会在最后一包丢 1~3 字节;新流程在主站侧补 0xFF 到 4 对齐并按补齐长度声明总量,绕开了这个坑,但<b>没有去改已在产线跑的 SPI 路径</b>。要修该单独一次改动、单独一次回归。</div>
</section>
<section class="page" id="p-old">
<h2>老流程:浏览器 → ESP32 → SPI → GD32 BOOT</h2>
<div class="chain">
<div class="node"><b>浏览器</b><span class="t">算 SHA-256<br>整文件一次 POST</span></div><div class="arrow">→</div>
<div class="node"><b>本机 2625</b><span class="t">只发静态页<br>不碰硬件</span></div><div class="arrow">→</div>
<div class="node"><b>ESP32 :80</b><span class="t">/ota/upload<br>每 4 KB 写分区</span></div><div class="arrow">→</div>
<div class="node drop"><b>ESP32 flash</b><span class="t">gd32_ota 252 KB<br>NVS 存元数据</span></div><div class="arrow">→</div>
<div class="node"><b>/ota/relay</b><span class="t">AES 三步握手<br>逐包读回转发</span></div><div class="arrow">→</div>
<div class="node hi"><b>GD32 BOOT</b><span class="t">SPI ISR 入队<br>主循环写 flash</span></div>
</div>
<p class="legend">红框 = 新流程里整个消失的一跳。</p>
<h3>关键常量</h3>
<table>
<tr><th>项</th><th class="n">值</th><th>出处</th></tr>
<tr><td>每帧总长 / 其中固件数据</td><td class="n">64 B / <b>48 B</b></td><td class="ev">config.h:140-146</td></tr>
<tr><td>SPI v2 帧开销</td><td class="n">20 B(16 头 + 4 CRC)</td><td class="ev">spi_link_v2.h:34-36</td></tr>
<tr><td>SPI 时钟 / 注释实测每帧</td><td class="n">13.333 MHz / ≈96 µs</td><td class="ev">sdkconfig.defaults:20;spi_master.cpp:80</td></tr>
<tr><td>ISR→主循环 DATA 槽 / flash 写缓冲槽</td><td class="n">4 / <b>8</b></td><td class="ev">config.h:178,180</td></tr>
<tr><td>ESP32 上传块 / 固件上限</td><td class="n">4 096 B / 216 KB</td><td class="ev">app_config.h:74,76</td></tr>
<tr><td>单包等 ACK / 最多重发 / 跨包停刷</td><td class="n">50 ms / 5 次 / 10 s</td><td class="ev">ota_relay.cpp:1181,1185,53</td></tr>
<tr><td>转发预算</td><td class="n">25 ms/包,总 10~120 s</td><td class="ev">ota_relay.cpp:356-368</td></tr>
<tr><td>BOOT 会话超时 / APP 延后启动窗口</td><td class="n">50 000(≈1 s) / 10 000 ms</td><td class="ev">config.h:168,169</td></tr>
<tr><td>握手各步超时(ESP32 侧)</td><td class="n">800 / 5000 / 3000 / 3000 ms</td><td class="ev">ota_relay.cpp:942,1036,1062,1090</td></tr>
</table>
<h3>一帧 DATA 的字节排布</h3>
<div class="byte">
<div class="key">0–3<small>会话号 LE</small></div>
<div class="key">4–7<small>包号 LE</small></div>
<div class="meta2">8–55<small>48 B AES-128 密文</small></div>
<div class="key">56–59<small>结束标志 IFEND</small></div>
<div class="key">60–63<small>CRC32(密钥参与)</small></div>
</div>
<p class="legend">外面再套 SPI v2 头:SOF0/SOF1/VER/FLAGS、SESSION@4、TX_SEQ@8、ACK_SEQ@9、CREDIT@10、TYPE@11、LEN@12,尾部 4 B MPEG-2 CRC32。<span class="ev">spi_link_v2.h:34-51;OTA.cpp:1002-1018;ota_relay.cpp:1135-1151</span></p>
<h3>握手三步(双向认证)</h3>
<table>
<tr><th style="width:38px">步</th><th>上位机 → BOOT</th><th>BOOT → 上位机</th><th>出处</th></tr>
<tr><td class="c">1</td><td>魔术字 <code>00 AC</code></td><td>回显 ACK,转 <code>WAITING_RANDOM</code></td><td class="ev">OTA.cpp:976-988</td></tr>
<tr><td class="c">2</td><td>16 B <code>nonce_server</code></td><td>36 B = UID(12)+nonce[8..15](8)+<code>AES(K_master,K_session)</code>(16),转 <code>WAITING_CONFIRM</code></td><td class="ev">OTA.cpp:1195-1219</td></tr>
<tr><td class="c">3</td><td><code>AES(K_session, nonce XOR 0x55)</code> 16 B</td><td><code>0xBA 0xC0</code>,转 <code>CHECKING_FIRMWARE</code></td><td class="ev">OTA.cpp:1224-1246</td></tr>
</table>
<p>状态机全序 <code>IDLE → WAITING_RANDOM → WAITING_CONFIRM → CHECKING_FIRMWARE → RECEIVING_FIRMWARE → FINISHED</code><span class="ev">OTA.h:223-230</span>。每包 CRC32(IEEE) 先把 16 B 会话密钥喂进去、再喂 48 B 明文,所以改一比特或重放旧包都对不上。<span class="ev">OTA.cpp:1414-1442</span> 主密钥两端硬编码同值。<span class="ev">config.h:158-161;app_config.h:150-153</span></p>
<h3>从收到字节到落 flash</h3>
<ul>
<li>EXTI ISR 只把帧入队(4 槽)→ <code>SpiOta::poll()</code> 在主循环解密校验并回 ACK → 再 drain 8 槽环形缓冲逐字 <code>fmc_word_program</code>。<span class="ev">SpiOta.cpp:99-114;OTA.cpp:725-757</span></li>
<li>背压靠 credit:<code>g_localCredit = 8 − flash_buf_cnt − pending</code>。<b>没有固定速率上限,也没有每包往返耗时统计。</b><span class="ev">OTA.cpp:1082-1085</span></li>
<li>去重:<code>包号 &lt; next_expected_packet</code> 判重,只回 ACK 不二次写 flash;重复 SPI 帧走 DUP-SEQ 回放缓存的最终 ACK。<span class="ev">OTA.cpp:1021-1031;SpiOta.cpp:409-427</span></li>
<li>擦除只擦本轮需要的页:<code>ceil(expected_size/2048)+1</code> 页,另单独保证 CRC 页被擦。<span class="ev">OTA.cpp:635-712</span></li>
<li>ACK 帧:<code>0xBA 0xC0</code> + 4 B 期望下一包号;末包特殊 seq <code>0xF0F0F0F0</code>;错误帧多 1 B 错误码。<span class="ev">OTA.cpp:251-260,1103,1285-1295</span></li>
<li>前端防呆:选文件即读前 8 字节判包身份(MSP∈SRAM、复位向量∈A 区;落 BOOT 区即拒),HEX 自动展平成 BIN(间隙填 0xFF)。<span class="ev">file-handle.js:458-516,563-574</span></li>
</ul>
<h3>失败与恢复分流</h3>
<p><code>main.cpp run()</code> 读三面标志分流:二者皆 AVAL 且 CRC 过 → 直接跳;CHECK AVAL / EN NOT_AVAL → 开 10 s 等 OTA 窗口;A 区坏但 B 区有效 → <code>move_app_data()</code> 整体搬回;都不行 → 长期留守等重下。FWDGT 复位会把 EN 标 NOT_AVAL,阻止反复跳进卡死的固件。<span class="ev">main.cpp:204-210,229-402</span></p>
<div class="card warn"><h3>代码里没有的东西(别当成有据)</h3>
<ul>
<li>SPI 中继链路的<b>实测</b>端到端耗时 / KB 每秒 —— 只有预算常数和注释里的 ~2 ms/包。</li>
<li>WiFi/HTTP 上传的断点续传:NVS 元数据预留了 <code>stored_size/last_seq</code>,HTTP 路径<b>未实装</b>。<span class="ev">gd32_flash_store.h:23;ota_coordinator.cpp:246</span></li>
<li>BOOT 侧握手各步的独立毫秒超时:统一走 <code>OTA_TIMEOUT</code> 计数。<span class="ev">OTA.cpp:431</span></li>
</ul></div>
<div class="card"><h3>同一后端的三条入口</h3>
浏览器、<code>tools/flash_gd32.py</code>(裸 BIN、不写台账)、<code>tools/ESP32/scripts/flash_gd32.py</code>(带 <code>{"total_size":N}</code> 头、写台账)三条都打 ESP32 固件 80 端口的同一组 <code>/ota/upload + /ota/relay + /system/status</code>,<b>没有独立通道</b>;设备侧对带不带 JSON 头都兼容。<span class="ev">http_handlers.cpp:403-407;tools/flash_gd32.py:88;scripts/flash_gd32.py:98</span></div>
</section>
<section class="page" id="p-new">
<h2>新流程:PC 主站 → EtherCAT 邮箱 → BOOT 写 flash</h2>
<div class="chain">
<div class="node"><b>浏览器</b><span class="t">ecat.html「BOOT 刷写」<br>页内自带 HEX→BIN</span></div><div class="arrow">→</div>
<div class="node"><b>ecat_native :8090</b><span class="t">/api/ota/ecat<br>镜像攒在 PC 内存</span></div><div class="arrow">→</div>
<div class="node"><b>I210 / Npcap</b><span class="t">loop_stop 独占<br>CoE SDO 分段下载</span></div><div class="arrow">→</div>
<div class="node"><b>LAN9252</b><span class="t">SM0 邮箱 256 B<br>PRAM FIFO 逐 DWORD</span></div><div class="arrow">→</div>
<div class="node hi"><b>GD32 BOOT</b><span class="t">SSC 攒满 4 KB 才回调<br>OTAHandler 写 flash</span></div>
</div>
<p class="legend">只有 5 跳,且 ESP32 完全不在里面。页面本身也是 <code>ecat_native</code> 发的,所以没有跨域、没有第二个服务。</p>
<h3>操作时序(六个 op,只有后两个碰总线)</h3>
<table>
<tr><th style="width:100px">op</th><th>做什么</th><th class="n">碰总线?</th><th>要点</th></tr>
<tr><td><code>open</code></td><td>声明镜像长度,清 PC 侧缓冲</td><td class="c">否</td><td>超 221,184 B 直接拒。<b>不抢 <code>g_bus_lock</code>、不 <code>loop_stop</code></b>,所以上传过程不会把正在跑的电机循环冻掉</td></tr>
<tr><td><code>push</code></td><td>追加一段 hex 到镜像尾部</td><td class="c">否</td><td>24,576 B/次 = hex 49,152 字符,卡在 <code>handle_conn</code> 的 65,535 B body 上限内</td></tr>
<tr><td><code>status</code> / <code>abort</code></td><td>查已收字节 / 丢缓冲</td><td class="c">否</td><td><code>abort</code> 只清 PC 侧,总线一个字节都没动</td></tr>
<tr><td><code>probe</code></td><td>读 <code>0x1008</code> 设备名 + <code>0x2500:03/:04</code></td><td class="c">是</td><td><b>纯诊断读数,不是门槛</b></td></tr>
<tr><td><code>run</code></td><td>真正的灌写:总长 → begin → 31 块 → commit</td><td class="c">是</td><td>全程持 <code>g_bus_lock</code> + <code>loop_stop()</code>;只有 commit 成功才不把 PDO 循环还回来(从站正在硬复位)</td></tr>
</table>
<h3>BOOT 侧只挂两个对象</h3>
<table>
<tr><th style="width:92px">索引</th><th class="n">类型</th><th>条目</th><th>语义</th></tr>
<tr><td><code>0x2500</code></td><td class="n">REC ×4<br>UINT32</td><td>:0 条目数 / :1 命令 / :2 总长 / :3 已收 / :4 状态</td>
<td>:1 写 <code>1</code>=begin、<code>2</code>=commit、<code>3</code>=abort;:4 bit0=允许刷写、bit1=会话活跃、高 16 位=错误码。<b>:3/:4 只读</b>,读的时候现取 <code>ota.ecatReceived()</code></td></tr>
<tr><td><code>0x2501</code></td><td class="n">VAR<br>OCTETSTRING<br>4 096 B</td><td>只有 <code>:00</code></td>
<td>数据通道。建成 VAR 而不是 ARR:VAR 的 <code>maxSubindex</code> 恒为 0,所以<b>只能写 <code>:00</code>,且 complete access 一律 UNSUPPORTED_ACCESS</b> ⇒ SOEM 侧必须传 <code>CA=FALSE</code>。<span class="ev">objdef.c:1278,1295,1352</span></td></tr>
</table>
<p>BitSize 是 UINT16 ⇒ 单条目天花板 8191 B,块长取 4096 就是这个原因。SSC 的分段下载<b>攒满整块才调一次 <code>OBJ_Write</code></b>(最后一帧才分发,且分发前不做通用长度校验),所以从站侧不需要 begin/data/commit 拼包状态机。<span class="ev">sdoserv.c:292-295;objdef.c:1352;sdoserv.c:714</span></p>
<h3>一个 4 KB 块在总线上的样子</h3>
<div class="byte">
<div class="meta2">initiate<small>带 240 B</small></div>
<div class="key">段 1<small>247 B</small></div>
<div class="key">段 2<small>247 B</small></div>
<div class="key">…<small>×14</small></div>
<div class="key">段 16<small>151 B</small></div>
</div>
<p class="legend">共 <b>17 帧搬 4096 B</b>。段长上限两端各算一次取小者:SOEM <code>maxdata = mbx_l − 0x10 = 240</code>(首帧带数),SSC 段 <code>256 − 6 − 3 = 247</code>。全图 124,992 B ⇒ 31 块 = 527 帧。<span class="ev">ethercatcoe.c:346,450;sdoserv.c:210</span></p>
<h3>放行判据只有一个执行点</h3>
<div class="card good">
<p><code>ecatArmed() = 配置页 word8 == 0xACA70001 || !(CHECK==AVAL &amp;&amp; EN==AVAL)</code></p>
<p>后半句和 <code>main.cpp run()</code> 决定"跳 APP"用的是同两个 flash 字,所以四种 BOOT 留守情形(空板首刷 / 下载中断电 / A 区 CRC 失败 / FWDGT 复位)天然放行,不需要任何先决条件;反过来 APP 正常在跑时两字皆 <code>OTA_AVAL</code>,令牌就是硬门 —— 而令牌写不进(SDO 摸不到配置页),所以刷不动一台正常机器。</p>
<p><code>ecatCommit()</code> 成功路径里先 <code>ecatArmSet(false)</code> 收回令牌再置三面标志:用完即弃。此刻 CHECK/EN 还是 <code>eraseApplicationArea</code> 标的 NOT_AVAL,所以收回这一拍救砖分支照样为真,不影响本次提交。<span class="ev">OTA.cpp:792-812,905-915</span></p>
</div>
<h3>刻意复用老流程的部分(一条都没新写)</h3>
<ul>
<li>擦:<code>eraseApplicationArea()</code> —— 同样先标三面 NOT_AVAL,同样按 <code>ceil(size/2048)+1</code> 页。</li>
<li>写:<code>writeApplicationData()</code> —— 唯一区别是主站先把镜像补 0xFF 到 4 对齐(<code>ecatFeed</code> 另有游标 4 对齐前置校验)。</li>
<li>校验:<code>validateApplication()</code> —— MSP∈SRAM、复位向量∈A 区,与前端包身份判定同判据。</li>
<li>提交:CRC → CHECK → AVAL → EN 同序;跳转都靠 <code>process()</code> 排空 <code>pending_app_jump</code>,<b>不在收包/邮箱上下文里跳</b>(否则会把 SM0 半包留在总线上)。</li>
<li>并发:<code>ecatBegin</code> 在 <code>isActive()</code>(SPI/CAN 会话活跃)时直接拒绝 ⇒ BOOT 里始终只有一个 flash writer。</li>
<li>退出:<code>jumpToApplication()</code> 里补 <code>bootEcatDeinit()</code> 拆 EXTI1/NVIC、LAN9252 IRQ 引脚、EXMC、DWT,否则 APP 会吃到 BOOT 留的 pending 中断并在被占用的总线上重跑 init。</li>
</ul>
<h3>新增的移植层花了多少</h3>
<table>
<tr><th>项</th><th class="n">值</th><th>备注</th></tr>
<tr><td>无 EtherCAT 基线</td><td class="n">36,340 B</td><td class="ev">Phase 0 对照组:关掉唯一调用者后 --gc-sections 剥回此值</td></tr>
<tr><td>邮箱-only SSC(空移植层桩件)</td><td class="n">57,816 B</td><td>净增 21,472 B —— 原方案声称的"12 KB"是假的</td></tr>
<tr><td><b>真移植层 + 会话(定版)</b></td><td class="n"><b>64,552 B</b></td><td>再 +6,736 B(EXMC/HBI/CSR 双读/PRAM/RST#/IRQ + 0x2500/0x2501 + arm 门控);<b>余 9,176 B</b></td></tr>
<tr><td>RAM</td><td class="n">32,392 / 131,072 B</td><td>堆 0xC00→<b>0x4000</b>(每笔分段下载 <code>ALLOCMEM(块长)</code> 走 malloc);栈仍 1 KB ⇒ SDO 回调里不许开块级大数组</td></tr>
</table>
</section>
<div class="foot">生成:Qoder 会话 · 证据基线 <code>GD32E50X_BOOT</code> v1.1.0.7 / <code>tools/ecat</code> ecat_native 22:18 / <code>ESP32_COMM</code> 现网分支 · 本轮改动均未提交 git</div>
</main>
<script>
document.querySelectorAll(".tab").forEach(function(b){b.onclick=function(){
document.querySelectorAll(".tab").forEach(function(x){x.classList.remove("on")});
document.querySelectorAll(".page").forEach(function(x){x.classList.remove("on")});
b.classList.add("on");
document.getElementById("p-"+b.dataset.p).classList.add("on");
scrollTo(0,0);
};});
</script>
</body></html>
+1 -1
View File
@@ -87,7 +87,7 @@
position: relative;
box-shadow: 0 0 10px rgba(47,216,193,.4);
}
.progress-fill::after {
.progress-fill.live::after {
content: ""; position: absolute; inset: 0;
background: linear-gradient(90deg, transparent 0%, rgba(255,255,255,.35) 50%, transparent 100%);
animation: sweep 1.6s linear infinite;
+24 -2
View File
@@ -93,6 +93,26 @@
50% { opacity: 1; }
}
/* ================= 无设备静默态:掐掉常驻动画 =================
* 常驻动画按显示器刷新率逐帧跑:本机 160Hz 实测「没接设备」时
* 主线程 140ms/s 全在重算+重绘(160 次/s recalc),renderer+GPU 各占约
* 一核的七成;全关后回到 ~0。设备离线且无传输在跑时这些动效没有任何
* 信息量,故由连接态统一挂 html[data-dcu-calm="1"] 关掉。
* 只掐 infinite 装饰动效;一次性入场/吐司动画与故障灯保留。 */
html[data-dcu-calm="1"] .progress-fill,
html[data-dcu-calm="1"] .progress-fill::after,
html[data-dcu-calm="1"] .heap-bar-track::after,
html[data-dcu-calm="1"] .status-task-bar::after,
html[data-dcu-calm="1"] .stage-status-bar .ssb-dot,
html[data-dcu-calm="1"] .pill.live .indicator,
html[data-dcu-calm="1"] .conn-indicator.warn .light,
html[data-dcu-calm="1"] .brand-logo::before,
html[data-dcu-calm="1"] .header-brand .dot-mark::before,
html[data-dcu-calm="1"] .header-brand .dot-mark::after,
html[data-dcu-calm="1"] .led-pulse {
animation: none !important;
}
/* ================= 入场:头部 + 卡片错落浮现 ================= */
.header-bar { animation: fadeInUp .5s ease both; }
.main-layout > div > .card { animation: fadeInUp .55s ease both; }
@@ -169,15 +189,17 @@
/* ================= 文件信息浮现 ================= */
.file-info.show { animation: fadeInUp .3s ease both; }
/* ================= 进度条:流动斜纹 ================= */
/* ================= 进度条:流动斜纹 =================
* 斜纹只在真的有进度时挂(.live 由 setProgress/setEcatPct 开关):
* 空闲时进度条 width:0%,看不见却在按刷新率重绘,纯白烧。 */
.progress-fill {
background-image:
linear-gradient(90deg, var(--esp32), #14b8a6),
repeating-linear-gradient(45deg, rgba(255,255,255,.2) 0 10px, rgba(255,255,255,0) 10px 20px);
background-size: 100% 100%, 200% 100%;
background-blend-mode: normal, overlay;
animation: flowDash 1.4s linear infinite;
}
.progress-fill.live { animation: flowDash 1.4s linear infinite; }
.progress-fill.done { animation: none; }
/* ================= 阶段胶囊:激活弹入 ================= */
+178 -1
View File
@@ -12,6 +12,8 @@
停 EtherCAT PDO 循环并从站退回 INIT(见 _app_lease 一节)
POST /api/shutdown → 结束本服务进程(窗口模式父进程用它通知子进程退出)
POST /ecat/api/<路径> → 反向代理到 tools/ecat 的 ecat_native.exe 的 /api/<路径>
GET /api/motor/ui-state → 读电机页配置快照(data/motor_ui_state.json)
POST /api/motor/ui-state → 写电机页配置快照到磁盘(关页/改动时前端自动刷)
为什么要代理 EtherCAT: 电机页(pages/motor.html)支持 WiFi 与 EtherCAT 两种传输,
后者的后端 tools/ecat 的 EtherCAT 服务只绑 127.0.0.1 且完全没有 CORS
@@ -52,6 +54,16 @@ from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
# 电机页配置落盘: CMD 表单 / 命令序列 / 刷新速度 / 链路与 EtherCAT 偏好等。
# 存 JSON 而不是只靠 localStorage —— WebView2/清缓存后 localStorage 会没,
# 用户要的「关掉之后还在电脑上」必须是真文件。路径固定在工具目录下, 便于备份。
UI_STATE_PATH = os.path.join(BASE_DIR, "data", "motor_ui_state.json")
UI_STATE_REL = "data/motor_ui_state.json"
# lab/spi_test 观测面板配置(PDO 档位/采样率/开关按钮)。与电机页分文件,
# 避免任一页面整包覆盖时把对方字段抹掉。
LAB_UI_STATE_PATH = os.path.join(BASE_DIR, "data", "spi_test_ui_state.json")
LAB_UI_STATE_REL = "data/spi_test_ui_state.json"
# 单实例互斥: 已有实例时, 新双击不创建第二个窗口(会隐形/端口漂移),
# 而是通过 /api/focus 让旧实例的窗口到前台。
g_mutex = None
@@ -208,7 +220,14 @@ def _set_ecat_realtime(pid: int) -> bool:
class _GuiApi:
"""暴露给窗口内 JS (window.pywebview.api) 的 GUI 控制接口。"""
"""暴露给窗口内 JS (window.pywebview.api) 的 GUI 控制接口。
形态约定:
- 网页/浏览器标签:无 pywebview,电机页 EtherCAT 走 HTTP /ecat/api/* 代理。
- 桌面 APP (esp32_app.vbs --window):JS 调本类 ecat_api,GUI 进程用与
代理同款的裸 socket 直连 ecat_native —— 省掉「浏览器 fetch → 服务子进程
→ 代理」两跳。iframe 页从 window.top.pywebview.api 取(同源)。
"""
def page_loaded(self):
"""页面每次加载完成后由注入脚本调用: 把窗口带到前台。"""
@@ -230,6 +249,65 @@ class _GuiApi:
except Exception:
pass
def bridge_info(self):
"""前端探测桌面桥是否可用。"""
return {
"ok": True,
"mode": "desktop",
"has_ecat_bridge": True,
"ecat_port": g_ecat_port,
}
def ecat_api(self, path, body=None, timeout_ms=4000):
"""桌面壳原生桥:JS → GUI 进程 → ecat_native,不经浏览器 HTTP。
path: "motor/send" | "/api/motor/send" | "/ecat/api/motor/send"
body: dict / JSON 字符串 / 省略
返回与 native 同构的 dict。
"""
p = str(path or "").strip()
p = p.split("?", 1)[0]
while p.startswith("/"):
p = p[1:]
if p.startswith("ecat/api/"):
p = p[len("ecat/"):]
elif p.startswith("ecat/"):
p = p[len("ecat/"):]
if p.startswith("api/"):
tail = p[len("api/"):]
else:
tail = p
proxy_path = ECAT_PROXY_PREFIX + tail # /ecat/api/<tail>
if body is None:
raw = b"{}"
elif isinstance(body, (bytes, bytearray)):
raw = bytes(body)
elif isinstance(body, str):
raw = body.encode("utf-8")
else:
try:
raw = json.dumps(body, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
except Exception as e:
return {"ok": False, "log": [f"原生桥 body 序列化失败: {type(e).__name__}: {e}"]}
timeout = max(0.2, float(timeout_ms or 4000) / 1000.0)
try:
status, data = _ecat_proxy(proxy_path, raw, timeout)
except Exception as e:
return {"ok": False, "log": [f"原生桥异常: {type(e).__name__}: {e}"]}
try:
obj = json.loads(data.decode("utf-8", "replace") or "{}")
except Exception:
obj = {"ok": False, "log": ["原生桥响应非 JSON"]}
if not isinstance(obj, dict):
obj = {"ok": False, "log": ["原生桥响应不是对象"]}
obj.setdefault("ok", status == 200)
if status >= 400 and "log" not in obj:
obj["log"] = [f"HTTP {status}"]
return obj
def _focus_existing(port):
"""让已有实例的窗口到前台。返回 True 表示确实存在旧实例。"""
@@ -537,6 +615,7 @@ _WS_MAGIC = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" # RFC 6455 握手固定常
g_app_leases = 0 # 当前挂着的租约连接数
g_app_lock = threading.Lock()
g_ecat_motor_live = False # 本服务确认过"PDO 循环在跑"(经代理观测)
g_ui_state_lock = threading.Lock() # 配置文件读写串行(原子替换, 防半截 JSON)
def _ws_accept_key(key):
@@ -609,6 +688,34 @@ def _json_ok(data):
return False
def _read_ui_state(path=None):
"""读配置快照 JSON。文件不存在/损坏 → None(前端回落 localStorage)。"""
p = path or UI_STATE_PATH
try:
with open(p, "r", encoding="utf-8") as f:
data = json.load(f)
return data if isinstance(data, dict) else None
except Exception:
return None
def _write_ui_state(obj, path=None, rel=None):
"""原子写配置快照。只接受 dict。默认写 data/motor_ui_state.json。"""
if not isinstance(obj, dict):
raise ValueError("ui-state 必须是 JSON 对象")
p = path or UI_STATE_PATH
r = rel or UI_STATE_REL
os.makedirs(os.path.dirname(p), exist_ok=True)
tmp = p + ".tmp"
with g_ui_state_lock:
with open(tmp, "w", encoding="utf-8") as f:
json.dump(obj, f, ensure_ascii=False, separators=(",", ":"))
f.flush()
os.fsync(f.fileno())
os.replace(tmp, p)
return {"ok": True, "path": r, "savedAt": obj.get("savedAt")}
def _note_ecat_loop(path, status, body):
"""代理透传时顺手记下"循环跑没跑", 免得租约空了去停别人的或白停一次。
@@ -646,6 +753,12 @@ class Handler(SimpleHTTPRequestHandler):
if path == APP_LEASE_PATH:
self._handle_ws_app_lease()
return
if path == "/api/motor/ui-state":
self._handle_ui_state_get()
return
if path == "/api/lab/ui-state":
self._handle_lab_ui_state_get()
return
# 根路径不再有 index.html(已移入 pages/), 直接送去标签外壳
if path in ("/", "/index.html"):
self.send_response(302)
@@ -681,6 +794,7 @@ class Handler(SimpleHTTPRequestHandler):
def do_POST(self):
path = self.path.split("?")[0]
path_l = path.lower()
if path == "/api/focus":
# 已有实例响应: 新实例应当退场
body = b'{"ok":true,"focused":true}'
@@ -692,6 +806,12 @@ class Handler(SimpleHTTPRequestHandler):
self._post_json(body)
threading.Thread(target=_shutdown_self, daemon=True).start()
return
if path_l == "/api/motor/ui-state":
self._handle_ui_state_post()
return
if path_l == "/api/lab/ui-state":
self._handle_lab_ui_state_post()
return
if path.startswith(ECAT_PROXY_PREFIX):
# 电机页的 EtherCAT 传输: 同源转发到 EtherCAT 后端(见 _ecat_proxy 注释)。
# 超时给足: /api/op/start 要走 PREOP→SAFEOP→OP, 内部还会自动重试一次,
@@ -793,6 +913,63 @@ class Handler(SimpleHTTPRequestHandler):
except OSError:
pass
def _handle_ui_state_get(self):
"""电机页配置快照 GET。有文件 → ok+state;无 → ok+state:null。"""
st = _read_ui_state(UI_STATE_PATH)
body = json.dumps(
{"ok": True, "state": st, "path": UI_STATE_REL},
ensure_ascii=False).encode("utf-8")
self._post_json(body)
def _handle_lab_ui_state_get(self):
"""lab/spi_test 配置快照 GET。"""
st = _read_ui_state(LAB_UI_STATE_PATH)
body = json.dumps(
{"ok": True, "state": st, "path": LAB_UI_STATE_REL},
ensure_ascii=False).encode("utf-8")
self._post_json(body)
def _read_ui_post_body(self):
"""读 POST body 并解包 {state:{...}} 信封。失败时返回 (None, err_msg)。"""
try:
length = int(self.headers.get("Content-Length") or 0)
except ValueError:
length = 0
if length <= 0 or length > 2 * 1024 * 1024:
return None, "空 body 或超过 2MB"
raw = self.rfile.read(length)
try:
obj = json.loads(raw.decode("utf-8", "replace"))
except Exception as e:
return None, f"JSON 解析失败: {type(e).__name__}"
if isinstance(obj, dict) and "state" in obj and isinstance(obj.get("state"), dict):
obj = obj["state"]
return obj, None
def _write_ui_state_route(self, path, rel):
"""POST → 原子写配置快照。路径固定在服务端,不接受客户端指定。"""
obj, err = self._read_ui_post_body()
if err is not None:
self._post_json(json.dumps(
{"ok": False, "log": [err]}, ensure_ascii=False).encode("utf-8"), 400)
return
try:
result = _write_ui_state(obj, path=path, rel=rel)
except Exception as e:
self._post_json(json.dumps(
{"ok": False, "log": [f"写入失败: {type(e).__name__}: {e}"]},
ensure_ascii=False).encode("utf-8"), 500)
return
self._post_json(json.dumps(result, ensure_ascii=False).encode("utf-8"))
def _handle_ui_state_post(self):
"""电机页配置快照 POST → data/motor_ui_state.json。"""
self._write_ui_state_route(UI_STATE_PATH, UI_STATE_REL)
def _handle_lab_ui_state_post(self):
"""lab/spi_test 配置快照 POST → data/spi_test_ui_state.json。"""
self._write_ui_state_route(LAB_UI_STATE_PATH, LAB_UI_STATE_REL)
def _post_json(self, body, status=200):
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
+7 -1
View File
@@ -2,7 +2,7 @@
* can_monitor.js - CAN 接收监控(独立页 can_monitor.html 与 can.html 右侧抽屉共用)
*/
import { showToast } from './common/toast.js';
import { escapeHtml } from './common/utils.js';
import { escapeHtml, flashBusyNow } from './common/utils.js';
import { getBaseUrl as hubBaseUrl } from './common/base-url.js';
import { deviceLink } from './common/device-link.js';
@@ -66,6 +66,9 @@ function getBaseUrl() {
}
function setConnState(ok, text) {
/* 无设备静默态:离线时掐掉常驻动画,规则见 css/responsive.css 的
html[data-dcu-calm="1"] 段(160Hz 屏上每帧重算+重绘 ≈ 白烧一核)。 */
document.documentElement.dataset.dcuCalm = ok ? '0' : '1';
/* 嵌入 can.html 时顶栏已有 canConnPill,不再用 monConn 重复占位 */
if (el.monConnPill) el.monConnPill.className = 'pill ' + (ok ? 'online' : 'offline');
if (el.monConnTxt) el.monConnTxt.textContent = text;
@@ -82,6 +85,9 @@ function setConnState(ok, text) {
async function pollFeedback() {
// 防重入:上一次 fetch 未完成则跳过本次 tick,避免并发请求叠加
if (state.fbInFlight) return;
/* 刷写/OTA 窗口内跳过:GD32 正在复位或停在 BOOT,0xC1 必被链路层拒
* (ESP32 侧一帧一条拒绝日志),问了也是白问。标记随刷写收尾自撤。 */
if (flashBusyNow()) return;
state.fbInFlight = true;
var ac = new AbortController();
var timer = setTimeout(function () { ac.abort(); }, 3000);
+22
View File
@@ -55,6 +55,27 @@ function setWidthIfChanged(el, widthStr) {
}
// ==================== 连接管理 ====================
/**
* 无设备静默态:设备离线且没有传输在跑时,给 <html> 挂 data-dcu-calm="1",
* 由 responsive.css 统一掐掉常驻动画。160Hz 屏上这些动效每帧触发重算+重绘,
* 实测未接设备时 renderer+GPU 各占约一核的七成。
*/
export function setQuietMode(quiet) {
var v = quiet ? '1' : '0';
if (document.documentElement.dataset.dcuCalm !== v) {
document.documentElement.dataset.dcuCalm = v;
}
}
/** 本轮是否有传输/OTA 在跑(此时即使设备短暂不响应也要保留动效)。 */
function transferOngoing() {
try {
return !!(window.pollingPausedForUpload || window.currentOtaTarget ||
window.isEsp32Rebooting || window.bootEcatBusy ||
(typeof window.otaSessionActive === 'function' && window.otaSessionActive()));
} catch (_) { return false; }
}
export function updateConnectionUi(connected, text) {
/* DeviceLink 统一层:外壳 Owner 广播的新鲜快照优先于本页局部轮询结论,
避免 OTA「已连接」与 LAN9252「失败」同屏分裂。独立直开页无快照则用参数。 */
@@ -87,6 +108,7 @@ export function updateConnectionUi(connected, text) {
setTextIfChanged(bc, connected ? '在线' : '离线');
setClassIfChanged(bc, 'v ' + (connected ? 'ok' : 'err'));
}
setQuietMode(!connected && !transferOngoing());
}
export function updateHealthUi(healthCode, freeHeap) {
var healthNames = ['正常', '警告', '危险', '关机'];
+65
View File
@@ -0,0 +1,65 @@
/**
* desktop-bridge.js — 桌面壳(pywebview)原生桥探测与调用
*
* 形态约定:
* esp32_app.vbs → --window → WebView2:JS 可经 window.pywebview.api 调到
* GUI 进程 Python,由它直连 ecat_native(不经浏览器 fetch、不经页面服务子进程代理)。
* esp32_web.vbs / 浏览器标签:无 pywebview,本模块返回 null,调用方回落 HTTP。
*
* 电机页在 tabs.html 的 iframe 内:pywebview 只注入顶层窗口,同源 iframe
* 必须向上找 window.parent / window.top 上的 pywebview.api。
*/
"use strict";
function collectApi() {
const roots = [];
try { roots.push(window); } catch (_) {}
try {
if (window.parent && window.parent !== window) roots.push(window.parent);
} catch (_) {}
try {
if (window.top && window.top !== window) roots.push(window.top);
} catch (_) {}
for (const w of roots) {
try {
const api = w.pywebview && w.pywebview.api;
if (api && typeof api.ecat_api === "function") return api;
} catch (_) { /* 跨源 parent 会抛 */ }
}
return null;
}
let _api = null;
/** @description 取桌面桥 API;没有则 null(不缓存失败,pywebview 可能稍后注入)。 */
export function getDesktopEcatApi() {
if (_api && typeof _api.ecat_api === "function") return _api;
_api = collectApi();
return _api;
}
/** @description 是否跑在 pywebview 桌面壳里(有壳但桥方法未就绪时也可能 true)。 */
export function isDesktopShell() {
try {
return !!(window.pywebview ||
(window.parent && window.parent !== window && window.parent.pywebview) ||
(window.top && window.top !== window && window.top.pywebview));
} catch (_) {
return false;
}
}
/**
* 桌面桥 POST。path 接受 "motor/send" / "/api/motor/send" / "/ecat/api/motor/send"。
* @returns {Promise<object>} 与 native 同构的 JSON
*/
export async function desktopEcatPost(path, body, timeoutMs) {
const api = getDesktopEcatApi();
if (!api) throw new Error("desktop bridge unavailable");
return api.ecat_api(path, body == null ? {} : body, timeoutMs || 4000);
}
/** @description 状态栏用:当前 ECAT 控制通道形态。 */
export function desktopBridgeLabel() {
return getDesktopEcatApi() ? "APP 原生桥" : "HTTP 代理";
}
+43 -5
View File
@@ -24,6 +24,7 @@ var ESP32_FAIL_ONLINE = 3; /* 连续失败 ≥3 判离线 */
var ESP32_POLL_MS = 1000;
var ECAT_POLL_MS = 2000;
var ECAT_BUS_RETRY_MS = 10000; /* 开总线失败后的重试间隔 */
var ecatDcGuardBusy = false; /* DC/周期自愈 in-flight */
var LS_ECAT_PERIOD = "dcu_ecat_period_ms";
var LS_ECAT_SPIN = "dcu_ecat_spin";
var LS_ECAT_ADAPTER = "dcu_ecat_adapter";
@@ -164,9 +165,13 @@ function createOwner() {
}
/**
* 开总线:status →(未开则)open 网卡 → motor/start 进 OP + 起 PDO 循环。
* 开总线:health →(未就绪则)status →(未开则)open 网卡 → motor/start 进 OP + 起 PDO 循环。
* 与 motor/transport-ecat ensureOpen/ensureStarted 同源,由外壳 Owner 在
* 控制台启动时强制执行,用户不必先去电机页把链路切到 EtherCAT。
*
* 已健康(bus_open + loop)时**直接复用**,不打 motor/start:
* 那条路由会先 loop_stop 再重进 OP,和电机页探测撞车时会假超时
* (横幅 signal timed out)并平白增加 SSC 楔死风险。
*/
function bootstrapEcatBus() {
if (stopped || ecatBusBusy) return;
@@ -180,13 +185,30 @@ function createOwner() {
var body = {};
try {
var v = parseFloat(localStorage.getItem(LS_ECAT_PERIOD));
if (v > 0) body.period_ms = v;
/* 无显式值 / 值过慢 → 固定 0.25ms。1ms+DC 会让发送出口回到 ~1s。 */
if (v > 0 && v <= 0.5) body.period_ms = v;
else body.period_ms = 0.25;
var sp = localStorage.getItem(LS_ECAT_SPIN);
if (sp === "0") body.spin = false;
else if (sp === "1") body.spin = true;
} catch (_) {}
} catch (_) { body.period_ms = 0.25; }
/* EtherCAT 控制必须 DC SYNC0≈PDO 周期,且周期应保持 250µs 档 */
{
var per = Number(body.period_ms) || 0.25;
if (per > 0.5) per = 0.25;
body.period_ms = per;
var us = Math.round(per * 1000);
if (us < 250) us = 250;
body.dc_cycle_us = us;
body.dc_shift_us = 0;
}
ecatPost("status", {}, 6000).then(function (st) {
ecatPost("native/health", {}, 3000).then(function (h) {
if (h && h.ok && h.bus_open && h.loop) {
ecatBusReady = true;
return null; /* 已在跑:跳过 open + motor/start */
}
return ecatPost("status", {}, 6000).then(function (st) {
var open = !!(st && st.ok);
return open ? Promise.resolve(st)
: ecatPost("open", { substr: adapter }, 20000);
@@ -195,11 +217,12 @@ function createOwner() {
throw new Error((r.log && r.log.join(" / ")) || "open 失败");
}
return ecatPost("motor/start", body, 30000);
});
}).then(function (r) {
if (r && r.ok === false) {
throw new Error((r.log && r.log.join(" / ")) || "motor/start 失败");
}
ecatBusReady = true;
if (r) ecatBusReady = true;
}).catch(function () {
/* 网线没插/从站未上电:退避后由 pollEcat 再触发 */
}).then(function () {
@@ -227,6 +250,21 @@ function createOwner() {
if (e.busOpen && e.loop) ecatBusReady = true;
else if (ecatBusReady && !e.busOpen) ecatBusReady = false; /* 总线掉了 → 允许重开 */
publish();
/* 自愈:DC/周期被配错时发送出口秒级 —— 2s 轮询里拉回 250µs */
if (e.busOpen && e.loop && !ecatDcGuardBusy) {
ecatDcGuardBusy = true;
ecatPost("native/health", {}, 2000).then(function (h) {
if (!h || !h.ok) return null;
var dcs = Number(h.dc_cycle_us);
var per = Number(h.period_ms);
if (h.dc === 1 && dcs === 250 && (h.period_ms == null || Math.abs(per - 0.25) < 0.001)) return null;
return ecatPost("motor/start", {
period_ms: 0.25, dc_cycle_us: 250, dc_shift_us: 0
}, 30000);
}).catch(function () {}).then(function () {
ecatDcGuardBusy = false;
});
}
bootstrapEcatBus();
}).catch(function () {
/* 字段访问/异常不得让 ECAT 轮询永久停摆 */
+17
View File
@@ -58,3 +58,20 @@ export function bytesToHex(bytes) {
}
return out;
}
/**
* @description 当前是否有刷写/OTA 在跑(跨 iframe 共享)。
* 多 iframe 保活外壳下各功能页是**独立文档**,window 变量不通用(刷写页的
* window.bootEcatBusy 在 CAN 页里读不到),所以走 localStorage 这一条共享通道:
* 刷写方(js/ota/boot_ecat.js)写 dcu_flash_busy_until = 预计结束时刻(ms),
* 轮询方据此避让。带到期时间,页面中途关掉/异常也不会把标记永久挂住。
* @returns {boolean} 刷写窗口内为 true
*/
export function flashBusyNow() {
try {
var until = parseInt(localStorage.getItem('dcu_flash_busy_until'), 10);
return Number.isFinite(until) && until > Date.now();
} catch (_) {
return false;
}
}
+679 -123
View File
File diff suppressed because it is too large Load Diff
+338 -54
View File
@@ -1,9 +1,12 @@
/**
* transport-ecat.js — 电机控制台的 EtherCAT 传输实现
*
* 后端是 tools/ecat 的 ecat_native.exe(C/SOEM 守护进程,另一个 git 仓库),经本地页面服务器
* 的 /ecat/api/* 反向代理同源访问 —— 那个服务只绑 127.0.0.1 且没有 CORS,
* 浏览器直连跨端口会卡在预检上,详见 esp32_server.py 的 _ecat_proxy 注释。
* 后端是 tools/ecat 的 ecat_native.exe(C/SOEM 守护进程,另一个 git 仓库)。
* 控制面双路径(2026-09-23):
* - 桌面 APP(esp32_app.vbs / --window):window.top.pywebview.api.ecat_api
* → GUI 进程裸 socket 直连 native(desktop-bridge.js),不经浏览器 HTTP;
* - 网页/浏览器:同源 /ecat/api/* 反向代理(见 esp32_server.py)。
* 无论哪条路径,真正上总线的 EtherCAT PDO 都由 native 后台循环维持。
*
* **全自动接入**:切到本链路不需要任何手工前置步骤。四级按需自愈:
* 1. 服务没起 → 代理侧 _ecat_autostart() 拉起 tools/ecat\build_native\ecat_native.exe(已有实例只复用)
@@ -11,6 +14,9 @@
* 3. 没进 OP → /api/motor/start 内部自己走 PREOP→SAFEOP→OP
* 4. 循环没跑 → /api/motor/start 起后台 PDO 循环(喂 1000ms 过程数据看门狗)
* 断线后 main.js 的探测器每 2s 重来一遍,所以中途拔网线再插也会自己恢复。
* 探测自身是**幂等**的:native/health 显示 bus_open+loop 时直接复用,
* 不再打 motor/start(那条路由会先 loop_stop,热路径假超时 / 楔死风险)。
* probe 超时后另有 healthCheck 只读复核,避免「实际已连上却报未连」。
*
* 本模块的**唯一职责**是把 EtherCAT 过程数据的形状翻译成 WiFi 传输那套契约,
* 好让 main.js 的解码器 / 节点列表 / STATUS 面板 / 波形 / 命令序列全部原样复用。
@@ -36,6 +42,9 @@
* 「ECAT 槽判活阈值」一节,纯函数以便脱离 DOM 自测。 */
import { staleLimitMs } from "./refresh-rate.js";
import { deviceLink } from "../common/device-link.js";
import {
desktopEcatPost, desktopBridgeLabel, getDesktopEcatApi,
} from "../common/desktop-bridge.js?v=20260923g";
/** 单个 PDO 周期回传的状态槽数。
* 固件 1.6.21 起段 2 密排整轮:一拍带齐总线电机(最多 9),空槽不占位。
@@ -101,8 +110,11 @@ export function createEcatTransport(ctx) {
/* ── 传输内部累积状态 ──
* 这些都是"WiFi 侧由固件/GD32 代为缓存、EtherCAT 侧必须前端自己攒"的东西。 */
const configs = new Map(); // node -> 24B CONFIG hex(0x000N0101)
const versions = new Map(); // node -> {node, seq, version} 供 fetchVersionNode
const versions = new Map(); // node -> {node, seq, version, at} 供 fetchVersionNode
const nodeIdReports = new Map();// node -> 应答 hex(0x000N0400/0401)
/* xrx 变更指纹:段3 只有「最近一帧」,内容不变时不要重复 dispatch,
* 否则每拍都把旧版本 seq+1,查询判定会失真。 */
let lastXrxKey = "";
const seqSeen = new Map(); // "ch:node" -> {seq, at, rx, iv} 判活 + 计更新次数;iv=实测变化间隔 EWMA(ms)
let periodMs = 0; // 后台 PDO 循环周期(由 /api/motor/start 回报)
/* 单个电机状态的真实刷新周期(ms,来自快照的 slot_period_ms)。
@@ -132,10 +144,35 @@ export function createEcatTransport(ctx) {
let started = false; // 后台循环是否已启动
let lastSnapshot = null; // 最近一次快照,供 fetchFeedback 复用(不重复打后端)
/** POST 到本地代理。失败抛 Error,附 transient,与 WiFi 传输的错误契约一致。 */
async function api(path, body, timeoutMs) {
const url = "/ecat/api/" + path.replace(/^\/+/, "");
/**
* POST 到 EtherCAT 后端。双路径:
* 同源 HTTP /ecat/api/*(esp32_server 服务子进程代理)—— 热路径优先;
* APP 桥 window.top.pywebview.api.ecat_api —— 低频控制面,或 HTTP 失败时兜底。
*
* 为什么热路径(motor/state、motor/rx)不再优先走桥:pywebview WebView2
* 每次 JS→Python 往返实测把本机 ~0.9ms 的 native 快照抬到约 20ms/拍,
* 6ms 刷新档被按在 ~50Hz;HTTP 代理 state+rx 串行 p50≈2.9ms(≈350Hz)。
* 桥仍保留给 send/status/probe 等低频路由,以及页面不在代理源上时的兜底。
*
* 失败抛 Error,附 transient,与 WiFi 传输的错误契约一致。
*/
/* 热路径:界面每拍轮询的路由。这些走 HTTP,不进 pywebview 桥。 */
const HOT_HTTP_PATHS = new Set(["motor/state", "motor/rx"]);
/** drainRx 最小间隔:6ms 档下每拍都 drain 会把请求量翻倍;CONFIG/版本
* 应答本身不按 166Hz 到达,20ms 足够跟上且显著降低代理/桥压力。 */
const DRAIN_MIN_MS = 20;
let lastDrainAt = 0;
async function callBridge(tag, body, timeoutMs) {
const t0 = performance.now();
const json = await desktopEcatPost(tag, body || {}, timeoutMs || 4000);
recordSlow("bridge:/" + tag, performance.now() - t0);
return json;
}
async function callHttp(tag, body, timeoutMs) {
const t0 = performance.now();
const url = "/ecat/api/" + tag;
let resp;
try {
resp = await fetch(url, {
@@ -155,28 +192,64 @@ export function createEcatTransport(ctx) {
const text = await resp.text();
let json = {};
try { json = text ? JSON.parse(text) : {}; } catch (e) {
throw new Error("EtherCAT 服务响应非合法 JSON");
const err = new Error("EtherCAT 服务响应非合法 JSON");
err.transient = true; /* 代理半截响应:下一拍重试即可,勿累计断线 */
throw err;
}
if (!resp.ok) {
/* 代理连不上后端会给 503 + 同构信封;后端的 404 体里带
* "服务改过但没重启"的排查提示,两者都在 log[] 里,原样冒泡给用户。 */
const err = new Error(
(json.log && json.log.length) ? json.log.join(" / ") : ("HTTP " + resp.status));
err.status = resp.status;
err.transient = (resp.status === 503);
err.transient = (resp.status === 500 || resp.status === 503 || resp.status === 429);
throw err;
}
return json;
}
async function api(path, body, timeoutMs, opts) {
const tag = String(path || "").replace(/^\/+/, "").split("?", 1)[0];
const preferHttp = !!(opts && opts.preferHttp) || HOT_HTTP_PATHS.has(tag);
const hasBridge = !!getDesktopEcatApi();
/* 热路径:HTTP 优先;失败再落桥(代理子进程挂了仍能用)。
* 低频路径:APP 壳里仍优先桥(send 等走 GUI 直连 native 更稳)。 */
if (preferHttp) {
try {
return await callHttp(tag, body, timeoutMs);
} catch (eHttp) {
if (!hasBridge) throw eHttp;
try {
return await callBridge(tag, body, timeoutMs);
} catch (eBridge) {
/* 两次都失败:优先抛 HTTP 错(与 isTransient 口径一致) */
throw eHttp;
}
}
}
if (hasBridge) {
const tBridge = performance.now();
try {
return await callBridge(tag, body, timeoutMs);
} catch (e) {
const err = e instanceof Error ? e : new Error(String(e));
if (!err.transient && !/desktop bridge unavailable/i.test(err.message || "")) {
recordSlow("bridge:/" + tag, performance.now() - tBridge, err.message);
}
/* 落到 HTTP 兜底 */
}
}
return callHttp(tag, body, timeoutMs);
}
/** ok:false 是后端的常规业务错误信道(HTTP 仍是 200)。 */
function unwrap(json, what) {
if (!json || json.ok !== true) {
const msg = (json && json.log && json.log.length) ? json.log.join(" / ") : (what + "失败");
const err = new Error(msg);
/* "后台 PDO 循环未启动" 是可自愈的:下一拍 ensureStarted 会重开,
* 归为瞬时错误,避免刚切过来的一瞬间就把页面判成断线。 */
err.transient = /未启动|周期异常|排队|busy/i.test(msg);
/* 可自愈业务态:循环未起/未连接/忙/排队 —— ensureStarted/probe 会重开。
* 把这些算硬失败会在高压毛刺下 5 拍内把页面判成断线(实测踩过)。 */
err.transient = /未启动|未连接|周期异常|排队|busy|暂时|稍后|重试|超时|timed?\s*out|bridge|序列化/i.test(msg);
throw err;
}
return json;
@@ -211,8 +284,58 @@ export function createEcatTransport(ctx) {
return r;
}
/**
* 轻量健康:native/health 只读缓存(不抢 g_bus_lock、不 loop_stop)。
* bus_open + loop=1 即后台 PDO 循环在跑,从站看门狗正被喂着 —— 这时候
* 再打 motor/start 反而有害:它会先 loop_stop 再核实/重进 OP,热路径上
* 既慢又可能撞前端 AbortSignal 假超时,文档也写明「已连接只复用」。
*/
async function readHealth() {
const h = await api("native/health", {}, 2500);
if (!h || h.ok !== true) return null;
return {
busOpen: !!h.bus_open,
op: !!h.op,
loop: !!h.loop,
cycles: Number(h.cycles) || 0,
};
}
/** 总线已开 + PDO 循环在跑 → 视为链路就绪,跳过 motor/start。 */
async function isBusHealthy() {
try {
const h = await readHealth();
return !!(h && h.busOpen && h.loop);
} catch (_) {
return false;
}
}
/** 从 motor/state 快照同步周期/自旋/钉核,供状态栏在跳过 start 后仍显示正确。 */
function syncFromSnapshot(snap) {
if (!snap) return;
if (snap.period_ms > 0) periodMs = snap.period_ms;
if (snap.slot_period_ms) slotPeriodMs = snap.slot_period_ms;
if (typeof snap.spin === "boolean") spinOn = snap.spin;
if (typeof snap.pcore_mask === "number") pcoreMask = snap.pcore_mask;
lastSnapshot = snap;
}
/** 确保已进 OP 且后台 PDO 循环在跑(1000ms 过程数据看门狗要求持续交换)。 */
async function ensureStarted(reqPeriodMs) {
/* 日常接入(无显式周期覆盖):循环还在跑就别动总线。
* motor/start 会先 loop_stop,停手 >1s 看门狗踢出 OP,重进还可能楔死邮箱。 */
if (!reqPeriodMs) {
if (await isBusHealthy()) {
started = true;
try {
const snap = unwrap(await api("motor/state", {}, 2500), "读电机快照");
syncFromSnapshot(snap);
return snap;
} catch (_) { /* 健康位说在跑但快照暂不可读:仍按已启动处理 */ }
return { ok: true, period_ms: periodMs, spin: spinOn };
}
}
const body = {};
/* PDO 周期不跟前端刷新率走:过程数据必须持续喂看门狗,
* 而界面刷新只是"读快照"。默认 4ms 由后端决定,这里只在显式给值时覆盖。
@@ -227,8 +350,21 @@ export function createEcatTransport(ctx) {
localStorage.setItem(LS_PERIOD_MIG, "1");
}
const v = parseFloat(localStorage.getItem(LS_PERIOD));
if (v > 0) body.period_ms = v;
} catch (_) { /* 隐私模式忽略 */ }
/* 用户显式设过才用;否则固定 0.25ms —— 1ms+DC 实测发送出口仍约 1s */
if (v > 0 && v !== 1) body.period_ms = v;
else body.period_ms = 0.25;
} catch (_) { body.period_ms = 0.25; }
}
/* EtherCAT 控制路径必须 DC SYNC0=250µs:
* free-run 或 DC=1ms 时,GD32 processOutput 消费输出 PDO 会拖到 ~1s;
* DC=250µs 时 motor/send → CAN 出口约 2ms。 */
{
let per = Number(body.period_ms) || 0.25;
if (per > 0.5) per = 0.25; /* 过慢周期直接钳回 250µs 档 */
body.period_ms = per;
body.dc_cycle_us = Math.round(per * 1000);
if (body.dc_cycle_us < 250) body.dc_cycle_us = 250;
body.dc_shift_us = 0;
}
/* 节拍源覆盖:只有用户显式设过才发,否则交给后端按周期自动决定
* (≤2ms 自旋)。存 "0" 可以强制回到省 CPU 的内核睡眠。 */
@@ -247,8 +383,14 @@ export function createEcatTransport(ctx) {
return r;
}
/** 从批量接收区/长帧快照来的帧:按 CAN ID 后 16 位分派到各累积器。 */
function dispatchFrame(id, dlc, dataHex) {
function xrxChangeKey(x) {
if (!x || !x.data) return "";
return x.id + "|" + x.dlc + "|" + x.data;
}
/** 从批量接收区/长帧快照来的帧:按 CAN ID 后 16 位分派到各累积器。
* fromXrx=true 表示段3快照复读,调用方须先做内容变更判断。 */
function dispatchFrame(id, dlc, dataHex, fromXrx) {
if (!dataHex) return;
const node = (id >> 16) & 0xF;
const sub = id & 0xFFFF;
@@ -256,10 +398,22 @@ export function createEcatTransport(ctx) {
configs.set(node, dataHex); // CONFIG 目标值回报
} else if (sub === 0x00FF) {
const prev = versions.get(node);
/* drainRx / xrx 内容变化 = 新到达或新快照,写入 versions 供查询提取。
* 同内容 xrx 复读:只刷新 at,不 bump seq(避免 seq 虚高)。 */
if (fromXrx && prev && prev.version === dataHex && prev.seq != null) {
versions.set(node, {
node,
seq: (prev ? prev.seq : 0) + 1, // 自建 seq:让上层"seq 增长=新应答"逻辑通用
seq: prev.seq,
version: dataHex,
at: Date.now(),
});
return;
}
versions.set(node, {
node,
seq: (prev && prev.seq != null) ? prev.seq + 1 : 1,
version: dataHex,
at: Date.now(),
});
} else if (sub === 0x0400 || sub === 0x0401) {
nodeIdReports.set(node, dataHex);
@@ -269,64 +423,101 @@ export function createEcatTransport(ctx) {
/** 消费批量接收区(取走即移除,语义是消费不是查看)。 */
async function drainRx() {
const r = await api("motor/rx", {}, 3000);
if (!r || !r.frames) return;
if (!r || !r.frames) return 0;
let n = 0;
for (const f of r.frames) {
if (!f.valid) continue;
/* STATUS 已由状态窗口按槽聚合,重复解一遍纯属浪费 */
if ((f.id & 0xFFFF) === 0x0100) continue;
dispatchFrame(f.id, f.dlc, f.data);
dispatchFrame(f.id, f.dlc, f.data, false);
n++;
}
return n;
}
/**
* 快照 → WiFi 的 /motor/state 契约。
* 关键点:online 不能直接用从站给的标志位,还要叠一层 seq 判活
* (阈值随实测回报周期自适应,见 refresh-rate.js 的 staleLimitMs)。
*
* ★ 快照槽位会「闪没」:dense 窗口 win_count 偶发 3→2(实测 wkc 仍 3/3),
* 电机整只从 motors[] 消失。若直接映射,UI 立刻「节点离线」+波形虚线。
* 这里对**刚见过**的槽做 last-known 保持:缺席但仍落在 staleLimit 窗口内
* → 沿用上一拍 payload 且 online=true;超过窗口才真离线。
*/
const lastSlot = new Map(); // "ch:node" -> {node,ch,dlc,seq,data}
const lastOnlineAt = new Map(); // "ch:node" -> 最近一次判定 online 的时刻
/** 从站 online=false 时的软保持:刚在线过又瞬时掉标志,不立刻翻离线 */
const ONLINE_FALSE_HOLD_MS = 500;
function snapshotToMotorState(snap) {
const now = Date.now();
const motors = [];
const present = new Set();
for (const m of (snap.motors || [])) {
const key = m.ch + ":" + m.node;
present.add(key);
const prev = seqSeen.get(key);
let rx = prev ? prev.rx : 0;
let at = prev ? prev.at : now;
let iv = prev ? prev.iv : 0;
if (!prev || prev.seq !== m.seq) {
if (prev) {
/* 实测变化间隔 EWMA(α=0.3):判活阈值跟着电机回报率走。电机改回报
* 档后 ~3 次新间隔收敛;突发降速无法预知,收敛前最多闪一次离线。 */
const d = now - prev.at;
iv = iv ? iv + (d - iv) * 0.3 : d;
}
rx = rx + 1; // 本地累计"看到过多少次更新",补 WiFi 的 rx_count 语义
rx = rx + 1;
at = now;
}
seqSeen.set(key, { seq: m.seq, at, rx, iv });
/* 从站说在线 **且** seq 还在动才算在线:快照永不删除历史槽。
* 阈值随实测回报周期放大(慢速电机不误判),下限 2s 兜底快周期。 */
const fresh = (now - at) <= staleLimitMs(iv);
let online = !!m.online && fresh;
if (online) lastOnlineAt.set(key, now);
else if ((now - (lastOnlineAt.get(key) || 0)) < ONLINE_FALSE_HOLD_MS) {
online = true; /* 软保持:标志瞬时 false 不立刻打脸 UI */
}
lastSlot.set(key, {
node: m.node, ch: m.ch, dlc: m.dlc, seq: m.seq, data: m.data,
});
motors.push({
node: m.node,
ch: m.ch,
/* 从站说在线 **且** seq 还在动才算在线:快照永不删除历史槽 */
online: !!m.online && fresh,
online,
dlc: m.dlc,
rx_count: rx,
seq: m.seq,
data: m.data,
/* CONFIG 从批量接收区累积而来(WiFi 侧是每电机自带的) */
config_data: configs.get(m.node) || "",
config_seq: 0,
config_rx_count: 0,
});
}
/* 本拍缺席的槽:仍在 stale 窗口内 → last-known 保持在线 */
for (const [key, slot] of lastSlot) {
if (present.has(key)) continue;
const prev = seqSeen.get(key);
if (!prev) continue;
if ((now - prev.at) > staleLimitMs(prev.iv)) continue;
motors.push({
node: slot.node,
ch: slot.ch,
online: true,
dlc: slot.dlc,
rx_count: prev.rx,
seq: prev.seq,
data: slot.data,
config_data: configs.get(slot.node) || "",
config_seq: 0,
config_rx_count: 0,
});
}
const head = snap.head || {};
return {
have_data: motors.length > 0,
active_count: head.active || 0,
/* 快照 ts 是服务端 time.time()(秒);与本地时钟比会受偏差影响,
* 只用它算个量级即可,UI 也只把 age 当"有没有数据"的粗指标。 */
age_ms: snap.ts ? Math.max(0, Math.round(now - snap.ts * 1000)) : null,
boot_active: !!head.boot_active,
motors,
@@ -342,24 +533,66 @@ export function createEcatTransport(ctx) {
supportsPeriodSync: false,
sendHint(ch) {
return "POST /api/motor/send \u00b7 CH" + ch +
(periodMs ? " \u00b7 PDO " + fmtPeriod(periodMs) : "");
return desktopBridgeLabel() + " · motor/send · CH" + ch +
(periodMs ? " · PDO " + fmtPeriod(periodMs) : "");
},
/**
* 连接探测 —— 一路自动到能用:
* 服务没起 → 代理侧按需拉起(esp32_server._ecat_autostart)
* 网卡没开 → ensureOpen() 代替用户点"连接"
* 没进 OP → ensureStarted() 里 motor/start 自己走 PREOP→SAFEOP→OP
* 循环没跑 → ensureStarted() 起后台 PDO 循环(喂 1000ms 看门狗)
* 四步都自动,用户切到 EtherCAT 链路就能直接用,不必先开别的页面点两下。
* 循环已在跑 → 直接复用(健康位只读,**不**再打 motor/start)
* 没进 OP / 循环没跑 → ensureStarted() 走 motor/start
* 用户切到 EtherCAT 链路就能直接用,不必先开别的页面点两下。
*
* 只有 /api/status 通不算连上 —— 循环没跑就读不到任何电机状态,
* 那样 LED 会绿着但界面全是 "--",比直接报未连接更难排查。
* 只有 /api/status 通不算「从站在线」—— 循环没跑、从站没进 OP 时界面全是
* "--"。probe/healthCheck 只保证主站链路可用(刷写也要能进页面);
* 工具栏在线灯由 main.js 的 isTargetOnline 按 AL/op/STATUS 判定。
*
* **为什么健康时必须跳过 motor/start**:它在 _BUS_EXCLUSIVE 语义下会先
* loop_stop 再核实 OP/重进;外壳 Owner 可能已把总线跑起来,页面每次探测
* 都去停一次循环,既假超时(横幅「signal timed out」)又增加 SSC 楔死风险。
*/
async probe() {
const st = await ensureOpen();
await ensureStarted();
/* 把 native health 的 op 位带给 UI:在线灯要区分「主站栈在跑」和「从站在 OP」 */
try {
const h = await readHealth();
if (st && h && st.__op == null) st.__op = !!h.op;
} catch (_) { /* 健康位读不到时靠 status 的 AL 字段 */ }
return st;
},
/**
* 轻量健康复核(probe 超时后的「其实已经连上了」判定)。
* 只读 native/health + status + motor/state,绝不 motor/start、不动总线。
* 成功返回 status 数据(形状与 probe 一致);不健康则抛错。
* 注意:这里只判断「主站链路可用」,从站是否在线由 UI 的 isTargetOnline
* 按 AL/op/STATUS 再判 —— 刷写场景从站可以停在 PREOP/BOOT 仍需页面可用。
*/
async healthCheck() {
const h = await readHealth();
if (!h || !h.busOpen) throw new Error("EtherCAT 服务未打开网卡");
if (!h.loop) throw new Error("PDO 循环未运行");
/* 自愈:DC/周期被配错(free-run 或 ≥1ms)时发送出口会到秒级 —— 拉回 250µs */
try {
const raw = await api("native/health", {}, 2000);
const dc = raw && raw.dc;
const dcs = raw && raw.dc_cycle_us;
const per = raw && raw.period_ms;
if (dc !== 1 || dcs !== 250 || (per != null && Math.abs(per - 0.25) > 0.001)) {
await api("motor/start", { period_ms: 0.25, dc_cycle_us: 250, dc_shift_us: 0 }, 30000);
}
} catch (_) { /* 自愈失败仍按 health 结果返回 */ }
const st = await api("status", {}, 2500);
if (!st || st.ok !== true) throw new Error("从站状态不可读");
if (st && typeof st === "object") st.__op = !!h.op;
started = true;
try {
const snap = await api("motor/state", {}, 2500);
if (snap && snap.ok === true) syncFromSnapshot(snap);
} catch (_) { /* 快照暂不可读不影响健康判定 */ }
return st;
},
@@ -407,14 +640,34 @@ export function createEcatTransport(ctx) {
if (snap.period_ms > 0) periodMs = snap.period_ms;
if (typeof snap.spin === "boolean") spinOn = snap.spin;
if (typeof snap.pcore_mask === "number") pcoreMask = snap.pcore_mask;
/* 有帧待取才去消费,省一次往返 */
/* 消费批量接收区。
* queued = 队列深度(可取帧数);count/pending 是本拍 PDO 新增/溢出。
* 三者任一有值才 drain。旧后端无 queued 时,查询等待期靠 drainPendingRx 兜底。 */
const rxb = snap.rx_batch;
if (rxb && (rxb.count > 0 || rxb.pending > 0)) {
const needDrain = !!(rxb && (
rxb.count > 0 ||
rxb.pending > 0 ||
(typeof rxb.queued === "number" && rxb.queued > 0)
));
/* 节流 drain:6ms 档下每拍 drain 会把 HTTP 请求量翻倍。
* queued 很深(积压)时强制立刻取,避免版本/CONFIG 应答延迟。 */
const nowDrain = Date.now();
const backlog = (rxb && (rxb.queued || rxb.pending || rxb.count)) || 0;
if (needDrain && (backlog >= 32 || (nowDrain - lastDrainAt) >= DRAIN_MIN_MS)) {
lastDrainAt = nowDrain;
try { await drainRx(); } catch (e) { /* 瞬时失败下拍再取 */ }
}
/* 长帧快照(CONFIG/版本这类 DLC24 帧在 8B 短快照里会被截断)也要分派 */
if (snap.xrx && snap.xrx.data) {
dispatchFrame(snap.xrx.id, snap.xrx.dlc, snap.xrx.data);
/* 段3 xrx:通用长帧快照(CONFIG/版本/节点ID)。
* 内容变化时分派进 configs/versions;查询路径另用 peekVersionXrx
* 直接读 snap,不依赖这里的 lastXrxKey 去重。 */
const xk = xrxChangeKey(snap.xrx);
if (xk) {
if (xk !== lastXrxKey) {
lastXrxKey = xk;
dispatchFrame(snap.xrx.id, snap.xrx.dlc, snap.xrx.data, true);
}
} else {
lastXrxKey = "";
}
return snapshotToMotorState(snap);
},
@@ -493,6 +746,42 @@ export function createEcatTransport(ctx) {
return versions.get(node) || null;
},
/** 版本查询等待期强制消费 motor/rx(不依赖 rx_batch.count)。 */
drainPendingRx() {
return drainRx();
},
/**
* 直接看 PDO 段3 xrx 是否正带着「本节点」的 0x00FF。
* xrx 是共享快照、不会被 drain 消费;seq 不增长时也能命中。
* 用于破解「xrx 上是别的节点版本 / 同内容不 bump seq」导致的阵发全败。
*/
async peekVersionXrx(node) {
try {
const st = await api("motor/state", {}, 2500);
lastSnapshot = st;
if (st && st.period_ms > 0) periodMs = st.period_ms;
const x = st && st.xrx;
if (!x || !x.data) return null;
const id = (x.id >>> 0);
if (((id >> 16) & 0xF) !== node || (id & 0xFFFF) !== 0x00FF) return null;
const prev = versions.get(node);
if (!prev || prev.version !== x.data) {
dispatchFrame(id, x.dlc, x.data, true);
}
const cur = versions.get(node);
return {
node,
seq: cur && cur.seq != null ? cur.seq : (prev && prev.seq != null ? prev.seq : 1),
version: x.data,
at: Date.now(),
src: "xrx",
};
} catch (_) {
return null;
}
},
/** 节点 ID 应答:异步到达,取走即清(供 main.js 在发完 GET 后短轮询)。 */
takeNodeIdReport(node) {
const hex = nodeIdReports.get(node);
@@ -554,22 +843,17 @@ export function createEcatTransport(ctx) {
},
/**
* 热换 PDO 周期(毫秒,允许亚毫秒:0.25 = 4kHz)。真机总线一送一回约
* 165µs,比这更快会被循环自然自限,报多少都不会弄坏东西。
*
* 走 /api/motor/period 而不是 /api/motor/start,理由与 setSpin 相同:
* motor/start 在总线互斥表里会先停循环,停手 >1s 从站掉 OP、重进可能楔死;
* 周期只是循环每拍重读的变量,热改全程喂狗不断流,随时可调可反复调。
* 亚毫秒周期自动就是自旋节拍(内核睡眠粒度 ~0.5ms 跑不出 250µs)。
*
* 取值存 localStorage:后端重启后 ensureStarted 会原样带回。
* 恢复默认:删掉 dcu_ecat_period_ms,跟随后端默认(固件修好前别低于 1ms)。
* 热换 PDO 周期(毫秒)。硬顶 0.5ms:≥1ms 时 GD32 输出 PDO 消费会拖到秒级。
* 后端 motor/period 会同步把 DC SYNC0 对齐到同一 µs 值。
*/
async setPdoPeriod(ms) {
const v = Number(ms);
let v = Number(ms);
if (!(v > 0)) throw new Error("周期必须是 >0 的毫秒数,收到: " + ms);
if (v > 0.25) v = 0.25; /* 钉死 250µs,杜绝 0.5/1ms */
if (v < 0.0625) v = 0.25;
const r = unwrap(await api("motor/period", { period_ms: v }, 8000), "热改 PDO 周期");
if (typeof r.period_ms === "number") periodMs = r.period_ms;
else periodMs = v;
if (typeof r.spin === "boolean") spinOn = r.spin;
try { localStorage.setItem(LS_PERIOD, String(periodMs)); } catch (_) {}
return periodMs;
+3 -2
View File
@@ -19,7 +19,8 @@
* fetchFeedback() -> {channels:[{ch,enabled,valid,rx,tx,err,tec,rec,bus_state}]}
* sendFrames(frames, {ch}) -> results[] frames=[{id,dlc,data}]
* sendBurst(frames, {suffix,dlc}) -> results[]
* fetchVersionNode(node) -> {node,seq,version}|null
* fetchVersionNode(node) -> {node,seq,version,at?}|null
* 可选:drainPendingRx() -> Promise 版本等待期强制消费批量接收区(ECAT)
* setPeriod(ms) -> void 不支持则空实现
* teardown() -> void 切走时收尾
* 可选:takeNodeIdReport(node)、debug()
@@ -30,7 +31,7 @@
"use strict";
import { createWifiTransport } from "./transport-wifi.js";
import { createEcatTransport } from "./transport-ecat.js";
import { createEcatTransport } from "./transport-ecat.js?v=20260611c";
/** localStorage key:记住上次选的传输,刷新页面后保持 */
export const LS_TRANSPORT = "dcu_motor_transport";
+103 -3
View File
@@ -11,6 +11,9 @@
* 0x04/0x02 改基址、段间空洞填 0xFF、逐行校验和),并用 checkGd32ImageFile 拦掉
* 「引导程序包 / 向量表非法」。所以这里只读扁平字节,不再抄一份 HEX 解析器 ——
* 解析器抄两份一定会漂,而灌错包的后果是"刷成功但永远跳不起来"。
* ⚠ 但刷前必须按 FSAPI 句柄重读磁盘(readFreshImage):本地重编会覆盖同路径的 .hex,
* 页面里的 window.gd32File 还是"选文件那一刻"的旧内容,不复读就会把旧包灌进 A 区,
* 表现为「控制台全绿、板子版本/Build 不变」。与 upload-esp32/BLE/电机 OTA 同一条规矩。
*
* 为什么点「直灌」时才 open+push,而不是选完文件就推:
* native 的镜像缓冲跨请求存活(op=abort 才丢)。选文件时推 = 服务端可能留着上一轮
@@ -160,8 +163,18 @@ function setRoleBadge(role, sw) {
if (swEl) swEl.textContent = sw || (r === 'unknown' ? '—' : '版本未知');
}
/** @description 复用 GD32 那条路的文件控件:同一条 processFile 做 .hex→bin 与镜像校验。 */
/** @description 复用 GD32 那条路的文件控件:同一条 processFile 做 .hex→bin 与镜像校验。
* 优先 FSAPI 选择器(与共享模式同一形状):它留下句柄并持久化,本卡片刷前才能
* "热重载"按句柄重读磁盘;传统 <input> 选完即无句柄,重编后会退化成中止+提示。 */
export function bootEcatPick() {
if (window.showOpenFilePicker && typeof window.pickFileViaFSAPI === 'function') {
Promise.resolve(window.pickFileViaFSAPI('gd32')).catch(function (e) {
if (e && e.name !== 'AbortError') {
window.addLog('W', 'ECAT刷写', 'FSAPI 选择失败: ' + (e.message || e) + ',可改用上传区重新选择');
}
});
return;
}
var input = el('fileInputGd32');
if (!input) {
window.showToast('找不到 GD32 文件选择控件', 'error');
@@ -232,7 +245,10 @@ function setEcatPct(p, msg) {
var fill = el('progressFill');
var pct = el('progressPct');
var txt = el('progressTxt');
if (fill) fill.style.width = n + '%';
if (fill) {
fill.style.width = n + '%';
fill.classList.toggle('live', n > 0 && n < 100);
}
if (pct) pct.textContent = n + '%';
if (txt && msg != null && msg !== '') txt.textContent = msg;
}
@@ -379,6 +395,63 @@ function clearArm() {
if (b) { b.textContent = 'EtherCAT 刷写 APP'; b.className = 'btn btn-block'; }
}
/**
* @description 刷前从磁盘重读固件(与 WiFi/BLE/电机 OTA 同一条规矩)。
* 本地重编会覆盖同路径的 .hex,而 window.gd32File 只是"选文件那一刻"的 File 对象,
* 之后磁盘变了它不会跟着变 —— 直接拿它刷,灌进去的是上次那包,表现为
* 「控制台全绿、板子版本/Build 不变」(2026-09-18 现场)。
* @return {Promise<Uint8Array|null>} null = 拿不到可信镜像,调用方必须中止
* (此刻总线未动、A 区未擦,中止零代价)。
*/
async function readFreshImage() {
var handle = window.gd32FileHandle;
if (!handle) {
/* 拖拽/传统文件框选的:没有句柄就无从确认磁盘上现在是什么,而本路径
* begin 会先整区擦 A 区 —— 比 WiFi 那条(警告后继续)严格一档,直接收手。 */
window.addLog('E', 'ECAT刷写',
'无法确认镜像是否为最新:当前文件未经文件系统句柄选择(拖拽/传统文件框)。' +
'请点上面「GD32 固件」那张卡的上传区重新选择后再刷 —— ' +
'本路径会先擦 A 区,拿旧包灌写等于白刷');
window.showToast('请重新选择 GD32 固件后再刷', 'error');
return null;
}
var fresh;
try {
fresh = await handle.getFile();
} catch (e) {
window.addLog('E', 'ECAT刷写',
'重新读取固件失败:' + (e && e.message ? e.message : e) +
'(磁盘上那个产物被移动/删除?)—— 已收手,一个字节都没写');
window.showToast('固件重新读取失败,请重新选择', 'error');
return null;
}
try {
/* 统一走 processFile:.hex 按最新源文件重转 BIN,.bin 也过同一道身份校验
* (checkGd32ImageFile 挡「引导程序包 / 向量表非法」),结果写回 window.gd32File。
* 与 upload-esp32 的热重载分支同款。 */
if (typeof window.processFile !== 'function') {
throw new Error('processFile 不可用');
}
await window.processFile(fresh, 'gd32', { silent: true, skipHandlePersist: true });
} catch (e) {
window.addLog('E', 'ECAT刷写', '重读的镜像解析失败:' + (e && e.message ? e.message : e));
window.showToast('固件解析失败,请重新选择', 'error');
return null;
}
var file = window.gd32File;
if (!file) {
window.addLog('E', 'ECAT刷写',
'重读的镜像未通过 GD32 身份校验(引导程序包/向量表非法)—— 已收手,一个字节都没写');
return null;
}
var mtime = file.lastModified
? new Date(file.lastModified).toLocaleTimeString('zh-CN', { hour12: false })
: '未知时间';
window.addLog('I', 'ECAT刷写',
'镜像热重载: ' + file.name + '(' + bytes(file.size) + ',磁盘 mtime ' + mtime + ')');
return new Uint8Array(await file.arrayBuffer());
}
/**
* @description 七步一次点完:读镜像 → 开总线并把镜像收进主站 → 交接进 BOOT
* → 回读身份 → 邮箱灌写(run) → 从站复位 → 回读确认。
@@ -407,6 +480,11 @@ export async function bootEcatFlash() {
clearArm();
window.bootEcatBusy = true;
/* 置跨页"刷写中"标记(读侧见 common/utils.js flashBusyNow):交接与 BOOT↔APP
* 期间 GD32 刚复位,ESP32 的 CAN 反馈查询(0xC1)必被链路层拒,问了也是白问、
* 还会在日志里刷屏 —— CAN/电机页的 1Hz 轮询据此避让。
* 带到期时间(10min,覆盖最坏灌写 + probe 自愈),页面异常也不会把标记挂死。 */
try { localStorage.setItem('dcu_flash_busy_until', String(Date.now() + 10 * 60 * 1000)); } catch (_) {}
refreshBootEcatUi();
var runBtn = el('bootEcatRunBtn');
if (runBtn) { runBtn.disabled = true; runBtn.textContent = '刷写中…'; }
@@ -417,7 +495,15 @@ export async function bootEcatFlash() {
setEcatStage(1, 'live', '读取中');
setEcatStatus('active', '读取镜像…');
try {
var u8 = new Uint8Array(await file.arrayBuffer());
/* 步骤 1:按句柄从磁盘重读镜像 —— 不能信页面缓存(见 readFreshImage 注) */
var u8 = await readFreshImage();
if (!u8 || u8.length === 0) {
setEcatStage(1, 'err', '未取得镜像');
setEcatStatus('err', '镜像没就绪');
setEcatPct(0, '没拿到可信镜像 —— 一个字节都没写(见日志)');
return;
}
file = window.gd32File; /* readFreshImage 已把重读结果写回共享槽 */
setEcatStage(1, 'done', bytes(u8.length));
setEcatStage(2, 'live', '开总线');
setEcatStatus('active', '打开主站总线…');
@@ -429,6 +515,10 @@ export async function bootEcatFlash() {
return;
}
setEcatStage(2, 'live', 'open');
/* 刷前身份读数(读数,不是门):记下 0x1008/0x100A,末尾与刷后比对 ——
* 「刷完版本没变」要能被控制台自证,而不是只看流程全绿。
* 此拍在交接之前,BOOT 那 10s 窗口还没开始计时。 */
var pre = await bootEcatProbe(false);
setEcatStatus('active', '推送到主站…');
setEcatPct(8, '打开主站缓冲…');
var o = await api({ op: 'open', size: u8.length });
@@ -579,6 +669,14 @@ export async function bootEcatFlash() {
setEcatStage(7, 'done', '完成');
setEcatStatus('ok', '刷写完成');
setEcatPct(100, '新 APP 已在总线上应答 · 0x100A = ' + (after.sw || '—'));
/* 刷前刷后版本一致 = 灌进去的多半还是原来那包(页面缓存旧文件/选错包),
* 流程全绿说明不了"换了版本" —— 补一条可观察告警(只提示,不拦:同版本重刷合法)。 */
if (pre && pre.sw && after.sw && after.sw === pre.sw) {
window.addLog('W', 'ECAT刷写',
'0x100A 刷前刷后都是 ' + after.sw + ' —— 若这次是换版本/改代码,' +
'确认刷的是最新镜像(本页刷前已按句柄重读磁盘;仍相同则应查构建产物是否真被重编)');
window.showToast('刷前刷后版本号相同,请确认刷的是最新固件', 'warning');
}
} else if (okRun) {
/* commit 成功只证明 A 区写进去了;邮箱读不到 APP 名 = EtherCAT 没恢复,
* 对用户就是「不能用」。这里禁止画成半成功:进度钉在提交完成那一档,
@@ -612,6 +710,8 @@ export async function bootEcatFlash() {
window.addLog('E', 'ECAT刷写', '刷写请求异常:' + e);
} finally {
window.bootEcatBusy = false;
/* 刷写收尾:撤掉跨页"刷写中"标记(正常与异常路径都走这里) */
try { localStorage.removeItem('dcu_flash_busy_until'); } catch (_) {}
if (runBtn) {
runBtn.textContent = 'EtherCAT 刷写 APP';
runBtn.className = 'btn btn-block';
+5 -1
View File
@@ -241,7 +241,11 @@ export function setProgress(pct, text) {
if (!progressFillEl || !progressFillEl.isConnected) progressFillEl = document.getElementById('progressFill');
if (!progressTxtEl || !progressTxtEl.isConnected) progressTxtEl = document.getElementById('progressTxt');
if (!progressPctEl || !progressPctEl.isConnected) progressPctEl = document.getElementById('progressPct');
if (progressFillEl) progressFillEl.style.width = n + '%';
if (progressFillEl) {
progressFillEl.style.width = n + '%';
/* 斜纹/扫光按刷新率重绘,只在 0<n<100 的真传输窗口才挂 */
progressFillEl.classList.toggle('live', n > 0 && n < 100);
}
if (progressTxtEl) progressTxtEl.textContent = text;
if (progressPctEl) progressPctEl.textContent = n + '%';
}
+2 -2
View File
@@ -28,8 +28,8 @@
<link rel="icon" href="data:,">
<link rel="stylesheet" href="../css/base.css?v=857f83a5">
<link rel="stylesheet" href="../css/layout.css?v=acd17001">
<link rel="stylesheet" href="../css/components.css?v=a2c3d4e5">
<link rel="stylesheet" href="../css/responsive.css?v=46449483">
<link rel="stylesheet" href="../css/components.css?v=quiet20260919a">
<link rel="stylesheet" href="../css/responsive.css?v=quiet20260919a">
<style>
/* 本页专属少量补充(令牌/组件全走 base/layout/components) */
.pm-grid { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; margin-bottom:14px; }
+163 -30
View File
@@ -720,11 +720,23 @@ function ls(key, val) { // localStorage 读写包装(隐私模式会
try { if (val === undefined) return window.localStorage.getItem(key); window.localStorage.setItem(key, val); }
catch (_) { return null; }
}
/* 面板开关持久化:刷新后恢复。冻结默认不恢复(冻住空面板没意义),其余全存。 */
/* 面板开关持久化:localStorage 快路径 + 磁盘 data/spi_test_ui_state.json。
* 保存:实时观测 / 冻结 / 原始窗口 / hex·帧重建 / 逐拍环 / 采样率 / PDO 档位 / SPI 测参。
* PDO 档位同时写共享键 dcu_ecat_period_ms,电机页与 EtherCAT 后端启动时同源。 */
const UI_OPT_KEY = "spi_test_ui_opts";
function saveUiOpts() {
const LAB_UI_API = "/api/lab/ui-state";
const LAB_UI_PATH_LABEL = "data/spi_test_ui_state.json";
const LS_ECAT_PERIOD = "dcu_ecat_period_ms";
let labOptsHadSave = false;
let _labDiskTimer = null, _labDiskPayload = null;
function collectUiOpts() {
const gv = (id) => { const n = document.getElementById(id); return n && n.value !== undefined ? n.value : ""; };
ls(UI_OPT_KEY, JSON.stringify({
return {
savedAt: Date.now(),
v: 2,
live: !!(E.wantLive || E.live),
frozen: !!E.frozen,
rxraw: !!E.rxraw,
heavyHex: !!E.heavyHex,
heavyFrames: !!E.heavyFrames,
@@ -736,26 +748,20 @@ function saveUiOpts() {
frames: gv("frames"),
payload: gv("payload"),
spiTimeout: gv("spiTimeout"),
}));
};
}
function applyToggleBtn(id, on, label) {
const n = document.getElementById(id);
if (!n) return;
n.classList.toggle("on", !!on);
n.setAttribute("aria-pressed", on ? "true" : "false");
const em = n.querySelector("em");
if (em) em.textContent = label;
else n.textContent = label + (on ? ":开" : ":关");
}
function restoreUiOpts() {
let o = null;
try { o = JSON.parse(ls(UI_OPT_KEY) || "null"); } catch (_) { o = null; }
if (!o) return;
/** 把快照应用到 E + DOM(localStorage 恢复 / 磁盘 hydrate 共用)。 */
function applyUiOpts(o) {
if (!o || typeof o !== "object") return;
labOptsHadSave = true;
E.rxraw = !!o.rxraw;
E.heavyHex = !!o.heavyHex;
E.heavyFrames = !!o.heavyFrames;
E.ring = !!o.ring;
E.frozen = false; /* 刷新后解冻,避免冻在「等待」占位 */
/* 冻结也恢复:用户明确要求按钮状态保存。空面板冻住无妨,解冻一键即可。 */
E.frozen = !!o.frozen;
E.wantLive = o.live !== undefined ? !!o.live : E.wantLive;
E.chartOpt = E.chartOpt || { max: false, skip: false };
E.chartOpt.max = !!o.chartMax;
E.chartOpt.skip = !!o.chartSkip;
@@ -765,10 +771,22 @@ function restoreUiOpts() {
applyToggleBtn("btnRing", E.ring, "逐拍环");
applyToggleBtn("btnChartMax", E.chartOpt.max, "单拍 max");
applyToggleBtn("btnChartSkip", E.chartOpt.skip, "跳拍");
applyToggleBtn("btnLive", !!(E.live || E.wantLive), "实时观测");
setFreezeUi();
const hz = document.getElementById("hz");
if (hz && o.hz !== undefined && o.hz !== "") hz.value = o.hz;
const pick = document.getElementById("periodPick");
if (pick && o.periodPick !== undefined) pick.value = o.periodPick;
if (pick && o.periodPick !== undefined && o.periodPick !== null) pick.value = o.periodPick;
/* PDO 档位与电机页/后端共享:没写 periodPick 时用共享键兜底 */
if (pick && (pick.value === "" || pick.value == null)) {
try {
const shared = window.localStorage.getItem(LS_ECAT_PERIOD);
if (shared) pick.value = shared;
} catch (_) {}
}
if (pick && pick.value) {
try { window.localStorage.setItem(LS_ECAT_PERIOD, String(pick.value)); } catch (_) {}
}
const frames = document.getElementById("frames");
if (frames && o.frames) frames.value = o.frames;
const payload = document.getElementById("payload");
@@ -781,6 +799,92 @@ function restoreUiOpts() {
}
}
function writePeriodShared(val) {
if (!val) return;
try { window.localStorage.setItem(LS_ECAT_PERIOD, String(val)); } catch (_) {}
}
function scheduleLabDiskSave(snapshot) {
_labDiskPayload = snapshot || collectUiOpts();
if (_labDiskTimer) clearTimeout(_labDiskTimer);
_labDiskTimer = setTimeout(flushLabDiskSave, 400);
}
function flushLabDiskSave() {
if (_labDiskTimer) { clearTimeout(_labDiskTimer); _labDiskTimer = null; }
const o = _labDiskPayload || collectUiOpts();
_labDiskPayload = null;
const body = JSON.stringify(o);
try {
fetch(LAB_UI_API, {
method: "POST",
headers: { "Content-Type": "application/json" },
body,
keepalive: true,
}).catch(() => {
try {
const blob = new Blob([body], { type: "application/json" });
if (navigator.sendBeacon) navigator.sendBeacon(LAB_UI_API, blob);
} catch (_) {}
});
} catch (_) {
try {
if (navigator.sendBeacon) navigator.sendBeacon(LAB_UI_API, new Blob([body], { type: "application/json" }));
} catch (_) {}
}
}
function saveUiOpts() {
const o = collectUiOpts();
ls(UI_OPT_KEY, JSON.stringify(o));
if (o.periodPick) writePeriodShared(o.periodPick);
scheduleLabDiskSave(o);
}
function restoreUiOpts() {
let o = null;
try { o = JSON.parse(ls(UI_OPT_KEY) || "null"); } catch (_) { o = null; }
if (!o) {
/* localStorage 空(WebView 清存储)→ 先用共享 PDO 档位兜底 */
const p = ls(LS_ECAT_PERIOD);
if (p) o = { periodPick: p, hz: "10", live: true };
}
if (!o) return;
applyUiOpts(o);
}
/** 磁盘优先 hydrate:比 savedAt,新者覆盖 localStorage。 */
async function hydrateLabUiFromDisk() {
let disk = null;
try {
const r = await fetch(LAB_UI_API, { cache: "no-store" });
if (r.ok) {
const j = await r.json();
disk = (j && j.ok && j.state) ? j.state : null;
}
} catch (_) { return; }
if (!disk || typeof disk !== "object") return;
let local = null;
try { local = JSON.parse(ls(UI_OPT_KEY) || "null"); } catch (_) {}
const diskAt = Number(disk.savedAt) || 0;
const localAt = local ? (Number(local.savedAt) || 0) : 0;
if (local && localAt > diskAt) {
applyUiOpts(local);
return;
}
applyUiOpts(disk);
try { ls(UI_OPT_KEY, JSON.stringify(disk)); } catch (_) {}
}
function applyToggleBtn(id, on, label) {
const n = document.getElementById(id);
if (!n) return;
n.classList.toggle("on", !!on);
n.setAttribute("aria-pressed", on ? "true" : "false");
const em = n.querySelector("em");
if (em) em.textContent = label;
else n.textContent = label + (on ? ":开" : ":关");
}
/* ═══════════════════ 面板切换 ═══════════════════ */
const PANES = { spi: $("#pane-spi"), ecat: $("#pane-ecat") };
function showPane(name) {
@@ -789,13 +893,16 @@ function showPane(name) {
ls("speedtest_pane", name);
if (name === "ecat") {
layoutChart();
/* 首次进来自动开;之后只在"被切走时本来是开着"的场合恢复 */
if (!E.everStarted || E.wantLive) startEcatIfIdle();
/* 有保存 → 只按 wantLive 恢复;无保存 → 保持原「首次自动开观测」习惯 */
if (E.wantLive || (!labOptsHadSave && !E.everStarted)) startEcatIfIdle();
} else if (E.live) {
/* 切到 SPI 就把看板轮询停掉:SPI 测的是 µs 级帧周期,这边 N Hz×M 条代理请求
* 会把自己的测量抬高 —— 先排除自己造的假象。 */
const was = E.live; stopEcat(); E.wantLive = was;
/* 切到 SPI:暂停轮询但记住 wantLive,回来自动恢复;不要走 stopEcat
* (会把 wantLive 清成 false 并落盘,刷新后就再也起不来了)。 */
E.live = false; E.pausedByHidden = false;
ticker.stop();
applyToggleBtn("btnLive", false, "实时观测");
setPill("#ecatState", "", "已暂停(在 SPI 页)");
E.wantLive = true;
}
}
for (const b of $$(".tab")) b.onclick = () => showPane(b.dataset.pane);
@@ -1243,12 +1350,14 @@ function startEcatIfIdle() {
eapi("native/health", {}, 6000).then((h) => { E.health = h; renderBus(E.lastState); }).catch(() => { E.health = null; });
ticker.start(1000 / pollHz());
requestTick();
saveUiOpts();
}
function stopEcat() {
E.live = false; E.wantLive = false; E.pausedByHidden = false;
ticker.stop();
applyToggleBtn("btnLive", false, "实时观测");
setPill("#ecatState", "", "已暂停");
saveUiOpts();
}
/* 后台标签页:Worker 定时器不受节流,live 着=持续打代理。暂停但记住 wantLive。 */
function pauseEcatHidden() {
@@ -1322,6 +1431,7 @@ $("#btnFreeze").onclick = () => {
else renderRaw(E.lastState || null, null);
}
setFreezeUi();
saveUiOpts();
};
$("#btnHeavyFrames").onclick = () => {
E.heavyFrames = !E.heavyFrames;
@@ -1345,10 +1455,14 @@ $("#hz").onchange = () => {
if (E.live) ticker.update(1000 / pollHz());
saveUiOpts();
};
/* PDO 档位 / SPI 测参变更即落盘 */
/* PDO 档位 / SPI 测参变更即落盘;档位同时写共享键供电机页/后端启动使用 */
["periodPick", "frames", "payload", "spiTimeout"].forEach((id) => {
const n = document.getElementById(id);
if (n) n.onchange = saveUiOpts;
if (!n) return;
n.onchange = () => {
if (id === "periodPick") writePeriodShared(n.value);
saveUiOpts();
};
});
/* ═══════════════════ 渲染:指标卡 ═══════════════════ */
@@ -2161,6 +2275,10 @@ $("#btnPeriod").onclick = async () => {
if (isNum(eff) && Math.abs(eff - v) < 1e-6) break;
}
const ok = isNum(eff) && Math.abs(eff - v) < 1e-6;
if (ok) {
writePeriodShared(eff);
saveUiOpts();
}
runBusy(ok ? "ok" : "bad", ok ? ("已生效 " + eff + "ms(" + f0(1000 / eff) + "Hz 档)")
: ("下发 " + v + "ms 但回读 " + eff + "ms —— 有他端在设周期(localStorage 劫持)"));
} catch (e) { runBusy("bad", "失败:" + e.message); }
@@ -2178,14 +2296,29 @@ $("#btnRxbClear").onclick = () => {
};
/* ═══════════════════ 初始化 ═══════════════════ */
showPane(ls("speedtest_pane") === "spi" ? "spi" : "ecat");
/* 先恢复配置(采样率/PDO 档位/开关/冻结/是否观测),再进面板 ——
* 否则 showPane 会用默认 10Hz 起观测,再被恢复值改掉却没重排节拍。 */
restoreUiOpts();
layoutChart();
restoreUiOpts(); /* 恢复上次的开关/采样率/曲线选项 */
showPane(ls("speedtest_pane") === "spi" ? "spi" : "ecat");
renderRaw(null, null); /* 面板③先画「未开启」占位,避免两块空白看起来像坏了 */
setFreezeUi();
window.addEventListener("beforeunload", () => { E.live = false; ticker.stop(); saveUiOpts(); });
/* 磁盘更新则覆盖(WebView 清 localStorage 后仍能找回上次档位/开关) */
hydrateLabUiFromDisk().then(() => {
if (PANES.ecat && !PANES.ecat.hidden && E.wantLive && !E.live) startEcatIfIdle();
setFreezeUi();
}).catch(() => {});
window.addEventListener("beforeunload", () => {
E.live = false; ticker.stop();
saveUiOpts();
flushLabDiskSave();
});
window.addEventListener("pagehide", () => { saveUiOpts(); flushLabDiskSave(); });
document.addEventListener("visibilitychange", () => {
if (document.visibilityState === "hidden") { saveUiOpts(); flushLabDiskSave(); }
});
/* 诊断入口:控制台里 __speedtest.E 能直接看当前累加的原始响应 */
window.__speedtest = { E, eapi, RateWindow, decodeStatus, renderRaw, renderRawDecode, saveUiOpts, restoreUiOpts };
window.__speedtest = { E, eapi, RateWindow, decodeStatus, renderRaw, renderRawDecode, saveUiOpts, restoreUiOpts, collectUiOpts, applyUiOpts };
})();
</script>
</body>
+3 -3
View File
@@ -8,9 +8,9 @@
<link rel="icon" href="data:,">
<link rel="stylesheet" href="../css/base.css?v=857f83a5">
<link rel="stylesheet" href="../css/layout.css?v=acd17001">
<link rel="stylesheet" href="../css/components.css?v=7bc7d5db">
<link rel="stylesheet" href="../css/components.css?v=quiet20260919a">
<link rel="stylesheet" href="../css/can.css?v=c06f5688">
<link rel="stylesheet" href="../css/responsive.css?v=46449483">
<link rel="stylesheet" href="../css/responsive.css?v=quiet20260919a">
</head>
<body class="can-page">
<div class="container can-container">
@@ -329,6 +329,6 @@
<span id="toastMsg">操作成功</span>
</div>
<script type="module" src="../js/can.js?v=20260801f"></script>
<script type="module" src="../js/can.js?v=20260919a"></script>
</body>
</html>
+3 -3
View File
@@ -8,9 +8,9 @@
<link rel="icon" href="data:,">
<link rel="stylesheet" href="../css/base.css?v=857f83a5">
<link rel="stylesheet" href="../css/layout.css?v=acd17001">
<link rel="stylesheet" href="../css/components.css?v=7bc7d5db">
<link rel="stylesheet" href="../css/components.css?v=quiet20260919a">
<link rel="stylesheet" href="../css/can.css?v=c06f5688">
<link rel="stylesheet" href="../css/responsive.css?v=46449483">
<link rel="stylesheet" href="../css/responsive.css?v=quiet20260919a">
</head>
<body class="can-page">
<div class="container can-container">
@@ -184,6 +184,6 @@
<span id="toastMsg">操作成功</span>
</div>
<script type="module" src="../js/can_monitor.js?v=20260727a"></script>
<script type="module" src="../js/can_monitor.js?v=20260919a"></script>
</body>
</html>
+3 -3
View File
@@ -14,9 +14,9 @@
<link rel="icon" href="data:,">
<link rel="stylesheet" href="../css/base.css?v=857f83a5">
<link rel="stylesheet" href="../css/layout.css?v=jumpconsole20260916b">
<link rel="stylesheet" href="../css/components.css?v=a2c3d4e5">
<link rel="stylesheet" href="../css/components.css?v=quiet20260919a">
<link rel="stylesheet" href="../css/can.css?v=c06f5688">
<link rel="stylesheet" href="../css/responsive.css?v=46449483">
<link rel="stylesheet" href="../css/responsive.css?v=quiet20260919a">
</head>
<body>
<div class="container">
@@ -562,7 +562,7 @@
必须持有租约,否则服务端在租约归零宽限期后会把循环又停掉。
与 tabs.html / motor.html 并存时连接数>1,互不影响。 -->
<script src="../js/common/app-lease.js"></script>
<script type="module" src="../js/app.js?v=20260916h"></script>
<script type="module" src="../js/app.js?v=20260919a"></script>
<script type="module" src="../js/ota/console-mode.js?v=20260916d"></script>
<script type="module" src="../js/ble_ota.js?v=20260901d"></script>
<!-- 内联兜底:同步 BLE 按钮 data-ble-state 与 checkbox.checked
+2 -2
View File
@@ -6,7 +6,7 @@
<title>电机控制台 — Motor CAN FD Console</title>
<!-- 空 favicon:阻止浏览器自动请求 /favicon.ico(本地静态服务无此文件会 404) -->
<link rel="icon" href="data:,">
<link rel="stylesheet" href="../css/motor.css?v=20260908a">
<link rel="stylesheet" href="../css/motor.css?v=20260920b">
</head>
<body>
@@ -430,7 +430,7 @@
</div>
</div>
<script type="module" src="../js/motor/main.js?v=20260912b"></script>
<script type="module" src="../js/motor/main.js?v=20260920b"></script>
<!-- 单独打开本页(不在标签外壳里)时, 由本页自己持有 app 租约 -->
<script src="../js/common/app-lease.js"></script>
</body>
+54 -10
View File
@@ -40,10 +40,12 @@ function section(t) { out.push(`\n${t}`); }
/* ── stub 后端:模拟 ecat_native 的状态机 ──
* opened=false 时 /api/status 回 {ok:false,log:["未连接"]}(真实行为),
* /api/open 之后才变 ok:true;/api/motor/start 记录被调用次数。 */
* /api/open 之后才变 ok:true;/api/motor/start 记录被调用次数。
* /api/native/health:bus_open/loop 跟 started 联动,供「已健康则跳过 start」快路径。 */
function makeStub(opts) {
const st = { opened: false, started: false, calls: [], openSubstr: null,
failOpen: opts && opts.failOpen, failStart: opts && opts.failStart };
failOpen: opts && opts.failOpen, failStart: opts && opts.failStart,
healthFail: opts && opts.healthFail };
const srv = http.createServer((req, res) => {
let body = "";
req.on("data", (c) => (body += c));
@@ -58,7 +60,17 @@ function makeStub(opts) {
try { p = body ? JSON.parse(body) : {}; } catch (_) {}
if (req.url === "/ecat/api/status") {
return send(st.opened
? { ok: true, slave: { name: "STUB", man: 0, id: 1, rev: 1, state: 8, al_status: 0 } }
? { ok: true, slave: { name: "STUB", man: 0, id: 1, rev: 1,
state: st.started ? 8 : 1, al_status: 0 } }
: { ok: false, log: ["未连接"] });
}
if (req.url === "/ecat/api/native/health") {
if (st.healthFail) return send({ ok: false, log: ["health down"] });
return send(st.opened
? { ok: true, name: "ecat_native", bus_open: 1,
op: st.started ? 1 : 0, loop: st.started ? 1 : 0,
slaves: 1, wkc: st.started ? 3 : 0,
cycles: st.started ? 10 : 0, queue_depth: 0 }
: { ok: false, log: ["未连接"] });
}
if (req.url === "/ecat/api/open") {
@@ -67,16 +79,18 @@ function makeStub(opts) {
return send({ ok: true, log: ["已连接 STUB"],
slave: { name: "STUB", man: 0, id: 1, rev: 1, state: 1, al_status: 0 } });
}
if (req.url === "/ecat/api/close") { st.opened = false; return send({ ok: true, log: [] }); }
if (req.url === "/ecat/api/close") { st.opened = false; st.started = false; return send({ ok: true, log: [] }); }
if (req.url === "/ecat/api/motor/start") {
if (st.failStart) return send({ ok: false, log: [st.failStart] });
st.started = true;
return send({ ok: true, log: [], period_ms: p.period_ms || 4 });
return send({ ok: true, log: [], period_ms: p.period_ms || 4, spin: true });
}
if (req.url === "/ecat/api/motor/state") {
if (!st.opened) return send({ ok: false, log: ["未连接"] });
return send({ ok: true, log: [], ts: Date.now() / 1000, wkc: 3, expected_wkc: 3,
cycles: 10, period_ms: 4, sent_seq: null, sent_frames: 0, queue_depth: 0,
return send({ ok: true, log: [], ts: Date.now() / 1000,
wkc: st.started ? 3 : 0, expected_wkc: 3, cycles: st.started ? 10 : 0,
period_ms: st.started ? 4 : 0, spin: true, pcore_mask: 0,
sent_seq: null, sent_frames: 0, queue_depth: 0,
bridge: { valid: true, ack: 0, rx_count: 0, tx_count: 0, drop: 0, tx_result: 0 },
xrx: null, rx_batch: { count: 0, lost: 0, pending: 0, frames: [] },
head: { active: 0, boot_active: false, win_base: 0, win_count: 0, global_seq: 0 },
@@ -114,20 +128,50 @@ section("[1] probe() 自动开网卡 + 自动起循环(四级链条的 2/3/4
check("periodMs 由后端回报写入", tp.debug().periodMs === 4, String(tp.debug().periodMs));
}
/* ── 2. 已连接时不要重复 open(api_open 会 close 旧 master,反复调会重置总线)── */
section("[2] 已连接则不重复 open(幂等)");
/* ── 2. 已连接且循环在跑时不要重复 open / motor/start(幂等)── */
section("[2] 已连接则不重复 open / motor/start(幂等,健康位快路径)");
{
const { st, srv, port } = await makeStub();
const un = bindFetch(port);
const tp = createEcatTransport(CTX);
await tp.probe();
const opensAfterFirst = st.calls.filter((c) => c === "/ecat/api/open").length;
await tp.probe(); // 第二次探测
const startsAfterFirst = st.calls.filter((c) => c === "/ecat/api/motor/start").length;
await tp.probe(); // 第二次探测:健康位应直接复用
const opensTotal = st.calls.filter((c) => c === "/ecat/api/open").length;
const startsTotal = st.calls.filter((c) => c === "/ecat/api/motor/start").length;
un(); srv.close();
check("首次 probe 调了一次 open", opensAfterFirst === 1, String(opensAfterFirst));
check("首次 probe 调了一次 motor/start", startsAfterFirst === 1, String(startsAfterFirst));
check("已连接后再 probe 不再 open(避免反复重置总线)",
opensTotal === 1, `共 ${opensTotal} 次`);
check("循环已在跑时不再打 motor/start(那条路由会先 loop_stop,假超时/楔死源)",
startsTotal === 1, `共 ${startsTotal} 次`);
check("第二次 probe 仍成功返回 status(健康位路径)", true);
}
/* ── 2b. healthCheck:probe 超时后的轻量复核 ── */
section("[2b] healthCheck:总线已健康时可确认连上,且不碰 motor/start");
{
const { st, srv, port } = await makeStub();
const un = bindFetch(port);
const tp = createEcatTransport(CTX);
await tp.probe();
const startsBefore = st.calls.filter((c) => c === "/ecat/api/motor/start").length;
const data = await tp.healthCheck();
const startsAfter = st.calls.filter((c) => c === "/ecat/api/motor/start").length;
check("healthCheck 返回 status(slave 在)", !!(data && data.slave));
check("healthCheck 不调用 motor/start",
startsAfter === startsBefore, `${startsBefore} → ${startsAfter}`);
check("healthCheck 后 debug().started 为 true", tp.debug().started === true);
/* 循环没跑 → healthCheck 必须抛错,不能把假连接报成在线 */
st.started = false;
let err = null;
try { await tp.healthCheck(); } catch (e) { err = e; }
un(); srv.close();
check("循环未运行时 healthCheck 抛错(不谎报在线)",
err !== null, err && err.message);
}
/* ── 3. 网卡打不开 → 报错要冒泡且**不**标 transient(否则页面一直转圈不给红灯)── */
+1 -1
View File
@@ -181,6 +181,6 @@
<script src="js/common/app-lease.js"></script>
<!-- DeviceLink Owner:全链路健康轮询只在本外壳跑一次,各 iframe 订阅 -->
<script>window.__dcuDeviceLinkOwner = true;</script>
<script type="module" src="js/common/device-link.js?v=20260916c"></script>
<script type="module" src="js/common/device-link.js?v=20260922a"></script>
</body>
</html>