diff --git a/.gitignore b/.gitignore index 5e8ab46..321ec70 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/GD32_OTA_FLOW_COMPARE.html b/GD32_OTA_FLOW_COMPARE.html new file mode 100644 index 0000000..40e9873 --- /dev/null +++ b/GD32_OTA_FLOW_COMPARE.html @@ -0,0 +1,269 @@ + + + +GD32 刷写流程对比:ESP32 中继(老)× EtherCAT 直灌(新) +
+ +

GD32 刷写流程对比:ESP32 中继(老)× EtherCAT 直灌(新)

+
2026-09-13 · BOOT v1.1.0.7(ER_ROM0 64,552 / 73,728 B)· 主站 ecat_native.exe 22:18 构建 · 每条数字带 文件:行号 · 镜像基准 = DCU_APP Debug .hex 展平后 124,992 B(本机实测)
+ +
+
2 604 → 527同张镜像的链路往返次数(结构硬数字)
+
48 → 241 B每帧净落 flash 的字节数
+
252 KB → 无中间存储:ESP32 缓存分区整个消失
+
0 次新流程的上板实测次数 —— 一条都没跑过
+
+ +

一句话结论

+新流程不是"更快的刷法",是"少一层先决条件的刷法"。省下的 5 倍往返是副产品,真正的收益是:出厂首刷不再要求板上先有一颗刷好并配好网的 ESP32。两条路并存互为备份,而且刻意复用了同一个 flash 写入者、同一个提交顺序、同一条掉电不变式 —— 新流程没有引入第二套"刷坏了怎么回来"的语义。
+ + + +
+

逐维度对照

+ + + + + + + + + + + + + + + + + + +
维度老:WiFi → ESP32 中继 → SPI新:PC 主站 → EtherCAT → SDO
先决条件板上必须已有 ① 刷好固件的 ESP32 ② 配好网的 ESP32 ③ 上位机知道设备 IP。esp32_server.py:830总线上有电、从站在 PREOP 即可。ESP32 可以完全没刷过。
参与部件浏览器 → 本机 2625(只发静态页)→ ESP32:80 → GD32。esp32_server.py:1-7浏览器 → 同一个 ecat_native.exe(页面也它发的)→ Npcap/I210 → GD32。ESP32 不在链路里。
镜像中间存储ESP32 flash 的 gd32_ota 分区 252 KB@0x11000,两段式:先整份存下算 SHA,/ota/relay 才起中继。partitions.csv:7-8;http_handlers.cpp:515;ota_relay.cpp:1128PC 内存 216 KB 静态缓冲 BOOT_DL_IMG_MAX 0x36000。不落任何中间设备。
浏览器→服务端请求1 次整文件 POST(/ota/upload)+ 1 次 /ota/relay + 轮询。无断点续传:每次从偏移 0 重写。upload-esp32.js:238-247;http_handlers.cpp:4406 次 push(每次 24,576 B → hex 49,152 字符,卡在 native 的 65,535 B body 上限内)+ 1 次 run。已冒烟:received 24576→49152→73728 精确累加
每帧净落 flash线上一帧 = v2 头 16 B + DATA 64 B + CRC 4 B;64 B 里只有 48 B 是固件。config.h:140-146;ota_relay.cpp:1135-11514 KB 块 = 17 个邮箱帧(1 initiate 带 240 B + 16 段×247 B)⇒ 每帧 241 B,是老的 5.0 倍。sdoserv.c:210,744
链路往返次数124,992 / 48 = 2 604 包,每包一次「发 → 等 ACK(50 ms 上限,最多重发 5 次)」。ota_relay.cpp:1181,1185124,992 / 4096 = 31 次 SDO 传输 = 527 帧,外加总长 / begin / commit 三个控制 SDO。
鉴权AES-128 三步双向握手,会话密钥作为前缀喂进每包 CRC32(防重放/篡改)。OTA.cpp:976-1246,1414-1442出厂形态零鉴权。唯一裁决点 ecatArmed() = 配置页令牌 或 A 区不可启动;commit 成功即收回令牌,用完即弃。OTA.cpp:792-812,905-915
主站复判身份?不存在该问题 —— ESP32 是唯一持有 AES 密钥的对端。不复判。读 0x1008 只当日志读数。APP 的对象字典里没有 0x2500/0x2501(实测只有 0x1600/0x1A00/0x1C12/0x1C13/0x2000/0x6000/0x7000),写上去只会 SDO abort,擦不到一个字节。
掉电不变式完全相同,且是刻意复用的。擦除前先把 A 区三面标志(CHECK/AVAL/EN)全标 NOT_AVAL ⇒ 任何时刻掉电都表现为「AVAL 未置 = 不可启动 = 留守 BOOT 可重灌」。OTA.cpp:635-637;config.h:37-43;main.cpp:229-402
提交顺序向量表校验 → calculateAppCrc+writeAppCrc → CHECK → AVAL → EN;跳转延后到 process() 排空 pending_app_jump。OTA.cpp:372-410逐字对齐同一条,只在 CRC 之后多插一步收回令牌。CRC 必须先于 AVAL —— verifyAppCrc() 对擦除态 0xFFFFFFFF 返回 true,真门控是三面标志。OTA.cpp:900-915
对在跑业务的影响SPI 中继是独立链路,不占 EtherCAT 环;但会话期间 BOOT 会丢弃 CAN OTA 帧。OTA.cpp:303-309;Can.cpp:174-179run 全程持 g_bus_lock + loop_stop() ⇒ PDO 循环冻结数秒。但目标本来就得停在 BOOT(进不了 SAFEOP/OP),实际没有循环可冻。
可观测性进度靠轮询 /system/status 的 relay_seq/relay_total_packets;每 32 包一次健康检查。upload-gd32.js:58,194;app_config.h:86从站 0x2500:03 实时报已落盘字节、:04 报 arm/会话位;主站 probe 一次读回,进度条两侧各一个独立读数。
刷 BOOT 自己两条都不行。BOOT 跑在 0x08000000,改不了自己正在执行的 flash ⇒ 永远只能 SWD/JTAG。新流程没改变这一点。
刷"正在跑 APP 的机器"可以 —— 这正是老流程的主用途(APP 收 OTA 请求、复位进 BOOT 再灌)。main.cpp:249-272还不行。A 区可启动 ⇒ 令牌是硬门,而置令牌的入口没接(ecatArmSet 目前只有 commit 里那个"收回"调用者)。要么 BOOT 加密会话加命令码(要花余下的 9,176 B),要么 APP 侧复位进 BOOT 前自己写 word8。
BOOT flash 代价基线 36,340 B+28,212 B ⇒ 64,552 B,余 9,176 B(用 87.6%);RAM 32,392 B(堆为分段下载放大到 16 KB,栈仍 1 KB)。GD32E50X_BOOT.axf.map
成熟度产线在用;有 CLI 与刷写台账 —— 只有 ESP32/scripts/flash_gd32.py 写 lab/_last_gd32_sha.txt,且要拿到"板上版本变了 / uptime 归零"的证据才写。scripts/flash_gd32.py:36,132-148代码接通、零上板。主站软件路径已冒烟(路由分发 / 缓冲累加 / 边界值全过),但 ESC 起板、0x1008 可读、SDO 字节序三条只有源码级推导。
+ +

关于"快了多少" —— 不能说

+老流程 ESP32 侧注释有实测:正常 ~2 ms/包、"777 包正常仅 ~1.6 s"。ota_relay.cpp:356-368 按这个常数换算,124,992 B = 2 604 包 ≈ 5.2 s 纯传输。
+新流程没有任何一次真机计时,这里填不出可比的数字 —— 5 倍往返差是代码结构决定的硬事实,端到端耗时不是。第一次上板要记的就是三件事:begin 实际阻塞多久、单块实际耗时、整轮总耗时。
+ +

为什么值得两条都留

+
    +
  • 覆盖场景不重叠:出厂首刷只有新流程能做(那时还没有 ESP32 固件);刷在跑的机器目前只有老流程能做(有 AES 通道)。
  • +
  • 故障域独立:WiFi 配不上 / ESP32 没刷 / 网卡拉不起 / 从站没进 PREOP —— 任意一边坏,另一边还是条路。
  • +
  • 共用同一个落盘点:两条最终都调 eraseApplicationArea / writeApplicationData / validateApplication / OTA_SET;BOOT 里始终只有一个 flash writer,ecatBegin 在 SPI/CAN 会话活跃时直接拒绝并发。
  • +
+ +

新流程的未证清单

+ + + + + + + +
#待证判据(一条就够)
1BOOT 侧 ESC 起不起得来(EXMC/HBI/RST#/IRQ 这套移植层从未在 BOOT 跑过)串口出 [BOOT][ESC] PDI ok DL=… 邮箱可用;否则 FAIL PDI 未就绪 DL=… EEP=…
2主站认不认出它在 BOOTprobe 回 name="GD32E508 DCU BOOT" 且 armed=1
3SDO 分段载荷字节序(源码推得原序,未实测)用现成 /api/sdo 往 0x2501 写 00 01 02 … 0F,看落 flash 后回读是否逐字节同序;不对就只在 ObjFoeDlWrite 一处加转换
4begin 的整区预擦到底阻塞多久、8 s 超时够不够串口时间戳与主站 ec_SDOwrite 往返计时对表
5刷完 APP 真能起来重新连接后 probe 的设备名变回 "GD32E508 DCU" —— 从另一次启动读的,不自证
+ +

顺带查到的一个既有缺陷(老流程同样有)

+writeApplicationData() 的循环条件是 i + 4 <= size ⇒ 不足 4 字节的尾巴被静默丢弃,注释里写的"用 0xFF 补齐一个 word"代码并没有做。OTA.cpp:762-782
+老流程每包固定 48 B(4 对齐)所以碰不到,只有非 4 对齐的镜像总长会在最后一包丢 1~3 字节;新流程在主站侧补 0xFF 到 4 对齐并按补齐长度声明总量,绕开了这个坑,但没有去改已在产线跑的 SPI 路径。要修该单独一次改动、单独一次回归。
+
+
+

老流程:浏览器 → ESP32 → SPI → GD32 BOOT

+
+
浏览器算 SHA-256
整文件一次 POST
→
+
本机 2625只发静态页
不碰硬件
→
+
ESP32 :80/ota/upload
每 4 KB 写分区
→
+
ESP32 flashgd32_ota 252 KB
NVS 存元数据
→
+
/ota/relayAES 三步握手
逐包读回转发
→
+
GD32 BOOTSPI ISR 入队
主循环写 flash
+
+

红框 = 新流程里整个消失的一跳。

+ +

关键常量

+ + + + + + + + + + + +
项值出处
每帧总长 / 其中固件数据64 B / 48 Bconfig.h:140-146
SPI v2 帧开销20 B(16 头 + 4 CRC)spi_link_v2.h:34-36
SPI 时钟 / 注释实测每帧13.333 MHz / ≈96 µssdkconfig.defaults:20;spi_master.cpp:80
ISR→主循环 DATA 槽 / flash 写缓冲槽4 / 8config.h:178,180
ESP32 上传块 / 固件上限4 096 B / 216 KBapp_config.h:74,76
单包等 ACK / 最多重发 / 跨包停刷50 ms / 5 次 / 10 sota_relay.cpp:1181,1185,53
转发预算25 ms/包,总 10~120 sota_relay.cpp:356-368
BOOT 会话超时 / APP 延后启动窗口50 000(≈1 s) / 10 000 msconfig.h:168,169
握手各步超时(ESP32 侧)800 / 5000 / 3000 / 3000 msota_relay.cpp:942,1036,1062,1090
+ +

一帧 DATA 的字节排布

+
+
0–3会话号 LE
+
4–7包号 LE
+
8–5548 B AES-128 密文
+
56–59结束标志 IFEND
+
60–63CRC32(密钥参与)
+
+

外面再套 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。spi_link_v2.h:34-51;OTA.cpp:1002-1018;ota_relay.cpp:1135-1151

+ +

握手三步(双向认证)

+ + + + + +
步上位机 → BOOTBOOT → 上位机出处
1魔术字 00 AC回显 ACK,转 WAITING_RANDOMOTA.cpp:976-988
216 B nonce_server36 B = UID(12)+nonce[8..15](8)+AES(K_master,K_session)(16),转 WAITING_CONFIRMOTA.cpp:1195-1219
3AES(K_session, nonce XOR 0x55) 16 B0xBA 0xC0,转 CHECKING_FIRMWAREOTA.cpp:1224-1246
+

状态机全序 IDLE → WAITING_RANDOM → WAITING_CONFIRM → CHECKING_FIRMWARE → RECEIVING_FIRMWARE → FINISHEDOTA.h:223-230。每包 CRC32(IEEE) 先把 16 B 会话密钥喂进去、再喂 48 B 明文,所以改一比特或重放旧包都对不上。OTA.cpp:1414-1442 主密钥两端硬编码同值。config.h:158-161;app_config.h:150-153

+ +

从收到字节到落 flash

+ + +

失败与恢复分流

+

main.cpp run() 读三面标志分流:二者皆 AVAL 且 CRC 过 → 直接跳;CHECK AVAL / EN NOT_AVAL → 开 10 s 等 OTA 窗口;A 区坏但 B 区有效 → move_app_data() 整体搬回;都不行 → 长期留守等重下。FWDGT 复位会把 EN 标 NOT_AVAL,阻止反复跳进卡死的固件。main.cpp:204-210,229-402

+ +

代码里没有的东西(别当成有据)

+
    +
  • SPI 中继链路的实测端到端耗时 / KB 每秒 —— 只有预算常数和注释里的 ~2 ms/包。
  • +
  • WiFi/HTTP 上传的断点续传:NVS 元数据预留了 stored_size/last_seq,HTTP 路径未实装。gd32_flash_store.h:23;ota_coordinator.cpp:246
  • +
  • BOOT 侧握手各步的独立毫秒超时:统一走 OTA_TIMEOUT 计数。OTA.cpp:431
  • +
+ +

同一后端的三条入口

+浏览器、tools/flash_gd32.py(裸 BIN、不写台账)、tools/ESP32/scripts/flash_gd32.py(带 {"total_size":N} 头、写台账)三条都打 ESP32 固件 80 端口的同一组 /ota/upload + /ota/relay + /system/status,没有独立通道;设备侧对带不带 JSON 头都兼容。http_handlers.cpp:403-407;tools/flash_gd32.py:88;scripts/flash_gd32.py:98
+
+
+

新流程:PC 主站 → EtherCAT 邮箱 → BOOT 写 flash

+
+
浏览器ecat.html「BOOT 刷写」
页内自带 HEX→BIN
→
+
ecat_native :8090/api/ota/ecat
镜像攒在 PC 内存
→
+
I210 / Npcaploop_stop 独占
CoE SDO 分段下载
→
+
LAN9252SM0 邮箱 256 B
PRAM FIFO 逐 DWORD
→
+
GD32 BOOTSSC 攒满 4 KB 才回调
OTAHandler 写 flash
+
+

只有 5 跳,且 ESP32 完全不在里面。页面本身也是 ecat_native 发的,所以没有跨域、没有第二个服务。

+ +

操作时序(六个 op,只有后两个碰总线)

+ + + + + + + +
op做什么碰总线?要点
open声明镜像长度,清 PC 侧缓冲否超 221,184 B 直接拒。不抢 g_bus_lock、不 loop_stop,所以上传过程不会把正在跑的电机循环冻掉
push追加一段 hex 到镜像尾部否24,576 B/次 = hex 49,152 字符,卡在 handle_conn 的 65,535 B body 上限内
status / abort查已收字节 / 丢缓冲否abort 只清 PC 侧,总线一个字节都没动
probe读 0x1008 设备名 + 0x2500:03/:04是纯诊断读数,不是门槛
run真正的灌写:总长 → begin → 31 块 → commit是全程持 g_bus_lock + loop_stop();只有 commit 成功才不把 PDO 循环还回来(从站正在硬复位)
+ +

BOOT 侧只挂两个对象

+ + + + + + +
索引类型条目语义
0x2500REC ×4
UINT32
:0 条目数 / :1 命令 / :2 总长 / :3 已收 / :4 状态:1 写 1=begin、2=commit、3=abort;:4 bit0=允许刷写、bit1=会话活跃、高 16 位=错误码。:3/:4 只读,读的时候现取 ota.ecatReceived()
0x2501VAR
OCTETSTRING
4 096 B
只有 :00数据通道。建成 VAR 而不是 ARR:VAR 的 maxSubindex 恒为 0,所以只能写 :00,且 complete access 一律 UNSUPPORTED_ACCESS ⇒ SOEM 侧必须传 CA=FALSE。objdef.c:1278,1295,1352
+

BitSize 是 UINT16 ⇒ 单条目天花板 8191 B,块长取 4096 就是这个原因。SSC 的分段下载攒满整块才调一次 OBJ_Write(最后一帧才分发,且分发前不做通用长度校验),所以从站侧不需要 begin/data/commit 拼包状态机。sdoserv.c:292-295;objdef.c:1352;sdoserv.c:714

+ +

一个 4 KB 块在总线上的样子

+
+
initiate带 240 B
+
段 1247 B
+
段 2247 B
+
…×14
+
段 16151 B
+
+

共 17 帧搬 4096 B。段长上限两端各算一次取小者:SOEM maxdata = mbx_l − 0x10 = 240(首帧带数),SSC 段 256 − 6 − 3 = 247。全图 124,992 B ⇒ 31 块 = 527 帧。ethercatcoe.c:346,450;sdoserv.c:210

+ +

放行判据只有一个执行点

+
+

ecatArmed() = 配置页 word8 == 0xACA70001 || !(CHECK==AVAL && EN==AVAL)

+

后半句和 main.cpp run() 决定"跳 APP"用的是同两个 flash 字,所以四种 BOOT 留守情形(空板首刷 / 下载中断电 / A 区 CRC 失败 / FWDGT 复位)天然放行,不需要任何先决条件;反过来 APP 正常在跑时两字皆 OTA_AVAL,令牌就是硬门 —— 而令牌写不进(SDO 摸不到配置页),所以刷不动一台正常机器。

+

ecatCommit() 成功路径里先 ecatArmSet(false) 收回令牌再置三面标志:用完即弃。此刻 CHECK/EN 还是 eraseApplicationArea 标的 NOT_AVAL,所以收回这一拍救砖分支照样为真,不影响本次提交。OTA.cpp:792-812,905-915

+
+ +

刻意复用老流程的部分(一条都没新写)

+ + +

新增的移植层花了多少

+ + + + + + +
项值备注
无 EtherCAT 基线36,340 BPhase 0 对照组:关掉唯一调用者后 --gc-sections 剥回此值
邮箱-only SSC(空移植层桩件)57,816 B净增 21,472 B —— 原方案声称的"12 KB"是假的
真移植层 + 会话(定版)64,552 B再 +6,736 B(EXMC/HBI/CSR 双读/PRAM/RST#/IRQ + 0x2500/0x2501 + arm 门控);余 9,176 B
RAM32,392 / 131,072 B堆 0xC00→0x4000(每笔分段下载 ALLOCMEM(块长) 走 malloc);栈仍 1 KB ⇒ SDO 回调里不许开块级大数组
+
+ +
生成:Qoder 会话 · 证据基线 GD32E50X_BOOT v1.1.0.7 / tools/ecat ecat_native 22:18 / ESP32_COMM 现网分支 · 本轮改动均未提交 git
+
+ + diff --git a/css/components.css b/css/components.css index 347edc9..7aed718 100644 --- a/css/components.css +++ b/css/components.css @@ -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; diff --git a/css/responsive.css b/css/responsive.css index 054cbc4..d5d0347 100644 --- a/css/responsive.css +++ b/css/responsive.css @@ -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; } /* ================= 阶段胶囊:激活弹入 ================= */ diff --git a/esp32_server.py b/esp32_server.py index 5a8e950..74711a9 100644 --- a/esp32_server.py +++ b/esp32_server.py @@ -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/ + + 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") diff --git a/js/can_monitor.js b/js/can_monitor.js index 5983ce4..7cdda03 100644 --- a/js/can_monitor.js +++ b/js/can_monitor.js @@ -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); diff --git a/js/common/connection.js b/js/common/connection.js index ca433f2..48ef6f1 100644 --- a/js/common/connection.js +++ b/js/common/connection.js @@ -55,6 +55,27 @@ function setWidthIfChanged(el, widthStr) { } // ==================== 连接管理 ==================== +/** + * 无设备静默态:设备离线且没有传输在跑时,给 挂 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 = ['正常', '警告', '危险', '关机']; diff --git a/js/common/desktop-bridge.js b/js/common/desktop-bridge.js new file mode 100644 index 0000000..bbd6d3c --- /dev/null +++ b/js/common/desktop-bridge.js @@ -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} 与 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 代理"; +} diff --git a/js/common/device-link.js b/js/common/device-link.js index 190f1ac..0ed1fcb 100644 --- a/js/common/device-link.js +++ b/js/common/device-link.js @@ -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,26 +185,44 @@ 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) { - var open = !!(st && st.ok); - return open ? Promise.resolve(st) - : ecatPost("open", { substr: adapter }, 20000); - }).then(function (r) { - if (r && r.ok === false) { - throw new Error((r.log && r.log.join(" / ")) || "open 失败"); + 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("motor/start", body, 30000); + return ecatPost("status", {}, 6000).then(function (st) { + var open = !!(st && st.ok); + return open ? Promise.resolve(st) + : ecatPost("open", { substr: adapter }, 20000); + }).then(function (r) { + if (r && r.ok === false) { + 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 轮询永久停摆 */ diff --git a/js/common/utils.js b/js/common/utils.js index bfee6bc..1f06c90 100644 --- a/js/common/utils.js +++ b/js/common/utils.js @@ -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; + } +} diff --git a/js/motor/main.js b/js/motor/main.js index 04655d0..efdf366 100644 --- a/js/motor/main.js +++ b/js/motor/main.js @@ -37,11 +37,11 @@ import { DEFAULT_ESP_IP, saveDeviceIp } from "../common/base-url.js"; -import { escapeHtml } from "../common/utils.js"; +import { escapeHtml, flashBusyNow } from "../common/utils.js"; /* 传输层:WiFi(HTTP) / EtherCAT(过程数据) 二选一,业务逻辑完全共用 */ import { - createTransport, loadTransportId, saveTransportId -} from "./transport.js"; + createTransport, loadTransportId, saveTransportId, LS_TRANSPORT +} from "./transport.js?v=20260611c"; import { deviceLink } from "../common/device-link.js"; /* 刷新速度「每链路一份」的纯逻辑(默认值/钳制/旧数据迁移), * 抽出去是为了能脱离 DOM 自测,见 refresh-rate.js 头注释。 */ @@ -98,6 +98,9 @@ const state = { chOnline: [0, 0, 0], // 各通道在线电机数(由 onMotorState 按 m.ch 统计,驱动下拉框信号标记) selectedNode: null, // 当前选中节点号 motors: [], // 最近一次 /motor/state 的 motors 数组 + bootActive: false, // 总线上是否有 0x103 BOOT 心跳 + slaveAl: null, // EtherCAT 从站 AL state(8=OP),来自 /api/status + slaveOp: false, // native/health 的 op 位:从站是否在 OP decoded: new Map(), // node -> 解码后的 STATUS 对象 decodedConfig: new Map(),// node -> 解码后的 CONFIG 对象(电机实时回报的目标模式/目标值) versions: new Map(), // node -> {soft,hard,boot} 查询到的版本号(持久化) @@ -122,6 +125,7 @@ const state = { statusInFlight: false, // /system/status 独立链路占用标记 failCount: 0, // 连续硬失败计数,达阈值才断线 probing: false, // 已在断线探测,避免重复 startProbe/toast + probeGen: 0, // 探测代际:晚到的失败不得覆盖更新的成功 lastConnToastAt: 0, // toast 去抖 offlineSince: 0, // 进入离线时刻,短离线恢复不 toast tickN: 0, // 轮询序号(每 5 轮附带 status) @@ -145,8 +149,14 @@ window.motorDbg = { /** * 连续「硬失败」多少次才判离线。 * HTTP 500/503 busy、超时 一律视为瞬时,不计入、不改 LED。 + * EtherCAT 桥/代理路径毛刺更多(WebView2 调度、代理端口探测、 + * 从站短暂 busy),阈值放宽,避免高压下 5 拍误断线并停掉命令序列。 */ const FAIL_THRESHOLD = 5; +const FAIL_THRESHOLD_ECAT = 12; +function failThresholdFor(tpId) { + return tpId === "ecat" ? FAIL_THRESHOLD_ECAT : FAIL_THRESHOLD; +} const CONN_TOAST_GAP_MS = 6000; /** 离线不足此时长就恢复:不弹「连接已恢复」(过滤毛刺) */ const RECOVER_TOAST_MIN_OFFLINE_MS = 8000; @@ -173,42 +183,114 @@ function getSharedDeviceIp() { return deviceLink.getIp(); } -/* ─── 输入持久化:刷新/重开页面后恢复上次输入 ─── - * 保存 strategy:用户改输入项时,把所有需保存项打包成 JSON 写 localStorage。 - * 恢复 strategy:页面加载时读出,按字段类型回填到对应 DOM + state。 - * 版本号按节点缓存(versions[node] = {soft,hard,boot}),切节点时回填。 */ -/* 注意:#refreshRate **不在**这里 —— 它按链路各存一份(refreshByTp), +/* ─── 输入持久化:刷新/重开/关掉后恢复上次配置 ─── + * 双写: + * 1) localStorage —— 同步快路径,页面秒开就能回填 + * 2) 磁盘文件 data/motor_ui_state.json(经本地服务器 /api/motor/ui-state) + * —— WebView2 清存储/换浏览器后仍在电脑上,满足「关掉之后还在」 + * 恢复:先用 localStorage 渲染,再异步拉磁盘;两者比 savedAt,新的覆盖。 + * 页隐藏/关闭时 pagehide + keepalive/sendBeacon 强制刷盘,避免 debounce 丢尾。 */ +/* 注意:#refreshRate **不在** PERSIST_FIELDS —— 它按链路各存一份(refreshByTp), * 走通用 DOM 回填会把上一条链路的值套到当前链路上。见 applyTransportRefresh。 */ const PERSIST_FIELDS = [ - "modeSelect", "inVoltage", "inVelocity", "inAngle", + "modeSelect", "inVoltage", "inVelocity", "inAngle", "inCurrent", "pidCurP", "pidVelP", "pidAngP", "repeatPeriod", "chSelect", "nodeIdInput", "periodStatusInput", "periodConfigInput", ]; +const UI_STATE_PATH_LABEL = "data/motor_ui_state.json"; + +/** 打包完整配置快照(localStorage + 磁盘共用同一形状)。 */ +function collectUiSnapshot() { + const o = { savedAt: Date.now(), v: 2 }; + for (const id of PERSIST_FIELDS) { + const el = document.getElementById(id); + if (el) o[id] = el.value; + } + if (state.selectedNode != null) o.selectedNode = state.selectedNode; + o.refreshByTp = Object.assign({}, state.refreshByTp); + o.tpId = state.tpId; + try { + o.transport = localStorage.getItem(LS_TRANSPORT) || state.tpId; + o.ecat = { + adapter: localStorage.getItem("dcu_ecat_adapter") || null, + period: localStorage.getItem("dcu_ecat_period_ms") || null, + spin: localStorage.getItem("dcu_ecat_spin") || null, + }; + } catch (_) { + o.transport = state.tpId; + } + if (seq.steps.length > 0 || seq.loops !== 0) { + o.seq = { loops: seq.loops, steps: seq.steps }; + } + if (state.versions && state.versions.size > 0) { + const vo = {}; + for (const [n, v] of state.versions) { + if (v && (v.soft || v.hard || v.boot)) { + vo[n] = { soft: v.soft || null, hard: v.hard || null, boot: v.boot || null }; + } + } + o.versions = vo; + } + return o; +} + +/** 把快照应用到 DOM + state(loadUiState / 磁盘 hydrate / 导入共用)。 */ +function applyUiSnapshot(o) { + if (!o || typeof o !== "object") return; + for (const id of PERSIST_FIELDS) { + const el = document.getElementById(id); + if (el && o[id] != null) el.value = o[id]; + } + if (o.selectedNode != null) state.selectedNode = o.selectedNode; + if (o.seq && Array.isArray(o.seq.steps)) { + seq.steps = o.seq.steps + .filter((s) => s && typeof s === "object") + .map(normStep); + seq.loops = clampNum(parseInt(o.seq.loops, 10) || 0, 0, 9999); + } + if (o.versions && typeof o.versions === "object") { + for (const [n, v] of Object.entries(o.versions)) { + const node = parseInt(n, 10); + if (Number.isFinite(node) && v) { + state.versions.set(node, { + soft: v.soft || null, hard: v.hard || null, boot: v.boot || null, + }); + } + } + } + state.refreshByTp = normalizeRefreshMap(o.refreshByTp, o.refreshRate); + /* EtherCAT / 链路偏好写回 localStorage,transport-ecat 与 device-link 启动时读 */ + try { + if (o.transport) localStorage.setItem(LS_TRANSPORT, String(o.transport)); + if (o.ecat && typeof o.ecat === "object") { + if (o.ecat.adapter) localStorage.setItem("dcu_ecat_adapter", String(o.ecat.adapter)); + if (o.ecat.period != null && o.ecat.period !== "") { + localStorage.setItem("dcu_ecat_period_ms", String(o.ecat.period)); + } + if (o.ecat.spin === "0" || o.ecat.spin === "1") { + localStorage.setItem("dcu_ecat_spin", String(o.ecat.spin)); + } + } + } catch (_) {} + /* DOM 已回填 → 同步 cmdCommitted。否则磁盘 hydrate/导入后, + * 界面显示新值,发送仍用旧默认,出现「输入框和发出的数据不一致」。 */ + syncCmdCommittedFromDom(); + /* 通道:PERSIST_FIELDS 只回填了 #chSelect,state.ch 必须跟着变。 + * 否则 hydrate/发送仍用旧值(默认 0),看起来像「通道没保存」。 */ + const chEl = document.getElementById("chSelect"); + if (chEl && chEl.value !== "" && chEl.value != null) { + const ch = parseInt(chEl.value, 10); + if (Number.isFinite(ch) && ch >= 0 && ch <= 2) state.ch = ch; + } +} function saveUiState() { + const o = collectUiSnapshot(); try { - const o = {}; - for (const id of PERSIST_FIELDS) { - const el = document.getElementById(id); - if (el) o[id] = el.value; - } - if (state.selectedNode != null) o.selectedNode = state.selectedNode; - /* 刷新速度:按链路分别保存(旧版是单个 refreshRate 字段,见 loadUiState 的迁移) */ - o.refreshByTp = Object.assign({}, state.refreshByTp); - /* 命令序列:步骤 + 循环次数(有内容才写入,保持旧数据兼容) */ - if (seq.steps.length > 0 || seq.loops !== 0) { - o.seq = { loops: seq.loops, steps: seq.steps }; - } - /* 版本缓存:state.versions 是 Map,序列化为普通对象 */ - if (state.versions && state.versions.size > 0) { - const vo = {}; - for (const [n, v] of state.versions) { - if (v && (v.soft || v.hard || v.boot)) vo[n] = { soft: v.soft || null, hard: v.hard || null, boot: v.boot || null }; - } - o.versions = vo; - } localStorage.setItem(LS_MOTOR_UI, JSON.stringify(o)); } catch (_) {} + scheduleDiskSave(o); + updateUiSaveHint("saved"); } function loadUiState() { @@ -216,35 +298,184 @@ function loadUiState() { const raw = localStorage.getItem(LS_MOTOR_UI); if (!raw) return; const o = JSON.parse(raw); - if (!o || typeof o !== "object") return; - for (const id of PERSIST_FIELDS) { - const el = document.getElementById(id); - if (el && o[id] != null) el.value = o[id]; - } - if (o.selectedNode != null) state.selectedNode = o.selectedNode; - /* 恢复命令序列(旧数据无 seq 字段则保持空); - * normStep 归一:新结构 {holdMs, actions[]} 直通,旧扁平步骤转单动作组 */ - if (o.seq && Array.isArray(o.seq.steps)) { - seq.steps = o.seq.steps - .filter((s) => s && typeof s === "object") - .map(normStep); - seq.loops = clampNum(parseInt(o.seq.loops, 10) || 0, 0, 9999); - } - /* 恢复版本缓存到 state.versions Map */ - if (o.versions && typeof o.versions === "object") { - for (const [n, v] of Object.entries(o.versions)) { - const node = parseInt(n, 10); - if (Number.isFinite(node) && v) state.versions.set(node, { soft: v.soft || null, hard: v.hard || null, boot: v.boot || null }); - } - } - /* 恢复刷新速度:按链路各一份,归一与旧数据迁移都在 refresh-rate.js 里。 - * loadUiState 在 initPage 阶段调用(polling 未启动),写 state 即可,无需重排定时器; - * 输入框由随后的 applyTransportRefresh() 按当前链路回填 —— 这里**不要**直接写 DOM, - * 否则会把另一条链路的值显示出来。 */ - state.refreshByTp = normalizeRefreshMap(o.refreshByTp, o.refreshRate); + applyUiSnapshot(o); } catch (_) {} } +/* ── 磁盘自动保存 ── */ +let _diskTimer = null; +let _diskPayload = null; +let _diskInFlight = false; + +function scheduleDiskSave(snapshot) { + _diskPayload = snapshot || collectUiSnapshot(); + if (_diskTimer) clearTimeout(_diskTimer); + _diskTimer = setTimeout(flushDiskSave, 600); +} + +function flushDiskSave() { + if (_diskTimer) { clearTimeout(_diskTimer); _diskTimer = null; } + const o = _diskPayload || collectUiSnapshot(); + _diskPayload = null; + const body = JSON.stringify(o); + _diskInFlight = true; + /* keepalive:关页/切走时浏览器仍会把请求发完(≤64KB;序列过大时回落 sendBeacon) */ + fetch("/api/motor/ui-state", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + keepalive: true, + }).then((r) => r.json()).then((j) => { + _diskInFlight = false; + if (j && j.ok) updateUiSaveHint("disk", j.path || UI_STATE_PATH_LABEL); + else updateUiSaveHint("disk-err"); + }).catch(() => { + _diskInFlight = false; + /* keepalive fetch 失败(例如服务重启瞬间)再用 sendBeacon 兜底一次 */ + try { + const blob = new Blob([body], { type: "application/json" }); + if (navigator.sendBeacon && navigator.sendBeacon("/api/motor/ui-state", blob)) { + updateUiSaveHint("disk", UI_STATE_PATH_LABEL); + return; + } + } catch (_) {} + updateUiSaveHint("disk-err"); + }); +} + +/** 关页/隐藏:停 debounce,立刻落盘(避免最后几次按键没写上)。 */ +function flushUiOnHide() { + if (_saveTimer) { clearTimeout(_saveTimer); _saveTimer = null; } + const o = collectUiSnapshot(); + try { localStorage.setItem(LS_MOTOR_UI, JSON.stringify(o)); } catch (_) {} + const body = JSON.stringify(o); + /* 优先 keepalive fetch;失败/过大用 sendBeacon(无 CORS 预检,关页可靠) */ + try { + fetch("/api/motor/ui-state", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body, + keepalive: true, + }).catch(() => { + try { + navigator.sendBeacon("/api/motor/ui-state", + new Blob([body], { type: "application/json" })); + } catch (_) {} + }); + } catch (_) { + try { + navigator.sendBeacon("/api/motor/ui-state", + new Blob([body], { type: "application/json" })); + } catch (_) {} + } +} +window.addEventListener("pagehide", flushUiOnHide); +window.addEventListener("beforeunload", flushUiOnHide); +document.addEventListener("visibilitychange", () => { + if (document.visibilityState === "hidden") flushUiOnHide(); +}); + +/** 磁盘优先 hydrate:比 savedAt,新者生效;应用后重绘相关 UI。 */ +async function hydrateUiFromDisk() { + let disk = null; + try { + const r = await fetch("/api/motor/ui-state", { 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") { + updateUiSaveHint("no-disk"); + return; + } + let local = null; + try { + local = JSON.parse(localStorage.getItem(LS_MOTOR_UI) || "null"); + } catch (_) {} + const diskAt = Number(disk.savedAt) || 0; + const localAt = local ? (Number(local.savedAt) || 0) : 0; + /* 磁盘更新或本地根本没有 → 采用磁盘(含首次只有文件的场景) */ + if (local && localAt > diskAt) { + updateUiSaveHint("disk", UI_STATE_PATH_LABEL); + return; + } + applyUiSnapshot(disk); /* 含 chSelect DOM + state.ch + cmdCommitted */ + try { localStorage.setItem(LS_MOTOR_UI, JSON.stringify(disk)); } catch (_) {} + applyModeVisibility(); + renderSeqList(); + if (typeof renderNodeList === "function") renderNodeList(); + updateNodeLabels(); + /* 不要用 state.ch 反写 #chSelect —— 旧逻辑会在 applyUiSnapshot 恢复后 + * 用仍是默认 0 的 state.ch 把通道框打回 CH0,保存的通道看起来丢了。 */ + state.ch = clampNum(parseInt(($("#chSelect") || {}).value, 10) || 0, 0, 2); + const chEl = $("#chSelect"); + if (chEl) chEl.value = String(state.ch); + applyTransportRefresh(); + updateChSelectOptions(); + if (typeof refreshChannelViews === "function") refreshChannelViews(); + updateUiSaveHint("disk", UI_STATE_PATH_LABEL); +} + +function updateUiSaveHint(mode, path) { + const el = $("#uiSaveHint"); + if (!el) return; + const p = path || UI_STATE_PATH_LABEL; + if (mode === "saved") { + el.textContent = "配置保存中…"; + el.title = "同时写 localStorage 与 " + p; + } else if (mode === "disk") { + el.textContent = "已存 " + p; + el.title = "配置已写入电脑文件,关页/重启后会自动恢复"; + } else if (mode === "disk-err") { + el.textContent = "落盘失败(仅 localStorage)"; + el.title = "本地页面服务可能未运行;配置仍存在浏览器里,但清缓存会丢"; + } else if (mode === "no-disk") { + el.textContent = "暂无磁盘备份"; + el.title = "改动任意配置或关页后会生成 " + p; + } +} + +/** 导出:下载完整配置 JSON(人工备份/拷到别的机器)。 */ +function exportUiState() { + const o = collectUiSnapshot(); + const blob = new Blob([JSON.stringify(o, null, 2)], { type: "application/json" }); + const a = document.createElement("a"); + const ts = new Date(); + const pad = (n) => String(n).padStart(2, "0"); + const name = `motor_ui_${ts.getFullYear()}${pad(ts.getMonth() + 1)}${pad(ts.getDate())}_${pad(ts.getHours())}${pad(ts.getMinutes())}${pad(ts.getSeconds())}.json`; + a.href = URL.createObjectURL(blob); + a.download = name; + document.body.appendChild(a); + a.click(); + setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 1000); + saveUiState(); +} + +/** 导入:选择 JSON → 应用 → 双写保存。 */ +function importUiStateFile(file) { + if (!file) return; + const rd = new FileReader(); + rd.onload = () => { + try { + const o = JSON.parse(String(rd.result || "")); + if (!o || typeof o !== "object") throw new Error("不是配置对象"); + applyUiSnapshot(o); + applyModeVisibility(); + renderSeqList(); + applyTransportRefresh(); + saveUiState(); + const t = typeof toast === "function" ? toast : null; + if (t) t("配置已导入并保存", "ok"); + else updateUiSaveHint("saved"); + } catch (e) { + if (typeof toast === "function") toast("导入失败: " + (e && e.message), "err"); + else updateUiSaveHint("disk-err"); + } + }; + rd.readAsText(file); +} + /* 在 PERSIST_FIELDS 上挂 change/input 监听,自动保存。 */ function bindAutoSave() { for (const id of PERSIST_FIELDS) { @@ -256,6 +487,25 @@ function bindAutoSave() { } } } + /* 序列循环次数不在 PERSIST_FIELDS(值随 seq 一起存),单独监听,改完立刻落盘 */ + const loops = $("#seqLoops"); + if (loops) { + loops.addEventListener("change", () => { + seq.loops = clampNum(parseInt(loops.value, 10) || 0, 0, 9999); + saveUiState(); + }); + } + const exp = $("#uiExportBtn"); + if (exp) exp.addEventListener("click", exportUiState); + const impBtn = $("#uiImportBtn"); + const impInput = $("#uiImportInput"); + if (impBtn && impInput) { + impBtn.addEventListener("click", () => impInput.click()); + impInput.addEventListener("change", () => { + importUiStateFile(impInput.files && impInput.files[0]); + impInput.value = ""; + }); + } } /* input 事件高频触发,debounce 300ms 避免每次按键都写 localStorage。 */ let _saveTimer = null; @@ -456,11 +706,20 @@ function faultCountOf(d) { * PID_angle_loop_P 152|16@1- → B19-20 */ /* ── CMD 数值输入提交制 ── - * 周期发送每 80~100ms 读一次输入框:若直接读 .value,键入 "200" 的中间态 - * "2"/"20" 会被当目标值发给电机。改为:输入过程不生效,按回车或点击别处 - * (blur 触发 change)才提交到 cmdCommitted,buildCmdBytes 只读已提交值。 */ + * 一帧发送(点「发送 CMD」/快捷按钮/加序列步骤)以**输入框当前值**为准: + * 构帧前 syncCmdCommittedFromDom(),所见即所发。 + * 周期发送(doSendCmd(true))仍读 cmdCommitted:键入 "200" 的中间态 + * "2"/"20" 不会被当成目标值打给电机;周期启动时会先按输入框提交一次。 + * 配置恢复/导入(applyUiSnapshot)也会同步,避免界面与发出数据脱节。 */ const CMD_VALUE_IDS = ["inVoltage", "inCurrent", "inVelocity", "inAngle", "pidCurP", "pidVelP", "pidAngP"]; const cmdCommitted = {}; +/** 把输入框当前值写入 cmdCommitted(发送/恢复/启动周期前调用)。 */ +function syncCmdCommittedFromDom() { + for (const id of CMD_VALUE_IDS) { + const el = document.getElementById(id); + if (el) cmdCommitted[id] = el.value; + } +} function initCmdCommit() { for (const id of CMD_VALUE_IDS) { const el = document.getElementById(id); @@ -492,8 +751,10 @@ function buildCmdBytes(values) { dv.setInt16(19, parseInt(values.pidAngP, 10) || 0, true); return new Uint8Array(buf); } -/* 表单版:读当前 CMD 输入区已提交值(cmdCommitted) */ -function buildFormCmdBytes() { +/* 表单版:默认以输入框当前值构帧(所见即所发)。 + * opts.fromCommitted=true(周期发送)只读 cmdCommitted,避免键入中间态。 */ +function buildFormCmdBytes(opts) { + if (!(opts && opts.fromCommitted)) syncCmdCommittedFromDom(); return buildCmdBytes({ mode: $("#modeSelect").value, voltage: cmdVal("inVoltage"), @@ -694,6 +955,13 @@ function isTransientError(e) { const msg = String(e.message || e); if (/timed?\s*out|aborted|AbortError|signal is aborted/i.test(msg)) return true; if (/^HTTP\s+(500|503|429)\b/.test(msg)) return true; + /* 网络毛刺:高刷 HTTP 下偶发 Failed to fetch / 连接被重置很常见, + * 算硬失败会在几拍内把整页打成「设备未连接」。真拔线会持续超时/拒连, + * 由 failCount 在更高阈值上兜住(见 noteHardFailure 的网络类加权)。 */ + if (/failed to fetch|networkerror|load failed|err_connection|econnreset|econnrefused|socket hang up|net::/i.test(msg)) { + e.transient = true; + return true; + } return false; } @@ -883,9 +1151,20 @@ function applyTransportUi() { } function noteHardFailure(e, source) { - if (e && e.transient) return; /* 500/503/超时等瞬时错误不计入 */ + if (e && e.transient) return; /* 500/503/超时/网络毛刺等瞬时错误不计入 */ state.failCount++; - if (state.failCount >= FAIL_THRESHOLD && state.connected) { + /* ECAT:status 1Hz 健康读失败不该和 motor 热路径同等累计 —— + * 从站 AL 抖一下就断电机页会误停周期发送/命令序列。 */ + if (state.tpId === "ecat" && source === "status" && state.connected) { + if (state.failCount < failThresholdFor("ecat") * 2) return; + } + const thr = failThresholdFor(state.tpId); + if (state.failCount >= thr && state.connected) { + try { + state.phaseDebug.lastHardFail = { + source, msg: e && e.message, at: Date.now(), failCount: state.failCount, thr, + }; + } catch (_) {} enterDisconnected(e && e.message, source); } } @@ -898,6 +1177,9 @@ function enterDisconnected(msg, source) { state.probing = true; state.failCount = 0; state.offlineSince = nowMs(); + state.bootActive = false; + state.slaveAl = null; + state.slaveOp = false; stopRepeat(true); stopSeq("连接中断,命令序列已停止", true); stopDataPolling(); @@ -963,36 +1245,61 @@ function summarizeLinkError(msg) { return s.length > 140 ? s.slice(0, 140) + "…" : s; } +/** 探测成功收尾:置在线、清横幅、开轮询。代际不符时丢弃(防晚到结果覆盖)。 */ +function finishProbeOk(gen, data) { + if (gen !== state.probeGen) return; + const offlineMs = state.offlineSince ? (nowMs() - state.offlineSince) : 0; + stopProbe(); + state.connected = true; + state.connecting = false; + state.offlineSince = 0; + state.failCount = 0; + state.probeErr = null; + state.probeErrFull = null; + $("#netBanner").classList.remove("show"); + applySystemStatus(data); + updateConnUi(); + /* 短离线恢复不弹 toast,避免毛刺「闪一下」 */ + if (offlineMs >= RECOVER_TOAST_MIN_OFFLINE_MS) { + connToast("连接已恢复 — " + transportTargetLabel(), "ok"); + } + startPolling(); +} + async function probeOnce() { if (!state.probing) return; + const gen = ++state.probeGen; state.ip = getSharedDeviceIp(); const label = $("#ipLabel"); if (label) label.textContent = transportTargetLabel(); try { const data = await state.tp.probe(); - const offlineMs = state.offlineSince ? (nowMs() - state.offlineSince) : 0; - stopProbe(); - state.connected = true; - state.connecting = false; - state.offlineSince = 0; - state.failCount = 0; - state.probeErr = null; - state.probeErrFull = null; - $("#netBanner").classList.remove("show"); - applySystemStatus(data); - updateConnUi(); - /* 短离线恢复不弹 toast,避免毛刺「闪一下」 */ - if (offlineMs >= RECOVER_TOAST_MIN_OFFLINE_MS) { - connToast("连接已恢复 — " + transportTargetLabel(), "ok"); - } - startPolling(); + if (gen !== state.probeGen || !state.probing) return; /* 已有更新的探测在跑 */ + finishProbeOk(gen, data); } catch (e) { + if (gen !== state.probeGen) return; /* 晚到的失败:别把后来的成功打成横幅 */ /* 把失败原因显示出来。 * 原来这里完全静默:WiFi 下还算合理(设备没开机是常态,横幅已说在连哪个 IP), * 但 EtherCAT 自动接入有四级步骤,任一级失败的原因都**可操作** * (邮箱楔死要复位 MCU / 网卡子串不对 / 板子没供电 / exe 没编译或 Npcap 未装), * 静默会让页面永远停在"探测中…",用户完全无从下手 —— 实测就踩到了这点。 */ const raw = (e && e.message) ? String(e.message) : "未知错误"; + /* EtherCAT:motor/start 超时 ≠ 总线没起来。 + * 外壳 Owner / 上一轮 start 可能已把 PDO 循环跑着,probe 里那记 + * motor/start 因 loop_stop+重进 OP 拖过 30s 才报 signal timed out。 + * 此时用只读健康位复核:在跑就当连上,别把「实际已连」标成失败。 */ + if (state.tpId === "ecat" && state.tp.healthCheck && + /timed?\s*out|signal|超时/i.test(raw)) { + try { + const data = await state.tp.healthCheck(); + if (gen === state.probeGen && state.probing) { + finishProbeOk(gen, data); + return; + } + return; /* 代际已变:结果交给新一轮 */ + } catch (_) { /* 真没起来 → 走下面的失败展示 */ } + } + if (gen !== state.probeGen || !state.probing) return; state.probeErrFull = raw; state.probeErr = summarizeLinkError(raw); const bt = $("#netBannerText"); @@ -1012,6 +1319,48 @@ function transportTargetLabel() { return (state.tpId === "ecat") ? "EtherCAT 从站" : ("IP " + state.ip); } +/** 当前通道上在线的电机数(1–9)。 */ +function onlineMotorCount() { + return (state.motors || []).filter(function (m) { + return m && m.online && m.node >= 1 && m.node <= 9; + }).length; +} + +const AL_NAMES = { 0: "NONE", 1: "INIT", 2: "PREOP", 3: "BOOT", 4: "SAFEOP", 8: "OP" }; + +/** + * 目标是否在线 —— 工具栏指示灯的唯一判据。 + * + * 灯的语义保持不变:就是「是否在线」。 + * - WiFi:ESP32 HTTP 可达即在线(标签是设备 IP)。 + * - EtherCAT:标签是「EtherCAT 从站」,在线 = 从站 AL 处于 OP。 + * 卡在 PREOP / INIT / SAFEOP 时,即使主站服务+网卡+PDO 循环在跑、 + * 读得到 AL、界面还有残留槽位,灯也不绿。 + */ +function isTargetOnline() { + if (!state.connected) return false; + if (state.tpId !== "ecat") return true; + /* AL 已读到:只有 OP 算在线。PREOP 等一律不绿。 */ + if (state.slaveAl != null) return state.slaveAl === 8; + /* AL 还没读到:仅当 native health 明确报 op 时才绿 */ + return state.slaveOp === true; +} + +/** 状态栏追加的在线状态提示(灯黄时把原因写出来)。 */ +function onlineStatusParts(data) { + const parts = []; + if (state.tpId === "ecat") { + const al = state.slaveAl; + if (al != null && al !== 8) { + parts.push("从站 " + (AL_NAMES[al] || ("0x" + al.toString(16))) + " · 未在线"); + } + } + if (state.connected && !isTargetOnline()) { + if (!parts.length) parts.push("未在线"); + } + return parts; +} + /** * 自动连接当前链路(本页无连接按钮)。 * WiFi 用主页共享 IP,EtherCAT 打本地 EtherCAT 服务。 @@ -1025,6 +1374,11 @@ function connectShared() { state.probeErr = null; /* 新一轮连接:清掉上次的失败原因 */ state.probeErrFull = null; state.offlineSince = nowMs(); + state.bootActive = false; + state.slaveAl = null; + state.slaveOp = false; + /* 新一轮探测:递增代际,作废在途的旧 probeOnce(其晚到失败不得再写横幅) */ + state.probeGen += 1; state.ch = parseInt($("#chSelect").value, 10) || 0; updateConnUi(); $("#netBanner").classList.add("show"); @@ -1038,18 +1392,46 @@ function connectShared() { function applySystemStatus(data) { const ip = state.tp.reportedIp(data); if (ip) setSharedDeviceIp(ip); - const parts = state.tp.describeStatus(data) || []; + /* 在线判定用的从站状态:AL 优先,native health 的 op 作补充 */ + if (data && data.slave && data.slave.state != null) { + state.slaveAl = Number(data.slave.state) & 0x0F; + state.slaveOp = state.slaveAl === 8; + } else if (data && data.__op != null) { + state.slaveOp = !!data.__op; + } + const parts = (state.tp.describeStatus(data) || []).concat(onlineStatusParts(data)); const el = $("#devStatus"); if (el) el.textContent = parts.length ? parts.join(" · ") : "在线"; + updateConnUi(); +} + +/* 无设备静默态:常驻动画在 160Hz 屏上每帧重算+重绘,实测未接设备时 + renderer+GPU 各占约一核的七成。规则见 css/responsive.css 的 + html[data-dcu-calm="1"] 段;本页不 import connection.js,故就地写同一标记。 */ +function setQuietMode(quiet) { + const v = quiet ? "1" : "0"; + if (document.documentElement.dataset.dcuCalm !== v) { + document.documentElement.dataset.dcuCalm = v; + } } function updateConnUi() { const led = $("#connLed"); const ipLabel = $("#ipLabel"); + setQuietMode(!state.connected); if (ipLabel) ipLabel.textContent = transportTargetLabel(); - /* 连接灯只反映稳定状态:在线绿 / 探测黄 / 离线红,中间失败不闪黄 */ + /* 指示灯 = 是否在线(语义不变)。 + * 绿 = 在线(isTargetOnline) + * 黄 = 主站/链路可达,但目标未在线(从站没进 OP / 收不到帧) + * 黄闪 = 探测中 + * 红 = 未连接 + * 以前 connected 就涂绿:EtherCAT 只要服务+网卡+PDO 循环在就算在线, + * 即使从站卡 PREOP、收不到任何 STATUS 也绿着 —— 灯起不到指示作用。 */ if (state.connecting || state.probing) { - if (led) led.className = "led led-warn led-pulse"; + if (led) { + led.className = "led led-warn led-pulse"; + led.title = "正在探测链路…"; + } if (!state.connected) { const ds = $("#devStatus"); /* 有失败原因就显示原因摘要,别一直"探测中…"(那等于什么都没说)。 @@ -1068,9 +1450,26 @@ function updateConnUi() { } } } else if (state.connected) { - if (led) led.className = "led led-on"; + if (led) { + if (isTargetOnline()) { + led.className = "led led-on"; + led.title = state.tpId === "ecat" + ? "EtherCAT 从站在线(AL OP)" + : ("设备在线 · " + (state.ip || "")); + } else { + led.className = "led led-warn"; + const alTxt = state.slaveAl != null + ? ("AL " + (AL_NAMES[state.slaveAl] || state.slaveAl)) : "AL 未知"; + led.title = state.tpId === "ecat" + ? ("EtherCAT 从站未在线(" + alTxt + ")— 卡在 PREOP/非 OP 时不亮绿灯;查供电/网线/从站状态") + : "链路可达但设备未就绪"; + } + } } else { - if (led) led.className = "led led-err"; + if (led) { + led.className = "led led-err"; + led.title = "未连接"; + } const ds = $("#devStatus"); if (ds) ds.textContent = "未连接"; } @@ -1344,6 +1743,10 @@ async function fetchMotorState(gen) { async function fetchFeedback(gen) { if (gen != null && gen !== state.pollGen) return false; if (!state.connected || state.probing) return false; + /* 刷写/OTA 窗口内跳过:GD32 正在复位或停在 BOOT,CAN 反馈注定拿不到 + * (WiFi 链上每次 0xC1 都被 ESP32 链路层拒并打日志)。跨 iframe 标记 + * 见 common/utils.js flashBusyNow,随刷写收尾自撤,下轮自动恢复。 */ + if (flashBusyNow()) return false; try { /* WiFi:/can/feedback?lite=1 —— 跳过 entries 数组(~3KB → ~200B), * 本页只用 ch/stats 字段,不需要 CAN 帧快照(那是监控页用的)。 @@ -1365,18 +1768,75 @@ async function fetchFeedback(gen) { * motors[] 可能含同 node 不同 ch 的多条记录,decoded 缓存键也必须带通道。 */ function motorKey(m) { return m.ch + ":" + m.node; } +/* ── UI 绘制合流 ──────────────────────────────────────────────── + * 热路径 HTTP 可到 100–300Hz,但 DOM/样式跟不上:每拍全量 + * renderNodeList + renderStatusPanel + flash 会把主线程打满, + * 表现为界面掉帧、Hz 反而上不去(rAF 被饿死)。 + * 策略:数据照常每拍解码进 state;绘制合并到 rAF,且最密 30Hz。 + * 波形 canvas 自有 33ms 采样环,不受此限。 */ +const UI_PAINT_MIN_MS = 33; +let uiPaintPending = false; +let lastUiPaintAt = 0; +let lastUiSig = ""; /* 连接灯/通道摘要签名,变了才写那几处 DOM */ +let lastConnSig = ""; +const _decodedReuse = new Map(); +const _decodedConfigReuse = new Map(); +const _statusRefs = {}; /* STATUS 面板静态节点缓存 */ + +function statusEl(id) { + let el = _statusRefs[id]; + if (el === undefined) { + el = document.getElementById(id); + _statusRefs[id] = el; + } + return el; +} + +function setClsIfChanged(el, cls) { + if (el && el.className !== cls) el.className = cls; +} +function toggleClsIfChanged(el, cls, on) { + if (!el) return; + const has = el.classList.contains(cls); + if (has !== !!on) el.classList.toggle(cls, !!on); +} + +function paintMotorUi(force) { + const now = nowMs(); + if (!force && now - lastUiPaintAt < UI_PAINT_MIN_MS) return false; + lastUiPaintAt = now; + refreshChannelViews(); + updateNodeLabels(); + return true; +} + +function scheduleUiPaint() { + if (uiPaintPending) return; + uiPaintPending = true; + const run = () => { + const wait = UI_PAINT_MIN_MS - (nowMs() - lastUiPaintAt); + if (wait > 0) { setTimeout(run, wait); return; } + uiPaintPending = false; + paintMotorUi(true); + }; + requestAnimationFrame(run); +} + function onMotorState(data) { const motors = (data && data.motors) ? data.motors : []; state.motors = motors; + state.bootActive = !!(data && data.boot_active); /* 记录实际数据到达时间戳滑动窗口,用于 Hz 计算 */ const ts = state.recvTs; ts.push(Date.now()); if (ts.length > RECV_HZ_WINDOW * 2) ts.splice(0, ts.length - RECV_HZ_WINDOW); - /* 解码每个 节点×通道 的 STATUS 帧 + CONFIG 帧 */ - const decoded = new Map(); - const decodedConfig = new Map(); + /* 解码复用 Map:每拍 new 两个 Map 是 200Hz 下的稳定分配源 */ + const decoded = _decodedReuse; + const decodedConfig = _decodedConfigReuse; + decoded.clear(); + decodedConfig.clear(); for (const m of motors) { const bytes = hexToBytes(m.data); const d = decodeStatus(bytes); @@ -1394,11 +1854,15 @@ function onMotorState(data) { for (const m of motors) { if (m.online && m.node >= 1 && m.node <= 9 && m.ch >= 0 && m.ch <= 2) chOnline[m.ch]++; } + const chSig = chOnline.join(",") + "|" + state.chEnabled.join(",") + "|" + (state.tickStuck || 0); + const chChanged = chSig !== lastUiSig; state.chOnline = chOnline; - updateChSelectOptions(); + if (chChanged) { + lastUiSig = chSig; + updateChSelectOptions(); + } - /* 固定节点 1–9:始终可选(含离线),便于电机已进 BOOT / 无 STATUS 时仍能刷写。 - * 自动选择:当前选中已是 1–9 则保持;否则优先当前通道的在线节点,其次任意在线,否则默认 1。 */ + /* 固定节点 1–9:始终可选(含离线),便于电机已进 BOOT / 无 STATUS 时仍能刷写。 */ let autoSelected = false; if (state.selectedNode == null || state.selectedNode < 1 || state.selectedNode > 9) { const online = motors.find((m) => m.online && m.ch === state.ch && m.node >= 1 && m.node <= 9) @@ -1407,22 +1871,33 @@ function onMotorState(data) { autoSelected = true; } - refreshChannelViews(); - updateNodeLabels(); - if (autoSelected) restoreVersionUi(state.selectedNode); /* 自动切节点时回填缓存版本 */ - - /* BOOT 徽章:总线上有电机在 Bootloader(0x103 心跳)时显示,独立于 N1-N9。 - * 0x103 固定 ID 不带节点号,故不归属任何 Nx,只作总线级 BOOT 提示。 - * 同时写 class + style,避免任何残留内联 display 把 .show 盖掉。 */ - const bootEl = $("#bootBadge"); - if (bootEl) { - const on = !!(data && data.boot_active); - bootEl.classList.toggle("show", on); - bootEl.style.display = on ? "flex" : "none"; - bootEl.setAttribute("aria-hidden", on ? "false" : "true"); - const sub = $("#bootBadgeSub"); - if (sub) sub.textContent = on ? "0x103 心跳" : "0x103"; + /* BOOT 徽章:内容不变不碰 DOM */ + const bootOn = !!state.bootActive; + const bootEl = statusEl("bootBadge"); + if (bootEl && bootEl._on !== bootOn) { + bootEl._on = bootOn; + bootEl.classList.toggle("show", bootOn); + bootEl.style.display = bootOn ? "flex" : "none"; + bootEl.setAttribute("aria-hidden", bootOn ? "false" : "true"); + const sub = statusEl("bootBadgeSub"); + if (sub) sub.textContent = bootOn ? "0x103 心跳" : "0x103"; } + + /* 连接灯/状态栏:签名不变则跳过(sendHint/LED 每拍重写是纯浪费) */ + const onlineCnt = chOnline[state.ch] || 0; + const connSig = [state.connected, state.connecting, state.probing, state.tpId, + state.slaveAl, state.slaveOp, onlineCnt, state.ch, state.probeErr].join("|"); + if (connSig !== lastConnSig) { + lastConnSig = connSig; + updateConnUi(); + } + + if (autoSelected) { + restoreVersionUi(state.selectedNode); + paintMotorUi(true); + return; + } + scheduleUiPaint(); } /* ─── 节点列表渲染 ─── @@ -1457,7 +1932,9 @@ function refreshChannelViews() { renderStatusPanel(); const onlineCount = (state.motors || []) .filter((m) => m.online && m.ch === state.ch && m.node >= 1 && m.node <= 9).length; - $("#nodeSummary").textContent = `${onlineCount}/9 在线 · CH${state.ch}`; + const summary = `${onlineCount}/9 在线 · CH${state.ch}`; + const sumEl = statusEl("nodeSummary") || $("#nodeSummary"); + if (sumEl && sumEl.textContent !== summary) sumEl.textContent = summary; } /** 节点卡片模式标签:电机 CONFIG 帧 B0(= FOC.control_mode)每周期实时回报,是唯一权威来源; @@ -1599,9 +2076,7 @@ nodeListEl.addEventListener("click", (e) => { state.selectedNode = parseInt(card.dataset.node, 10); scopeSys.reset(); /* 换电机:清波形历史,避免两台电机数据混流 */ saveUiState(); /* 选中节点变化时持久化,刷新后仍选中此节点 */ - renderNodeList(); - updateNodeLabels(); - renderStatusPanel(); + paintMotorUi(true); restoreVersionUi(state.selectedNode); /* 回填该节点缓存的版本号 */ }); nodeListEl.addEventListener("keydown", (e) => { @@ -1693,8 +2168,8 @@ function renderStatusPanel() { const d = (motor && motor.online) ? state.decoded.get(motorKey(motor)) : null; const offline = !motor || !motor.online || !d; - $("#offlineOverlay").classList.toggle("show", offline); - $("#statusLive").classList.toggle("hide", offline); + toggleClsIfChanged(statusEl("offlineOverlay"), "show", offline); + toggleClsIfChanged(statusEl("statusLive"), "hide", offline); if (offline) { if (!state.connected) { setTextIfChanged($("#offlineTitle"), "设备未连接"); @@ -1713,11 +2188,20 @@ function renderStatusPanel() { return; } - setTextIfChanged($("#rtAngle"), d.rtAngle.toFixed(2)); - setTextIfChanged($("#rtVelocity"), d.rtVelocity.toFixed(1)); - setTextIfChanged($("#rtCurrent"), d.rtCurrent.toFixed(0)); - setTextIfChanged($("#mcuTemp"), d.mcuTemp.toFixed(2) + " °C"); - setTextIfChanged($("#motTemp"), d.motTemp.toFixed(2) + " °C"); + /* flash 只在数值真变化时打:30Hz 绘制下无条件 flash 等于每秒 90 个定时器 */ + const flashNum = (id, txt) => { + const el = statusEl(id); + if (!el) return; + if (el.textContent !== txt) { + el.textContent = txt; + flashEl(el); + } + }; + flashNum("rtAngle", d.rtAngle.toFixed(2)); + flashNum("rtVelocity", d.rtVelocity.toFixed(1)); + flashNum("rtCurrent", d.rtCurrent.toFixed(0)); + setTextIfChanged(statusEl("mcuTemp"), d.mcuTemp.toFixed(2) + " °C"); + setTextIfChanged(statusEl("motTemp"), d.motTemp.toFixed(2) + " °C"); /* 目标值:优先使用电机 CONFIG 帧回报(0x000N0101, Motor→Host), * 无 CONFIG 帧时回退到前端 lastSentTargets 记录 */ @@ -1786,8 +2270,6 @@ function renderStatusPanel() { } setFlag("flagItemInit", "flagInit", "flagInitState", d.initStatus !== null, d.initStatus !== 0, "NORMAL", initFaultTxt); - - flashEl($("#rtAngle")); flashEl($("#rtVelocity")); flashEl($("#rtCurrent")); } /* ═══════════════════════ /can/feedback 处理(状态栏) ═══════════════════════ */ @@ -1895,7 +2377,8 @@ async function doSendCmd(silent) { if (!requireNode()) return; if (inBusCooldown()) return; const node = state.selectedNode; - const bytes = buildFormCmdBytes(); + /* silent=周期:读已提交值;一帧发送:先按输入框同步再构帧 */ + const bytes = buildFormCmdBytes({ fromCommitted: !!silent }); const id = ID_CMD(node); const dataHex = bytesToHexStr(bytes); const mode = parseInt($("#modeSelect").value, 10) || 0; @@ -1975,6 +2458,7 @@ function startRepeat() { stopSeq("周期发送已启动,命令序列已停止", true); /* 与命令序列互斥 */ const period = getPeriod(); periodInput.value = period; + syncCmdCommittedFromDom(); /* 周期起点 = 输入框当前值,与一帧发送一致 */ stopTimer("repeat"); repeatTimer = setInterval(() => doSendCmd(true), period); state.poll.repeat = repeatTimer; @@ -2086,8 +2570,9 @@ function normStep(s) { return { holdMs: holdMs, collapsed: collapsed, actions: [actionFromAny(s)] }; } function cloneAction(a) { return Object.assign({}, actionFromAny(a)); } -/* 读取当前 CMD 表单已提交值 + 当前通道/选中节点,作为一个新 action(与 buildFormCmdBytes 同源) */ +/* 读取当前 CMD 表单输入框值 + 当前通道/选中节点,作为一个新 action(与一帧发送同源) */ function defaultActionFromForm() { + syncCmdCommittedFromDom(); return { ch: clampNum(state.ch || 0, 0, 2), node: (state.selectedNode >= 1 && state.selectedNode <= 9) ? state.selectedNode : 1, @@ -2634,15 +3119,60 @@ async function fetchMotorVersionNode(node) { } } +function setVerWaiting(label) { + ["verSoft", "verHard", "verBoot"].forEach((k) => { + const el = $("#" + k); + if (el) { el.textContent = label; el.classList.add("ver-empty"); } + }); +} + +/** EtherCAT:强制消费 motor/rx(WiFi 无此接口,空操作)。 */ +async function drainVersionRx() { + if (state.tp && typeof state.tp.drainPendingRx === "function") { + try { await state.tp.drainPendingRx(); } catch (_) {} + } +} + +/** 取一次版本:先 drain,再 map,最后盯 xrx 本节点 0x00FF。 */ +async function sampleVersionOnce(node, prevSeq, sentAt) { + await drainVersionRx(); + const mapV = await fetchMotorVersionNode(node); + if (mapV && mapV.version && + (prevSeq == null || mapV.seq > prevSeq || (mapV.at && mapV.at >= sentAt))) { + return mapV; + } + if (state.tp && typeof state.tp.peekVersionXrx === "function") { + const x = await state.tp.peekVersionXrx(node); + if (x && x.version) return x; + } + return null; +} + +/** + * EtherCAT 专用等待:不绑主 tick,每 ~25ms 主动 drain + 看 xrx。 + * 旧逻辑只在 pollLoop 末尾 checkVersionWait,且要求 seq 增长 —— + * xrx 上是别的节点/同内容时会整段「无回报」,表现为阵发全败。 + */ +async function waitVersionEcat(node, prevSeq, sentAt, timeoutMs) { + const deadline = nowMs() + (timeoutMs || 2500); + while (nowMs() < deadline) { + const v = await sampleVersionOnce(node, prevSeq, sentAt); + if (v && v.version) return v; + await sleep(25); + } + return null; +} + /** 版本等待:挂载到主 tick(pollLoop),每拍检查一次 seq,命中或超时才 resolve。 * 节拍跟随 state.refreshIntervalMs(与 motor 状态轮询共用同一调度), * 不再开独立 sleep 循环,避免与主 tick 节拍错位造成的窗口延迟。 * 超时返回 null(与原轮询语义一致)。 */ -function waitMotorVersionOnTick(node, prevSeq, timeoutMs) { +function waitMotorVersionOnTick(node, prevSeq, timeoutMs, sentAt) { return new Promise((resolve) => { state.versionWait = { node, prevSeq, + sentAt: sentAt || nowMs(), deadline: nowMs() + (timeoutMs || 5000), resolve, }; @@ -2650,8 +3180,8 @@ function waitMotorVersionOnTick(node, prevSeq, timeoutMs) { } /** 主 tick 每拍调用:检查挂起的版本等待。 - * seq 增长 → resolve 并清空;超时 → resolve(null) 并清空。 - * 瞬时错误忽略,下一拍重试。 */ + * seq 增长,或(ECAT)缓存时间戳不早于本次发送 → resolve。 + * EtherCAT 必须先 drain:版本帧可能已在 motor/rx 队列,但快照 count 只报本拍新增。 */ async function checkVersionWait() { const w = state.versionWait; if (!w) return; @@ -2661,8 +3191,8 @@ async function checkVersionWait() { return; } try { - const v = await fetchMotorVersionNode(w.node); - if (v && (w.prevSeq == null || v.seq > w.prevSeq)) { + const v = await sampleVersionOnce(w.node, w.prevSeq, w.sentAt); + if (v && v.version) { state.versionWait = null; w.resolve(v); } @@ -2676,41 +3206,61 @@ $("#queryVerBtn").addEventListener("click", async () => { const node = state.selectedNode; const reqId = ID_GET_VERSION_REQ(node); /* 0x000N00FF 请求(与应答同 ID) */ const bytes = new Uint8Array(24); + const isEcat = !!(state.tp && state.tp.id === "ecat"); + /* EtherCAT:xrx 单槽 + 多节点抢槽,多打并独立轮询 */ + const maxAttempts = isEcat ? 3 : 1; - ["verSoft", "verHard", "verBoot"].forEach((k) => { - const el = $("#" + k); - if (el) { el.textContent = "查询中…"; el.classList.add("ver-empty"); } - }); + setVerWaiting("查询中…"); - /* 记录查询前该节点的版本 seq,用于判定电机是否给出新应答 */ - const before = await fetchMotorVersionNode(node); - const prevSeq = before ? before.seq : null; + await drainVersionRx(); - const res = await sendOneFrameWhenFree(reqId, 24, bytesToHexStr(bytes)); - const time = new Date().toLocaleTimeString("zh-CN", { hour12: false }); - $("#sbLastSend").textContent = - `最近发送: GET_VERSION 请求 ${hexId(reqId)} → 等 GD32 0xC6 同步 … ${time}`; + let v = null; + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + const before = await fetchMotorVersionNode(node); + const prevSeq = before ? before.seq : null; + const sentAt = nowMs(); - if (!res.ok) { - if (!res.networkError && !res.busy) { - ["verSoft", "verHard", "verBoot"].forEach((k) => { - const el = $("#" + k); - if (el) { el.textContent = "失败"; el.classList.add("ver-empty"); } - }); + const res = await sendOneFrameWhenFree(reqId, 24, bytesToHexStr(bytes)); + const time = new Date().toLocaleTimeString("zh-CN", { hour12: false }); + $("#sbLastSend").textContent = isEcat + ? `最近发送: GET_VERSION ${hexId(reqId)} · ECAT 第${attempt}/${maxAttempts}次 … ${time}` + : `最近发送: GET_VERSION 请求 ${hexId(reqId)} → 等应答 … ${time}`; + + if (!res.ok) { + if (res.busy || res.networkError) { + if (attempt < maxAttempts) { await sleep(200); continue; } + setVerWaiting("--"); + restoreVersionUi(node); + toast(res.busy ? "发送通道仍忙,请稍后重试" : "网络错误,版本查询未发出", "warn"); + return; + } + setVerWaiting("失败"); toast(`GET_VERSION 发送失败: ${res.desc}`, "err"); + return; } - return; + state.txCount++; + + const timeoutMs = (attempt < maxAttempts) ? 2000 : 3000; + if (isEcat) { + v = await waitVersionEcat(node, prevSeq, sentAt, timeoutMs); + } else { + const immediate = await sampleVersionOnce(node, prevSeq, sentAt); + if (immediate && immediate.version) { + v = immediate; + } else { + v = await waitMotorVersionOnTick(node, prevSeq, timeoutMs, sentAt); + } + } + if (v && v.version) break; + if (attempt < maxAttempts) await sleep(100); } - state.txCount++; - const v = await waitMotorVersionOnTick(node, prevSeq, 5000); if (v && v.version) { const parsed = parseVersionPayload(v.version); if (parsed && (parsed.soft || parsed.hard || parsed.boot)) { setVer("verSoft", parsed.soft || "--"); setVer("verHard", parsed.hard || "--"); setVer("verBoot", parsed.boot || "--"); - /* 缓存到 state.versions 并持久化,刷新/切节点后回填 */ state.versions.set(node, { soft: parsed.soft || null, hard: parsed.hard || null, boot: parsed.boot || null }); saveUiState(); const bl = parsed.byteLen || 0; @@ -2724,12 +3274,11 @@ $("#queryVerBtn").addEventListener("click", async () => { return; } } - ["verSoft", "verHard", "verBoot"].forEach((k) => { - const el = $("#" + k); - if (el) { el.textContent = "无回报"; el.classList.add("ver-empty"); } - }); + setVerWaiting("无回报"); toast( - `已发 ${hexId(reqId)},5s 内未收到电机新版本的 0xC6 同步`, + isEcat + ? `已发 ${hexId(reqId)}×${maxAttempts},Node ${node} 版本未进 xrx/motor/rx` + : `已发 ${hexId(reqId)},5s 内未收到电机新版本应答`, "warn" ); }); @@ -3645,6 +4194,7 @@ $("#chSelect").addEventListener("change", () => { updateChSelectOptions(); /* 同步选框字色:切到有信号通道则绿色,否则默认 */ /* 按新通道重刷节点列表 / STATUS 面板 / 在线汇总(通道视图过滤立即生效) */ refreshChannelViews(); + saveUiState(); /* 通道也是持久化字段,切换后立刻落盘(bindAutoSave 的 change 也会存,这里显式双保险) */ /* 通道切换不额外拉 feedback:等下一轮 pollLoop,避免与单飞链并发 */ }); @@ -4078,14 +4628,20 @@ applyTransportUi(); /* 按当前链路能力刷新 OTA 页签可用性 + 提 window.phaseDebug = { get lastSlowReq() { return state.phaseDebug && state.phaseDebug.lastSlowReq; }, get slowStats() { return state.phaseDebug && state.phaseDebug.slowStats; }, + get lastHardFail() { return state.phaseDebug && state.phaseDebug.lastHardFail; }, reset() { if (state.phaseDebug) { state.phaseDebug.lastSlowReq = null; state.phaseDebug.slowStats = {}; + state.phaseDebug.lastHardFail = null; } console.log("[phaseDebug] 慢请求统计已清空"); }, }; -/* 延迟一帧再连,避免阻塞首屏渲染 */ -setTimeout(connectShared, 80); +/* 先恢复 localStorage(秒开),再拉磁盘配置(可能更新),最后才开始连接 —— + * 磁盘里可能带着链路/序列,连早了会先用旧参数探测一圈。 */ +(async () => { + try { await hydrateUiFromDisk(); } catch (_) {} + connectShared(); +})(); diff --git a/js/motor/transport-ecat.js b/js/motor/transport-ecat.js index 9917d3e..5da425a 100644 --- a/js/motor/transport-ecat.js +++ b/js/motor/transport-ecat.js @@ -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.seq, + version: dataHex, + at: Date.now(), + }); + return; + } versions.set(node, { node, - seq: (prev ? prev.seq : 0) + 1, // 自建 seq:让上层"seq 增长=新应答"逻辑通用 + 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; diff --git a/js/motor/transport.js b/js/motor/transport.js index 5f67bf0..0298283 100644 --- a/js/motor/transport.js +++ b/js/motor/transport.js @@ -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"; diff --git a/js/ota/boot_ecat.js b/js/ota/boot_ecat.js index 9c68449..71ec0c4 100644 --- a/js/ota/boot_ecat.js +++ b/js/ota/boot_ecat.js @@ -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 选择器(与共享模式同一形状):它留下句柄并持久化,本卡片刷前才能 + * "热重载"按句柄重读磁盘;传统 选完即无句柄,重编后会退化成中止+提示。 */ 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} 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'; diff --git a/js/ota/progress.js b/js/ota/progress.js index eebcaba..cfdd20e 100644 --- a/js/ota/progress.js +++ b/js/ota/progress.js @@ -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 0 && n < 100); + } if (progressTxtEl) progressTxtEl.textContent = text; if (progressPctEl) progressPctEl.textContent = n + '%'; } diff --git a/lab/lan9252_pm.html b/lab/lan9252_pm.html index 2d4cd95..19c8405 100644 --- a/lab/lan9252_pm.html +++ b/lab/lan9252_pm.html @@ -28,8 +28,8 @@ - - + +