Compare commits

..
57 Commits
Author SHA1 Message Date
cbb 297c020a20 docs(review): 专家评审 2026-10-19 2026-10-20 21:27:39 +08:00
cbb eac3717f9a feat: 添加局域网设备发现功能和链路胶囊优化
- 实现局域网设备发现面板,支持探测ESP32中继板并自动切换端点
- 添加POST /api/lan/probe接口用于设备探测,返回三态判决结果
- 实现快扫和全段扫描两种模式,支持种子地址优先探测
- 添加端点唯一写手机制,防止多处同时修改设备IP
- 优化链路胶囊UI显示WiFi/EtherCAT/BLE连接状态和设备名称
- 修复EtherCAT链路OTA刷写期间PDO循环暂停和AL状态处理问题
- 调整PDO周期设置范围为0.125-4ms,支持亚毫秒显示为微秒
- 添加ESP32 WiFi使能开关控件样式和数据来源角标显示
2026-10-13 00:48:34 +08:00
cbb 514070b36d fix(ui): 「谁可以动总线」收到一处判定;healthCheck 与 OTA 页不再自己 loop_stop
同一症状(点一次发送→很久才动/不动)至少有五条路径能到达,上一轮只加固了
native 检测层那一条的一半。本 commit 关掉两条由我们自己的前端反复按 loop_stop
的成因——不需要硬件故障就能把健康总线掐停:motor/start 内部先 loop_stop,停手
>1000ms 从站过程数据看门狗就踢出 OP,反复这样会把 SSC 邮箱楔到只能断电。

1) healthCheck 违反自己的契约。文件注释写着「只读…绝不 motor/start、不动总线」,
   代码却是内联的 dc !== 1 || dcs !== 250 → motor/start。这把「回包没有 dc 字段」
   当成「DC 配错」,于是一篇错误回包/一份旧 exe 就打出一记 loop_stop,而这条路径
   只在 probe() 超时后走(那一刻已经有一记 loop_stop 在飞)。
   git 史:断言 383535b 引入即红 → 2304ed6 改走 ecat-bus.repairEcatDc(含「缺字段
   不动总线」守卫)转绿 → 7ea80f0 前端回滚删掉那次调用,又红。
2) OTA 负载页以「读数不新鲜」为理由无条件 motor/start。固件 <1.5.61(段5 恒 0)、
   GD32 没跑 APP、首个 1s 窗口没滚完都会读数不新鲜而循环完全健康——一个跟电机无关
   的页面于是周期性掐停总线。现在先证明再动手;native 完全没起仍算「该拉起」,
   刷写体验不变。

做法:判定与预算只放 ecat-bus.js——
  proveEcatLoop(h)  'up'|'down'|'unknown':三态是重点,证明不了就不许自动动手
  proveEcatDc(h)    同上,把「缺字段=证明不了」收编成唯一实现
  ensureEcatLoopRepair()  in-flight 锁 + 最小间隔 20s(时间戳先写后发,落
  localStorage 才跨标签页/跨 F5)+ 连续 3 次封顶;reason 随 body 走,供代理侧统一上限
repairEcatDc 新增「native 正在全量重连时不动总线」;isEcatBusHealthy 与 proveEcatLoop
共用同一处判定(两个「看起来在跑」迟早漂移)。transport-ecat 的 readHealth 附带原始
回包 raw,证明函数与界面读同一份,不再抄一遍字段名。

测试:test_ecat_autostart.mjs 26→57 项。stub 现在能喂任意 health 形状并按 reason
计数,新增 [2c] 反空断言(DC 确实配错时必须恰又一次且带 reason)、[2d] 三态矩阵、
[2e] OTA 决策、[2f] 预算(间隔/封顶/复位后放行)。另加 scripts/check_fe_guard_mutation.py:
把实现逐条退回旧形状看断言是否真变红(全绿=那条断言是自证的)——M1..M6 实测全部变红,
还原后基线仍 57/0。配套门槛见 tools/ecat/check_guard_regression.py 的 S6/S7。

未做(另计):loop=0 的三重闩锁(transport 的 started 位、transient 正则、device-link
的 e.loop=loadValid||op 假位——native /api/status 的 slave.state 是缓存,循环停了仍报 8)
与 UI 对 loop/guard 的可见性;以及 loop_stop 不清 g_cmd_q 造成的「重启那刻陈旧命令一起灌出去」。
2026-09-22 00:30:33 +08:00
cbb 83a035c6d6 test(guard): 验收链改名入库 + 补真退出码;tx 冻结守卫真机门槛两段
之前 e2e_web_send_latency.py / recover_ecat_after_reset.py / measure_click_to_motion.py
全在 .gitignore 里(e2e_/measure_ 通配),换台机器 clone 就跑不了验收;且
recover 脚本零失败路径(--skip-e2e 无条件退 0)。现在统一到已跟踪的 check_ 前缀,
.gitignore 一行例外都不用加,并把"跑过了"和"恢复了"绑成一件事。

- check_recover_after_reset.py:force_reopen 后改为轮询到 dc=1/250us/loop=1/op=1/
  wkc>=3(上限 15s,替掉原来单点采样那次假 loop=0),并断言守卫心跳在涨、
  发一帧 3s 内出口必须跟上;退出码 0/1/2(前置不满足=BLOCKED=2)。
- check_web_send_latency.py:新增第 6 段读 health.guard(旧 exe 无 guard 块 /
  alive_ticks 不涨 都算 FAIL),第 5 段改调已入库的 check_click_to_motion.py。
  它每次发送只等 2s,结构上看不到 8s 判据,所以只报"守卫当时是死是活",
  不拿它冒充触发验证。
- check_guard_idle_silence.py:健康总线的"另一极"——90s 静默 + 25s 1Hz 发送,
  要求心跳在涨、armed 恒 0、fires/wr 零增量、每次发送都被消费。这是防
  "把守卫修成重连风暴"的真机版(对应离线 T1b/T4/T5/T6)。
- check_guard_fires_hw.py:需人在场按 GD32 RESET 制造冻结,证明 armed 秒起、
  tx_stale_s 爬过 8s、fires +1、日志有 [GUARD] 全量重连、松手后 20s 内出口
  重新前进,再验证 60s 冷却内不第二次触发;--with-pm-hold 另证"静音"时心跳仍在涨。
  只认 guard.fires,不认 wr(wr 汇总所有恢复路径,会以错误的理由通过)。
- guard_probe.py:两个真机脚本共用同一份前置判据与通路选择(先 :2625 页面代理,
  再直连 native 8090..8095)。前置没满足一律 BLOCKED——loop=0 或 pm_hold=1 时判据
  按设计是静音的,那种"绿了"毫无意义,所以判据只放一个执行点。
2026-09-21 23:30:07 +08:00
cbb 8ec7b31539 docs(compose): host-code-quality 与现状对齐(安全重做 + e2e 口径) 2026-09-21 03:16:09 +08:00
cbb 8043d8e59b chore: gitignore 使用 UTF-8 注释并忽略 _backup/output 2026-09-21 03:13:49 +08:00
cbb 7c6d3eabcf chore: 界面诊断脚本入库;忽略 _backup/output 现场目录 2026-09-21 03:13:16 +08:00
cbb 9925009a92 refactor(motor): 电机 OTA 外提 ota.js(P1 重做第 3 步)
createOtaSys 注入 \$ 与轮询/版本依赖;STATUS offlineOverlay 契约保留。
连接/轮询/STATUS 渲染仍留在 main.js,本轮不再动连接状态机。
UI 验收:绿灯 + 节点数据 + 遮罩 display:none + 控制台无报错。
2026-09-21 03:13:04 +08:00
cbb aab526ffa8 refactor(motor): 命令序列外提 seq.js(P1 重做第 2 步)
createSeqSys 注入依赖;main 保留 seq/renderSeqList/stopSeq 绑定。
STATUS offlineOverlay 契约未改。UI 验收通过(绿灯+数据+无报错)。
2026-09-21 03:10:47 +08:00
cbb aa1e14d4cb refactor(motor): 示波器外提 scope.js(P1 重做第 1 步)
从完好 main.js 机械外提示波器 IIFE 为 createScopeSys;
STATUS 契约 offlineOverlay/statusLive 保持不动。
UI 验收:overlay display none、电机数据在、无控制台报错。
2026-09-21 03:08:20 +08:00
cbb 7ea80f01ef fix(ui): 电机前端回滚到 P1 拆分前,恢复 STATUS 离线层契约
根因:拆分后 renderStatusPanel 操作 #statusOffline/#statusPanel,
HTML 实际 id 是 offlineOverlay/statusLive,遮罩永不摘掉。
CSS/HTML 结构本就未重写;本提交恢复完整 main.js 与 dual-link 传输层。
2026-09-21 03:04:09 +08:00
cbb a797d5a8d6 docs(compose): host-code-quality 真机 e2e 27/0 通过记入 Verification 2026-09-21 02:57:00 +08:00
cbb 43b2d4bc13 fix(ui): 修复 HTML 中文编码损坏(PowerShell 默认编码误写)
从 2c6235d/261caf5 恢复 motor/index/can 页 UTF-8,
仅用 Python 以 UTF-8 重写 ?v= 缓存戳;boot_ecat 去掉误写问号。
勿再对 UTF-8 源文件用 Get-Content|Set-Content 默认编码。
2026-09-21 02:52:54 +08:00
cbb 34c3aebf6f docs(compose): host-code-quality 记录最终提交区间 2c6235d..b215fdf 2026-09-21 02:32:25 +08:00
cbb b215fdf966 docs(compose): host-code-quality delivered——R1–R16 整改收口与评审校准 2026-09-21 02:32:14 +08:00
cbb c6510c1f00 docs+fix(quality): 更正 boot_ecat 代理注释;评审后校准 Verification 口径 2026-09-21 02:31:56 +08:00
cbb fd98ad6864 docs(compose): host-code-quality delivered——R1–R16 整改与验证口径 2026-09-21 02:30:04 +08:00
cbb 06605067a5 fix(ota): boot_ecat 引入 ECAT_DEFAULT_ADAPTER 并更正代理重发注释 2026-09-21 02:28:47 +08:00
cbb 2304ed6999 refactor(motor+hygiene): P1 拆分 main.js 并收敛总线/toast + P3 卫生清理
R4: main.js 4647→1894 行,外提 scope/ota/seq/conn-poll
R5: waitVersion/queryNodeIdParam 下沉 transport
R6: ecat-bus.js 单点 bootstrap;repairEcatDc 缺 DC 字段不 motor/start
R7: toast-stack + utils 统一 DOM/quiet 助手
R12–R16: scripts 精简至正式清单、报告进 docs/reports、lab dump 清理、
README/CSS 所有权说明、网卡默认 I210 单点
2026-09-21 02:28:26 +08:00
cbb d2b787761b docs(compose): host-code-quality 记录 R1–R16 整改结果与验证口径 2026-09-21 02:28:05 +08:00
cbb beeae8a382 chore(hygiene): P3 报告归档 docs/reports + scripts 收敛 + lab 清理 + CSS/README 约定
正式 scripts 仅保留 README 自测清单;根目录报告迁入 docs/reports;
lab 采样 dump 出库;CSS 注明 btn/toast 所有权并 bump ?v=。
2026-09-21 02:26:42 +08:00
cbb 261caf50b7 refactor(motor): P1 拆分 main.js + 收敛连接/总线状态机
R4 外提 scope/ota/seq/conn-poll;R5 waitVersion/queryNodeIdParam 下沉
transport;R6 ecat-bus 单点 bootstrap;R7 toast-stack + utils 去重。
main.js 4647→~1894 行。?v= 已 bump 20260924a。
2026-09-21 02:26:39 +08:00
cbb b70f5ed0d6 refactor(server): P2 拆出 ecat_client/ecat_proc 并归一化桥路径 2026-09-21 02:22:32 +08:00
cbb 67dd1702f0 fix(server): P0 代理非幂等不重发 + 跨进程 native 互斥复用 + 默认网卡 I210
R1: autostart 后禁止自动重发 flash/motor/send 等副作用路径;
    超时类失败也不再串打其它端口。
R2: README 默认网卡改为与代码一致的 I210。
R3: 命名互斥锁 + 先复用健康监听实例,避免 GUI/服务子进程互相 kill。
2026-09-21 02:14:16 +08:00
cbb 27a8fc1797 docs(compose): 上位机代码质量评估 host-code-quality + 积分耦合报告产物
全仓冗余/复杂度审计(3.5/5),交付重构清单 R1–R16;
附带入库 ecat 积分耦合 HTML 报告与 N2/N3 成对测试脚本。
2026-09-21 02:12:04 +08:00
cbb 2c6235d920 feat(ecat): PDO 快发 ESF1 桌面强制直通,堵死慢发回落
桌面 APP 下 ECAT motor 发送只走 pywebview ESF1→native TCP;
删除桥内 HTTP/JSON 回落。附延迟测量与 pack 自测脚本与规格文档。
2026-09-21 02:11:49 +08:00
cbb 383535bd1d feat(web): 桌面原生桥 + 配置落盘 + 无设备静默态;排除调试脚本
变更点:
1. esp32_server.py
   - /api/motor/ui-state、/api/lab/ui-state:配置原子落盘 data/*.json(WebView2 清存储仍在)
   - _GuiApi.ecat_api/bridge_info:桌面壳经 GUI 进程裸 socket 直连 ecat_native,不经浏览器 HTTP
2. js/common/desktop-bridge.js(新)
   - 探测 pywebview.api(含 iframe top/parent),transport-ecat 优先走原生桥
3. connection.js + responsive.css + components.css
   - setQuietMode:离线且无传输时 html[data-dcu-calm=1] 掐常驻动画
   - 进度条斜纹仅 .live 挂载,空闲不再按刷新率空转重绘
4. device-link.js
   - 先查 native/health,已健康跳过 motor/start,避免 loop_stop 重进 OP 假超时
   - 周期强制 0.25ms + DC SYNC0 对齐;轮询自愈被配错的 DC/周期
5. motor/main.js + transport-ecat.js
   - 配置双写 localStorage + 磁盘 ui-state;pagehide/sendBeacon 刷盘;导入导出
   - 探测代际 probeGen;ECAT 失败阈值 12;跟踪 bootActive/slaveAl/slaveOp
6. lab/spi_test.html、ota/boot_ecat.js、can_monitor 等跟随桥/静默态
7. .gitignore
   - 排除 scripts/_* 与 anal/collect/measure/trace 等调试脚本、lab 产物、data/ 运行态
2026-09-20 22:58:00 +08:00
cbb e91d53e0fc feat(ecat): 启动后把 ecat_native 提到 RealTime;跳拍优化实验脚本与报告
- esp32_server: Popen 后外部 SetPriorityClass(RT/High),native 自设会被钳成 High
- 入库 skip_* 标定/实验脚本与 REPORT_ecat_skip_opt_20260917
- 前端 WIP:layout/app/polling/boot_ecat/index
- 不含 lab/skip_meter 原始采样与 preview 截图
2026-09-17 02:32:20 +08:00
cbb b3a3a365bb fix(conn): updateConnectionUi 判离线时同步清空运行指标 2026-09-16 20:47:57 +08:00
cbb 91a534514d fix(conn): 拔线后不再永远「已连接」
1) pollStatus 把 5s 超时 AbortError / HTTP 非 2xx 计入失败,走离线+清指标
2) DeviceLink 快照不得把本页已判离线改回在线(Owner 连败 3 次前会盖绿灯)
2026-09-16 20:38:37 +08:00
cbb 5459bdeff7 fix(ui): 断线清指标加强——DeviceLink 离线也清 + 模块缓存版本戳
入口/stages/polling 带 ?v=20260916d,避免深层 import 被浏览器缓存成旧版;
外壳快照 ESP32 offline 时同样 clearLiveMetricsOnDisconnect。
2026-09-16 20:29:29 +08:00
cbb d73e2fb5b8 chore: 入库 boot_ecat.js(app.js 已 import) 2026-09-16 12:32:02 +08:00
cbb e98950cde2 chore: 解决 stash pop 与 device-link 合并冲突并收回本地 WIP
保留 ConnectionHub(deviceLink 导入/ECAT 自启/pmHold 幂等)与主线
WIP(断线清指标、AUTO_POLL=false、boot_ecat 等)。
2026-09-16 12:31:39 +08:00
cbb 0c772f5924 Merge branch 'feat/device-link' 2026-09-16 12:29:56 +08:00
cbb 6f1b3c2706 fix(pm): pmHold 幂等,清醒轮询不再每 2s 打「豁免解除」
pollOnce 每 2s 调 markAwake();原先无脑 pmHold(0) 会重复 POST 并刷日志。
现跟踪 pmHoldOn,仅在状态翻转时发请求并记一行。
2026-09-16 12:26:46 +08:00
cbb f9502579ef feat(link): 外壳启动强制 open+motor/start,不必等电机页切 EtherCAT
DeviceLink Owner 在控制台加载后自动 bootstrapEcatBus:status→open→
motor/start,沿用 hub 网卡与 period/spin 持久化。成功后不重复 start;
掉线 10s 退避重开。有线 ECAT 与 WiFi 不冲突。
2026-09-16 12:24:45 +08:00
cbb 4b4559e772 fix(server): worktree 启动时 ECAT_DIR 回指主检出 tools/ecat
从 .worktrees/<slug> 跑 esp32_server 时 join(BASE_DIR,'..','ecat') 会拼成
.worktrees/ecat,找不到 ecat_native.exe。先走出 .worktrees 再取兄弟目录。
2026-09-16 12:19:55 +08:00
cbb 0629bf0910 docs(compose): device-link ConnectionHub 扩展记入规格与报告 2026-09-16 12:12:36 +08:00
cbb 033ece2cca feat(link): ConnectionHub 统一 WiFi/ECAT/BLE 端点与健康态
各页不再各自读写 dcu_esp_ip / dcu_ecat_adapter:deviceLink 提供
wifiBaseUrl/ecatPost/adapter API,OTA/CAN/motor/ecat_load/lab 走同一入口。
连接灯只覆盖在线布尔,保留调用方语义文案。
2026-09-16 12:12:24 +08:00
cbb 353d936abb docs(compose): device-link 规格 delivered 2026-09-16 02:54:08 +08:00
cbb 6b5a9f9adc fix(link): ECAT 轮询必重挂;连接灯不覆盖语义文案;BLE 通知成功后再上报 2026-09-16 02:53:53 +08:00
cbb 670b833dc6 feat(link): DeviceLink 外壳单例统一 ESP32/ECAT/BLE 健康态
各页只共用 IP、各自 fetch 导致同屏「已连接/连接失败」分裂。
外壳 Owner 轮询 /system/status 与 EtherCAT 状态,BroadcastChannel
广播快照;连接灯只认共享层,SPI 失败单独文案不覆盖设备离线。
2026-09-16 02:50:12 +08:00
cbb be26f4daa2 docs(compose): tab-keepalive 规格标为 delivered 并写入验证报告 2026-09-16 02:41:13 +08:00
cbb eaafee1312 feat(shell): tabs 多 iframe 常驻保活,切标签不再销毁功能页
单 iframe 换 src 会卸载文档,WebSocket/轮询/传输状态全灭。
改为一标签一懒创建 iframe,切换只改 visibility;F5/⟳ 只 reload 激活帧。
2026-09-16 02:37:18 +08:00
cbb 9bb804ec35 docs(lab): 更正 PRAM 写归因为从站侧成本;lan9252_pm 加 pmHold 看门狗豁免 2026-09-13 04:13:28 +08:00
cbb 209a869f96 feat(motor): 反馈刷新率 Hz 适配(0x0400/0x0401 扩展格式)+ ECAT 密排槽与判活自适应
反馈刷新率(配套电机固件 v0.4.1:0x0400 DLC4/0x0401 DLC2 扩展格式,
选择器 0x0100=STATUS · 0x0101=CONFIG 帧,协议值为周期 ms):
- 「节点 ID」页签新增 STATUS/CONFIG 刷新率读取/写入;界面单位一律 Hz
  (写入按 周期ms=round(1000/Hz) 换算下发,读取反算 Hz 并附原始周期,
  取整有损如实展示),0=停发,写入掉电保存;配置值以「读取」回读电机为准
- WiFi 应答回执走 0xC1 per-ID 快照(/can/feedback 全量 entries):
  GD32 0xC0 ACK 仅 1 字节状态、不携带电机回帧;同 ID 回帧快照只存最新
  一条,故一次快照只配一次请求(STATUS/CONFIG 分开读写)
- ECAT 回执走批量接收区 takeNodeIdReport,按长度分派 2B 旧格式/4B GET/5B SET ACK
- GET_NODE_ID 迁移到扩展选择器 0x0000,兼容旧固件 2B 节点 ID 应答
  (SET DLC2 旧格式行为不变)
- transport-wifi 新增 fetchFeedbackFull(全量 CAN 反馈快照)

EtherCAT/基础设施(密排槽固件 1.6.21 配套):
- 快照槽位 5→9 一拍整轮(扫描周期数 6→1);判活阈值随实测 seq 间隔
  自适应 clamp(3×EWMA, 2s, 30s),秒级回报档不再被固定 2s 误判离线;
  STATUS 面板 Hz 明确为网页到达口径(按槽 seq 实测电机回报率的方案已否掉)
- esp32_server.py:ecat_native 单实例收紧(后起独占 + taskkill 残留实例,
  双实例并存会抢 Npcap 致后起者抓不到从站)+ native stdout 落盘
- flash_gd32.py:刷写后校验「GD32 真的在跑新代码」(版本变化或 uptime 归零),
  防"写入≠在跑"假台账
- lab:新增 LAN9252 电源诊断台、PDO 布局页;SPI 测速并入 EtherCAT 接收看板
  并更名「SPI/ECAT 通信测速」;移除 continuity_test.html;新增 ECAT 单拍耗时
  优化报告;/_local/ 排除入库;测试同步更新(62+58 项全过)
2026-09-12 20:10:47 +08:00
cbb 81096d0425 feat(motor): 新增 EtherCAT 周期速率扫描分析工具链,支持 sweep 脚本、指标校准、绘图与 HTML 报告生成 2026-09-10 03:02:14 +08:00
cbb 4e018af168 feat(motor): 序列每步新增绿色「单发」按钮,支持仅发送该步动作一次 2026-09-07 22:18:50 +08:00
cbb 3b156547bc chore(gitignore): 为已入库的通用 ECAT 采样探针加白名单例外 2026-09-07 21:56:34 +08:00
cbb 03caa4acd4 chore: 采样/自测产物与一次性探针脚本移出版本库
- 删除已入库的 ecat_after*.csv 与 scripts/ecat_torn_hits.jsonl
- .gitignore 增加 *.csv / *.jsonl / scripts/_*_out.txt 与
  scripts/probe_*.py / selftest_*.py 规则
- 保留 README 引用的回归自测 test_*.py|mjs 与 browser_check_*.mjs
2026-09-07 21:55:42 +08:00
cbb ef9472334a docs: 新增 .qoder Repowiki 知识库文档 2026-09-07 12:19:26 +08:00
cbb 43d42ad891 docs: 新增 CHANGELOG, 记录 2026-09-05 三项变更
app 窗口租约(关窗自动停从站)、首页 GD32 CPU 负载率指标、PDO 周期
缓存迁移与测试对齐; 面向使用者的视角, 实现细节指向 README 与提交。
2026-09-05 19:58:50 +08:00
cbb 12879aa81a fix(motor): 清掉存量 4ms PDO 周期缓存, 测试对齐 pcore_mask
- transport-ecat: localStorage 的 dcu_ecat_period_ms 是 Python 时代默认
  4ms 存进去的(browser_check_period.mjs 收尾也写 "4"), native 后端默认
  已改小, 存量 "4" 会在每次 ensureStarted 把总线压回旧默认。加一次性
  迁移标记 dcu_ecat_period_mig, 只删一次; 之后显式设 4 视为用户选择。
  "恢复默认"从 setPdoPeriod(4) 改为删键跟随后端默认(固件修好前别低于
  1ms)。
- test_motor_transport: 自旋钉核显示早已改用快照字段 pcore_mask(P 核),
  测试还停在 ecore_mask 旧字段旧措辞, 本次对齐(含跨 32 位掩码、不连续
  核号区间用例)。

验证: node scripts/test_motor_transport.mjs 66/66 通过。
2026-09-05 19:58:45 +08:00
cbb 9cbde690c6 feat(ota): 首页运行指标卡新增 GD32 CPU 负载率(EtherCAT 通道)
DCU_APP 固件 >=1.5.61 把"中断+非延迟处理段"占用率(1.5.63 收窄定义)
写进 EtherCAT 输入 PDO 段 5 诊断区(1s 滚动窗口, 0.1% 分辨率), native
经 /api/app/load 提供纯缓存读; 前端 js/ota/ecat_load.js 经同源代理
/ecat/api/app/* 轮询并渲染到 #mGd32Load。

- 与 /system/status 轮询完全独立: ESP32 离线互不影响, 失败不计入
  ESP32 连接失败计数
- 失败静默退避 1s→5s→30s, 不用 EtherCAT 的用户不按 1Hz 触发 esp32_server
  的自动拉起逻辑
- 无效显示 --: valid:false(总线未开/循环未跑/固件过旧)或 age_ms>3s
  (PDO 循环已停, 值不再滚动); >=85% 琥珀色提示余量吃紧
- 页面上无 #mGd32Load 时自动空转(其他页共用 app.js 不受影响)
2026-09-05 19:58:36 +08:00
cbb 99fb9bcb4a feat(server): app 窗口租约, 全部窗口关闭自动停 EtherCAT 循环
PDO 循环起来后 native 侧一直跑, 其 --parent-pid 自清理只覆盖"本服务拉起
的实例死掉"; 浏览器模式关窗后本服务还在、窗口模式复用现成 ecat_native
(parent 是别人)这两种常见离开方式都够不着 —— 从站卡在 OP、自旋节拍白占
一个核。现由每个 app 页面与页面服务器的 WebSocket 租约兜底:

- esp32_server.py: 新增 GET /ws/app-lease(手写 RFC6455 握手 + 帧泵)。
  连接计数归零后隔 3s GRACE 调后端 /api/op/stop 停循环退回 INIT; 断开
  感知靠 TCP FIN / Close 帧回显而非心跳(后台标签页节流不误停); 代理
  透传时 _note_ecat_loop 记账"循环是否本服务跑起来的", 只停自己确认过
  的; /api/shutdown 与 serve_forever 退出路径同步补停(定时器随进程死亡)。
- js/common/app-lease.js: 租约客户端, 3~30s 退避重连, file:// 静默跳过。
- 接入 tabs.html(外壳持有, 内部切标签不断连)与 pages/motor.html(单独
  打开时页面自持)。
- 测试: scripts/test_app_lease.py(裸 TCP 全路径, 假 _ecat_post) +
  scripts/test_app_lease_e2e.mjs(真 Edge headless + 假后端 8091, 不碰
  真总线)。README 补设计动机与要点。
2026-09-05 19:58:25 +08:00
cbb c189a8563e feat(motor): ESP32 侧适配 EtherCAT native 后端, 清理 Python 时代残留
tools/ecat 后端已从 Python(ecat_server.py, 已删除)切到原生守护进程
build_native/ecat_native.exe。本仓(ESP32 电机控制台)的 EtherCAT 传输经
/ecat/api/* 代理打它, 这次把代理实现与全部相关措辞对齐到 native。

代理与自启(esp32_server.py):
- ECAT_ENTRY/ECAT_PYW(src/ecat_server.py + venv/pythonw) → ECAT_NATIVE_EXE
  (build_native/ecat_native.exe); autostart 直接 Popen 单文件 exe, 不再经
  venv/pythonw; 清场/--parent-pid/端口漂移三条契约 native 原样镜像, 注释同步
- 文件头与代理段里 ecat_server.py 措辞订正为 native

传输层注释(js/motor/*):
- main/transport/transport-ecat/refresh-rate 里"后端是 ecat_server.py /
  用 venv 拉起"等 Python 时代说法统一改为 ecat_native.exe
- MOT_SLOTS_PER_PDO/MOTOR_SLOT_COUNT 引用改指 ecat_native.c(同名存在);
  MOTOR_SCAN_CYCLES native 无此常量, 改按真实来源写 ⌈27/7⌉=4
- main.js 环境错误分类正则 /venv|pysoem|Npcap/ → /Npcap|build_native|
  守护进程|全被占/: 前两者 native 永不吐给前端(死分支), 后者对齐 autostart
  真实文案, 让"exe 没编译"这类错误不被 140 字截断

README/motor.html 同步; 新增 scripts/browser_check_period.mjs(内核级 PDO
周期热调的浏览器实测, 与既有 browser_check_*.mjs 同族)。

验证: py_compile esp32_server.py 通过; 6 个改动 JS + 新脚本 node --check
(ESM)全过; 正则对三类 native 错误命中、对废弃 venv/pysoem 不命中;
js/ 与 scripts/ 重扫 ecat_server|venv|pysoem 已空。
2026-09-05 03:56:52 +08:00
cbb 2d1743ca4f feat(motor): 支持 WiFi 与 EtherCAT 双链路传输
- 抽象电机传输层,WiFi 与 EtherCAT 实现分离,解码、状态面板、波形和命令序列共用
- 新增 EtherCAT 后端反向代理与自动拉起,解决跨端口 CORS 与预检失败问题
- 刷新速度按链路独立存储,支持 EtherCAT 低延迟读取与 WiFi 稳定轮询
- 更新电机页链路选择、不支持页签置灰样式、README 说明及相关测试脚本
2026-09-04 12:18:06 +08:00
175 changed files with 23886 additions and 3332 deletions
+60
View File
@@ -15,9 +15,69 @@ env/
*.map *.map
*.log *.log
# 采样 / 自测产物(现场跑出来的原始数据,动辄几十 MB,勿入库)
*.csv
*.jsonl
scripts/_*_out.txt
# 一次性排障探针(跟着某次现场问题写的,问题闭环后即失效)
scripts/probe_*.py
scripts/selftest_*.py
# 本地一次性排障产物(页面测试驱动 / 诊断脚本 / 截图 / 测试报告),不入库
/_local/
# 例外:这两个已入库,是可复用的通用采样器
!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/_*
# 一次性分析/采样/测量/追踪脚本
# 注:验收链上的脚本一律用 check_ 前缀入库(check_web_send_latency /
# check_recover_after_reset / check_click_to_motion / check_guard_*),
# 下面这些 e2e_/measure_/capture_ 通配只挡新的一次性草稿
scripts/anal_*.py
scripts/collect_*.py
scripts/capture_*.py
scripts/measure_*.py
scripts/e2e_*.py
scripts/trace_*.py
scripts/flash_gd32_ecat.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/ .vscode/
.idea/ .idea/
*.swp *.swp
Thumbs.db Thumbs.db
desktop.ini desktop.ini
# 本地现场备份(可用前端快照)与 UI 诊断输出
/_backup/
/output/
@@ -0,0 +1,28 @@
schema_version: 1
module_path: scripts_and_tests
title: ECAT 调试、固件刷写与端到端测试脚本集
scope:
- scripts/
source_files: []
depends_on: []
related_to:
- path: python_server
reason: 脚本 test_app_lease_e2e.mjs 等连接本地页面服务器的 /ws/app-lease 端点做端到端断言(端口探测与 POST 触发)。
kk_module_id: adba262f-05b7-4a9b-ba4f-3edc007d80fb
kk_ordinal: 3
kk_cards:
architecture_design:
id: 51246f25-7864-40cd-812b-3af7b95b1e02
title: ECAT 调试、固件刷写与端到端测试脚本集
coding_conventions:
id: 86785324-6a18-4cc3-8952-47c808437f14
title: ECAT 调试、固件刷写与端到端测试脚本集
overview:
id: 13d0dd8e-2f0f-4d11-89de-5f07971aed07
title: ECAT 调试、固件刷写与端到端测试脚本集
tech_stack:
id: 70c89e3a-602b-425b-8570-86d077473604
title: ECAT 调试、固件刷写与端到端测试脚本集
unique_setup_and_commands:
id: f9104fb6-b2a4-4696-9135-8ed0b2ead005
title: ECAT 调试、固件刷写与端到端测试脚本集
@@ -0,0 +1 @@
纯 Python + Node.js + PowerShell + Batch;通过 urllib/socket/raw HTTP 轮询 ESP32 服务的 `/ota/*`、`/api/motor/state`、`/motor/state?bin=1` 以及 WebSocket 接口;ECAT 撕裂检测采用“同 seq 多份 payload”不变量 + 字段跳变 p99 尺度的双判据。
@@ -0,0 +1,5 @@
本模块为独立的辅助脚本集,按功能分为四层:
- 固件与设备操作层:`flash_gd32.py` 通过 HTTP 把 Intel HEX 展平 BIN 并经由 ESP32 OTA 中继写入 GD32,轮询 `/system/status` 确认 `GD32_COMPLETE` 后写台账 `_last_gd32_sha.txt`;`build_mem.bat` / `cpu_sample.ps1` / `wv2_roles.ps1` 分别负责 IDF 构建与 Windows 进程采样。
- ECAT 数据采集与诊断层:`rec_ecat_slots.py` 以固定频率轮询服务 `/api/motor/state`,去重落盘到 CSV(M/F/H 三类行),作为后续分析的唯一输入;`probe_ecat_slot_atomicity.py` 在此基础上做同一 seq 重复读的撕裂判定;`probe_ecat_vs_wifi.py` 将 ECAT 帧与 WiFi 二进制 `/motor/state?bin=1` 对照,逐字节验证一致性;`anal_ecat_jumps.py` 基于 p99 尺度+孤立尖峰形态检测撕裂,并提供 `--selftest` 注入混合帧的灵敏度自检。
- 服务端行为验证层:`test_app_lease.py` 通过 `sys.path.insert` 直接 import `esp32_server`,用假 `_ecat_post/_ecat_find_port` 启动真实 HTTP 服务,模拟 WS 租约建立/Grace 重连/进程退出等场景断言 EtherCAT 循环启停;Python 版的浏览器 E2E 用例由对应 `.mjs` 借助 CDP (`ws://127.0.0.1:9333`) 操控 WebView 页面执行断言。
- 依赖方向单向向外:所有脚本只读本地文件与服务 HTTP API,不反向修改业务代码;采集与分析脚本之间通过固定 CSV 格式(`ecat_slots_raw.csv`)解耦。
@@ -0,0 +1 @@
面向 DCU/ESP32 平台的运行期工具与测试集合:含 GD32 固件刷写、ECAT 撕裂检测与分析、内存批化仿真、浏览器 E2E 用例及 Windows 性能采样。
@@ -0,0 +1,5 @@
- 刷写 GD32:`python scripts/flash_gd32.py [期望版本]`,需先 `idf.py build` 产出 `DCU_APP/out/GD32E508_DCU_APP/GD32E508VE/Release/GD32E508_DCU_APP.hex`,并通过 `DCU_ESP_IP` 环境变量指定目标 IP
- ECAT 撕裂检测:先跑 `python scripts/rec_ecat_slots.py [秒数] [端口] [输出csv]` 生成原始流,再 `python scripts/probe_ecat_slot_atomicity.py [端口] [秒数]` 或 `python scripts/anal_ecat_jumps.py ecat_slots_raw.csv [--k 8] [--selftest]`
- ECAT vs WiFi 对比:`python scripts/probe_ecat_vs_wifi.py [秒数]`,要求本机同时提供 ECAT 状态 JSON 与 ESP32 WiFi 二进制接口
- 浏览器 E2E:先起 `node --inspect-brk` + Chrome DevTools,再以 CDP 端口 9333 连接后执行各 `browser_check_*.mjs`;`test_app_lease.py` 可直接 `python scripts/test_app_lease.py`
- Windows 性能:`scripts/cpu_sample.ps1` 统计 `msedgewebview2|ecat_native|pythonw|python` 进程 CPU/WorkingSet
@@ -0,0 +1,6 @@
- 脚本入口统一为 `if __name__ == "__main__": main()` 模式,命令行参数通过 `sys.argv` 顺序解析(支持可选的端口、时长等)。
- HTTP 客户端封装为顶层函数(如 `post/get` 或 `once()`),统一处理超时、`HTTPError` 与 UTF-8 解码替换,避免重复样板码。
- ECAT 数据采集类脚本使用 TCP socket 直接拼接 HTTP 请求头并发出 POST,收到空字节流后再 `partition(b"\r\n\r\n")` 解析 JSON 体。
- 长耗时运行通过 `while time.time() - t0 < DUR_S` 形式的时间窗口控制退出,错误路径一律 `except Exception: continue` 容错而非中断。
- 大量去重/缓存场景采用 dict/set 并配合定期截断(如 `len(seen_m) > 400000` 时取尾部 200000 项),控制内存增长上限。
- 断言式测试使用 `assert ... raise AssertionError(...)` 配合 PASS/INFO 日志串联用例,失败即非零退出码,便于 CI 集成。
@@ -0,0 +1,26 @@
schema_version: 1
module_path: js_frontend/ble
title: BLE 通道 OTA 客户端(Web Bluetooth GATT)
scope:
- js/ble/
source_files: []
depends_on: []
related_to: []
kk_module_id: e695976e-f72c-4d3a-867b-fb80827337d8
kk_ordinal: 2
kk_cards:
architecture_design:
id: e5ac84aa-1a14-4238-840f-63cc430d2c4c
title: BLE 通道 OTA 客户端(Web Bluetooth GATT)
coding_conventions:
id: a5fa8c1f-0636-432f-a5f4-9b973378b212
title: BLE 通道 OTA 客户端(Web Bluetooth GATT)
overview:
id: 4a0792d4-88cf-45fa-86ab-ddce531e07e2
title: BLE 通道 OTA 客户端(Web Bluetooth GATT)
tech_stack:
id: b1c611b2-296d-4545-adf6-acf77eeaa116
title: BLE 通道 OTA 客户端(Web Bluetooth GATT)
unique_setup_and_commands:
id: 5d391cf3-8e98-4ea3-90bb-b48da99b4259
title: BLE 通道 OTA 客户端(Web Bluetooth GATT)
@@ -0,0 +1 @@
Web Bluetooth API(navigator.bluetooth.requestDevice / GATT Server + Characteristic + notify),配合 File System Access API(showOpenFilePicker)支持热重载;通过 0xA1 ACK + 64 窗口 ACK_WINDOW 实现流控。
@@ -0,0 +1 @@
模块按职责划分为八个 ES 模块文件,依赖方向单向:`constants.js` 集中定义 GATT UUID、OTA 协议码(ACK/FINISH/SHA256/CTRL)及 `TARGETS.{esp32,gd32}` 配置,作为所有其他文件的常量来源;`state.js` 维护全局状态(device/server/characteristic 引用、上传标志、ackWaiter、会话标识等)并提供 DOM 元素缓存与通用工具(bleLog/toHex/sleep 等);`gatt.js` 实现 BLE 生命周期(connectBle/disconnectBle/readVersion/onDisconnected)以及 GATT notify 分发 `handleNotify`,将协议事件路由至 transfer/session/verify;`transfer.js` 实现数据面:buildPacket 打包 seq+totalSize+payload+CRC、ACK 等待 Promise 封装 waitForAck、进度刷新 updateProgressFromAck;`session.js` 管理多轮 OTA 结果等待(waitForOtaResult/nextOtaSessionId/finishBelongsToWaiter)与 abort 流程;`stages.js` 提供 1~6 阶段状态栏的 UI 操作(setBleStage/setBleStageError/updateBleStageLabels),被 gatt/transfer/file 共同消费驱动动画;`file.js` 统一文件入口,处理 HEX→BIN 转换、GD32 包身份检测、SHA-256 缓存,并在 upload 中屏蔽重复选择;`verify.js` 仅暴露 show/hide 校验信息面板的方法。跨通道(BLE/WiFi)通过 `connectionMode` 变量与共享 DOM id(progressFill/logScroll 等)协作,确保界面一致性。
@@ -0,0 +1 @@
基于 Web Bluetooth API 实现 ESP32/GD32 双目标设备的 BLE 固件在线升级,负责 GATT 连接、分包传输、ACK 窗口与 SHA-256 校验。
@@ -0,0 +1 @@
BLE 连接必须在用户手势内触发(click handler 中使用 `e.preventDefault()` 保证 navigator.bluetooth.requestDevice 合法);需浏览器启用 `web-bluetooth` 权限并过滤 namePrefix 为 'ESP32-OTA'。
@@ -0,0 +1,6 @@
- 协议常量集中声明于 constants.js,并以命名空间式导出(如 BLE_OTA_NOTIFY_ACK、BLE_OTA_FINISH_ERR_*、TARGETS.*),所有业务模块通过 import 使用而非魔法数字硬编码。
- DOM 节点在 state.js 启动时一次性缓存到 els 对象,高频路径直接读取属性,避免重复 getElementById;stages.js 对共用状态栏也采用首读缓存 + isConnected 失效回退模式。
- 阶段 UI 通过 stages.js 的 setBleStage(stageNum, detail, barState) 统一推进,错误路径使用 setBleStageError(detail) 将当前 active 节点标记 err,禁止各模块自行修改 className。
- 异步长耗时操作采用“waiter”模式:用 state.ackWaiter / state.otaResultWaiter 存储 resolve/reject + timer,超时或中止时主动清除并 reject,防止悬挂 Promise。
- 日志输出统一经 state.bleLog(tag, message, level) 封装后注入 common/log.js 的 addLog,标签前缀固定为 'BLE-*',便于侧边栏筛选。
- 跨通道状态判断使用 isBleUploading()(state.uploading || state.autoFlashing)与 window.isUploading 组合判定,避免单个通道上传时另一通道 UI 被意外重置。
@@ -0,0 +1,27 @@
schema_version: 1
module_path: js_frontend/can
title: CAN 控制台前端:DBC 解析、报文发送与接收监控
scope:
- js/can/
- js/can_monitor.js
source_files: []
depends_on: []
related_to: []
kk_module_id: b5c93572-2980-4d8b-abcb-49299366edc6
kk_ordinal: 4
kk_cards:
architecture_design:
id: 8c1ea8cb-f95b-48ee-856a-50f8336e3eea
title: CAN 控制台前端:DBC 解析、报文发送与接收监控
coding_conventions:
id: cee80565-5287-44a1-881b-e9087ff9c0ed
title: CAN 控制台前端:DBC 解析、报文发送与接收监控
overview:
id: ee9c08d3-0aed-4e95-8a7f-f446a2debab4
title: CAN 控制台前端:DBC 解析、报文发送与接收监控
tech_stack:
id: 7d94fd27-c122-4f4b-a175-b9c7ac60a3ad
title: CAN 控制台前端:DBC 解析、报文发送与接收监控
unique_setup_and_commands:
id: 7b2bd04a-c5d6-4bce-954b-193adc49892a
title: CAN 控制台前端:DBC 解析、报文发送与接收监控
@@ -0,0 +1 @@
纯浏览器端 JavaScript(ES Module),使用 TextDecoder('utf-8' | 'gb18030') 解析 DBC 文件、localStorage 会话存储、AbortController+setTimeout 双超时控制 fetch、Clipboard API 降级 execCommand 回退。
@@ -0,0 +1,8 @@
模块按职责拆分为五个 ES Module,依赖方向单向收敛于 `dbc.js`:
- `dbc.js` 为基础设施层,导出共享 state/el、IP 工具、字节格式化工具,以及 DBC 文本读取(UTF-8 / GB18030/BOM 自动回退)和 DBC 解析器(BO_/SG_/CM_/VAL_ 行 → { messages, nodeNames, messageMap }),不依赖其他 can 子模块。
- `messages.js` 负责报文列表渲染、关键字与发送节点筛选、节点过滤面板 UI 及批量选择。
- `signals.js` 基于 DBC 信号定义生成内联信号表格,维护 signalValues bag,处理 Motorola/Intel 位序、有符号/无符号、枚举、因子/偏移编解码,并维护当前选中报文的编码预览。
- `send.js` 作为 HTTP 出口,统一通过 `fetch(getBaseUrl() + '/can/send_batch')` 发原始帧或构造后的批量帧,封装错误码映射(CAN_SEND_ERR_DESC / GD32_ERR_DESC),维护发送历史与剪贴板复制。
- `persist.js` 将多 DBC 原文、signalValues、checkedKeys/expandedKeys、transmitterFilter、history、表单状态序列化到 localStorage(键 `dcu_can_session_v1`),并以防抖 `scheduleCanPersist()` + 失败时逐步砍历史/信号值的方式保证写入安全;恢复时逆向重建 DBC。
- `can_monitor.js` 是独立的 1Hz 轮询入口,调用 `/can/feedback` 获取 3 路 CAN 通道 RX/TX/Drop/Err/TEC/REC/bus_state/entries,渲染三卡片及 ID snap 表,可被 `can.html` 右侧抽屉复用。
模块间通过 ES Module live binding 共享 `state` 与 `el`,避免重复缓存 DOM 与状态副本。
@@ -0,0 +1 @@
实现浏览器端 CAN 调试工具的 DBC 文件解析、报文过滤/信号编辑、批量帧发送与会话持久化,并提供独立页的 CAN 通道反馈轮询监控。
@@ -0,0 +1 @@
无需构建步骤;直接由网页加载 ES Module。`readDbcText` 支持 UTF-8 优先、GB18030 回退并去除 BOM,用于本地拖入的 .dbc 文件;`parseDbc` 自行解析 BO_/SG_/CM_/VAL_ 标准 DBC 片段。
@@ -0,0 +1,6 @@
- 跨子模块共享可变状态与 DOM 引用集中放在 `dbc.js` 导出的 `state`/`el` 对象上,各子模块通过 import 获取同一引用,利用 ES Module live binding 保证同步。
- 对外部不可信文本一律使用 `escapeHtml`/`sanitizeHtml` 后再插入 innerHTML,所有数据属性赋值前先经 `escapeHtml`,禁止拼接 HTML 字符串直接使用用户输入。
- 网络请求统一使用 `new AbortController()` + `setTimeout` 双保险取消,防止在连接 reset 等边缘场景下 await 永久挂起。
- 高频本地持久化采用「收集事件 → scheduleCanPersist → 防抖定时器 → saveCanSessionNow」模式,并在 writePayload 失败时逐级减少 payload(压缩历史→清 signalValues)以容忍 localStorage 配额不足。
- DLC/字节数组输出统一走 `padHex(value, width)` 与 `bytesToHex(bytes)`,确保十六进制展示宽度一致、全大写。
- 错误信息分层展示:完整描述走 buildSendResponseText,简短文案走 buildSendResponseShort 去掉冗余前缀,使历史记录空间有限时仍保留核心原因。
@@ -0,0 +1,23 @@
schema_version: 1
module_path: js_frontend
title: ESP32 DCU 前端 Web 控制台集合
scope:
- js/
source_files: []
depends_on: []
related_to: []
kk_module_id: 34c369aa-9944-449c-bc32-3fb459da6f08
kk_ordinal: 2
kk_cards:
architecture_design:
id: 0226951f-1a73-43f5-adb5-ded1b74dbe63
title: ESP32 DCU 前端 Web 控制台集合
coding_conventions:
id: 66764ffe-f832-4992-a6b4-064aab166812
title: ESP32 DCU 前端 Web 控制台集合
overview:
id: 8298ce61-963b-4a66-b17b-8293a4472e54
title: ESP32 DCU 前端 Web 控制台集合
tech_stack:
id: 22e24b2a-6f8d-4d54-b181-e2ec9ff24243
title: ESP32 DCU 前端 Web 控制台集合
@@ -0,0 +1,28 @@
schema_version: 1
module_path: js_frontend/ota
title: 前端 OTA 刷写会话与进度管理
scope:
- js/ota/
source_files: []
depends_on: []
related_to:
- path: js_frontend/ble
reason: 两者均为浏览器端固件升级方案:js/ota 走 WiFi ESP32 HTTP(分块上传+SHA-256),js/ble 走 Web Bluetooth GATT(分包 ACK+窗口+SHA-256),同一业务目标的不同实现。
kk_module_id: 697d6afd-9c08-42a6-900d-2b51dd09c507
kk_ordinal: 1
kk_cards:
architecture_design:
id: 7e17f40a-f0ea-4d4d-bba0-99559868e617
title: 前端 OTA 刷写会话与进度管理
coding_conventions:
id: 79bc6166-c07c-4910-b704-f898340b0b69
title: 前端 OTA 刷写会话与进度管理
overview:
id: 95c1a351-1033-42a9-a77b-086cd3d5186c
title: 前端 OTA 刷写会话与进度管理
tech_stack:
id: b16589eb-b0da-4e30-960e-4a0d3b051ed3
title: 前端 OTA 刷写会话与进度管理
unique_setup_and_commands:
id: 93240d0d-1e8c-4a7d-9f5f-9e26a332988e
title: 前端 OTA 刷写会话与进度管理
@@ -0,0 +1 @@
纯浏览器端 JavaScript (ES Module),使用 IndexedDB 持久化 FileSystemFileHandle 实现刷新后热重载恢复;XHR+fetch 混合进行文件分块上传;通过 ES import 引入 ../common/*(log、toast、utils、connection)。
@@ -0,0 +1,5 @@
模块以**集中式会话状态机**(session.js)为核心,定义 IDLE→UPLOADING/VERIFYING/RELAYING/REBOOTING→DONE/FAILED 的状态流转,所有 UI、上传、轮询通过 window.* 全局总线共享 otaSessionState / otaSessionTarget / activeUploadChip 等标志,避免分散布尔位导致的“网络错误后轮询覆盖”问题。
- 入口编排:console-mode.js 接管 switchStageFlow,提供 WiFi/BLE/ESP32/GD32 多通道目标切换与共享固件文件同步;upload-esp32.js 作为统一入口 startUpload(chip) 处理互斥探测、热重载重读、XHR 分块上传、SHA-256 校验与设备重启。
- 后端协议:upload-gd32.js 仅负责 POST /ota/relay 触发 GD32 中继,并通过 emitRelayProgress/markGd32OtaUiComplete/startRelayProgressWatch 与 stages.js 中的 status 轮询路径协同;ECT 负载显示由独立的 ecat_load.js 基于 /ecat/api/app/load 退避轮询。
- UI 层:progress.js 维护折叠面板、阶段节点 es1..es6/s1..s6 与进度条;stages.js 把 relay_state/sys_state_code 映射到 GD32 阶段节点并调用 progress 更新仪表盘;file-handle.js 封装 HEX→BIN、IndexedDB FSAPI handle 持久化、localStorage 偏好(flashTarget、文件元信息)。
- 依赖方向单向:console-mode → upload-* → session + progress + file-handle,stages/ecat_load 消费 session/progress,无任何反向引用。
@@ -0,0 +1 @@
为 ESP32/GD32/DPU 提供浏览器侧的固件选择、分块上传、校验、中继触发与会话状态机,并渲染阶段节点与进度条。
@@ -0,0 +1 @@
无构建脚本——模块由 HTML 页面直接按模块加载;EtherCAT 负载显示需主站 esp32_server 打开 ECAT native 循环并提供 /api/app/load,本模块仅做 100ms 退避轮询(失败→5s→30s),不强制拉起后端。
@@ -0,0 +1,6 @@
- 跨模块共享可变状态统一挂载在 window.* 上(如 isUploading、activeXhr、otaSessionState、activeUploadChip),各模块通过导入函数访问,而非显式传参。
- 所有会话生命周期变更必须经由 session.js 的 otaSessionTransition() 驱动,禁止直接修改窗口标志,保证是Uploading/Verify/Reboot/Done/Fail 等终态时自动清理按钮、阶段与定时器。
- 失败路径统一调用 progress.cleanupAfterOtaFailure(reason) 进入 FAILED 态并清除残留 DOM/定时器/进程句柄,使后续 status 轮询不会覆盖失败 UI。
- DOM 写入采用「先比较后写入」模式(setNodeText/setNodeClass/setTitle)或缓存 lastDisplayedRelayState,避免每秒轮询时无条件赋值导致样式失效重排。
- 长耗时用户操作(选文件、热重载、请求)均包裹 try/catch 并以 addLog('W'/'E','OTA'/'文件',...) 记录,同时用 showToast 给出用户可见反馈,区分日志与提示。
- 轮询器以幂等的 start*/stop* 成对暴露(stopRelayProgressWatch、startUploadWaitHeartbeat、startEcatLoadPoller),内部持 timerId 并在页面无对应元素或已运行时直接 return,避免重复调度。
@@ -0,0 +1,22 @@
schema_version: 1
module_path: js_frontend/common
title: 前端公共工具库(连接/轮询/日志/Toast/IndexedDB)
scope:
- js/common/
source_files: []
depends_on: []
related_to: []
kk_module_id: 83fe5acc-8cc9-4cca-8aa5-93ffb96585bf
kk_cards:
architecture_design:
id: 985b41f4-d6f1-4ff0-b9c0-e6ccc216fdf4
title: 前端公共工具库(连接/轮询/日志/Toast/IndexedDB)
coding_conventions:
id: 3d9852ce-9b31-4829-af73-d79d122dc321
title: 前端公共工具库(连接/轮询/日志/Toast/IndexedDB)
overview:
id: f6c3b043-050f-4761-8bb6-2fac2945de55
title: 前端公共工具库(连接/轮询/日志/Toast/IndexedDB)
tech_stack:
id: 5488e585-4aad-4812-ae92-aafb6c8ac269
title: 前端公共工具库(连接/轮询/日志/Toast/IndexedDB)
@@ -0,0 +1 @@
ES Module (`export/import`);HTTP 轮询使用原生 `fetch` + `AbortController` 实现超时取消;WebSocket 长连接由 `app-lease.js` 自持;设备事件持久化走 IndexedDB (`IDBFactory.open('esp_dream_heap_low')`,版本 1);DOM 高频更新采用 `requestAnimationFrame` 合并写入与字符串对比去变更。
@@ -0,0 +1,9 @@
模块按职责拆分为独立 ES 模块,各文件通过 `import` 组合,不相互强耦合:
- `connection.js`:封装 DOM 渲染缓存(`elCache`、`set*IfChanged` 写前比较)和 `/system/status` UI 更新(连接胶囊、健康态、启动自检面板),是上层业务页渲染的核心。
- `polling.js`:基于递归 `setTimeout` 的轮询调度器,维护 `/system/status` 与 `/logs` 双通道 in-flight 锁、5s `AbortController` 超时、上传期暂停、Page Visibility 感知,并协调 OTA 状态机;导出 `startPolling / scheduleStatusPoll / pausePollingForUpload` 等生命周期 API。
- `log.js`:带去重 + 自动滚动 + 环形缓冲(`MAX_LOGS=1000`)+ rAF 批量滚动的控制台。
- `toast.js`:支持 success/error/warning 三类弹窗,含 5s 去重防抖 + 3s 自动隐藏。
- `heap-low-history.js`:基于 IndexedDB (`esp_dream_heap_low`) 持久化“最低空闲内存”创新低记录,含去重 key `(ts_ms|min_free_heap|free_heap|alloc_count)`、上限裁剪与离线可用保证。
- `app-lease.js`:自执行 IIFE 建立到 `/ws/app-lease` 的 WebSocket 长连接并 3~30s 递增退避重连,作为服务端停 EtherCAT 循环的窗口凭证。
- `base-url.js` / `utils.js`:纯函数辅助层(IP/URL 拼串、字节/时间/运行时间格式化、HTML 转义、hex 转换),被其他子模块复用,无浏览器副作用。
依赖方向:业务页 → connection/polling/log/toast → base-url/utils;只有 polling→connection 反向引用用于失败诊断,避免连接态被业务直接操作。
@@ -0,0 +1 @@
为 ESP32 DCU Web 管理端提供跨页面共享的连接状态、系统轮询、日志缓冲、提示框、IndexedDB 历史与基础 URL/格式化等通用能力。
@@ -0,0 +1,6 @@
- 对频繁读取的 DOM 节点在模块级以 `var elCache[id] = document.getElementById(id)` 方式缓存,并通过 `getEl()` 统一访问,避免每帧重复 `getElementById`。
- 文本/类名/宽度等 DOM 属性写入前先比较旧值(`textContent !== v`、`className !== cls`、元素上 `_lastW` 缓存宽度),仅在变化时才触碰样式,避免强制布局重排。
- 轮询器统一用递归 `setTimeout` 而非 `setInterval` 调度,并在每次回调末尾按 `max(0, interval - elapsed)` 重新挂载下一次任务,保证周期稳定且不会因慢响应重叠。
- 所有通过 `innerHTML` 渲染的用户可控文本均先经 `escapeHtml` 转义,防止注入。
- 需要幂等的 UI 绑定(如健康启动面板切换按钮)通过 HTML 自定义属性(如 `dataset.bootToggleBound === '1'`)标记是否已处理,避免重复 `addEventListener`。
- 可选 API(如 Toast、IndexedDB、autoScroll 按钮)在执行前做存在性判断,不存在时静吞错误,保证核心轮询逻辑在无 DOM/无痕模式环境下仍可工作。
@@ -0,0 +1,30 @@
schema_version: 1
module_path: js_frontend/app_entries
title: 前端应用入口聚合层(ESP32 OTA / BLE OTA / CAN 诊断)
scope:
- js/app.js
- js/ble_ota.js
- js/can.js
source_files: []
depends_on: []
related_to:
- path: python_server
reason: OTA、BLE、CAN 控制台页面均由 python_server 的静态文件服务提供,并通过 base-url.js 中 localStorage 存储的 dcu_esp_ip 以同源请求访问设备 API。
kk_module_id: d15c38ec-fd6c-4494-8521-a48360031a6b
kk_ordinal: 5
kk_cards:
architecture_design:
id: 441b093f-3154-4236-87c1-714b50c314c2
title: 前端应用入口聚合层(ESP32 OTA / BLE OTA / CAN 诊断)
coding_conventions:
id: b6fef608-2072-43c3-8b54-488a03451825
title: 前端应用入口聚合层(ESP32 OTA / BLE OTA / CAN 诊断)
overview:
id: 41949d5a-6e02-4b39-a0d9-23dcc19a84ee
title: 前端应用入口聚合层(ESP32 OTA / BLE OTA / CAN 诊断)
tech_stack:
id: 3d5e73da-051c-455f-a5d9-9d897c816023
title: 前端应用入口聚合层(ESP32 OTA / BLE OTA / CAN 诊断)
unique_setup_and_commands:
id: b4aa8897-362a-45f5-9b97-fe13dbf1072a
title: 前端应用入口聚合层(ESP32 OTA / BLE OTA / CAN 诊断)
@@ -0,0 +1 @@
ES Module (type="module") + Fetch API + Web Bluetooth (GATT) + DBC/CAN 报文解析
@@ -0,0 +1,5 @@
本模块由三个互相独立的入口脚本组成,均为薄编排层:
- `js/app.js`:ESP32 WiFi OTA 控制台入口,import `ota/*`、`common/*` 等子模块后把所有交互函数显式挂载到 `window.*`,以兼容 HTML 内联 `onclick`;同时承担顶层副作用(拖拽区绑定、SPI 输入监听、状态轮询)。通过共享状态对象 `otaSessionState`、`OtaSessionState` 在 ota 子模块间传递会话信息。
- `js/ble_ota.js`:BLE OTA 控制台入口,导出单一 `window.bleOta` API 对象(含 `init/state/disconnect/isUploading` 等),调用 `ble/session.bindEvents()` 和 `state` 完成初始化。
- `js/can.js`:CAN 诊断页面入口,导入 `can/dbc.js`、`can/messages.js`、`can/signals.js`、`can/send.js`、`can/persist.js` 以及 `common/utils`、`common/toast`,通过共享的 `el`/`state` 单例在各子模块间保持最新 DOM 引用与选中状态;使用带版本戳的 `?v=` 查询参数字面量强制缓存失效(如 `send.js?v=20260801f`),同时避免在 import 路径上加版本戳导致浏览器重复实例化单例。
依赖方向:三个入口只向上暴露到 `window`,被上层 HTML 或测试脚本消费;向下仅依赖各自子目录模块与 `common/*`,无横向耦合。
@@ -0,0 +1 @@
汇总 ESP32 WiFi OTA、BLE OTA 与 CAN 诊断三个控制台页面的入口脚本,通过 ES Module import 组装子模块并将函数挂载到 window,提供统一的事件绑定与 UI 初始化。
@@ -0,0 +1 @@
模块内 import 路径禁止加 `?v=` 版本号,版本戳仅出现在 HTML `<script src>` 标签上,以避免同一模块因不同 querystring 产生双份实例(破坏 state/toast 单例);CAN 页面中向特定文件追加硬编码 `?v=...` 以强制重新加载已发布过的模块。
@@ -0,0 +1,4 @@
- 入口脚本采用「薄编排 + window 挂载」模式:import 子模块函数后集中赋值给 `window.xxx`,供 HTML 内联事件处理器或外部脚本调用。
- 各入口先构造本地工具函数(如 `getBaseUrl`、`setConnectionState`、`resetSelectionState`),再执行 DOM cache 与事件绑定,最后调用统一的 `init` 入口完成启动。
- 子模块间的共享状态与 DOM 引用通过 ESM 导出的单例对象(`state`、`el`)传递,而非全局散落的闭包。
- 对 `common/utils`、`common/toast` 等通用能力按需要局部重名导入并立即挂载到 `window`,形成稳定的跨模块 API 边界。
@@ -0,0 +1 @@
ES Module + Web Browser API(Web Bluetooth/GATT、Web Worker、IndexedDB),运行于 ESP32 内置 Web Server 托管的静态资源环境,通过 HTTP 长轮询与服务端协议通信。
@@ -0,0 +1 @@
顶层目录以功能划分为若干独立子系统:common 提供跨子模块复用的 connection/polling/log/toast/utils;app_entries(js/app.js、can_monitor.js、ble_ota.js)作为单页应用入口,通过 ES Module import 将各子系统(ota/ble/motor/can)组合到 window 命名空间并绑定 UI 事件;各子系统内部自包含业务逻辑,如 ota 定义固件升级会话阶段,ble 基于 GATT 通道驱动同一流程,motor 通过 transport-ecat/wifi 可插拔传输层抽象驱动状态机。各子系统之间不直接互相 import,而是由浏览器加载的脚本各自按需引入 common,形成“入口聚合 + 功能隔离”的多页式架构。
@@ -0,0 +1 @@
汇聚 OTA、BLE、电机控制与 CAN 诊断等多个浏览器端控制台页面,共享连接、轮询、日志等公共能力并通过 app 入口聚合装配。
@@ -0,0 +1,27 @@
schema_version: 1
module_path: js_frontend/motor
title: 电机控制台多传输模块
scope:
- js/motor/
source_files: []
depends_on: []
related_to:
- path: js_frontend/can
reason: CAN 控制台与电机控制台均通过 POST /can/send_batch 发送帧到 ESP32 设备,但 CAN 用于通用报文编辑而电机用于特定 CMD/CONFIG 控制流程。
- path: python_server
reason: 电机控制台通过 /ecat/api/* 反向代理访问本地 ecat_native.exe 的 /api/motor/*(transport-ecat.js 中拼接路径发送)。
kk_module_id: 71087b31-4c6d-4a07-8d58-1f3e348444bf
kk_ordinal: 3
kk_cards:
architecture_design:
id: 00c57461-8294-47bf-88b9-8c362ac14bfc
title: 电机控制台多传输模块
coding_conventions:
id: 13ef69f8-0bc9-4b61-bd64-c0fa7a39ef2c
title: 电机控制台多传输模块
overview:
id: df61a7ec-5adc-4d4f-b0e9-a2867d026ab5
title: 电机控制台多传输模块
tech_stack:
id: 1dea780b-05c9-4fe6-ad60-ffef2d6854ec
title: 电机控制台多传输模块
@@ -0,0 +1 @@
原生 JavaScript ES Module,使用 Web Worker 作为定时节拍源;WiFi 链路通过 Fetch + AbortSignal.timeout 访问设备 HTTP 端点;EtherCAT 链路经本地页面服务器的同域反向代理(绑定 127.0.0.1,无 CORS)访问后端 `ecat_native.exe` 守护进程;二进制状态协议用 DataView 解析(魔数 0x4D53,v0.70/v0.74 双版本兼容)。
@@ -0,0 +1,9 @@
模块以 `main.js` 为 ES Module 入口(由 motor.html 外提),集中维护全局 `state`(连接态、刷新间隔、解码缓存、命令序列、版本缓存等)并通过工具栏在 `wifi` / `ecat` 两条链路之间切换。
内部按职责分层:
- 传输选择层 `transport.js` 导出 `createTransport(id, ctx)` 工厂,根据 id 创建具体实现,并以 `ctx` 注入 `getBaseUrl`、`recordSlow`、`isTransient` 避免循环依赖。
- 两路传输独立实现同一契约(见 `transport-ecat.js` 头注释列出的接口表):`transport-wifi.js` 调用 ESP32 的 HTTP API(`/system/status`、`/motor/state?bin=1`、`/can/send_batch` 等);`transport-ecat.js` 经本地页面服务器 `/ecat/api/*` 反向代理到 `tools/ecat` 的 C 守护进程,完成全自动接入(自动拉起服务、打开网卡、进入 OP、起 PDO 循环)并将 EtherCAT 过程数据形状补齐为 WiFi 契约(config_data、version seq、nodeIdReport)。
- `refresh-rate.js` 抽离为纯函数库,按链路各自管理默认值/最小值/钳制/旧键迁移,使刷新策略可脱离 DOM 自测。
- `tick.worker.js` 是一个 Dedicated Worker,仅负责按指定 interval 持续投递 `tick` 消息,规避浏览器后台标签页对 setTimeout/setInterval ≥1s 节流。
依赖方向单向收敛于 `main.js` → `transport.*`;上层解码器、节点列表、STATUS 面板、波形、命令序列和全部流控策略(互斥/退避/冷却/latest-wins)完全不感知底层链路差异。
@@ -0,0 +1 @@
提供电机控制台的业务逻辑入口,通过可插拔的 WiFi/HTTP 与 EtherCAT 传输层抽象,驱动状态轮询、帧发送、命令序列与波形等共享逻辑。
@@ -0,0 +1,5 @@
- 两种传输实现统一通过工厂 `createTransport` 产出,并按相同方法签名(probe/fetchStatus/fetchMotorState/sendFrames/fetchVersionNode 等)对外暴露,主流程只调用抽象接口。
- 传输层错误对象统一附加 `.transient` 标记与可选 `.status`,由 main.js 的失败计数与断线逻辑识别,区分瞬时错误与硬失败。
- 慢请求通过注入的 `recordSlow(url, ms, errMsg?)` 回调上报,所有 fetch/POST 路径都包裹计时与记录。
- 用户偏好状态写入 localStorage 时采用「按链路分组」的 Map 结构(如 `refreshByTp`、历史 `dcu_motor_transport`),新链路扩展只需改默认表而无需改动持久化格式。
- Worker 通信仅使用 `{type, ...}` 消息体(start/update/stop 对应 tick 启停),不传递复杂对象,保持轻量心跳协议。
@@ -0,0 +1,3 @@
- 每个子系统保持自包含目录结构(会话/状态/阶段/传输分离),仅通过 common 提供的工具函数与外部解耦
- UI 交互由入口脚本统一注册到 window,子模块不直接操作 DOM 全局对象
- 设备通信均通过 common 的 polling/connection 封装进行周期性数据推送,而非自行实现定时器
@@ -0,0 +1,27 @@
schema_version: 1
module_path: python_server
title: ESP32 调试控制台本地 Web 服务器与窗口外壳
scope:
- esp32_server.py
- esp32_app.vbs
- esp32_web.vbs
source_files: []
depends_on: []
related_to: []
kk_module_id: e6df9393-2f7e-4478-8bd9-79e0f4f83b91
kk_cards:
architecture_design:
id: f440bf26-cce8-4839-9f95-db46b9d180db
title: ESP32 调试控制台本地 Web 服务器与窗口外壳
coding_conventions:
id: e9f360b3-eb76-441e-9f4d-595c77cf8874
title: ESP32 调试控制台本地 Web 服务器与窗口外壳
overview:
id: 2871bc12-68a1-4218-ab17-febc24f79968
title: ESP32 调试控制台本地 Web 服务器与窗口外壳
tech_stack:
id: 6c6357bf-8885-499f-a9ab-ff7df848bde6
title: ESP32 调试控制台本地 Web 服务器与窗口外壳
unique_setup_and_commands:
id: e191608a-80b0-4a83-b47a-c14460f54b3a
title: ESP32 调试控制台本地 Web 服务器与窗口外壳
@@ -0,0 +1 @@
Python `http.server` + `threading` 线程型 HTTP 服务器;pywebview (WebView2) 桌面容器;VBS (`CreateObject('WScript.Shell')`) 调用 `pythonw.exe`;Windows Job Object 管理 ecat_native.exe 生命周期;原生 C/SOEM 后端 `build_native/ecat_native.exe`。
@@ -0,0 +1,7 @@
由单个 Python 进程构成,核心是 `http.server.ThreadingHTTPServer` + `SimpleHTTPRequestHandler` 派生类:
- 路由层:根路径 302 到 `/tabs.html`,`/pages/*`、`/css/*`、`/js/*` 等走内置目录静态映射;`/ws/app-lease` 实现基于全局计数器的 app 窗口租约协议(全部断开自动停止 EtherCAT PDO 循环并从站退回 INIT);`POST /api/shutdown` 终止本服务。
- EtherCAT 反向代理:`/ecat/api/<路径>` 通过 TCP 转发到 `tools/ecat/build_native/ecat_native.exe` 的 8090–8095 端口,首次命中时按进程 ID 复用已有实例,否则以 `--parent-pid` 拉起子进程并由 Job Object 连带回收。
- 单实例互斥:通过命名互斥量 + `/api/focus` HTTP 唤醒旧实例,避免双击重复弹出。
- 前端注入:响应 HTML 时在 `</body>` 前注入 `_RELOAD_SNIPPET`,为 pywebview 提供刷新按钮、F5/Ctrl+R 键盘拦截和返回主页行为。
- 启动外壳:`esp32_app.vbs` 使用 `pythonw.exe --window` 启动 pywebview 桌面窗口(无控制台),`esp32_web.vbs` 用相同入口但以隐藏方式启动并交由系统默认浏览器打开。
依赖方向单向向下:VBScript → Python HTTP 服务 → 可选拉起的 ecat_native.exe;所有业务 API 实际由 ESP32 设备 (80 端口) 提供,本服务不直接读写硬件。
@@ -0,0 +1 @@
提供 ESP32 调试 Web UI 的静态文件服务、EtherCAT 后端反向代理与 app 窗口租约管理,并通过 VBScript 启动 pywebview 桌面窗口或浏览器标签。
@@ -0,0 +1 @@
两个 VBS 入口:双击 `esp32_app.vbs` 启动带地址栏的 pywebview 桌面窗口(`--window`),双击 `esp32_web.vbs` 以隐藏窗口模式在默认浏览器中打开;Windows 环境变量 `PYTHONUTF8=1` 由 VBS 预先设置。EtherCAT 模式会按需自动检测并拉起 `tools/ecat/build_native/ecat_native.exe`(端口 8090–8095 探测)。
@@ -0,0 +1,5 @@
- 所有对外暴露能力集中在单一 Python 主进程内:路由处理、WebSocket 租约状态、子进程管理均以模块级全局变量(如 `g_mutex`、`g_httpd`、`g_ecat_child`、`g_win`)共享,不使用类实例或配置对象。
- 子进程生命周期统一通过 Windows Job Object + `--parent-pid` 参数管理,确保父进程退出后子进程被内核连带回收。
- 对外部资源访问(旧实例焦点、ECAT 端口探测、子进程启动)一律包裹 `try/except` 并用模块级锁串行化,避免因并发导致多实例或多线程竞争。
- 响应内容增强采用注入模式:将 JS/CSS 片段作为常量字符串定义并在发送 HTML 前通过 `<meta>` 或 `</body>` 标记插入,而非生成独立中间页。
- 跨组件通信约定保持一致:VBScript 通过 `pythonw.exe <脚本> [--window]` 命令行开关切换模式,HTTP 端点统一使用 JSON body + `application/json` content-type。
@@ -0,0 +1,27 @@
schema_version: 1
module_path: frontend_pages
title: ESP32/DCU 调试控制台前端页面集
scope:
- pages/
- css/
- tabs.html
source_files: []
depends_on: []
related_to:
- path: python_server
reason: esp32_server.py 作为静态文件服务器提供 tabs.html、pages/ 与 lab/ 目录下的 HTML,页面由本服务同源 serving。
kk_module_id: d0ed905a-8c06-4326-a9e3-22a026d276f7
kk_ordinal: 1
kk_cards:
architecture_design:
id: 921fdcb0-3189-46ef-963e-539b2f5e2b3b
title: ESP32/DCU 调试控制台前端页面集
coding_conventions:
id: 4a7606c7-8beb-455d-95f1-4605e6668630
title: ESP32/DCU 调试控制台前端页面集
overview:
id: 6eafe8be-cffd-4e08-9002-1332165ffedf
title: ESP32/DCU 调试控制台前端页面集
tech_stack:
id: eb3279e1-7af2-4ade-ba37-5bb6f4118b15
title: ESP32/DCU 调试控制台前端页面集
@@ -0,0 +1 @@
纯静态 HTML + CSS + 原生 JS (无构建工具、无框架);CAN DBC 解析、BLE/GAP 连接、OTA 传输逻辑交由 `js/` 目录下的模块脚本加载。
@@ -0,0 +1 @@
以 `tabs.html` 作为单窗口多标签的外壳,通过 `<iframe>` 嵌入 `pages/` 下各功能页(OTA 主页 `index.html`、CAN 通信 `can.html`、CAN 监控 `can_monitor.html`、电机控制 `motor.html`),并引用同级 `lab/` 下的实验页;标签列表由 JS 的 `TABS` 数组驱动,当前激活标签经由 iframe `load` 事件检测 pathname 后同步高亮并持久化至 localStorage (`dcu_tab`)。所有共享样式集中在 `css/`:`base.css` 定义深色设计令牌与全局重置;`layout.css` 提供 header-bar、卡片、表单、按钮、抽屉等布局组件;`components.css` 实现上传区、进度条、指标卡、阶段胶囊等原子组件;`responsive.css` 管理断点与合成器友好动画;`can.css`、`motor.css` 为各自功能的独立主题。每个 page HTML 通过 link 引用基础样式,其中 `motor.html` 自带完整 seed tokens 隔离主题,不依赖 `base.css` 颜色变量。页面内嵌 JavaScript 或通过 `<script type="module">` 加载 `../js/...` 逻辑脚本,与后端 ESP32 HTTP API (`/can/*`, `/ota/*`) 交互。
@@ -0,0 +1 @@
提供 ESP32/DCU 设备的 OTA 中继、CAN 发送/监控与电机控制等 Web 调试页面的静态外壳与样式体系。
@@ -0,0 +1,6 @@
- 每个页面 HTML 统一在 head 内引入空 favicon (`data:,`) 以避免浏览器请求导致本地静态服务 404。
- 页面间通过相对路径 `../css/...` 引用共享样式,并通过版本号 query 参数(如 `?v=857f83a5`)强制缓存失效。
- 状态指示使用 `.pill` + `.indicator` 元素组合,通过添加 `online/offline/processing/live/idle` 类切换状态颜色与呼吸灯效果。
- 卡片采用 `.card.card--<子系统>` 修饰类注入顶部色带与图标底色,子系统色来自 `--esp32 / --gd32 / --ble / --can / --motor / --mem` 等语义 token。
- 动画关键帧仅操作 transform/opacity 以保证合成器直通;box-shadow/background-position 等重绘型动画仅限短暂临时态(如 dragover、OTA 阶段)。
- 响应式行为通过 `@media (max-width: 1100px)` 与 `640px` 两档断点在 `responsive.css` 中集中维护,页面侧不重复定义布局断点。
@@ -0,0 +1,58 @@
schema_version: 1
module_path: root
title: ESP32 DCU 调试控制台仓库根
scope:
- README.md
- CHANGELOG.md
- .gitignore
- .gitattributes
- esp32_server.py
- esp32_app.vbs
- esp32_web.vbs
- tabs.html
- serve.bat
source_files: []
depends_on: []
related_to: []
kk_module_id: 217e0343-beda-41a9-bfc6-c01fb7f0cd43
kk_cards:
architecture_design:
id: c7e1b8bd-0f31-4088-802e-47c5fc6a78d7
title: ESP32 DCU 调试控制台仓库根
coding_conventions:
id: b42250fb-a810-4b88-87b4-8f7e6c2cbf3f
title: ESP32 DCU 调试控制台仓库根
overview:
id: 2d136a23-5904-4499-855b-80b1a32528d6
title: ESP32 DCU 调试控制台仓库根
tech_stack:
id: a3a9f3a6-78c7-40ee-ac5f-cc215e42fdd9
title: ESP32 DCU 调试控制台仓库根
unique_setup_and_commands:
id: 182bc6fc-a797-4b4d-b58f-e2c87fb9e1a1
title: ESP32 DCU 调试控制台仓库根
build_system:
id: 9014488e-3a7f-413b-bbbf-c2c013eaa659
title: Windows + ESP-IDF 多入口构建与部署体系
configuration_system:
id: 6302e196-7d4f-44d3-9757-72efd9035b99
title: 前端 localStorage + 命令行参数 + VBScript 启动参数的分层配置系统
dependency_management:
id: 1d5e10e6-d5cd-4510-9159-e554b8d2ea6c
title: 依赖管理:无第三方 Python/JS 包,纯内置模块 + 独立 ecat_native.exe 子进程
error_handling:
id: 6136fa8e-72aa-43f5-a824-5d5ba4a66cf4
title: 前端错误传播与 Toast/日志呈现,后端 Python 广泛 try/except 兜底
frontend_style:
id: 7f41416c-8e1a-47e9-a286-6e7ccc8c3a20
title: DCU Mission Control 前端样式体系:深色令牌、子系统语义色与 CSS 模块化
logging_system:
id: c72b9e5c-43ed-41d4-933a-189ddde135e2
title: 前端日志系统:去重环形缓冲 + /logs 服务端拉取 + 统一 addLog 接口
kk_repo_cards:
- build_system
- configuration_system
- dependency_management
- error_handling
- frontend_style
- logging_system
@@ -0,0 +1,50 @@
## 1. 采用的系统/方法
仓库是一个混合工程:Web 前端(HTML/CSS/JS)+ Python3 本地调试服务器,以及通过外部脚本触发的 ESP-IDF 固件构建。没有 Makefile、Dockerfile、CI 流水线或统一的包管理器;构建由**多个独立的 Windows 批处理脚本和 Python 启动器**拼凑而成,分别承担不同的构建目标。
### 1.1 前端静态服务与桌面窗口
- 核心运行体 `esp32_server.py` 用 stdlib `http.server.ThreadingHTTPServer` 在本地提供静态页面,并实现 `/ecat/api/*` 反向代理到 `tools/ecat/build_native/ecat_native.exe`(端口 8090–8095 自动探测)。
- 三种启动入口:
- `serve.bat`:控制台模式,默认端口 2625,输出日志方便调试。
- `esp32_app.vbs`:`pythonw.exe` 启动 pywebview 桌面窗口(无控制台),子进程承载 HTTP 服务,避免 pythonw 后台线程被杀。
- `esp32_web.vbs`:`pythonw.exe` 启动后默认打开浏览器标签页。
- 窗口模式下服务端向 HTML 响应注入 `_reload.html`,为 WebView2 补充 F5 / Ctrl+R 刷新能力。
### 1.2 ESP-IDF 固件构建
- `scripts/build_mem.bat` 通过 Git Bash 调用 `idf.py build` 构建 `G:\Robot_v2\DCU\ESP32_COMM` 下的 DCU/ESP32 固件。
- 预先 `unset MSYSTEM*` / `MINGW_*` 环境变量绕过 ESP-IDF 的 MSys 检测,并将工具链路径固定到 `C:\Espressif` 与 `F:\software\.espressif\v6.0\esp-idf\export.bat`——路径硬编码于脚本中。
### 1.3 GD32 OTA / 刷写
- `scripts/flash_gd32.py`:经本服务的 Web API 把 GD32 固件中继下载到设备侧 Flash。
- EtherCAT 链路依赖 `tools/ecat/build_native/ecat_native.exe`(由 `build_native.bat` 编译,需 VS2022 + Npcap),首次请求时由 `esp32_server.py` 的 `_ecat_autostart` 自动拉起(带 `--parent-pid` 防止清场已有实例),未找到时用端口扫描复用已运行的进程。
## 2. 关键文件
| 文件 | 角色 |
|------|------|
| `esp32_server.py` | 静态文件服务、EtherCAT 反向代理、app 窗口租约、ECAT 守护进程管理 |
| `serve.bat` | 控制台模式启动器(Python 标准输出可见) |
| `esp32_app.vbs` | pywebview 桌面窗口启动器(隐藏控制台) |
| `esp32_web.vbs` | 默认浏览器 tab 启动器(隐藏控制台) |
| `scripts/build_mem.bat` | 通过 ESP-IDF 构建 ESP32 端固件 |
| `scripts/flash_gd32.py` | 通过本服务中继刷写 GD32 固件 |
| `README.md` | 记录目录约定、双链路架构与三处构建依赖 |
## 3. 架构与约定
- **分层清晰**:前端是纯静态资源,不碰硬件;业务 API 跨域访问 ESP32 固件的 80 端口;只有 EtherCAT 后端需要本机 C/SOEM 守护进程。
- **同源代理解 CORS**:ECAT 后端绑 127.0.0.1 且禁止预检,所有 POST JSON 请求统一走 `/ecat/api/*` 同源转发,页面与两个仓库(前端 + ecat)互不耦合。
- **自动生命周期管理**:ECAT 守护进程首次代理时才拉起,`--parent-pid` 保证父退出后自杀;已存在进程直接复用(端口 8090–8095 轮询探测),避免重复拉起触发对从站的 INIT 清理。
- **单实例锁**:多双击 `esp32_app.vbs` 时通过 `/api/focus` 聚焦已有实例而非创建新窗口。
- **版本化缓存**:README 明确“改 CSS/JS 后必须 bump 引用处的 `?v=` 缓存版本”,否则浏览器加载旧代码。
- **模块组织**:新增功能页放 `pages/` 并在 `tabs.html` 的 TABS 数组登记;一次性实验页放 `lab/` 保持自包含。
## 4. 约束与规则
- **ESP32 固件构建依赖外仓路径**:`scripts/build_mem.bat` 将 `ESP_IDF_EXPORT` 指向 `F:\software\.espressif\v6.0\esp-idf\export.bat`,并 cd 到 `G:\Robot_v2\DCU\ESP32_COMM` 再 `idf.py build`——这要求开发者拥有与仓库一致的磁盘布局,不是可移植构建。
- **ECAT 后端必须先存在或可由本服务拉起**:若未找到 `tools/ecat/build_native/ecat_native.exe`,电机链路会报错提示“请先运行 tools\ecat\build_native.bat”;前端 `js/motor/main.js` 对错误消息中包含 `build_native`/Npcap 的情况做归类。
- **WebSocket 租约决定循环启停**:`ws/app-lease` 上所有 app 窗口断开则自动停止 EtherCAT PDO 循环并把从站退回 INIT。
- **刷新率上限与链路绑定**:WiFi 链路默认 250ms、最低 50ms;ECAT 链路默认 50ms、最低 1ms(受限于 Python Event.wait 与 GIL)。切链路时各记一份 `state.refreshByTp` 并立即重排节拍。
- **1ms ECAT 档位 ≠ 1000Hz**:底层以 UNTHROTTLED_MAX_MS=20 作为“不限速”阈值,1ms 档实际约 125 Hz;低于 20ms 要同时关掉理论值与 tickStuck 统计,否则界面会撒谎。
## 5. 不存在的内容
- 没有 Makefile / Gradle / npm scripts / Dockerfile / CI YAML — 该项目在 Windows 环境内用批处理与 VBScript 手工组装构建/发布流程。
- 无统一的版本号定义文件(版本号来源于 Git tag / changelog 和设备端 SHA-256)。
- 无交叉编译配置:ESP-IDF 构建路径硬编码,ECAT native 产物预期在 Windows 上由 VS2022 构建。
@@ -0,0 +1,46 @@
# ESP32 DCU 调试控制台 — 配置系统
## 1. 系统与分层
本仓库没有集中式的配置文件(无 `*.yaml`、`*.toml`、`*.env`),采用三层组合方式完成运行时配置:
- **用户偏好层**:浏览器端 `localStorage`,用于持久化设备 IP、电机传输链路、刷新率、CAN DBC 设置等。
- **进程/工具层**:Python 服务通过 `argparse` 解析命令行参数(`--port`、`--window`、`--no-browser`);Windows 批处理/VBScript 再提供默认值与启动入口。
- **外部依赖层**:通过调用外部子进程(`tools/ecat/build_native/ecat_native.exe`)并传入 `--parent-pid` 等方式传递进程间通信所需配置。
## 2. 关键文件
- `esp32_server.py`:服务端核心。HTTP 服务器(端口探测/绑定)、EtherCAT 反向代理、pywebview 窗口外壳、子进程生命周期管理,均通过 argparse 与模块级常量硬编码控制。
- `serve.bat`:控制台模式启动器,设 `PORT=2625` 后执行 `python esp32_server.py --port %PORT%`。
- `esp32_web.vbs` / `esp32_app.vbs`:分别以隐藏窗口/桌面窗(pywebview)方式启动 `esp32_server.py`,区别仅在于是否传 `--window`。
- `js/common/base-url.js`:统一封装设备 IP 的 `localStorage` key(`dcu_esp_ip`)与默认值(`DEFAULT_ESP_IP = '192.168.31.127'`)。注释明确“各页面共用”“只负责公共实现”。
- `js/motor/transport.js`:维护 ECAT/WiFi 传输切换状态,key 为 `dcu_motor_transport`。
- `js/motor/transport-ecat.js`:ECAT 传输配置,含网卡适配器、周期时间(4ms→250us 迁移标记 `LS_PERIOD_MIG`、`LS_SPIN`)等,全部落 localStorage。
- `js/can/persist.js`、`js/ota/file-handle.js`:各自功能页的偏好持久化(DBC、刷新目标 `LS_FLASH_TARGET`、最近固件元数据 `LS_FILE_META`)。
- `js/app.js`:主标签外壳页面,读写主页的共享设备 IP。
## 3. 架构与设计约定
### 3.1 设备发现与连接
- 所有页面向设备发起 API 请求的目标由 `getBaseUrl()` 计算:`http://` + 当前设备 IP,IP 从 `localStorage['dcu_esp_ip']` 取,无有效值回退到 `192.168.31.127`。
- 代码注释强调 “每个页面一致”“空值忽略”,对 `localStorage` 的访问被 try/catch 包裹(兼容隐私模式)。
### 3.2 端口与服务启动
- 端口优先级:`serve.bat` → `--port` → 默认 `2625` → `_bind_server` 自动递增重试(若占用则打印 `端口 xxx 已被占用, 尝试 xxx+1`)。
- 默认监听 `127.0.0.1`(不暴露公网)。
- Windows 下通过 Job Object 绑定子进程生命周期,父进程退出时内核连带杀掉服务子进程。
### 3.3 EtherCAT 后端自动发现与启动
- 使用内置端口数组 `ECAT_PORTS = (8090..8095)` 轮询检测正在运行的 `ecat_native.exe`。
- 首次需要时在 `g_ecat_lock` 保护下拉起子进程并传入 `--parent-pid`,避免独立实例清理逻辑误杀旧进程。
- 已运行实例通过 `_port_taken(p)` 复用,不再重复拉起。
### 3.4 多语言环境配置
- 所有中文字符串(日志、UI 文案、提示、注释中的路径与说明)均以 UTF-8 输出:`serve.bat` 先执行 `chcp 65001` 并将 `PYTHONUTF8=1` 写入子进程环境;`esp32_web.vbs` / `esp32_app.vbs` 同样设置 `PYTHONUTF8=1`。
- 前端 JS 侧无多语言文件,所有文案直接嵌入 HTML/JS 源文件中。
## 4. 约束与规则
- **设备 IP 键名唯一**:`dcu_esp_ip` 由 `js/common/base-url.js` 统一定义并通过 export 导出,所有功能页通过该共享 key 协作(OTA/CAN/电机页注释明确声明此约定)。
- **无外部配置文件的强制要求**:整个项目未使用 `.env`/`.yaml`/`*.cfg`/`*.ini` 等任何集中配置格式,如需调参必须修改源码常量或命令行参数。`esp32_server.py` 中除 `argparse` 外,其余行为均通过模块级变量(如 `ECAT_DIR`、`ECAT_PORTS`、`BASE_DIR`)与命令行参数组织。
- **安全边界**:服务默认仅监听 `127.0.0.1`;EtherCAT 原生后端也绑 `127.0.0.1` 且无 CORS,故页面需经本服务同源反向代理(`/ecat/api/*`)访问,避免跨域预检失败。
- **前端本地存储可容错**:每次 `localStorage.setItem/getItem` 都包裹 try/catch(应对无痕/隐私模式抛出异常);写前做 trim 校验,空值即忽略。
- **进程边界配置不可穿透**:VBScript 层通过命令行参数(`--window`)切换模式,但 Python 内部不会反查环境变量;子进程通过标准输出打印 `PORT={...}` 供父进程解析,而非通过共享配置文件通信。
## 5. 总结
这是一个**纯前端 + 简易 Python 静态服务器**场景下的轻量配置系统:设备地址和界面偏好完全落在浏览器的 `localStorage` 中,服务进程本身通过命令行参数与模块内常量硬编码驱动,不存在 YAML/环境变量等集中式配置机制。其优点是无依赖、可直连浏览器调试;代价是新增配置项需要同时修改多处 JS/Python 源码和脚本入口。
@@ -0,0 +1,26 @@
## 一、使用的依赖管理体系
本仓库**没有传统的包管理器**:根目录与所有子目录下不存在 `package.json`、`go.mod`、`requirements.txt`、`Pipfile*`、`*.lock`、`.npmrc` 等任何依赖声明文件;前端侧使用浏览器原生 ES module(`type="module"`)通过相对路径直接引用 `../css/*`、`../js/common/*`,不经过 npm/yarn/pnpm 解析。
Python 后端 (`esp32_server.py`) 仅依赖 Python **标准库**(`http.server.SimpleHTTPRequestHandler` / `ThreadingHTTPServer`、`socket`、`subprocess`、`ctypes`、`urllib.request`、`threading`、`hashlib` 等),以及一个可选的桌面外壳依赖 `pywebview`。该可选依赖在运行时探测缺失时回退到普通浏览器模式:`print("pywebview 未安装: pip install pywebview —— 回退浏览器模式")`,并通过 `serve.bat` / `esp32_web.vbs` 走无 GUI 路径。
非 Python 二进制依赖以独立可执行形式存在:EtherCAT 后端为编译后的单文件 `build_native/ecat_native.exe`(由外部脚本 `build_native.bat` 构建),由主服务按需在 8090–8095 端口范围内发现并启动(带 `--parent-pid` 实现父进程退出时自我结束),不属于当前仓库的包依赖图。
## 二、关键文件
- `esp32_server.py`:唯一 Python 入口;通过 `import http.server`、`ctypes`、`subprocess` 等标准库提供服务、反向代理、窗口管理与 ecat 子进程编排。
- `README.md`:文档化的启动方式(`esp32_app.vbs` / `esp32_web.vbs` / `serve.bat`),明确了 pywebview 可选性以及 ecat_native 的外部依赖关系。
- `scripts/build_mem.bat`:调用 ESP-IDF 构建 DCU/ESP32_COMM 固件的辅助脚本,不引入 Python/JS 依赖。
- `js/`、`pages/`、`css/`:纯前端资源,无任何 `<script src="node_modules/...">`,全部以相对 URL 引用。
## 三、架构与约定
1. **零打包策略**:`esp32_server.py` 直接 serve 静态 HTML/CSS/JS,页面内用 `<script type="module" src="../js/app.js">` 等加载,依赖解决完全由浏览器原生 ES module 完成。
2. **可选桌面外壳**:pywebview 作为可选依赖——有则用 `pythonw` + pywebview 开 WebView2 桌面窗口(无控制台窗体);缺则回退到系统默认浏览器标签页模式,由 `serve.bat` / `esp32_web.vbs` 启动。
3. **外置二进制子进程模式**:EtherCAT 能力不在 Python 进程中提供,而是通过子进程协议拉取 `tools/ecat/build_native/ecat_native.exe`,服务端做 `/ecat/api/*` 同源转发来绕过其无 CORS + 127.0.0.1 绑定问题。该二进制由另一个 Git 仓库产出,当前仓库只负责发现与生命周期管理。
4. **单实例互斥**:通过本地 API `/api/focus` 把新双击重定向到已有实例前台窗口,避免并发拉起多个服务端实例。
## 四、约定与约束
- **前端资源版本化**:修改 CSS/JS 后必须 bump 引用的 `?v=` 缓存参数(否则浏览器加载旧代码),这是 README 中明确记录的强制约定。
- **JS 模块内部引用须用 `import.meta.url`**:不能写文档相对路径(如 Worker 文件),页面层级变化会 404(见 `js/motor/main.js` 的 `TICK_WORKER_URL`)。
- **新增功能页必须在 `tabs.html` 的 `TABS` 数组登记**,实验性/一次性页面放 `lab/` 并保持自包含。
- **设备地址配置保存在 `localStorage.dcu_esp_ip`**(默认 `192.168.31.127`,见 `js/common/base-url.js`),本服务不持有设备 IP,业务 API 全部直连 ESP32 固件 80 端口。
- **Python 侧不得随意引入三方库**:当前仅依赖标准库与可选 `pywebview`;若需增加第三方包,应评估是否仍走“纯标准库 + 可选 GUI”的路径,因为仓库中没有 `requirements.txt` 或 `pip freeze` 产物,依赖变更无法通过 CI 锁定复现。
- **ECAT 子进程必须带 `--parent-pid`**:否则它会清理同端口段其他实例,且主服务退出后会留下孤儿进程占用 Npcap 适配器与端口(见注释阶段 4 迁移说明)。
@@ -0,0 +1,5 @@
## 1. 总体方法
本仓库的「错误处理」由三块构成:
- **Python 服务端 (esp32_server.py)**:大量使用裸 `except Exception:` 包裹每个 I/O / 子进程/线程入口,把网络/文件/Windows 句柄异常吞掉并继续运行;不存在自定义异常类型体系。
- **前端工具脚本 (scripts/*.py)**:通过 `raise SystemExit(...)` + `urllib.error.HTTPError` 区分成功/失败路径,主流程用 try 捕获异常后终止。**没有**统一的 Result/Either 封装。
- **浏览器前端 (js/…)**:采用「Promise + fetch AbortSignal.timeout → throw Error」的异步错误模型;所有可见故障统一经 `toast.js` 的 `showToast(msg, 'success'|'error'|'warning')` 在 UI 弹出(5s 去重),并通过 `log.js` 的 `addLog(level, tag, msg)` 结构化写入页面日志面板。BLE OTA、OTA 上传等长链路则在各自模块内维护状态机 (`stages.js` / `progress.js` / `session.js`),通过 `setBleStageError`、`cleanupAfterOtaFailure` 等函数显式转入错误阶段。
@@ -0,0 +1,39 @@
## 1. 整体方法与工具
- 纯原生 CSS,无 SCSS/Sass/Less、无 Tailwind、无 PostCSS 等构建管线;`pages/*.html` 直接以 `<link>` 引入静态 `.css`(带 `?v=<hash>` 缓存破坏参数)。
- 全局样式按职责拆为独立文件并通过 HTML 顺序引入:
- `css/base.css`:设计令牌(`:root` 变量)、全局 reset、背景网格与氛围光、滚动条、选区、focus 可见性。
- `css/layout.css`:header-bar、主布局 grid、卡片/表单/按钮/状态胶囊 (pill)。
- `css/components.css`:上传区、进度条、阶段切换胶囊 (progress-phase)、指标卡片、徽章、Toast、Tooltip 等可复用 UI 块。
- `css/can.css`:CAN 工作台专用样式,并派生 `motor-launch-card` 复用该布局仅换强调色。
- `css/responsive.css`:`@media` 断点(1100px / 640px)与全部关键帧动画 + 卡片入场交错延迟。
- `css/motor.css`:独立的「电机控制台」子主题,自带 seed tokens,不依赖 base/css 的共享令牌集。
- 无任何外部组件库或设计系统包,一切视觉规范通过 CSS Custom Properties 落地。
## 2. 关键文件
- `css/base.css` — 设计令牌中心,集中定义底色层级、子系统语义色、字体栈、圆角与阴影。
- `css/layout.css` — header-bar、main-layout、card、form、pill、card--{esp32,gd32,ble,can,motor,mem,status} 变体修饰类。
- `css/components.css` — upload-zone / progress-fill / metric-grid / badge-stat / progress-phase-row 等。
- `css/can.css` — can-launch-card / .can-grid / .can-header / .can-toolbar-grid 等,同时提供 motor-launch-card 变体。
- `css/responsive.css` — 断点 + @keyframes + 入口淡入交错延迟 + hover/拖拽微交互。
- `css/motor.css` — 独立的 seed token 主题(`--seed-*`),自给自足的 reset 和 toolbar/main-grid。
- `pages/index.html` — 统一引入 base/layout/components/can/responsive 样式的入口示例。
## 3. 架构与设计约定
- **设计令牌分层**:`base.css` 在 `:root` 下分组声明基础层(`--bg-root/--bg-panel/--bg-card`)、边框层、文字层、子系统语义色层(ESP32=青绿 `#2fd8c1`、GD32=琥珀 `#fcbf3f`、BLE=蓝 `#5fa8ff`、CAN=绿 `#43d97b`、motor=青 `#22d3ee`、mem=紫 `#a88bfa`)、状态层(green/amber/red),并以 `--accent/--accent-dim/--border-focus` 兼容旧引用。
- **子系统色彩编码**:通过在 card/pill/metric 上使用 `card--{esp32,gd32,ble,can,motor}` 等修饰类,配合 `--card-accent` 覆盖实现同一组件多色系复用;header-top 用三色渐变线暗示 ESP32→BLE→GD32 的子系统谱系。
- **排版与字体**:中文优先苹方/雅黑(`--font-display`),英文回退 Segoe UI/system-ui;数字/SHA/HEX/日志统一使用 `--font-mono`(SF Mono → Fira Code → Cascadia → Consolas)。`14px` 基准字号,`line-height: 1.55`。大量数值区域应用 `.tabular` 利用 `font-variant-numeric: tabular-nums` 对齐。
- **布局**:主容器 `max-width: 1480px`,默认两列 `grid-template-columns: 400px 1fr`,靠 `layout.css` 中的 `.main-layout` 组织,响应式在 `responsive.css` 中降为一列。
- **卡片与组件**:卡片顶部有 2px 渐变彩色强调带(由 `::before` 生成,颜色由 `--card-accent` 控制),hover 抬升并增强描边;按钮、输入框、选择器均走 `layout.css` 的统一 `.btn`/`.input`/`.form-label`。
- **响应式策略**:双断点 `1100px`(桌面窄屏)和 `640px`(移动端)切换 layout、badge 网格列数、抽屉宽度计算方式等;没有 mobile-first 媒体查询前缀,属于 progressive enhancement 思路。
- **动画性能约定**:注释中明确要求常驻无限动画只动 `transform/opacity`(合成器直通),禁止频繁触发重绘的 `box-shadow/background-position` 用于关键帧;例如 `shimmer`、`ringRipple`、`glowBreathFade`、`flowDash` 都遵循该规则。
- **独立子主题**:`css/motor.css` 拥有自己的 `:root { --seed-* }` 种子令牌派生一套颜色/字体/间距,并自带完整 reset,因此可以脱离 `base.css` 单独运行在 `pages/motor.html`——这是仓库内唯一采用“隔离主题”的方式。
- **页面级注入**:每个 HTML 通过 `<link rel="stylesheet" href="../css/*.css?v=...">` 注入;版本号哈希用于静态缓存破坏,未使用任何打包/热更新。
## 4. 约束与可观察到的规则
- 所有色调均来自 `base.css` 定义的 CSS Custom Properties,新增组件应优先引用 `var(--*)` 而非硬编码颜色值(已有组件如 pill、card、progress、metric 均如此)。
- 子系统色需从预定义的 `--esp32/--gd32/--ble/--can/--motor/--mem` 选取,避免自创新语义色;可通过 `*-hi`(高光)/ `*-dim`(半透明背景)组合完成 hover/accent 效果。
- 卡片必须携带 `card--{子系统}` 修饰类以正确填充顶部强调带与图标背景(`layout.css` 已为每类预定义 `--card-accent` 与 `--card-accent-dim`)。
- 按钮统一走 `.btn/.btn-primary/.btn-success/.btn-danger/.btn-outline/.btn-ghost` 家族,避免新建按钮样式;新增样式应在 `layout.css` 或 `components.css` 中扩展。
- 动画遵循 `responsive.css` 顶部注释的性能约定:常驻 animation 仅改变 `transform/opacity`;短暂态可使用 `box-shadow/background-position`。
- 响应式布局必须通过 `css/responsive.css` 中添加 `@media` 规则进行覆盖,不在页面 HTML 或 JS 中做 DOM 尺寸判断来切换样式。
- 如需全新独立页(如 motor 控制台),可仿照 `css/motor.css` 的 seed-token 模式创建隔离主题,但应尽量复用 base 组件类以保持一致的交互体验。
@@ -0,0 +1,59 @@
## 1. 使用的方案
项目在前端(ESP32/DCU Web 控制台)实现了自研轻量级日志子系统,位于 `js/common/log.js`。其职责是:**接收带标签的日志条目 → 去重/过滤 → 维护 DOM 面板 + 环形缓冲 → 按级别样式化渲染**。
日志数据并非由前端直接构造所有业务事件,而是通过 `js/common/polling.js` 以轮询方式从后端 ESP32 设备拉取 `/system/status` 与 `/logs` 两条流,把后端的结构化输出注入到统一的 UI 中;同时各模块通过 `addLog(level, tag, message)` 主动写入调试类日志。因此该“logging system”是 **后端实时日志拉取 + 前端集中式渲染** 的组合。
## 2. 核心文件
- `js/common/log.js` — 日志缓存、去重、DOM 渲染、过滤、自动滚动、条数上限裁剪的核心库(`addLog`、`clearLogs`、`setLogFilter`、`toggleAutoScroll` 四导出)
- `js/common/polling.js` — 定时递归 setTimeout 调度 `/system/status` 与 `/logs` 轮询,并将远端行 `^[DIEW] ([^|]+)\|(.*)$` 解析为 `addLog(m[1], m[2], m[3])`
- `css/motor.css`、`components.css`、`can.css`、`layout.css`、`responsive.css`、`base.css` — `.log-line`、`.lt/.lv/.lg/.lm` 等日志元素的 CSS 样式
- `pages/index.html`、`pages/can_monitor.html`、`pages/motor.html`、`tabs.html` — 包含 `#logScroll`、`#logCount`、`#autoScrollBtn`、`.log-filter` 按钮的页面骨架(由 `log.js` 通过 ID 直接操作)
- 消费端(导入 `addLog` 的模块举例):`js/app.js`、`js/common/connection.js`、`js/ble/state.js`、`js/ota/file-handle.js`、`js/ota/session.js`、`js/ota/upload-esp32.js`、`js/ota/upload-gd32.js`、`js/common/polling.js`、`js/ota/progress.js`
## 3. 架构与设计约定
### 日志条目结构
每个日志是一个三元组 `(level, tag, message)`,经 `formatTime` 加上本地时间戳,再被 HTML 转义(调用 `utils.escapeHtml` 防 XSS)后渲染为一行:
```
[时间] [级别] [TAG] 消息
```
其中级别 `lv` 使用 class `D|I|W|E`,tag 用 `lg`,消息用 `lm`。
### 去重策略
- 同一帧内相邻重复项:通过 `makeLogHash(level, tag, message)` 计算字符串哈希并与 `lastLogHash` 比较,完全相同的连续条目只写一次。
- 用于避免高频率状态上报产生刷屏。
### 缓冲与裁剪
- 内存采用普通数组 `logEntries`,`MAX_LOGS = 1000` 作为硬上限;超出时 `trimIfNeeded()` 移出最旧的 DOM 节点并 shift 掉对应 entry。
- 没有持久化历史到 localStorage。
### 过滤与 UI
- 全局 `logFilter` ∈ `{ all, D, I, W, E }`,由页面 `#autoScrollBtn` 与 `.log-filter` 控制。
- `setLogFilter` 会清空 `#logScroll` 并按当前 filter 重建 Fragment 显示,支持运行时切换。
- `toggleAutoScroll` 切换自动滚动开关,文字在 “🔽 自动滚动” / “⏸️ 暂停滚动” 间切换。
- 自动滚动使用 `requestAnimationFrame` 批量合并,防止每条日志都触发强制布局。
### 来源分流
- **后端实时流**:`polling.js` 周期性 GET `/logs`,将每行正则 `^(.) (.+)\|(.+)$` 解析为 level/tag/message,直接 `addLog(...)` 投递。
- **前端主动日志**:连接、OTA、BLE、文件处理等各模块遇到关键流程点(断线、重启、握手、校验失败、SHA 不匹配等)调用 `addLog`,按 `connection`/`系统`/`诊断`/`上传`/`文件`/`OTA`/`内存`/`BLE` 等 tag 区分领域。
### 后端协议契约(由 poller 侧 enforce)
- `/system/status` 返回 JSON,含 `sys_state_code`、`relay_state`、堆低水位等字段。
- `/logs` 返回文本行,格式约定为 `LEVEL TAG|MESSAGE`(首字符为 `D/I/W/E`,然后空格、tag、竖线、正文),这是前后端共享的契约,而非任何框架约定。
## 4. 约定与约束
- **唯一入口**:全仓库前端日志通过 `common/log.js` 暴露的四函数进出;外部模块仅 import/addLog,不允许自行创建日志 DOM。
- **必须带 tag**:所有调用均传入二级分类 tag(如 `'连接'`、`'诊断'`、`'上传'`、`'文件'`、`'内存'`、`'系统'`、`'BLE'`、`'OTA'`),以便后期按 tag 过滤或筛选。
- **级别限定**:level 严格为 `D`(debug)、`I`(info)、`W`(warn)、`E`(error) 其一;UI 据此设置 CSS 颜色与过滤器类别。
- **XSS 防护**:level/tag/message 插入 innerHTML 前全部经 `escapeHtml` 处理;新增内容也需遵循此规则。
- **性能约束**:单条日志路径禁用多次 DOM query;高频重复内容依赖去重;批量追加走 `DocumentFragment`(filter 重建时);自动滚动批量化到下一 rAF。
- **轮询间隔**:`status` 与 `logs` 轮询默认 1000ms,上传期间暂停(`pausePollingForUpload`),GD32 中继期临时降至 400ms(`setStatusPollIntervalMs(400)`)以减少日志滞后,结束后恢复。
- **页面前置条件**:页面需存在 `#logScroll`、`#logCount`、`#autoScrollBtn`、`.log-filter` 四个选择器目标,否则 `clearLogs`/`setLogFilter`/`toggleAutoScroll` 无作用但不会抛错。
- **未覆盖场景**:Node.js / Python 后端脚本目录(`scripts/*.py`、`scripts/*.mjs`)普遍使用原生 `console.*`(见 grep 结果),不使用前端日志系统——说明本日志系统限定于浏览器端控制台。
## 5. 总结
这是一个围绕前端 DOM 的微型日志子系统:通过统一的 `addLog(level,tag,message)` 接口汇聚多模块的调试信息,并以环形缓冲 + 去重 + 自动滚动的形式挂载到页面上;同时通过 `polling.js` 把后端 ESP32 的 `/logs` 实时串流入这个面板,形成“设备端打 log + 前端集中呈现”的统一调试窗口。
@@ -0,0 +1 @@
Python + pywebview 作为本地桌面容器,JavaScript (ES modules) 构成前端控制台,EtherCAT PDO/Slot 协议贯穿 JS 前端与 Python 后端通信,Windows VBScript/bat 作为统一启动外壳。
@@ -0,0 +1 @@
仓库以 Python 后端 `esp32_server.py` 为统一入口,通过 VBScript (`esp32_app.vbs` / `esp32_web.vbs`) 或批处理 (`serve.bat`) 在 Windows 上拉起 pywebview 桌面窗口或浏览器标签,将 `pages/`、`js/`、`lab/` 等静态资源暴露给前端;`pages/*` 是薄壳 HTML,实际逻辑落在 `js/motor`、`js/ota` 等子模块中,由 JS 前端通过 WebSocket 与服务端进行 EtherCAT/CAN/Ota 通信;`scripts/` 提供 ECAT 撕裂检测、GD32 固件刷写、浏览器 E2E 测试等运行时工具,面向同一服务端接口;`lab/` 提供离线诊断页面和原始抓包 CSV,供产线验证总线连通性。各子模块围绕同一套“本地 Web 服务器 + ECAT 反向代理”契约协作,无需独立部署。
@@ -0,0 +1 @@
汇聚 ESP32/DCU Web 调试服务、多页面前端、JS 客户端、ECAT 工具脚本与 lab 诊断资源的单仓调试控制台,提供本地启动与跨平台运行入口。
@@ -0,0 +1 @@
根目录 `serve.bat` 提供一键启动服务并在默认浏览器打开 `tabs.html`;VBScript (`esp32_app.vbs` / `esp32_web.vbs`) 可在无浏览器环境下以 pywebview 桌面窗口方式加载调试页面,实现双模式运行。
@@ -0,0 +1,3 @@
- 所有需要与服务端通信的页面统一通过 `tabs.html` 以 app 窗口租约形式加载,避免多窗口抢占 ECAT 循环
- 前端静态页面集中在 `pages/` 目录并以薄壳 HTML 形式存在,业务逻辑迁移到 `js/` 下的 ES module 文件组织
- 浏览器端测试与运行时诊断脚本集中于 `scripts/`,按功能(ECAT/CPU 采样/刷写)划分命名空间并可直接从仓库根执行
@@ -0,0 +1,27 @@
schema_version: 1
module_path: lab
title: 调试诊断与 EtherCAT 采样数据
scope:
- lab/
- ecat_slots_raw.csv
source_files: []
depends_on: []
related_to: []
kk_module_id: 3f11b5ed-b020-40ab-be49-36bfc4fea893
kk_ordinal: 4
kk_cards:
architecture_design:
id: a83bab26-0eaa-442b-8401-d73020dbf89c
title: 调试诊断与 EtherCAT 采样数据
coding_conventions:
id: a0e4b780-bcb8-4a6c-bf02-5152189b1dee
title: 调试诊断与 EtherCAT 采样数据
overview:
id: 19093973-5734-403e-b0e2-ace21e683cb1
title: 调试诊断与 EtherCAT 采样数据
tech_stack:
id: 86af3241-db34-4640-bc79-bed4bab9aace
title: 调试诊断与 EtherCAT 采样数据
unique_setup_and_commands:
id: 35ccbf10-bc4b-46d9-9446-49ef5d4be525
title: 调试诊断与 EtherCAT 采样数据
@@ -0,0 +1 @@
纯前端 HTML + 内联 CSS/JS(无外部依赖),通过浏览器 fetch 直接访问 ESP32/GD32 内置 HTTP 端点;EtherCAT 采样数据采用自定义 CSV 格式,字段含 kind(H/M/B/X)、微秒级时间戳及十六进制 PDO payload。
@@ -0,0 +1 @@
本模块由两部分互不耦合的资源组成:lab/ 下是独立、自包含的浏览器单文件测试页(spi_test.html 通过 GET /spi/speedtest 调用固件探针;continuity_test.html 通过 LAN9252 通断测试),它们不依赖项目其他 JS/CSS,通过 serve.bat 直接访问;ecat_slots_raw.csv 则是按固定列(kind、时间戳、设备/槽号等)记录的 EtherCAT PDO/Slot 原始采样日志,被 scripts/probe_ecat_* 等 Python 分析脚本消费。_last_gd32_sha.txt 用于记录最近一次 GD32 固件 SHA 以判断固件是否变更。整体无构建步骤,仅作为静态资源与数据采集产物存放区。
@@ -0,0 +1 @@
提供 SPI/以太网链路独立诊断 HTML 测试页及 EtherCAT PDO/Slot 原始抓包 CSV,用于产线和调试阶段的硬件连通性与总线吞吐验证。
@@ -0,0 +1 @@
使用仓库根目录 serve.bat 启动本地 HTTP 服务器后,在浏览器打开 http://localhost:2625/spi_test.html 或 continuity_test.html 即可运行诊断;测试前需确保设备 IP 正确并通过网线连接至同一网段。
@@ -0,0 +1,3 @@
- 每个诊断页均为自包含单文件 HTML,所有样式与脚本内联,不引入任何外部 css/js 依赖
- CSV 采样文件以紧凑列式文本表示 EtherCAT 周期帧,使用单字符 kind 标识 H/M/B/X 报文类型并附带带符号小数毫秒时间戳
- 临时工具文件头部注释明确标注功能标签(如「SPI-SPEEDTEST」)及删除方式,便于后续清理时对照固件端点一并移除
+154
View File
@@ -0,0 +1,154 @@
# 知识卡导出索引文件
schema_version: 1
locale: zh-CN
commit: 43d42ad891b652f79db1f37091141cb7a2b4e314
branch: main
exported_at: "2026-09-06T19:46:36Z"
modules:
frontend_pages:
dir_name: ESP32_DCU 调试控制台前端页面集
title: ESP32/DCU 调试控制台前端页面集
scope:
- pages/
- css/
- tabs.html
source_files: []
children: []
depends_on: []
related_to:
- path: python_server
reason: esp32_server.py 作为静态文件服务器提供 tabs.html、pages/ 与 lab/ 目录下的 HTML,页面由本服务同源 serving。
js_frontend:
dir_name: ESP32 DCU 前端 Web 控制台集合
title: ESP32 DCU 前端 Web 控制台集合
scope:
- js/
source_files: []
children:
- js_frontend/app_entries
- js_frontend/ble
- js_frontend/can
- js_frontend/common
- js_frontend/motor
- js_frontend/ota
depends_on: []
related_to: []
js_frontend/app_entries:
dir_name: 前端应用入口聚合层(ESP32 OTA _ BLE OTA _ CAN 诊断)
title: 前端应用入口聚合层(ESP32 OTA / BLE OTA / CAN 诊断)
scope:
- js/app.js
- js/ble_ota.js
- js/can.js
source_files: []
children: []
depends_on: []
related_to:
- path: python_server
reason: OTA、BLE、CAN 控制台页面均由 python_server 的静态文件服务提供,并通过 base-url.js 中 localStorage 存储的 dcu_esp_ip 以同源请求访问设备 API。
js_frontend/ble:
dir_name: BLE 通道 OTA 客户端(Web Bluetooth GATT)
title: BLE 通道 OTA 客户端(Web Bluetooth GATT)
scope:
- js/ble/
source_files: []
children: []
depends_on: []
related_to: []
js_frontend/can:
dir_name: CAN 控制台前端:DBC 解析、报文发送与接收监控
title: CAN 控制台前端:DBC 解析、报文发送与接收监控
scope:
- js/can/
- js/can_monitor.js
source_files: []
children: []
depends_on: []
related_to: []
js_frontend/common:
dir_name: 前端公共工具库(连接_轮询_日志_Toast_IndexedDB)
title: 前端公共工具库(连接/轮询/日志/Toast/IndexedDB)
scope:
- js/common/
source_files: []
children: []
depends_on: []
related_to: []
js_frontend/motor:
dir_name: 电机控制台多传输模块
title: 电机控制台多传输模块
scope:
- js/motor/
source_files: []
children: []
depends_on: []
related_to:
- path: js_frontend/can
reason: CAN 控制台与电机控制台均通过 POST /can/send_batch 发送帧到 ESP32 设备,但 CAN 用于通用报文编辑而电机用于特定 CMD/CONFIG 控制流程。
- path: python_server
reason: 电机控制台通过 /ecat/api/* 反向代理访问本地 ecat_native.exe 的 /api/motor/*(transport-ecat.js 中拼接路径发送)。
js_frontend/ota:
dir_name: 前端 OTA 刷写会话与进度管理
title: 前端 OTA 刷写会话与进度管理
scope:
- js/ota/
source_files: []
children: []
depends_on: []
related_to:
- path: js_frontend/ble
reason: 两者均为浏览器端固件升级方案:js/ota 走 WiFi ESP32 HTTP(分块上传+SHA-256),js/ble 走 Web Bluetooth GATT(分包 ACK+窗口+SHA-256),同一业务目标的不同实现。
lab:
dir_name: 调试诊断与 EtherCAT 采样数据
title: 调试诊断与 EtherCAT 采样数据
scope:
- lab/
- ecat_slots_raw.csv
source_files: []
children: []
depends_on: []
related_to: []
python_server:
dir_name: ESP32 调试控制台本地 Web 服务器与窗口外壳
title: ESP32 调试控制台本地 Web 服务器与窗口外壳
scope:
- esp32_server.py
- esp32_app.vbs
- esp32_web.vbs
source_files: []
children: []
depends_on: []
related_to: []
root:
dir_name: ESP32 DCU 调试控制台仓库根
title: ESP32 DCU 调试控制台仓库根
scope:
- README.md
- CHANGELOG.md
- .gitignore
- .gitattributes
- esp32_server.py
- esp32_app.vbs
- esp32_web.vbs
- tabs.html
- serve.bat
source_files: []
children:
- frontend_pages
- js_frontend
- lab
- python_server
- scripts_and_tests
depends_on: []
related_to: []
scripts_and_tests:
dir_name: ECAT 调试、固件刷写与端到端测试脚本集
title: ECAT 调试、固件刷写与端到端测试脚本集
scope:
- scripts/
source_files: []
children: []
depends_on: []
related_to:
- path: python_server
reason: 脚本 test_app_lease_e2e.mjs 等连接本地页面服务器的 /ws/app-lease 端点做端到端断言(端口探测与 POST 触发)。
@@ -0,0 +1,31 @@
# 诊断结论(本次核心交付)
你的怀疑正确,确实冲突,且共两层:
1. **主站楔死看门狗无豁免(自动拉起的推手)**
`G:\Robot_v2\DCU\tools\ecat\ecat_native.c` 的 wedge_worker 后台线程:WKC 异常持续 5s 即判死,之后每 ~8s 无限重拉 INIT→OP,直到成功,全程无「用户手动休眠」豁免。日志实锤(`build_native\native_stdout.log`):
`t=176002.9s 判死(从站掉 OP) → 重拉失败(枚举不到从站) → t=176011.0s 重拉成功已进 OP`,与你任务管理器图上 ~20s 流量缺口完全吻合。
2. **GD32 固件进睡眠时没冻结 HBI 访问者(芯片睡不住的根因)**
`EscLan9252::pmDrive()` 写完 PMT_CTRL 进入 D1-D3 后,GD32 侧 SSC 主循环 `MainLoop()→ECAT_Main()` 每圈仍无条件 HBI 读 0x220(AL 事件寄存器),另有 250ms AL mask 守护、5s AL 心跳,全部无睡眠门控。按 1.6.24 实测「任何 HBI 访问(含读)都会唤醒芯片」,**芯片进入睡眠后毫秒级就被 GD32 自己打回 D0**。你看到的低谷不是「睡了多久」,而是被唤醒后 SM/FMMU 失效、主站重建连接所需的时间。
3. 次要发现:电源页 `enterMode` 已正确暂停轮询并写 sessionStorage(同标签页安全),但换新标签页打开会丢标记 → 自动状态读(HBI 读)直接把芯片唤醒,属潜在坑。
# 修复计划
## Phase A:PC 侧(立即生效,不动固件)
- **A1** `ecat_native.c`:新增 `g_pm_hold` 标志 + `POST /api/pm/hold {on:0|1}`;在三处跳过——pdo_worker 的楔死判定(~859-908 行)、wedge_worker 巡检(~2940-2990 行)、`/api/motor/state` 路由快路径(~2262-2300 行);hold 时重置检测计时避免恢复后误触发。`build_new.bat` 重编,`verify_wedge_fix.py` 回归。
- **A2** `lab\lan9252_pm.html`:enterMode 成功后发 `pm/hold on=1`;手动唤醒/NRST 流程开头先发 `on=0`(恢复主站自愈)再走 recoverEcatMaster;banner 文案同步。
- **A3**(小改)睡眠标记另写一份带时间戳的 localStorage,新标签页打开时据此跳过自动状态读。
- 效果:休眠期间主站不再自动拉回 OP;但芯片仍会被 GD32 自己唤醒,EtherCAT 保持断开直到你手动唤醒(行为=「我睡了就是睡了」,但低功耗目的未真正达成)。
## Phase B:GD32 固件根因修复(需 cbuild 构建 + 刷板)
- **B1** `G:\Robot_v2\DCU\DCU_APP\GD32E508_DCU_APP\comm\esc_lan9252.cpp/.h`:新增全局挂起标志;pmDrive op=1/2/3 成功进睡眠时置位,op=4 唤醒成功/nrstDrive/hardwareReset 时清除。
- **B2** 门控点:`sys\task_runner.cpp` 的 `MainLoop()`、`ethercatAleMaskGuard()`、`ethercatAlHeartbeat()`;PDI/Sync0 EXTI ISR 顶部查标志直接返回(ISR 内 HBI 读同样会唤醒芯片)。0xC5/0xC4 轮询不碰 ESC,无需改。
- **B3** 核对唤醒后 SSC 阴影状态一致性(现有页面唤醒路径一律 NRST+motor/start,NRST 会全复位芯片)。
- **B4** 按 cbuild+vcpkg AC6+ninja 链构建刷板,刷完 `/api/motor/start` 恢复 OP。
- 加上 B 后芯片才能真正保持 D1-D3 低功耗态。
## 验证
- 进睡眠后任务管理器流量掉 0 并**保持**,native 日志无 WEDGE 重拉;GD32 串口 `[ECAT][HB]` 心跳停止(B 后);间隔 2s/10s/30s 查询状态仍为睡眠态;手动唤醒后流量恢复进 OP。
- hold=0 时正常楔死自愈回归不受影响。
+412 -9
View File
@@ -16,8 +16,8 @@
| `serve.bat` | 控制台模式 | 端口 8080,窗口内可见服务器日志,用于调试 | | `serve.bat` | 控制台模式 | 端口 8080,窗口内可见服务器日志,用于调试 |
窗口模式为何拆两个进程:`pythonw` + `pythonnet`(pywebview 依赖)环境下 Python 窗口模式为何拆两个进程:`pythonw` + `pythonnet`(pywebview 依赖)环境下 Python
后台线程会被静默杀死,把 HTTP 服务放进独立子进程可规避,与 `tools/ecat/ecat_server.py` 后台线程会被静默杀死,把 HTTP 服务放进独立子进程可规避,与 `tools/ecat` 的
同款结构。pywebview 窗口没有地址栏与刷新按钮,因此服务器会向 HTML 响应注入 窗口模式同款结构。pywebview 窗口没有地址栏与刷新按钮,因此服务器会向 HTML 响应注入
`_reload.html`(右下角 ⟳ 悬浮按钮 + F5/Ctrl+R 拦截)。 `_reload.html`(右下角 ⟳ 悬浮按钮 + F5/Ctrl+R 拦截)。
`build_mem.bat`(在 `scripts/`)是从 Git Bash 调用 ESP-IDF 构建 `DCU/ESP32_COMM` 的辅助脚本 `build_mem.bat`(在 `scripts/`)是从 Git Bash 调用 ESP-IDF 构建 `DCU/ESP32_COMM` 的辅助脚本
@@ -30,29 +30,101 @@ tabs.html 标签外壳 = 工具入口(GET / 会 302 到这里)
pages/ 主功能页,各自引用 ../css 与 ../js pages/ 主功能页,各自引用 ../css 与 ../js
lab/ 自包含实验页,零外部依赖,删文件即可移除 lab/ 自包含实验页,零外部依赖,删文件即可移除
css/ js/ 共享样式与 ES module css/ js/ 共享样式与 ES module
scripts/ 构建辅助脚本 scripts/ 正式自测/测量脚本(清单见下;一次性探针不入库)
docs/compose/spec/ compose 功能规格
docs/reports/ 历史调研报告(原根目录 ECAT_*/REPORT_*)
esp32_server.py 本地页面服务 + 桌面窗口外壳 esp32_server.py 本地页面服务 + 桌面窗口外壳
esp32_app.vbs esp32_web.vbs serve.bat 三个启动入口 esp32_app.vbs esp32_web.vbs serve.bat 三个启动入口
``` ```
## scripts/ 正式清单(仅保留这些)
| 脚本 | 用途 |
|------|------|
| `build_mem.bat` | Git Bash 下构建 ESP32_COMM |
| `test_motor_transport.mjs` | 双链路传输契约 |
| `test_motor_refresh.mjs` | 刷新速度每链路一份(纯逻辑) |
| `test_ecat_autostart.mjs` | 自动接入链条(stub) |
| `test_app_lease.py` / `test_app_lease_e2e.mjs` | 关窗停总线 |
| `test_esf_pack.py` | PDO 快发 ESF1 打包 |
| `check_esf_latency.py` / `check_latency_breakdown.py` / `check_e2e_click_motion.py` | 快发与点击延迟测量 |
| `check_web_send_latency.py` / `check_recover_after_reset.py` / `check_click_to_motion.py` | 网页全链路自测(原 `e2e_web_send_latency.py`)、GD32 复位后恢复并验收、点击→STATUS 动作 |
| `check_guard_idle_silence.py` / `check_guard_fires_hw.py` / `guard_probe.py` | tx 冻结守卫真机门槛:健康总线不许被重连 / 制造冻结证明守卫真会触发;共用前置与通路(改 native 后跑,见 `tools/ecat/MIGRATION.md` 加固段) |
| `browser_check_{refresh,tick,floor,period}.mjs` | 浏览器侧 DOM/节拍实测 |
| `probe_ecat_slot_atomicity.py` / `probe_ecat_vs_wifi.py` | 可复用采样器(gitignore 白名单) |
现场 `_tmp_*` / `skip_*` / `anal_*` 等一次性脚本**不入库**;历史报告在 `docs/reports/`。
## 页面 ## 页面
`tabs.html` 是单窗口多标签外壳,用 iframe 承载下列功能页,激活标签记忆在 `tabs.html` 是单窗口多标签外壳,用**多 iframe 常驻保活**承载下列功能页:每个标签一个懒创建 iframe,切换只改可见性、不销毁文档(否则页内 WebSocket/轮询会随导航全灭)。激活标签记忆在
`localStorage` 的 `dcu_tab`(按文件名匹配,与目录层级无关): `localStorage` 的 `dcu_tab`(按文件名匹配,与目录层级无关;启动只预载该标签):
| 页面 | 用途 | 主要脚本 | | 页面 | 用途 | 主要脚本 |
|------|------|----------| |------|------|----------|
| `pages/index.html` | OTA 中继控制台(ESP32 WiFi OTA / GD32 relay / BLE OTA) | `js/app.js`、`js/ota/*`、`js/ble_ota.js`、`js/ble/*` | | `pages/index.html` | OTA 中继控制台(ESP32 WiFi OTA / GD32 relay / BLE OTA) | `js/app.js`、`js/ota/*`、`js/ble_ota.js`、`js/ble/*` |
| `pages/can.html` | CAN 通信(DBC 解析、消息编辑与批量发送) | `js/can.js`、`js/can/*` | | `pages/can.html` | CAN 通信(DBC 解析、消息编辑与批量发送) | `js/can.js`、`js/can/*` |
| `pages/can_monitor.html` | CAN 接收监控(独立页;也被 `can.html` 内嵌复用) | `js/can_monitor.js` | | `pages/can_monitor.html` | CAN 接收监控(独立页;也被 `can.html` 内嵌复用) | `js/can_monitor.js` |
| `pages/motor.html` | 电机控制台(CAN FD) | `js/motor/main.js`、`js/motor/tick.worker.js` | | `pages/motor.html` | 电机控制台(CAN FD,WiFi / EtherCAT 双链路) | `js/motor/main.js`、`js/motor/transport*.js`、`js/motor/tick.worker.js` |
| `lab/continuity_test.html` | LAN9252 通断测试(自包含,含 WebGL 液态玻璃效果) | 内联 | | `lab/spi_test.html` | 【临时功能 SPI-SPEEDTEST】SPI/ECAT 通信测速(标签名「SPI/ECAT 通信测速」;含 EtherCAT 接收看板,完全自包含,删文件即可移除) | 内联 |
| `lab/spi_test.html` | 【临时功能 SPI-SPEEDTEST】SPI 链路测速,完全自包含,删文件即可移除 | 内联 |
## 设备发现与选择(局域网)
顶栏 `🔍 设备`(`tabs.html`)与 OTA 页 IP 行的 `🔍 发现`(`pages/index.html`)打开同一个面板:
扫出局域网里哪几台是这块 ESP32 中继板,点一行就把当前设备端点改成它,六个标签一起跟着切。
- **探测原语在服务侧且无状态**:`POST /api/lan/probe {"ips":[≤64],"ports":[80],"timeout_ms":750,"local":true}`
→ 逐 IP 一条判决 + `scan` 计数 + `local`(本机默认路由地址与派生 /24)。实现 `lan_probe.py`,
不建 job 表、不常驻线程、不落盘;分批/进度/取消/缓存全在 `js/common/lan-picker.js`。
- **判据只有一处**(`lan_probe.classify`),页面不复算。探测固定打 `/system/status?lite=1`
(固件里这条路径 256 B 栈缓冲、不抢 JSON 工作锁、不发 SPI 事务)。三态判决:
`board`(形状对得上,STA 需自报 IP 与探测地址一致)/ `rejected`(**有反证**:非 200、非 JSON、
无 `wifi` 对象、STA 自称在别处)/ `unknown`(连不上、读超时、或形状合但落不进前两类)。
超时**不算**"不是板子",也不算"没找到",单独列出来。
- **两档扫描**:`快扫` 先打种子地址(当前端点、192.168.31.127、192.168.31.1、上次发现到的,≤8 个,实测 ~0.1–1s),
**种子没命中就自动扩到整段**再扫一次——设备换了 DHCP 地址正是快扫最常见的失效场景;扩段后仍没命中才说"本轮没扫到"。
`全段` 直接打默认路由派生的 /24(实测 253 个地址:服务端各批耗时之和 6.0s,页面双路并发墙钟约 3.5s)。
耗时由"不存在的地址等满 750ms 截止"主导,服务侧批内并发 48(跨不同地址,一块板子全程只被占 1 个 socket)。
面板上报的"用时"是页面墙钟,不是各批累加。CIDR 可手填;
非默认路由的网段(比如 EtherCAT 那块 I210)永远不自动扫。
- **不打扰正在跑的东西**:当前已选端点不进候选表(它的活体判决就是 `device-link` 那条 1Hz
`/system/status` 轮询,不再另开 socket——设备 httpd 只有 6 个 socket 且开了 `lru_purge`,
多开会踢掉网页自己的 keep-alive);`flashBusyNow()`(localStorage `dcu_flash_busy_until`,跨 iframe)
为真时既不能发起扫描也不能换端点;离线自动补扫额外要求 ECAT 环没在跑且距上次 ≥60s,
且**只列候选、绝不自动换设备**。
- **端点只有一个写手**:`deviceLink.setIp / selectDevice`,IPv4 校验在 `js/common/base-url.js:saveDeviceIp`
单点做,非法值**拒绝且不改写现值**,各页输入框只是视图。选中时记一份设备指纹(版本 / git_hash),
Owner 之后每次拉 status 都核 `snap.endpointCheck = match|mismatch|unverified`——设备换了、
或这地址被另一块板占了,会被标出来而不是悄悄当成同一台。
- **已知限制(面板里也这么写)**:固件 `/system/status` 不上报 mac/chip_id,两块同款板只能靠 IP 与
`uptime_sec` 区分(认领办法:按板上复位键后快扫,新上电那台 uptime < 30s)。STA 失败回退 AP 的板子
自称 `192.168.31.1`,PC 还连着路由器 WLAN 时它在三层上不可达,扫不到是正确行为,要真人连
`ESP32_OTA_RELAY`。mDNS(README 旧版曾写 `esp32-ota-relay.local`)**没有**编进固件。
- 自测:`python scripts/test_lan_probe.py`(离线,假板绑本机网卡地址跑真 socket,含"起/关服务判决必须翻转"
的灵敏度对照);`python scripts/check_lan_discovery.py --expect 1`(真网段门槛,
用 RFC 5737 `192.0.2.0/28` 做反向对照,前置不满足报 BLOCKED/exit 2 而不算通过)。
## 链路胶囊(WiFi / ECAT / BLE)的灯色与文案
`connection.js:updateLinkPills()` 是**唯一**写这三颗 pill 的 class 与文字的地方,判据全部取自
`device-link` 快照。历史上 ECAT 那颗有三个写手(快照一条、`app.js` 的 0x1008 probe 一条、
重连边沿检测一条),节奏和判据都不同,实测出现过"绿点 + 文字离线"的自相矛盾;现在
`app.js:refreshEcatPillName()` 只把读到的名字公布到 `window.__ecatProbeName`,不再碰 DOM。
ECAT 分级:`!busOpen` → 离线(后端/网卡未开);`busOpen && loop` → 在线(绿,显示从站名);
`busOpen && !loop` → 琥珀并标 AL 档位(1=INIT 2=PREOP 3=BOOT 4=SAFEOP 8=OP,见快照
`links.ecat.slaveState`)。注意 `links.ecat.online` 仍是老判据(status 有回且带 slave 对象即真),
只表示"总线侧有应答",不代表能跑——`updateConnectionUi()` 的整体在线判定还在用它,
要收紧得单独定(会影响 OTA 页那张大在线卡)。
## 目录约定 ## 目录约定
- `css/` —— `base`(变量/基础)、`layout`、`components`、`can`、`motor`、`responsive`。 - `css/` —— `base`(变量/基础)、`layout`、`components`、`can`、`motor`、`responsive`。
- `js/common/` —— 跨页公用:`base-url`(设备 IP)、`connection`、`polling`、`log`、`toast`、`utils`、`heap-low-history`。 **所有权**:`.btn` 共享 chrome 在 `layout.css`;`components.css` 管跨页 `.toast`
(居中单条);`motor.css` 的 `.btn` / `.toast-container` 是电机页主题覆盖与
堆叠 Toast,**不要**再往 layout/components 抄一份电机专用规则。
- `js/common/` —— 跨页公用:`base-url`(设备 IP + IPv4 校验单点)、`connection`、`polling`、`log`、
`toast`(居中)、`toast-stack`(容器堆叠,电机页)、`ecat-bus`(ECAT 接入策略单点)、
`device-link`(端点唯一写手 + 全链路 Owner)、`lan-picker`(设备发现面板)、`utils`、`heap-low-history`。
- `js/ota/`、`js/ble/`、`js/can/`、`js/motor/` —— 按功能域拆分的 ES module,跨域引用只走 `js/common/`。 - `js/ota/`、`js/ble/`、`js/can/`、`js/motor/` —— 按功能域拆分的 ES module,跨域引用只走 `js/common/`。
- 子系统配色:ESP32 = 青绿 · GD32 = 琥珀 · BLE = 蓝 · CAN = 绿 · 内存/调试 = 紫。 - 子系统配色:ESP32 = 青绿 · GD32 = 琥珀 · BLE = 蓝 · CAN = 绿 · 内存/调试 = 紫。
@@ -67,6 +139,337 @@ esp32_app.vbs esp32_web.vbs serve.bat 三个启动入口
- OTA 流程以设备端为真相来源:WiFi OTA 由设备回读分区算 SHA-256 后自动重启; - OTA 流程以设备端为真相来源:WiFi OTA 由设备回读分区算 SHA-256 后自动重启;
BLE OTA 由前端发 `0xF8 + 32B SHA-256`、设备端比较并通过 FINISH 事件报结果 BLE OTA 由前端发 `0xF8 + 32B SHA-256`、设备端比较并通过 FINISH 事件报结果
(前端不做 SHA 预比较);GD32 relay 状态以 `/system/status` 为准。 (前端不做 SHA 预比较);GD32 relay 状态以 `/system/status` 为准。
## 电机控制台的双链路(WiFi / EtherCAT)
同一个页面、同一套解码器与界面,只有"数据怎么进出"这一层可换,
由工具栏「链路」下拉框切换(记在 `localStorage` 的 `dcu_motor_transport`,默认 WiFi):
| 链路 | 后端 | 状态 | 发送 |
|------|------|------|------|
| `wifi` | ESP32 固件 80 端口 | `GET /motor/state?bin=1` | `POST /can/send_batch` / `send_burst` |
| `ecat` | `tools/ecat` 的 `ecat_native.exe`(C/SOEM 守护进程,经本服务 `/ecat/api/*` 代理) | `POST /api/motor/state` 读后台 PDO 快照 | **桌面 APP:只走 ESF1 二进制快发**(`ecat_send_raw` → TCP `fast_send_port`,**失败不回落 HTTP**);浏览器标签:`POST /ecat/api/motor/send` |
- 接口定义见 `js/motor/transport.js` 头部;两个实现分别在 `transport-wifi.js` / `transport-ecat.js`。
**业务逻辑(解码、节点列表、STATUS 面板、波形、命令序列、以及全部流控
——互斥/退避/冷却/latest-wins)两条链路完全共用**,新增链路只实现接口即可。
- **PDO 快发(pdo-fast-send)**:桌面壳(`esp32_app`)下 ECAT 全部 motor 发送
**强制** `pywebview.api.ecat_send_raw` → GUI ESF1 → native 持久 TCP 入队。
**已删除** Python 桥内的 HTTP send_raw / JSON motor/send 回落,前端在有桥时
也不再回落 JSON——快发失败直接报错,防止误走慢路径。浏览器无壳仍用 HTTP。
真机对照:TCP FSOK p50≈0.08ms;HTTP 常见 8–12ms 尾部。规格:
`docs/compose/spec/pdo-fast-send.md`。测量:`python scripts/check_esf_latency.py`
与 `scripts/check_latency_breakdown.py`。
- **为什么要 `/ecat/api/*` 反向代理**:EtherCAT 后端只绑 `127.0.0.1` 且没有 CORS
(无 `Access-Control-Allow-Origin`,也没有 `do_OPTIONS` —— 预检直接 501),
而本页 API 是 POST + JSON 属非简单请求,浏览器必发预检,跨端口直连必失败。
改那边要动另一个 git 仓库,把页面搬过去又会让两种链路分裂成两份页面,
所以在 `esp32_server.py` 里同源转发(端口在 8090-8095 间自动探测,见 `_ecat_proxy`)。
- EtherCAT 链路的已知差异(都在 `transport-ecat.js` 头注释里写明):
1. 每通道 TEC/REC/err 过程数据里没有 → 一律回 `null` 让界面显示 `--`,
不编 0 冒充正常(区分"通信正常但无此数据"与"真的是 0");
2. CONFIG(`0x000N0101`) / 版本(`0x000N00FF`) / 节点 ID 应答不在状态窗口里,
由传输层从批量接收区累积后回填,上层无感;
3. 电机 OTA 的**进料口**不同:WiFi 走 `POST /motor/ota`(ESP32 存 flash,回读算
SHA-256 比对)再经 SPI 0xD1 喂;EtherCAT 走 CoE 邮箱 `0x2511/0x2510`
把明文灌进 GD32 的 48KB 暂存区,**从站在电机进 BOOT 之前**核分块 CRC32 与
整镜像 CRC32(`DCU_APP/comm/ecat_motor_ota.cpp`)。AES 握手、CAN 单发停等、
重发、`0AAC` 运行确认这套引擎两条链路完全同一个,`ota.js` 的流程也只写一遍。
刷写期间 native worker 独占总线并停 PDO 循环(各通道运动帧一起暂停,与 WiFi
模式刷写等效),结束后自动补一次 PREOP→SAFEOP→OP——从站的 SM2 过程数据看门狗
会在这十几秒里以 AL `0x001B` 把 OP 拖回 SAFEOP,不补就是"刷完假在线"。
- EtherCAT 的 PDO 周期由后台循环独立维持(从站 SM2 有 **1000ms 过程数据看门狗**,
停超 1 秒会 OP→SAFEOP 掉线报 AL `0x001B`),与界面刷新率无关;
故切走链路时**不停**后台循环。
- **刷新速度按链路各记一份**(`state.refreshByTp`,纯逻辑在 `js/motor/refresh-rate.js`),
切链路时自动换成该链路自己的值,界面输入框(含 `min`/`step`)同步回填、节拍立即重排:
| 链路 | 默认 | 下限 | 为什么 |
|------|------|------|--------|
| `wifi` | 250 ms | 50 ms | 每拍是一次无线 HTTP 往返,还要与 GD32 `0xC5` 主动推送周期对齐;再快只会让请求排队积压 |
| `ecat` | 50 ms | **6 ms** | 只是读本机后台 PDO 快照,无总线代价;总线周期另由后台循环维持(默认 4 ms)。下限取 6 ms 是因为绘制上限由显示器刷新率封顶(本机 160 Hz),再快的轮询没有消费者 |
两条链路**不能共用一个变量**:ECAT 的快值带到 WiFi 上会让 HTTP 请求排队
(`tickStuck` 常亮黄),WiFi 的 250ms 带到 ECAT 上又白白浪费了本地快照的低延迟。
旧数据(早期只存单个 `refreshRate`)只迁进 `wifi` —— 那个值是为无线调的,
不该套到 ECAT 上。
- **ECAT 的 6ms 语义是「不限速」而不是 166Hz**(`UNTHROTTLED_MAX_MS = 20`)。
单飞语义下上一拍完成才发下一拍,而每拍要过一次 HTTP(代理 → `ecat_native` → 回来),
实测均值 ≈1.8ms → 6ms 档约 **160 Hz**,正好贴着本机 160Hz 屏的绘制上限。
所以低于 20ms 的档位要**同时**关掉两处显示,否则界面会撒谎:
1. `tickStuck` 卡顿黄标 —— 该档贴着往返耗时,尾部抖动就会撞上"上一拍还在飞",
这是设计行为,再报"连续 N 拍未按时完成"就是误报;
2. 「≈N Hz」理论值 —— 6ms 硬算是 166Hz。这时只显示 `statusHzText`
按数据到达时间实测出来的频率(其滑动窗口在该档改用固定 2s,
否则 `6ms × 2 × 10 = 120ms` 的旧数据 cutoff 会把刚到的样本全清空)。
- **STATUS 面板的 Hz 显示的是网页实际刷新率(数据到达口径),不是电机的
CAN 回报率**(`recvTs` 滑窗实测)。电机 CAN 回报率可变且可能高于链路承载
(WiFi 慢于电机时界面拿不到中间帧),这时按电机回报率显示就是谎报 ——
面板要如实反映"这份数据每秒真到几次",快链路自动跑出高值,慢链路如实
反映卡顿。按槽 seq 实测电机回报率的方案实现过、被明确否掉(2026-09-12:
用户要的恰是到达口径),seq 现在只用于下面的判活。
- **ECAT 判活阈值随实测回报周期自适应**(`staleLimitMs` = clamp(3×EWMA, 2s, 30s))。
固定 2s 的问题:电机回报周期调到秒级后,两次**正常**回报之间就超过 2s,
节点会反复判离线(LED 闪烁、波形画虚线、面板切离线覆盖层)。现在阈值随该槽
实测 seq 间隔放大到 3 倍,下限 2s 仍兜底快周期,上限 30s 防一次长停顿把
EWMA 拉飞后迟迟不判离线;突发降速收敛前最多闪一次离线(无法预知,固有延迟)。
WiFi 链路的 online 仍以固件判定为准(固件仓库另有阈值,前端不越权改写)。
- **轮询不要快过绘制上限**:1ms 档实测能跑到 **~550 Hz**(`ecat_native` 侧
写字节 ÷ 快照 1.6KB 反推),但画面最多只能显示 160 Hz —— 多出来的 390 次/s
在 renderer / 网络服务 / browser 进程 / Python 代理上各点一次,实测这条链
≈1.0 核(renderer 0.35 + 网络服务 0.26 + browser 0.27 + 代理 0.15),
降到 160 Hz 约省 0.7 核。所以下限抬到 6ms;存量 localStorage 里的 1ms
会在读取时钳到 6ms。
- **代理转发绝不能给 socket 传 timeout**(`_ecat_post`,已踩过)。
给 socket 设 timeout 会让 Python 把它切成非阻塞模式,而 **Windows 上非阻塞
connect 要靠 select 轮询完成,于是撞上约 10ms 的系统调度粒度**。
同一个 `/api/motor/state` 请求实测(本机回环,200 次均值):
| 客户端写法 | 平均耗时 | 慢拍(>8ms) | 背靠背吞吐 |
|------------|----------|-----------|-----------|
| `urllib.urlopen(req, timeout=5)` | 5.38 ms | 70/200 | 186 Hz |
| 裸 socket,连接阶段不设 timeout | **1.43 ms** | **0/200** | **699 Hz** |
| 直连 `ecat_server`(不过代理) | 0.70 ms | 0/200 | 1421 Hz |
`ecat_server` 自己只要 0.7ms,那 10ms 全是代理这一层白交的过路费 ——
排查时很容易误判成"EtherCAT 就这么快"。现在 `_ecat_post` 手写最小 HTTP/1.0
客户端:`create_connection` 不传 timeout(本机回环要么立刻成功要么立刻
`ECONNREFUSED`,不会挂住),连上之后才 `settimeout` 管收发。
这一改让当时的 1ms 档从 62 Hz 提到 125 Hz(该档现已按绘制上限抬到 6ms 下限)。
- **别把 EtherCAT 当以太网用:PDO 窗口要自适应**(`ecat_server._cover_bases`,已踩过)。
从站一个 PDO 只带 7 个状态槽,而槽表有 27 个(9 节点 × 3 通道是**容量**,
不是实际接入数)。旧实现每周期把窗口 `+7` 无脑轮扫 4 个窗口,于是
**单台电机**的状态只有 `周期率 / 4` 的更新速度 —— 实测 4ms 周期下总线跑
225 Hz,而每个槽只有 **56 Hz**;现场只接 2 台电机时,4 个窗口里有 3 个
整窗都在传空槽(280B/周期只有 80B 有效)。
关键事实(读固件 `ethercat_can_bridge.cpp` 的 `buildInput` 得到):从站的
`win_base` 可以是**任意槽号**,它只是从 `base` 开始逐槽填到越界即停,
并不要求 `base` 是 7 的倍数。所以让窗口盖住"有主的那几个槽"即可,
稀疏总线上一个窗口就够 —— 在线电机每个 PDO 周期都刷新,快 4 倍,
总线开销一点没多加。两个必需的配套:
1. **开机首圈先走完整轮全表扫描**再切自适应。否则第一个窗口刚看见 1 号电机
就把覆盖集合收缩到那一个窗口,后面的槽再也扫不到(自测第 2 项会炸);
2. **周期性插入发现扫描**(`MOTOR_DISCOVERY_EVERY = 64` 周期)找热插上来的
电机,游标必须走 `MOTOR_DISCOVERY_BASES`。朴素的"每次 +7、越界归零"在
27 槽 / 7 槽一窗时是 `0,7,14` 之后到 21 > 20 直接绕回 0,
于是 **slot 21..26 永远扫不到**,挂在 node8/node9 上的电机再也发现不了
(自测第 10 项专门守这条)。
单槽刷新周期由快照的 `slot_period_ms` 回报,前端状态栏在它与 PDO 周期
不相等时会**两个都显示**(`PDO 4ms · 单槽 16ms`)—— 只报 PDO 周期就是
谎报数据新鲜度。
- **PDO 周期压不进 1ms 不是 EtherCAT 的错,是 Windows 内核睡眠的粒度**
(`ecat_server._motor_wait`)。把一次周期分段计时(真机,period_ms=1):
| 环节 | 实测 |
|---|---|
| `send_processdata` 调用 | 48 µs |
| `receive_processdata` 等从站回帧 | **118 µs** ← 真正的总线时间 |
| 解码 + 建快照 | 5 µs |
| 合计 | **165 µs** → 理论上能跑 ≈6000 Hz |
| 实际周期 | **1360 µs** |
差的 1.2ms 全在"睡到下一拍"。ETG 官方口径是周期 **≤100 µs**、抖动 ≤1 µs、
还有 8 kHz 闭环的示例,所以 165 µs 已经属于正常水平,**慢的是节拍源**:
`Event.wait(1ms)` 实测 p50 1.075 / p90 2.003 ms,稳定超调 0.4~1.0 ms。
也别指望"先睡后自旋"补救 —— 睡过头时截止点已经过了,自旋无事可做
(实测 hybrid 与裸 `wait` 完全一样)。`timeBeginPeriod(1)` 同样没用。
唯一能跑准的是**自旋到绝对截止点**:实测 p50 1.000 / p90 1.001 ms、抖动
0.000 ms。但自旋有两个必须处理的坑:
**坑 1:自旋循环不能持 GIL。** 第一版写成 `while time.perf_counter() < d: pass`,
真机读快照的 HTTP p50 从 0.6ms 直接劣化到 **40ms** —— 页面等于废了。原因是
CPython GIL 切换间隔默认 5ms,纯 Python 循环能连拿好几个 5ms 不放。改成用
ctypes 调 `QueryPerformanceCounter`(ctypes 外部调用默认释放 GIL)后,其它线程
实测 p50 回到 0.56ms 基线。`perf_counter` 与 QPC 同一时间基(实测差 ≤6µs),
所以截止点可以直接用 `perf_counter()` 算。
**坑 2:自旋要钉到 P 核(快核组)。** 它必然 100% 占住一个逻辑处理器,自由
调度会乱跳。本机 i7-14700K(8P/16E)—— 早期为"别抢 HTTP 的 P 核"钉到 E 核,
但 2026-09-05 实测发现超时拍主体是网卡驱动抖动、与钉哪组核无关,且 E 核单核
性能弱、解释器段更长,遂按用户要求改回钉 P 核:让节拍跑在单核性能最高的核上,
HTTP 线程溢出到组内其余核(16 个 P 线程 ≫ 并发 HTTP 数)。改向后真机 250µs 档
5×2000 拍复测 **99.3~99.6%**,与钉 E 核基线(99.4~99.7%)持平,无回退。
核号是**实测**出来的(把线程逐个钉到每个逻辑处理器跑同一段纯计算,按
**最大间隙**分快慢两组、取快的那组)—— 这台机器上 `GetLogicalProcessorInformationEx`
直接 `ERROR_BAD_LENGTH`、`GetSystemCpuSetInformation` 拿不到 EfficiencyClass、
`GetDynamicProcessorCounts` 未导出、处理器组只有一个,没有可用的 API 捷径。
用间隙法而非固定倍数(如 0.75×最慢),因为被 SMT 兄弟拖累的 P 核也能到
16.7ms,固定倍数会把它误判成 E 核。分不出两类核时返回 0 = 不钉。
探测在服务启动时后台做(约 1.6s),**绝不能在 PDO 线程里现场探测** ——
那 1.6s 不喂过程数据,从站 1000ms 看门狗会把它踢出 OP。
开关与可见性:
- 周期 ≤ `MOTOR_SPIN_AUTO_MAX_MS`(2ms) 自动自旋,长周期仍用内核睡眠省 CPU;
- `motor/start` 接受 `spin`,但**改这一项请走 `/api/motor/spin`** —— 它在
`_BUS_EXCLUSIVE_PATHS` 之外,只翻标志;`motor/start` 会先停循环,停手超过
看门狗就把好板子弄出楔死了(实测踩过两次);
- 前端状态栏显示 `⚡ 自旋节拍 · 钉在 P 核 0-15`,可用
`motorDbg.state.tp.setSpin(false)` 或 localStorage `dcu_ecat_spin=0` 关掉;
环境变量 `ECAT_PIN_PCORES=0` 不钉核、`ECAT_PCORE_MASK=0xFFFF` 手工指定。
- 自测第 11~14 项分别守:自旋精度、不持 GIL、P 核分类与线程级隔离、
`api_motor_start` 的 OP 状态核实。
- **内核级周期可热调到 250µs(4kHz),随时可改**。`period_ms` 已是 float,
`/api/motor/period`(`_BUS_EXCLUSIVE_PATHS` 之外,不停循环、喂狗不断流,
理由同 `/api/motor/spin`)接受 `period_ms`(float) 或 `period_us`,运行中随时调、
可反复调,下一拍即生效;`motor/start` 也收 float,但只用于冷启动。
- 物理墙:真机一送一回 ≈165µs。下限 `MOTOR_PERIOD_MIN_MS`=62.5µs 只是**允许
请求**的表达值,比 165µs 更快的档自然自限(实测率 < 目标),不会弄坏循环;
保底 4kHz(250µs) 在预算内。亚毫秒自动就是自旋节拍(睡眠粒度睡不出 250µs;
强制 `spin:false` 时后端会明确警告)。
- 假从站 0.25ms 档实测 **3979Hz**(自测第 15 项;假从站收发是纯内存操作,
该项证明"自旋+float+热调"机制本身驱动得到 4kHz 档,不含真机总线时间)。
- **真机总线负载下的忠实代理(自测第 16 项)**:给假从站收发注入按真机分段
实测拆开的 166µs(send 48 + receive 118)且用 `_spin_until` 释放 GIL(与真机
pysoem 在 Npcap 调用期间放 GIL 一致),网格算法在 250µs 档**精确收敛到
4000Hz**。这证明真机旧算法 3680Hz(92%) 的缺口来自"每拍迟到累进周期",
网格修复消除了它 —— 是真机 4kHz 的强预测(仍非真机终验,断电后跑
`rate_probe.py --periods 0.25` 确认)。
- 节拍用**绝对网格截止点**(锚点 + n×周期),不是"本拍 t0 + 周期":后者把
每拍自旋/重拿 GIL 的迟到 ε 累进周期(250µs 档 ε≈30µs → 只有 89%);
超期时**跳过**过期网格拍而不是挪锚点(挪锚点把零头白丢)。配套给自旋态
线程挂 TIME_CRITICAL(钉核只选了核,不保证该核上谁先跑;回落 NORMAL
在不自旋时)。真机极限(wkc 全程 3/3,最终网格+跳过拍代码,2026-09-04 归因复测,
rate_probe 测速 bug 修复后的口径,见下两条):
**250µs→3977Hz(99.5%)**、**200µs→4949Hz(99%,即 ~4.9kHz)**、
150µs→5672Hz(85%,work p50≈170µs 物理自限) —— 保底 4kHz 达成:250µs 档 99.5%,
要硬 ≥4000Hz 跑 200~240µs 档实测 ~4.9kHz。旧算法(本拍 t0+周期)同档只有
3680Hz(92%),网格修复把缺口压到 0.5%。
- 为什么此前 96~97% 对周期不敏感(2026-09-04 归因结论):**观测代码自身是热循环里
最大的流程不稳定源**。`cyc_us` 分位数原先每 8 拍在 PDO 循环内重算一次,每次排序
7 段 × 400 样本 ≈400~600µs,250µs 档一次就吃掉整拍还多 —— 固定节拍触发、与周期
长短无关,所以 250µs 和 200µs 成功率几乎一样;铁证:迟到拍的相邻间隔恰好落在
mod-8 位置(194/250 对)。修复:分位数彻底移出循环,读侧 `_cyc_fresh`(0.25s TTL,
在 HTTP 请求线程现算),PDO 循环每拍只做 O(1) 的 5 点时间线 append。GC 洗清
(开机以来 gen0 仅 7 次、最大 89µs、零次落在超时窗内)。剩余 ~0.5% 空载损耗按拍内
分段拆到底了(15s 完全不碰服务、只末尾单次导出,超时拍 2.11%、真丢吞吐 Σ(k-1)=0.55%,
与 rate_probe 的 99.5% 对齐):**57% 主导段是 send_processdata**(基线 45µs,超时拍
98~190µs —— Npcap/I226 驱动发送路径抖动),**43% 是 receive 等待**(基线 100µs,
尾部到 3ms —— 从站响应 + NIC RX DPC);而 pre(拿锁+重拿 GIL)max 仅 7µs,**证明空载
时那 0.5% 根本不是 GIL 争抢**,是 pysoem 两次调用自身的驱动抖动,Python 侧已无税可削。
UI 轮询叠加时才另加 ~0.3~1%(cadence_diag 在 hammer 下把部分超时拍归因到 HTTP 时间窗,
但空载超时率 2.11% 与 hammer=1 的 2.10% 几乎相同 → 超时拍主体是驱动固有抖动,非 HTTP)。
两个测量陷阱:① `/api/motor/dbg` 本身很重(一次 dump 2048 拍元组 + json 序列化长时间
持 GIL),连续抓会自污染,归因必须"静默一段后单次导出";② skip_total/Σk 会高估损失
(一拍微超就把整个过期网格拍跳过记 k=1,实际只丢超出松弛量的部分),**真丢吞吐 = Σ(k-1)**。
归因工具:`/api/motor/dbg`(每拍时间线+网格跳过数+GC/HTTP 事件
日志,读内存不碰总线、不进独占路由)与 `tools/ecat/cadence_diag.py`(把迟到拍与
GC/HTTP 时间窗求交集做归因,stdlib-only)。
- **测速脚本自己也曾是"假负载"**(2026-09-04 二次踩坑):rate_probe 旧版
`sample()` 的分子取最后一次轮询的 cycles、分母却在循环退出后才算,退出前那次
`sleep(0.1)` 让 dt 比 cycles 窗口多 0.1s —— Hz 被系统性低估 3~5% 且与周期无关,
把真实的 99.2% 报成 96%,一度被误读成"HTTP 轮询负载吃掉 3 个点"。修复后读数与
cadence_diag 的拍间时间线(不依赖 cycles 轮询)互相印证。教训:**两条独立测量
路径对不上时,先怀疑测量本身,再怀疑系统**;带载/空载的区分要用拍间时间线做基准。
- 前端:`motorDbg.state.tp.setPdoPeriod(0.5)`(合法档 0.125–4ms,DC 与 period 同值;
默认 `setPdoPeriod(0.25)`)。取值存 localStorage `dcu_ecat_period_ms`,后端重启后由
`ensureStarted` 自动带回;状态栏 <1ms 显示 µs(`PDO 250µs`,与后端
`_fmt_period` 同口径),且每拍从快照同步周期 —— 别的标签页或脚本改的
不会显示成过期值。
- 极限测速复用脚本:`tools/ecat/rate_probe.py`(只用标准库,对正在跑的
服务加压):逐级下发周期档,报实测 Hz/达成率/cyc_us/HTTP p50/wkc,
wkc 不达预期自动停止加压,测完自动恢复测前周期。**循环没跑时会自动
open+start**,所以断电重启板子后直接敲这一条命令即可,不必先开电机页;
自动 start 失败(典型=邮箱楔死)会如实打印原因。`--no-autostart` 可关掉。
- **原生 PDO 守护进程(A:把热循环从 Python 换成 C/SOEM;现已是生产后端)**。
pysoem 本就是 SOEM(C) 的包装,热循环的 ~5kHz 天花板来自解释器/GIL,不是总线。
`tools/ecat/` 下 `ecat_native.c` + `build_native.bat`(MSVC 直编 SOEM 源码,
`soem-src/` 从 pysoem sdist 解出),产出 `build_native/ecat_native.exe`:
独占 I226、自己 bring-up 进 OP、跑网格+自旋+分段计时的 PDO 线程。**阶段 4
(2026-09-04)已完成 big-bang 切换**:native 复刻全部 24 条路由(含 SDO/EEPROM/
flash/sync_diag 与 shutdown/focus/parent-pid/端口漂移/陈旧实例清场契约),
接管 8090,Python 版已彻底删除(含备份)。
迁移全程与逐字段对拍记录见 `tools/ecat/MIGRATION.md`。
当年原型阶段的**真机实测(2026-09-05,wkc 3/3)**:work p50 从 Python 的
~176µs 降到 **~135µs**(解释器税没了),250µs 档超时拍 2.11%→**0.45%**,
天花板从 Python 的 ~5kHz 抬到 **~7kHz**(125µs 档 7050Hz、62.5µs 档 7070Hz,
平台期 = 总线一送一回 ~135µs 的物理墙,不再是 Python 墙)。**结论**:换语言
只值"5k→7k + 尾抖动干净",那 0.5% 的驱动周期维护停顿(~2.5s 一次)与语言无关、
依旧存在;要再快得出网卡栈(换 I210 / Linux PREEMPT_RT)。bring-up 踩坑:SOEM
master 的全局 `ecx_context` 初始化器漏了新增的 DCtO/DCl 两字段(MSVC 下 C1311),
已在 `soem-src` 打补丁;且状态迁移必须带 ACK 位(SSC 对带错误位且不含 ACK 的
请求直接忽略),否则卡 PREOP。
- **EtherCAT 链路全自动接入**,切过去即用,不需要先双击 `tools/ecat` 的 vbs。
四级按需自愈,每级都幂等(已连接就只复用,不重复动总线):
1. **服务没起** → `esp32_server._ecat_autostart()` 直接拉起
`tools/ecat\build_native\ecat_native.exe`(阶段 4 起不再经 venv/pythonw,
启动即绑口,实测 <1s 就绪)。两个要点不变:**必须先探测再拉起**,因为
native 独立启动时会清场(对 8090..8095 逐个优雅 shutdown,那会把用户已
开着的实例关掉、从站带回 INIT);拉起时**必须带 `--parent-pid`**,既跳过
那段清场,也让它随本服务退出而自我了断,不留孤儿占着 Npcap 适配器和端口。
2. **网卡没开** → `ensureOpen()` 调 `/api/open`,代替用户去 `ecat.html` 点"连接"。
网卡匹配子串在工具栏「链路」右边的输入框里改(默认 `I210`,
存 `localStorage` 的 `dcu_ecat_adapter`;与 `device-link.js` /
`esp32_server.py` autostart / 页面输入框一致)。
3. **没进 OP** → `/api/motor/start` 内部自己走 PREOP→SAFEOP→OP(会自动重试一次,
最坏约 24s,所以前端超时给 30s、代理也给 30s)。
4. **循环没跑** → 同一个 `/api/motor/start` 起后台 PDO 循环。
断线后每 2s 自动重来一遍,中途拔网线再插会自己恢复。
- **关掉 app 必停从站**(上面"自动接入"的反向操作,`esp32_server.py` 的
`_app_lease_*` 一节):每个 app 页面与页面服务器保持一条 WebSocket
(`js/common/app-lease.js` → `GET /ws/app-lease`),**最后一条**租约连接断开
(所有 app 窗口关闭)后,服务端调后端 `/api/op/stop` 停 PDO 循环并把从站退回
INIT。为什么必须有它:循环在喂从站 1000ms 过程数据看门狗,没人停它就永远
卡在 OP、自旋节拍白占一个核;而 native 的 `--parent-pid` 看门狗只覆盖
"本服务拉起的实例",浏览器模式关窗后服务还活着、复用现成实例时 parent
又是别人,两种常见离开方式都够不着。要点:
- 断开感知靠 TCP FIN / Close 帧回显,**不是心跳计时器** —— 后台标签页的
定时器节流不会把"挂着后台的 app"误判成已关闭(服务端必须回 Close 回显,
否则浏览器拆不掉 TCP,Edge 实测踩过);
- 断连后隔 3s GRACE 才停:F5 刷新只断几百毫秒,不该被当成关窗;
- 外壳内切标签不停(`tabs.html` 自己持有租约),与 `transport-ecat.teardown`
"切到 WiFi 看看不该打断 EtherCAT" 的既有设计一致;
- 只停"本服务经代理确认跑过"的循环(`g_ecat_motor_live` 代理记账),
直接从 ecat 控制台(:8090)起的循环不动;
- 服务进程自己的退出路径(`/api/shutdown`、Ctrl+C)同步补停一次 ——
租约定时器随进程死亡,等不到 GRACE 到点。
代价:每次重开 app 都要重走 PREOP→SAFEOP→OP,有概率撞 SSC 邮箱楔死
(见下一段)—— 这是"关闭必须停"这个要求本身的代价,不是机制缺陷。
自测:`python scripts/test_app_lease.py`(服务端逻辑,裸 TCP 全路径)+
`node scripts/test_app_lease_e2e.mjs`(真 Edge headless + 真服务器,
假后端钉在 8091,绝不碰真总线)。
- 接入失败时**必须能看到原因**:横幅与 `#devStatus` 显示归类后的可执行提示
(`summarizeLinkError`),完整诊断放在两者的 `title` 里。
典型:SSC 邮箱楔死会把 64 条重复 `MailboxError` + SM 现场(2000+ 字符)压成
"需复位 DCU 板 MCU 才能恢复" —— 这个故障软件无法自愈
(成因与规避的完整记录在 `tools/ecat/MIGRATION.md` 阶段 1/4 与
`tools/ecat/ecat_native.c` 的 `kill_stale_instances()` 注释)。
- 自测:
- `node scripts/test_motor_transport.mjs --port <页面服务端口>` —— 传输契约
(真实 import 生产代码打真实代理;EtherCAT 实机段在板子不可用时会自动跳过,
因此项数会随板子状态在 33/36~43 之间浮动,`失败 0` 才是判据)。
- `python tools/ecat/native_enc_diff.py` —— CAN 编码回归(native enctest 路由
输出 vs `native_enc_golden.json` 金标准,4 组用例逐字节,byte0=seq 状态字节
除外 + seq 递增校验;不碰网卡,native 在跑即可)。金标准是阶段 2 与 Python
`_fill_can_out` 逐字节对拍通过后固化的;改 `fill_can_out`/`pack_tx_batch` 后必跑。
原 16 项假从站后端自测(含窗口自适应 SparseSlave 专项)已随 Python 后端删除,
其覆盖结论记录在 `tools/ecat/MIGRATION.md`。
- `G:\Robot_v2\DCU\tools\ecat\check_guard_regression.bat` —— **tx 冻结守卫**回归门槛。
现场症状:点一次「发送 CMD」后 GD32 不消费输出 PDO,health 全绿而
`bridge.tx_count/ack` 冻死,看着像网页慢,实为 EtherCAT 楔死。默认三段
(纯逻辑 38 项 / 源码契约 / 构建指纹)不碰网卡、十几秒出结论;`--live` 加稳定性
套件与 90s 空闲静默;`--live --wedge` 加真机触发(要按一次 GD32 RESET,电机停一下)。
SKIP 不折算 PASS:有段没跑就退 2。改 `wedge_worker`/`guard_tx.h`/发送路由后必跑;
30 秒自检与判据契约见 `tools/ecat/MIGRATION.md` 的「加固 2026-09-21」段。
- `node scripts/test_ecat_autostart.mjs` —— 自动接入链条(stub 后端,
不依赖板子,19 项覆盖开网卡/幂等/换网卡/各级失败语义)。
- `node scripts/test_motor_refresh.mjs` —— 刷新速度每链路一份(55 项,
纯逻辑、不依赖板子/浏览器:默认值、**按链路下限**、旧单值迁移、
存量 1ms 钳到 6ms、两链路互不污染、不限速谓词边界)。
- 浏览器实测(需 headless Chrome/Edge 开 `--remote-debugging-port=9333`,
页面服务端口可作第一个参数传入,默认 8080):
`node scripts/browser_check_refresh.mjs [port]` 验 DOM 回填与
跨刷新持久化(22 项);`node scripts/browser_check_tick.mjs [port]` 数真实请求频率,
确认切链路后**实际节拍**跟着变而不只是 state 变了(9 项,实测 20.0/2.0 Hz);
`node scripts/browser_check_floor.mjs [port]` 验 ECAT 下限档 6ms(26 项:
`min`/`step` 同步、通过浏览器原生校验、实测 ≈160 Hz、无卡顿黄标、
面板 Hz 不谎报、硬填旧的 1ms 被抬到 6ms、切 WiFi 时 6ms 被钳到 50)。
`node scripts/browser_check_period.mjs [port]` 验内核级周期热调(12 项:
`setPdoPeriod(0.25)` 走 `/motor/period` 不碰 `/motor/start`、状态栏显示
`PDO 250µs`(亚毫秒用 µs)、自动自旋标注、localStorage 持久化、页面重载后
`ensureStarted` 自动带回、`setPdoPeriod(4)` 可热改到 4ms 档)。
- 监控 UI 需区分 `valid`(通信是否成功)与 `count > 0`(是否有数据), - 监控 UI 需区分 `valid`(通信是否成功)与 `count > 0`(是否有数据),
以便分辨「正常空闲」和「真实通信错误」。 以便分辨「正常空闲」和「真实通信错误」。
- 响应式 grid 表隐藏列用 `0fr` 宽度实现,不要改变 grid track 数量。 - 响应式 grid 表隐藏列用 `0fr` 宽度实现,不要改变 grid track 数量。
+106
View File
@@ -133,3 +133,109 @@ body::after {
outline-offset: 2px; outline-offset: 2px;
border-radius: 4px; border-radius: 4px;
} }
/* ============================================================
局域网设备发现(js/common/lan-picker.js)
这里只放弹窗本身和它特有的排布。按钮 / 表单 / 输入框 / 列表行
一律复用 layout.css 的既有类(.btn .btn-outline .btn-sm .input
.input-group .form-label .jump-history-item .jh-tag .jh-msg .jh-time
与 .ok/.err/.info 状态色),不再另造一套控件外观。
============================================================ */
.lan-slot { display: flex; align-items: center; gap: 8px; padding: 0 6px; }
/* 遮罩:居中弹出 + 周围高斯模糊(backdrop-filter 作用于遮罩下方的整个页面) */
.lan-mask {
position: fixed; inset: 0; z-index: 900;
display: none; align-items: center; justify-content: center;
padding: 24px;
background: rgba(4, 8, 14, .62);
-webkit-backdrop-filter: blur(9px) saturate(.85);
backdrop-filter: blur(9px) saturate(.85);
animation: lanFade .16s ease;
}
.lan-mask.show { display: flex; }
@keyframes lanFade { from { opacity: 0; } to { opacity: 1; } }
@keyframes lanPop {
from { opacity: 0; transform: translateY(10px) scale(.985); }
to { opacity: 1; transform: none; }
}
/* 盒子沿用 .card 的渐变底与顶部子系统色带,描边/阴影/圆角同 .modal-box */
.lan-box {
position: relative;
width: min(760px, 100%); max-height: min(82vh, 780px);
overflow: auto;
background: linear-gradient(170deg, var(--bg-card) 0%, rgba(15,23,38,.96) 100%);
border: 1px solid var(--border-strong);
border-radius: var(--radius-lg);
box-shadow: 0 24px 64px rgba(0,0,0,.6);
animation: lanPop .18s ease;
}
.lan-box::before {
content: ""; position: absolute; top: 0; left: 0; right: 0; height: 2px;
background: linear-gradient(90deg, var(--esp32) 0%, var(--ble) 55%, var(--gd32) 100%);
opacity: .8;
border-radius: var(--radius-lg) var(--radius-lg) 0 0;
}
.lan-head {
position: sticky; top: 0; z-index: 2;
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
padding: 14px 18px; border-bottom: 1px solid var(--border-subtle);
font-family: var(--font-display); font-size: 13px; font-weight: 600;
letter-spacing: .05em; color: var(--text);
background: linear-gradient(170deg, var(--bg-card) 0%, rgba(15,23,38,.96) 100%);
}
.lan-ep {
font-family: var(--font-mono); font-size: 11px; font-weight: 400;
letter-spacing: 0; color: var(--text-dim);
}
.lan-head .btn { margin-left: auto; }
.lan-body { padding: 14px 18px 18px; display: flex; flex-direction: column; gap: 12px; }
.lan-tools { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.lan-field { display: flex; align-items: center; gap: 8px; }
.lan-field .form-label { margin-bottom: 0; }
.lan-field .input { width: 172px; padding: 7px 10px; font-size: 12px; }
.lan-check {
display: flex; align-items: center; gap: 6px; cursor: pointer;
font-family: var(--font-display); font-size: 11px;
letter-spacing: .08em; text-transform: uppercase; color: var(--text-label);
}
.lan-progress { font-family: var(--font-mono); font-size: 11.5px; color: var(--text-label); }
.lan-progress.lan-busy { color: var(--esp32); }
/* 列表行 = .jump-history-item 既有排布,但改成网格:
没有操作按钮的"当前端点"行和有按钮的行才能按同一套列宽对齐
(flex 布局下两行的右侧列会各自漂移,就是"对不齐"的来源)。 */
.lan-list {
list-style: none; margin: 0; padding: 0; overflow: hidden;
border: 1px solid var(--border); border-radius: var(--radius-sm);
background: var(--bg-card);
}
.lan-list .jump-history-item {
display: grid; align-items: center; gap: 8px;
grid-template-columns: 46px minmax(0, 1fr) auto 52px;
padding: 6px 12px; cursor: pointer;
}
.lan-list .jump-history-item > .jh-msg { min-width: 0; }
.lan-list .jump-history-item > .jh-time { text-align: right; }
.lan-list .jump-history-item > .lan-sub { grid-column: 1 / -1; }
.lan-list .jump-history-item:hover { background: var(--bg-hover); }
.lan-list .jump-history-item.lan-cur { background: var(--esp32-dim); }
.lan-list .jump-history-item.lan-stale { opacity: .55; }
.lan-ip { color: var(--text); font-weight: 700; }
.lan-meta { color: var(--text-dim); font-weight: 400; }
.lan-acts { display: flex; gap: 4px; flex-shrink: 0; }
.lan-sub { flex-basis: 100%; font-size: 11px; color: var(--text-label); }
.lan-empty, .lan-cut { padding: 12px; font-size: 12px; color: var(--text-dim); text-align: center; }
.lan-note { min-height: 1.2em; font-family: var(--font-mono); font-size: 11.5px; color: var(--text-dim); }
.lan-note-warn { color: var(--amber); }
.lan-note-error { color: var(--red); }
.lan-misc {
display: flex; flex-direction: column; gap: 8px;
border-top: 1px solid var(--border-subtle); padding-top: 12px;
}
.lan-block { font-family: var(--font-mono); font-size: 11.5px; color: var(--text-label); }
.lan-block b { color: var(--text-dim); font-weight: 600; }
.lan-block ul { margin: 4px 0 0; padding-left: 18px; }
.lan-hint { color: var(--text-dim); }
+13
View File
@@ -199,6 +199,19 @@ body.can-page {
z-index: 5; z-index: 5;
backdrop-filter: blur(14px); backdrop-filter: blur(14px);
background: rgba(15,22,31,.86); background: rgba(15,22,31,.86);
gap: 12px;
padding: 12px 16px;
}
.can-header .header-status {
gap: 6px;
}
.can-header .header-status .pill {
padding: 5px 10px;
font-size: 11px;
}
.can-header .header-status .link-pill {
padding: 5px 11px 5px 8px;
gap: 6px;
} }
/* sticky+backdrop-filter 合成层冲突致 fadeInUp 卡首帧(translateY(10px)+opacity:0), /* sticky+backdrop-filter 合成层冲突致 fadeInUp 卡首帧(translateY(10px)+opacity:0),
* 禁用入场动画。选择器需 .header-bar.can-header 以胜过 responsive.css 的 .header-bar 规则 */ * 禁用入场动画。选择器需 .header-bar.can-header 以胜过 responsive.css 的 .header-bar 规则 */
+69 -3
View File
@@ -87,7 +87,7 @@
position: relative; position: relative;
box-shadow: 0 0 10px rgba(47,216,193,.4); box-shadow: 0 0 10px rgba(47,216,193,.4);
} }
.progress-fill::after { .progress-fill.live::after {
content: ""; position: absolute; inset: 0; content: ""; position: absolute; inset: 0;
background: linear-gradient(90deg, transparent 0%, rgba(255,255,255,.35) 50%, transparent 100%); background: linear-gradient(90deg, transparent 0%, rgba(255,255,255,.35) 50%, transparent 100%);
animation: sweep 1.6s linear infinite; animation: sweep 1.6s linear infinite;
@@ -119,6 +119,27 @@
overflow: hidden; overflow: hidden;
transition: border-color .22s, transform .22s, box-shadow .22s; transition: border-color .22s, transform .22s, box-shadow .22s;
} }
/* ---------- 数据来源角标(WiFi / ECAT / BLE) ----------
右上角小胶囊:标出本格当前数据从哪条链路来。
为双路径无感切换预留:通路变化时 JS 调 setCellSource() 改 data-src + 文案。 */
.src-badge {
position: absolute; top: 5px; right: 5px;
font-family: var(--font-mono); font-size: 9px; font-weight: 600;
letter-spacing: .06em; line-height: 1;
padding: 3px 5px; border-radius: 4px;
border: 1px solid transparent;
opacity: .88; pointer-events: none; user-select: none;
z-index: 2;
}
.src-badge.src-wifi { color: var(--esp32); background: var(--esp32-dim); border-color: rgba(47,216,193,.25); }
.src-badge.src-ecat { color: var(--mem-hi); background: var(--mem-dim); border-color: rgba(168,139,250,.28); }
.src-badge.src-ble { color: var(--ble); background: rgba(95,168,255,.12); border-color: rgba(95,168,255,.25); }
.src-badge.src-dual {
color: var(--text); background: linear-gradient(90deg, var(--esp32-dim), var(--mem-dim));
border-color: rgba(47,216,193,.35);
}
.src-badge.src-off { color: var(--text-label); background: rgba(255,255,255,.04); border-color: var(--border); }
.metric::before { .metric::before {
content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px;
background: var(--metric-accent, var(--esp32)); background: var(--metric-accent, var(--esp32));
@@ -169,8 +190,10 @@
/* ================= 主状态徽章 ================= */ /* ================= 主状态徽章 ================= */
.badge-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; } .badge-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; }
.badge-stat { .badge-stat {
position: relative; /* src-badge 绝对定位锚点(缺了会飘到卡片/页面角) */
background: var(--bg-elevated); border: 1px solid var(--border); background: var(--bg-elevated); border: 1px solid var(--border);
border-radius: var(--radius); padding: 16px 16px 13px; text-align: center; border-radius: var(--radius); padding: 22px 16px 13px; text-align: center;
overflow: hidden;
transition: border-color .22s, transform .22s, box-shadow .22s; transition: border-color .22s, transform .22s, box-shadow .22s;
} }
.badge-stat:hover { .badge-stat:hover {
@@ -445,6 +468,11 @@
border-color: var(--red); color: #fff; border-color: var(--red); color: #fff;
box-shadow: 0 0 12px rgba(255,107,107,.4); box-shadow: 0 0 12px rgba(255,107,107,.4);
} }
.stage-node.warn .stage-dot {
background: linear-gradient(135deg, #e6a23c, #cf9236);
border-color: #e6a23c; color: #1a1203;
box-shadow: 0 0 12px rgba(230,162,60,.4);
}
.stage-label { .stage-label {
font-family: var(--font-display); font-family: var(--font-display);
font-size: 11px; color: var(--text-label); text-align: center; line-height: 1.3; font-size: 11px; color: var(--text-label); text-align: center; line-height: 1.3;
@@ -457,10 +485,12 @@
} }
.stage-node.live .stage-label, .stage-node.live .stage-label,
.stage-node.done .stage-label, .stage-node.done .stage-label,
.stage-node.err .stage-label { color: var(--text); } .stage-node.err .stage-label,
.stage-node.warn .stage-label { color: var(--text); }
.stage-node.live .stage-detail { color: var(--esp32-hi); } .stage-node.live .stage-detail { color: var(--esp32-hi); }
.stage-node.done .stage-detail { color: var(--green); } .stage-node.done .stage-detail { color: var(--green); }
.stage-node.err .stage-detail { color: var(--red); } .stage-node.err .stage-detail { color: var(--red); }
.stage-node.warn .stage-detail { color: #e6a23c; }
/* ---- 阶段状态条 ---- */ /* ---- 阶段状态条 ---- */
.stage-status-bar { .stage-status-bar {
@@ -501,6 +531,12 @@
} }
.stage-status-bar.err .ssb-dot { background: var(--red); box-shadow: 0 0 8px rgba(255,107,107,.4); animation: none; } .stage-status-bar.err .ssb-dot { background: var(--red); box-shadow: 0 0 8px rgba(255,107,107,.4); animation: none; }
.stage-status-bar.err .ssb-text { color: var(--red); } .stage-status-bar.err .ssb-text { color: var(--red); }
.stage-status-bar.warn {
border-color: rgba(230,162,60,.35);
background: linear-gradient(135deg, rgba(230,162,60,.09), rgba(230,162,60,.02));
}
.stage-status-bar.warn .ssb-dot { background: #e6a23c; box-shadow: 0 0 8px rgba(230,162,60,.4); animation: none; }
.stage-status-bar.warn .ssb-text { color: #e6a23c; }
/* ---- 校验信息面板 ---- */ /* ---- 校验信息面板 ---- */
.stage-verify-info { .stage-verify-info {
@@ -600,7 +636,9 @@
} }
/* ================= Toast ================= */ /* ================= Toast ================= */
/* 跨页居中 Toast(#toast / #toastIco / #toastMsg)。电机页堆叠 Toast 见 motor.css .toast-container */
.toast { .toast {
position: fixed; top: 45%; left: 50%; position: fixed; top: 45%; left: 50%;
transform: translateX(-50%) translateY(30px); transform: translateX(-50%) translateY(30px);
z-index: 9999; z-index: 9999;
@@ -703,6 +741,34 @@ code { color: var(--esp32-hi); font-family: var(--font-mono); }
.ble-low-table td:nth-child(7), .ble-low-table td:nth-child(9) { text-align: left; } .ble-low-table td:nth-child(7), .ble-low-table td:nth-child(9) { text-align: left; }
.ble-low-table td:first-child { color: var(--text-dim); } .ble-low-table td:first-child { color: var(--text-dim); }
/* ================= ESP32 WiFi 使能开关(PC9 / WLESS_EN) ================= */
.wifi-en-inline {
margin-left: auto;
display: flex; align-items: center; gap: 8px;
position: relative; z-index: 2;
}
.wifi-en-inline-label {
font-size: 12px; font-weight: 600; color: var(--text-dim); white-space: nowrap;
}
.wifi-switch { position: relative; display: inline-block; width: 44px; height: 24px; flex-shrink: 0; }
.wifi-switch--sm { width: 36px; height: 20px; }
.wifi-switch input { opacity: 0; width: 0; height: 0; }
.wifi-slider {
position: absolute; inset: 0; cursor: pointer; border-radius: 999px;
background: #3a4a63; transition: background .18s ease;
}
.wifi-slider::before {
content: ""; position: absolute; width: 18px; height: 18px; left: 3px; top: 3px;
border-radius: 50%; background: #fff; transition: transform .18s ease;
}
.wifi-switch--sm .wifi-slider::before {
width: 14px; height: 14px; left: 3px; top: 3px;
}
.wifi-switch input:checked + .wifi-slider { background: var(--green); }
.wifi-switch input:checked + .wifi-slider::before { transform: translateX(20px); }
.wifi-switch--sm input:checked + .wifi-slider::before { transform: translateX(16px); }
.wifi-switch input:focus-visible + .wifi-slider { outline: 2px solid var(--esp32); outline-offset: 2px; }
/* ================= BLE 连接按钮(launch 风格) ================= /* ================= BLE 连接按钮(launch 风格) =================
圆形物理按键:未连接=暗淡待机;勾选=已连接,蓝光环绕 + 文字波浪动画。 圆形物理按键:未连接=暗淡待机;勾选=已连接,蓝光环绕 + 文字波浪动画。
checkbox 隐藏,由 JS setConnectionState 驱动 checked,避免异步连接失败导致状态错位。 */ checkbox 隐藏,由 JS setConnectionState 驱动 checked,避免异步连接失败导致状态错位。 */
+270 -2
View File
@@ -103,6 +103,27 @@
font-family: var(--font-mono); font-family: var(--font-mono);
} }
/* ---- 顶栏三链路胶囊(WiFi / ECAT / BLE) ---- */
.link-pill { gap: 6px; padding: 5px 10px 5px 8px; }
.link-pill-tag {
font-size: 10px; font-weight: 700; letter-spacing: .04em;
padding: 1px 5px; border-radius: 4px;
color: var(--esp32); background: var(--esp32-dim);
}
.link-pill-tag.tag-ecat { color: var(--mem-hi); background: var(--mem-dim); }
.link-pill-tag.tag-ble { color: var(--ble); background: rgba(95,168,255,.12); }
.link-pill #pillWifiTxt,
.link-pill #pillEcatTxt,
.link-pill #pillBleTxt,
.link-pill #canNetTxt {
font-size: 11px; overflow: hidden; text-overflow: ellipsis;
}
/* WiFi 要带 IP+信号;ECAT/BLE 连接后显示设备名 */
.link-pill #pillWifiTxt,
.link-pill #canNetTxt { max-width: 22em; }
.link-pill #pillEcatTxt { max-width: 14em; }
.link-pill #pillBleTxt { max-width: 18em; }
/* ================= 主布局 ================= */ /* ================= 主布局 ================= */
.main-layout { .main-layout {
display: grid; display: grid;
@@ -196,7 +217,9 @@ select option { background: var(--bg-panel); color: var(--text); }
.input-group .input { flex: 1; } .input-group .input { flex: 1; }
/* ================= 按钮 ================= */ /* ================= 按钮 ================= */
/* 共享 .btn chrome(OTA/CAN 等页)。电机页主题按钮在 motor.css,勿互相复制。 */
.btn { .btn {
display: inline-flex; align-items: center; justify-content: center; gap: 7px; display: inline-flex; align-items: center; justify-content: center; gap: 7px;
padding: 9px 17px; border: none; border-radius: var(--radius-sm); padding: 9px 17px; border: none; border-radius: var(--radius-sm);
font-family: var(--font-display); font-family: var(--font-display);
@@ -249,6 +272,69 @@ select option { background: var(--bg-panel); color: var(--text); }
color: #04142b; color: #04142b;
box-shadow: 0 4px 16px rgba(95,168,255,.3), inset 0 1px 0 rgba(255,255,255,.25); box-shadow: 0 4px 16px rgba(95,168,255,.3), inset 0 1px 0 rgba(255,255,255,.25);
} }
#modeEcatBtn.btn-primary {
background: linear-gradient(135deg, #8b5cf6 0%, #6366f1 100%);
color: #0f0a1f;
box-shadow: 0 4px 16px rgba(139,92,246,.3), inset 0 1px 0 rgba(255,255,255,.25);
}
#modeWifiBtn.btn-outline {
border-color: rgba(47,216,193,.45);
color: var(--esp32);
}
#modeBleBtn.btn-outline {
border-color: rgba(95,168,255,.45);
color: var(--ble);
}
#modeEcatBtn.btn-outline {
border-color: rgba(139,92,246,.45);
color: #a78bfa;
}
#modeWifiBtn.btn-outline:hover:not(:disabled) {
border-color: var(--esp32);
background: rgba(47,216,193,.08);
color: var(--esp32);
}
#modeBleBtn.btn-outline:hover:not(:disabled) {
border-color: var(--ble);
background: rgba(95,168,255,.08);
color: var(--ble);
}
#modeEcatBtn.btn-outline:hover:not(:disabled) {
border-color: #8b5cf6;
background: rgba(139,92,246,.08);
color: #a78bfa;
}
/* 升级目标:左右两个等宽扁按钮(GD32 琥珀 · ESP32 青绿) */
.target-tab-row {
display: flex;
gap: 8px;
margin-bottom: 14px;
}
.target-tab-btn {
flex: 1 1 0;
min-width: 0;
min-height: 40px;
padding: 10px 14px;
font-size: 14px;
font-weight: 700;
letter-spacing: .04em;
flex-direction: row;
gap: 8px;
border-radius: var(--radius-sm);
}
.target-tab-btn .target-tab-ico {
font-size: 15px;
line-height: 1;
opacity: .9;
}
.target-tab-btn .target-tab-txt {
line-height: 1.1;
}
.target-tab-btn.btn-outline {
border-width: 1.5px;
background: rgba(255,255,255,.03);
}
#targetGd32Tab.btn-primary { #targetGd32Tab.btn-primary {
background: linear-gradient(135deg, var(--gd32) 0%, #f59e0b 100%); background: linear-gradient(135deg, var(--gd32) 0%, #f59e0b 100%);
color: #2b1d02; color: #2b1d02;
@@ -257,13 +343,195 @@ select option { background: var(--bg-panel); color: var(--text); }
#targetEsp32Tab.btn-primary { #targetEsp32Tab.btn-primary {
background: linear-gradient(135deg, var(--esp32) 0%, #14b8a6 100%); background: linear-gradient(135deg, var(--esp32) 0%, #14b8a6 100%);
color: #04231e; color: #04231e;
box-shadow: 0 4px 16px rgba(47,216,193,.28), inset 0 1px 0 rgba(255,255,255,.25);
} }
/* 未选中时用各自子系统的淡描边,避免两个灰按钮看不出身份 */
#targetGd32Tab.btn-outline {
border-color: rgba(252,191,63,.45);
color: var(--gd32);
}
#targetEsp32Tab.btn-outline {
border-color: rgba(47,216,193,.45);
color: var(--esp32);
}
#targetGd32Tab.btn-outline:hover:not(:disabled) {
border-color: var(--gd32);
background: rgba(252,191,63,.08);
color: var(--gd32);
}
#targetEsp32Tab.btn-outline:hover:not(:disabled) {
border-color: var(--esp32);
background: rgba(47,216,193,.08);
color: var(--esp32);
}
#ppEcatOta.active {
color: #2b1d02;
background: linear-gradient(135deg, var(--gd32), #f59e0b);
box-shadow: 0 2px 12px rgba(252,191,63,.35);
}
/* EtherCAT 连接说明 + 探测:与卡片内其他控件同一套字重/字距 */
.ecat-mode-note {
margin: 0 0 12px;
padding: 10px 12px;
border-radius: var(--radius-sm);
border: 1px solid var(--border-subtle);
background: rgba(252,191,63,.06);
font-size: 12px;
line-height: 1.7;
color: var(--text-dim);
}
.ecat-mode-note b {
color: var(--gd32);
font-weight: 600;
}
#bootEcatProbeBtn {
margin-bottom: 10px;
border-color: rgba(252,191,63,.4);
color: var(--gd32);
background: rgba(252,191,63,.05);
}
#bootEcatProbeBtn:hover:not(:disabled) {
border-color: var(--gd32);
background: rgba(252,191,63,.12);
color: var(--gd32-hi);
}
/* ---- APP ↔ BOOT 跳转控制台 ---- */
.jump-role-panel {
display: grid;
grid-template-columns: 1fr;
gap: 10px;
margin-bottom: 12px;
}
@media (min-width: 420px) {
.jump-role-panel { grid-template-columns: 140px 1fr; align-items: stretch; }
}
.jump-role-badge {
display: flex; flex-direction: column; justify-content: center; align-items: center;
gap: 2px; min-height: 92px; padding: 12px 10px;
border-radius: 12px; border: 1px solid var(--border);
background: var(--bg-elevated); text-align: center;
transition: border-color .2s, background .2s, box-shadow .2s;
cursor: pointer; user-select: none;
}
.jump-role-badge:hover {
border-color: var(--gd32);
box-shadow: 0 0 0 3px rgba(252,191,63,.12);
}
.jump-role-badge:active { transform: scale(0.98); }
.jump-role-badge .jump-role-label {
font-family: var(--font-display); font-size: 10px; letter-spacing: .12em;
text-transform: uppercase; color: var(--text-label);
}
.jump-role-badge strong {
font-family: var(--font-display); font-size: 18px; font-weight: 700;
letter-spacing: .04em; color: var(--text);
}
.jump-role-badge .jump-role-sw {
font-family: var(--font-mono); font-size: 11px; color: var(--text-dim);
}
.jump-role-badge[data-role="app"] {
border-color: rgba(47,216,193,.55);
background: rgba(47,216,193,.1);
box-shadow: 0 0 0 3px rgba(47,216,193,.08);
}
.jump-role-badge[data-role="app"] strong { color: var(--esp32); }
.jump-role-badge[data-role="boot"] {
border-color: rgba(252,191,63,.6);
background: rgba(252,191,63,.12);
box-shadow: 0 0 0 3px rgba(252,191,63,.1);
}
.jump-role-badge[data-role="boot"] strong { color: var(--gd32); }
.jump-role-badge[data-role="unknown"] strong { color: var(--text-dim); font-size: 15px; }
.jump-role-meta {
display: grid; grid-template-columns: 1fr 1fr; gap: 6px 10px;
font-size: 12px;
}
.jump-role-meta > div {
display: flex; flex-direction: column; gap: 2px; min-width: 0;
}
.jump-role-meta span {
color: var(--text-label); font-family: var(--font-display);
font-size: 10px; letter-spacing: .08em; text-transform: uppercase;
}
.jump-role-meta b {
font-family: var(--font-mono); font-weight: 600; color: var(--text);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.jump-action-row {
display: grid; grid-template-columns: 88px 1fr; gap: 8px; margin-top: 4px;
}
.jump-btn-primary {
min-height: 44px;
background: linear-gradient(135deg, var(--gd32) 0%, #f59e0b 100%);
color: #2b1d02 !important; font-weight: 700;
box-shadow: 0 4px 14px rgba(252,191,63,.28);
}
.jump-btn-primary:hover:not(:disabled) {
filter: brightness(1.05);
color: #2b1d02 !important;
}
.jump-btn-secondary { min-height: 44px; }
.jump-btn-test {
margin-top: 8px; min-height: 40px;
border-color: rgba(252,191,63,.35); color: var(--gd32);
background: transparent;
}
.jump-btn-test:hover:not(:disabled) {
background: rgba(252,191,63,.1); border-color: var(--gd32);
}
.jump-btn-test:disabled { opacity: .45; cursor: not-allowed; }
.jump-history {
margin-top: 12px; border: 1px solid var(--border-subtle);
border-radius: var(--radius-sm); background: var(--bg-card);
overflow: hidden;
}
.jump-history-head {
padding: 8px 12px; border-bottom: 1px solid var(--border-subtle);
font-family: var(--font-display); font-size: 11px; letter-spacing: .1em;
text-transform: uppercase; color: var(--text-label);
}
.jump-history-list {
max-height: 148px; overflow-y: auto; padding: 6px 0;
}
.jump-history-empty {
padding: 14px 12px; font-size: 12px; color: var(--text-dim); text-align: center;
}
.jump-history-item {
display: flex; align-items: flex-start; gap: 8px;
padding: 6px 12px; font-size: 12px; line-height: 1.45;
font-family: var(--font-mono);
}
.jump-history-item + .jump-history-item { border-top: 1px dashed var(--border-subtle); }
.jump-history-item .jh-tag {
flex-shrink: 0; min-width: 42px; text-align: center;
padding: 1px 6px; border-radius: 999px; font-size: 10px; font-weight: 700;
background: var(--bg-input); color: var(--text-dim);
}
.jump-history-item.ok .jh-tag { background: rgba(61,220,142,.18); color: var(--can-hi); }
.jump-history-item.err .jh-tag { background: rgba(255,107,107,.16); color: var(--red); }
.jump-history-item.info .jh-tag { background: rgba(47,216,193,.14); color: var(--esp32); }
.jump-history-item .jh-msg { flex: 1; color: var(--text); word-break: break-all; }
.jump-history-item .jh-time {
flex-shrink: 0; color: var(--text-dim); font-size: 10px;
}
/* GD32 OTA 大按钮 = 琥珀(区别于 ESP32 青绿) */ /* GD32 OTA 大按钮 = 琥珀(区别于 ESP32 青绿) */
#uploadGd32Btn { #uploadGd32Btn {
background: linear-gradient(135deg, var(--gd32) 0%, #f59e0b 100%); background: linear-gradient(135deg, var(--gd32) 0%, #f59e0b 100%);
color: #2b1d02; color: #2b1d02;
box-shadow: 0 6px 20px rgba(252,191,63,.3), inset 0 1px 0 rgba(255,255,255,.3); box-shadow: 0 6px 20px rgba(252,191,63,.3), inset 0 1px 0 rgba(255,255,255,.3);
} }
#bootEcatRunBtn {
background: linear-gradient(135deg, var(--gd32) 0%, #f59e0b 100%);
color: #2b1d02;
box-shadow: 0 6px 20px rgba(252,191,63,.3), inset 0 1px 0 rgba(255,255,255,.3);
}
#bootEcatRunBtn:disabled {
opacity: .38;
}
#uploadEsp32Btn { #uploadEsp32Btn {
box-shadow: 0 6px 20px rgba(47,216,193,.32), inset 0 1px 0 rgba(255,255,255,.28); box-shadow: 0 6px 20px rgba(47,216,193,.32), inset 0 1px 0 rgba(255,255,255,.28);
} }
@@ -275,10 +543,10 @@ select option { background: var(--bg-panel); color: var(--text); }
background: var(--bg-elevated); border: 1px solid var(--border); background: var(--bg-elevated); border: 1px solid var(--border);
font-size: 13px; font-weight: 500; font-size: 13px; font-weight: 500;
} }
.conn-indicator .light { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; } .conn-indicator .light { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; background: var(--text-label); }
.conn-indicator.ok .light { background: var(--green); box-shadow: 0 0 8px rgba(61,220,142,.6); } .conn-indicator.ok .light { background: var(--green); box-shadow: 0 0 8px rgba(61,220,142,.6); }
.conn-indicator.err .light { background: var(--red); box-shadow: 0 0 8px rgba(255,107,107,.5); } .conn-indicator.err .light { background: var(--red); box-shadow: 0 0 8px rgba(255,107,107,.5); }
.conn-indicator.warn .light { background: var(--amber); } .conn-indicator.warn .light { background: var(--amber); box-shadow: 0 0 8px rgba(252,191,63,.45); }
.helper-note { .helper-note {
margin-top: 10px; padding: 10px 13px; margin-top: 10px; padding: 10px 13px;
+48
View File
@@ -109,7 +109,9 @@ label { display: block; }
border-color: var(--border-strong); border-color: var(--border-strong);
} }
.toolbar-right a.btn:hover { color: var(--primary); border-color: color-mix(in srgb, var(--seed-primary) 40%, transparent); } .toolbar-right a.btn:hover { color: var(--primary); border-color: color-mix(in srgb, var(--seed-primary) 40%, transparent); }
/* 电机页主题 .btn —— 与 layout.css 共享 chrome 刻意分离(页面级覆盖),勿抄进 components */
.btn { .btn {
display: inline-flex; align-items: center; gap: 6px; display: inline-flex; align-items: center; gap: 6px;
padding: 6px 14px; border-radius: var(--radius-sm); padding: 6px 14px; border-radius: var(--radius-sm);
font-size: 0.85rem; font-weight: 550; letter-spacing: 0.02em; font-size: 0.85rem; font-weight: 550; letter-spacing: 0.02em;
@@ -376,6 +378,12 @@ label { display: block; }
} }
.tab-btn:hover { color: var(--fg); } .tab-btn:hover { color: var(--fg); }
.tab-btn.active { color: var(--primary); border-bottom-color: var(--primary); } .tab-btn.active { color: var(--primary); border-bottom-color: var(--primary); }
/* 当前链路不支持的页签(如 EtherCAT 下的电机 OTA):置灰但保留可见,
悬浮 title 说明怎么才能用 —— 直接隐藏会让人以为功能被删了。 */
.tab-btn.tab-disabled {
color: var(--fg-dim, #666); cursor: not-allowed; opacity: .55;
}
.tab-btn.tab-disabled:hover { color: var(--fg-dim, #666); }
.tab-content { flex: 1; overflow-y: auto; min-height: 0; padding: var(--sp-4); } .tab-content { flex: 1; overflow-y: auto; min-height: 0; padding: var(--sp-4); }
.tab-page { display: none; } .tab-page { display: none; }
.tab-page.active { display: block; } .tab-page.active { display: block; }
@@ -648,7 +656,9 @@ label { display: block; }
.sb-last-send { margin-left: auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 40%; color: var(--fg-dim); } .sb-last-send { margin-left: auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 40%; color: var(--fg-dim); }
/* ═══════════════════════ TOAST & MODAL ═══════════════════════ */ /* ═══════════════════════ TOAST & MODAL ═══════════════════════ */
/* 电机页堆叠 Toast 容器 —— 与 components.css 居中 Toast 是两套 UI,勿合并。 */
.toast-container { .toast-container {
position: fixed; bottom: 60px; left: 50%; transform: translateX(-50%); position: fixed; bottom: 60px; left: 50%; transform: translateX(-50%);
display: flex; flex-direction: column; gap: var(--sp-2); z-index: 1000; display: flex; flex-direction: column; gap: var(--sp-2); z-index: 1000;
pointer-events: none; pointer-events: none;
@@ -717,6 +727,10 @@ body:has(.net-banner.show) .main-grid { padding-top: calc(var(--sp-3) + 29px); }
border-color: color-mix(in srgb, var(--seed-primary) 55%, transparent); border-color: color-mix(in srgb, var(--seed-primary) 55%, transparent);
background: color-mix(in srgb, var(--seed-primary) 5%, var(--bg-root)); background: color-mix(in srgb, var(--seed-primary) 5%, var(--bg-root));
} }
.seq-step.sending {
border-color: color-mix(in srgb, var(--ok) 55%, transparent);
background: color-mix(in srgb, var(--ok) 5%, var(--bg-root));
}
.seq-locked .seq-step { opacity: .55; pointer-events: none; } .seq-locked .seq-step { opacity: .55; pointer-events: none; }
.seq-step-head { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; margin-bottom: var(--sp-2); } .seq-step-head { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; margin-bottom: var(--sp-2); }
.seq-idx { font-family: var(--font-mono); font-size: 0.78rem; font-weight: 600; color: var(--primary); min-width: 4.2em; } .seq-idx { font-family: var(--font-mono); font-size: 0.78rem; font-weight: 600; color: var(--primary); min-width: 4.2em; }
@@ -748,8 +762,42 @@ body:has(.net-banner.show) .main-grid { padding-top: calc(var(--sp-3) + 29px); }
} }
.seq-ops button svg { flex-shrink: 0; } .seq-ops button svg { flex-shrink: 0; }
.seq-ops button:hover:not(:disabled) { background: var(--primary-dim); color: var(--primary); } .seq-ops button:hover:not(:disabled) { background: var(--primary-dim); color: var(--primary); }
.seq-ops button.seq-send-one { color: var(--ok); }
.seq-ops button.seq-send-one:hover:not(:disabled) { background: var(--ok-dim); color: var(--ok); }
.seq-ops button.seq-del:hover:not(:disabled) { background: var(--danger-dim); color: var(--danger); } .seq-ops button.seq-del:hover:not(:disabled) { background: var(--danger-dim); color: var(--danger); }
.seq-ops button:disabled { opacity: .25; cursor: default; } .seq-ops button:disabled { opacity: .25; cursor: default; }
/* 拖动把手:按住上下换位(取代旧的上移/下移按钮),聚焦后 ↑↓ 方向键同样可移 */
.seq-grip {
flex-shrink: 0; width: 18px; height: 26px; margin-left: -4px;
display: inline-flex; align-items: center; justify-content: center;
color: var(--fg-dim); border-radius: var(--radius-sm);
cursor: grab; user-select: none; -webkit-user-select: none; touch-action: none;
transition: background .15s, color .15s;
}
.seq-grip:hover { color: var(--primary); background: var(--primary-dim); }
.seq-grip:active { cursor: grabbing; }
.seq-grip:focus-visible { outline: 2px solid var(--primary); outline-offset: 1px; }
.seq-grip svg { pointer-events: none; }
/* 折叠:默认摘要不显示;折叠行收起动作组、只留头部 + 一行功能摘要 */
.seq-fold svg { transition: transform .15s; }
.seq-step:not(.collapsed) .seq-fold svg { transform: rotate(90deg); }
.seq-summary {
display: none; font-family: var(--font-mono); font-size: 0.72rem; color: var(--fg-muted);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.seq-sum-sep { color: var(--border-strong); margin: 0 var(--sp-2); }
.seq-sum-more { color: var(--fg-dim); margin-left: var(--sp-2); }
.seq-step.collapsed { padding: 6px var(--sp-3); }
.seq-step.collapsed .seq-step-head { margin-bottom: 0; }
.seq-step.collapsed .seq-actions { display: none; }
.seq-step.collapsed .seq-summary { display: block; margin-top: 2px; }
/* 拖拽中:整表(含被拖行)临时收成单行,腾出空间便于看清插入位置 */
.seq-list.dragging { gap: var(--sp-2); user-select: none; }
.seq-list.dragging .seq-step { padding: 6px var(--sp-3); }
.seq-list.dragging .seq-step-head { margin-bottom: 0; }
.seq-list.dragging .seq-actions { display: none; }
.seq-list.dragging .seq-summary { display: block; margin-top: 2px; }
.seq-step.drag-src { opacity: .45; border-style: dashed; border-color: var(--primary); }
.seq-step-fields { display: grid; grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); gap: var(--sp-2); } .seq-step-fields { display: grid; grid-template-columns: repeat(auto-fit, minmax(110px, 1fr)); gap: var(--sp-2); }
.seq-step-fields .field-label { font-size: 0.62rem; } .seq-step-fields .field-label { font-size: 0.62rem; }
.seq-step-fields .seq-num { width: 100%; } .seq-step-fields .seq-num { width: 100%; }
+24 -2
View File
@@ -93,6 +93,26 @@
50% { opacity: 1; } 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; } .header-bar { animation: fadeInUp .5s ease both; }
.main-layout > div > .card { animation: fadeInUp .55s ease both; } .main-layout > div > .card { animation: fadeInUp .55s ease both; }
@@ -169,15 +189,17 @@
/* ================= 文件信息浮现 ================= */ /* ================= 文件信息浮现 ================= */
.file-info.show { animation: fadeInUp .3s ease both; } .file-info.show { animation: fadeInUp .3s ease both; }
/* ================= 进度条:流动斜纹 ================= */ /* ================= 进度条:流动斜纹 =================
* 斜纹只在真的有进度时挂(.live 由 setProgress/setEcatPct 开关):
* 空闲时进度条 width:0%,看不见却在按刷新率重绘,纯白烧。 */
.progress-fill { .progress-fill {
background-image: background-image:
linear-gradient(90deg, var(--esp32), #14b8a6), linear-gradient(90deg, var(--esp32), #14b8a6),
repeating-linear-gradient(45deg, rgba(255,255,255,.2) 0 10px, rgba(255,255,255,0) 10px 20px); 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-size: 100% 100%, 200% 100%;
background-blend-mode: normal, overlay; 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; } .progress-fill.done { animation: none; }
/* ================= 阶段胶囊:激活弹入 ================= */ /* ================= 阶段胶囊:激活弹入 ================= */
+100
View File
@@ -0,0 +1,100 @@
---
feature: device-link
status: delivered
updated: 2026-09-16
branch: feat/device-link
commits: 353d936..033ece2
---
# Device Link / ConnectionHub(全链路统一连接层)
## Report
**What was built** — 在既有 DeviceLink 健康广播之上扩展为 ConnectionHub:`deviceLink` 统一持有
WiFi IP、EtherCAT 网卡子串与 `ecatPost`,并保留 Owner/Client 快照。OTA(app.js)、CAN、
can_monitor、motor、ecat_load、LAN9252 改为从 Hub 读端点/订在线布尔;BLE 仍经 `reportBle`
上报。连接灯只覆盖布尔,不替换调用方语义文案。
**Verification** —
- `node --check` 全部改动 JS → PASS
- `test_motor_transport.mjs` 58/58 PASS
- `test_motor_refresh.mjs` 62/62 PASS
- `test_ecat_autostart.mjs` 19/19 PASS
- 独立评审后修复 3 个 critical:读路径 setIp 风暴、CAN 文案覆盖、嵌入页误标轮询离线
**Journey log** —
- 「共用 IP」≠「共用连接」——健康态必须有单一 Owner
- Hub subscribe 只覆盖在线布尔,绝不替换调用方语义文案
- 禁止在读路径调 setIp:Owner 会把每次 set-ip 当成立即重探
- 嵌入 can.html 时 hub 不得写 fbPollStatus(那是 feedback 轮询自己的状态)
## [S1] Problem
多 iframe 保活后,各页仍**各自**判定「设备连上了没有」,只是共用一个 IP 字符串:
- OTA 页轮询 `/system/status` → 顶栏「WiFi 已连接」
- LAN9252 页轮询 `/spi/send` → 同一 IP 却显示「连接失败」
- EtherCAT 状态/负载又是一条独立轮询,网卡子串在 4 处重复读 `localStorage`
- BLE 在页内 GATT,其它页完全不知
- CAN / can_monitor / motor 各有一套 `getBaseUrl` 包装
用户直觉是「同一条设备连接」,实现却是 N 条互不证明的 HTTP/蓝牙会话和重复端点配置。
## [S2] Design
### 原则
1. **外壳持有链路健康轮询**(tabs.html Owner),功能页不再各自判定「设备在线」。
2. **BroadcastChannel**(`dcu-device-link`)广播快照;页内只订阅渲染。
3. **端点配置只在 ConnectionHub**:IP(`dcu_esp_ip`)、网卡(`dcu_ecat_adapter`)读写集中。
4. **命令仍页内发**,连接灯只认 DeviceLink 布尔(文案保留调用方语义)。
5. BLE 为一等通道但单独适配:只广播状态,GATT 细节留在 `ble/*`。
6. **外壳启动强制开总线**:Owner 首轮即 `status→open→motor/start`;成功后不再重复
`motor/start`,掉线后 10s 退避重试。有线 EtherCAT 与 WiFi 不争网卡。
### ConnectionHub 客户端 API(`deviceLink`)
| API | 用途 |
|-----|------|
| `subscribe` / `getSnapshot` / `channelOnline` | 健康快照 |
| `getIp` / `setIp` / `wifiBaseUrl` | WiFi 端点 |
| `getEcatAdapter` / `setEcatAdapter` | EtherCAT 网卡子串 |
| `ecatPost(path, body, timeoutMs)` | 统一 `/ecat/api/*` 代理请求 |
| `reportBle` | BLE 状态上报 |
Owner 消息:`hello` / `snapshot` / `set-ip` / `ble-state`。
### 接入
| 位置 | 行为 |
|------|------|
| `tabs.html` | Owner |
| Owner `bootstrapEcatBus` | 外壳启动即 open+motor/start,不必等电机页切 EtherCAT |
| `connection.js` | 快照新鲜时覆盖 connected 布尔 |
| `app.js` | IP/baseURL 走 `deviceLink`(保留 `#espIp` 输入回退) |
| `can/dbc.js` + `can.js` | setDeviceIp 广播;顶栏灯订阅 hub |
| `can_monitor.js` | baseURL 走 base-url/hub;灯订阅 hub |
| `motor/main.js` | IP 订阅 hub 同步 `state.ip` |
| `motor/transport-ecat.js` | 网卡子串经 hub 读写 |
| `ota/ecat_load.js` | `deviceLink.ecatPost` |
| `lab/lan9252_pm.html` | 灯订阅 hub;open 网卡经 hub |
| `lab/spi_test.html` | 共享 IP 键(自包含页不深耦合请求层) |
| `js/ble/gatt.js` | 通知成功后 reportBle(true),失败/断 false |
## [S3] Out of Scope
- 不串行化 SPI/CAN/OTA 命令队列
- 不迁 SharedWorker
- 不改固件协议
- 不把 BLE GATT 强行对齐 HTTP/ECAT 请求接口
- spi_test 保持可整文件删除,不注入业务请求逻辑
## Tasks
- [x] T1: device-link.js Owner+Client + BC 协议 (covers: S2)
- [x] T2: tabs.html 启动 Owner (covers: S2)
- [x] T3: OTA connection.js 接入 DeviceLink (covers: S2)
- [x] T4: LAN9252 灯与 SPI 文案 (covers: S2)
- [x] T5: BLE 状态上报 (covers: S2)
- [x] T6: 语法检查 + 评审修复 (covers: S2)
- [x] T7: Hub 扩展 wifiBaseUrl/ecatPost/adapter API (covers: S2)
- [x] T8: OTA/CAN/motor/ecat_load/lab 迁到 Hub 端点 (covers: S2)
+67
View File
@@ -0,0 +1,67 @@
---
feature: ecat-rx-stuck
status: in-progress
updated: 2026-09-16
branch: main
commits:
---
# EtherCAT 从机帧接收异常(输入全 0 / 发帧后才流动)
## Report
真机验收(2026-09-16,主站 kick 已上线):`motor/start` **不发业务帧** 即
`wkc=3/3`、`rxraw.layout=dense`、magic=0xA5、gseq=429、motors=1、wr=0。
日志:`OP 后输入已流动(magic=1 diag=1 rx 431→431),踢帧 8 拍`。
## [S1] Problem
EtherCAT 进入 OP 后,主站 `/api/motor/pdoin` 与 `/api/motor/rxraw` **整包 600B 全 0**:
无 dense magic(0xA5)、gseq=0、桥 rx_count=0、段 5 诊断 seq=0、`app/load.valid=false`。
现场 `wkc=2/3`(期望 3)。用户一旦经 `/api/motor/send` 发出任意帧,输入立刻流动:
`wkc=3/3`、magic=A5、电机槽/桥状态出现。之后 `wr` 楔死恢复次数持续上涨。
真机复现(2026-09-16,GD32 APP 1.6.41 + 主站 native):
1. `motor/start` → 日志「OP 后输入无流动…重走 INIT→OP」→ 仍全 0
2. `motor/send` 一帧 → 同一秒内 pdoin 出现 dense 布局与真实电机状态
3. SPI `/spi/speedtest` 正常(ESP32↔GD32 链路本身无病)
## [S2] Design
### 根因(主)
从站 SSC 在 **SM-Synchron** 下应用与输入映射靠 SM2 事件(PDI_Isr)驱动;
MainLoop 的 free-run 分支在 `bEcatFirstOutputsReceived==TRUE` 后整段跳过。
主站 idle 输出 `seq=0` 时,从站 `processOutput`/冷启动路径迟迟不把输入 PDO
真正填进 ESC——`buildInput`+`fillDiagTail` 依赖 `EcatPdo_Process()` 被调用。
主站一旦发送(seq 沿变化)→ outputProbe 全量读 → 冷启动/应用路径苏醒 → 输入开始流动。
### 次因
1. **主站 OP 活性判据过窄**(`do_op_start`):只看两次 `pump_cycles` 之间
`bridge.rx_count` 是否变化。无 CAN 流量时 rx_count 恒 0 → 必判「无流动」→
无意义重走 INIT→OP;有流量时 12ms 窗口也可能采不到 → 同样误判。
2. **从站 1.6.33 新鲜度救援只写不填**(`EcatDbg_OpInputFreshness`):输入冻结时
只调 `PDO_InputMapping()`,从空的 `s_inBuf` 拷 0 写 ESC,等于把「冻结」固化成「全 0」。
3. 从站 1.6.41 warm-jump 跳过 RST# 的路径本身可接受(BYTE_TEST 门),但与上述
「应用未醒」叠加后,首帧前输入窗口长期全 0。
### 修复契约
| 侧 | 行为 |
|----|------|
| 主站 | 进 OP 后、起循环前:用 **dlc=0 + seq 沿** 的空输出连打数拍,制造 outputProbe 边沿、不发 CAN。活性判据改为「输入含 dense magic 或 diag seq 非 0 或 rx_count 变化」。 |
| 从站 | `EcatDbg_OpInputFreshness` 强制刷新前先 `EcatPdo_Process()`,再 `PDO_InputMapping()`。 |
| 从站版本 | patch → 1.6.42(仅救援路径,无布局变更,可与主站独立升级)。 |
## [S3] Out of Scope
- 不改 PDO 线格式 / SII / 600B 布局
- 不改 SPI 协议(0xE8 撕裂已有 ESP32 侧未提交修复,另案)
- 不回退 1.6.41 warm-jump 探针策略(BYTE_TEST 门保留)
- 不改电机/CAN 桥业务语义
## Tasks
- [x] T1: 主站 OP 后 seq 沿踢帧 + 活性判据加宽 — acceptance: 不发业务帧时 pdoin 出现 0xA5 且 gseq 可前进 (covers: S2)
- [x] T2: 从站新鲜度救援先 Process 再 InputMapping — acceptance: PDI 停摆时主站读到非全 0 输入 (covers: S2)
- [x] T3: 真机复验 start 后不 send 即有输入 — acceptance: motor/start 后 2s 内 rxraw.layout=dense (covers: S2; depends: T1)
+99
View File
@@ -0,0 +1,99 @@
---
feature: host-code-quality
status: delivered
updated: 2026-09-23
branch: main
commits: 2c6235d..8043d8e(含评估、P0/P2/P3、界面回滚与安全重做 scope/seq/ota)
---
# 上位机代码质量评估(冗余 / 复杂度 / 屎山风险)
## Report
**What was built** — 全仓质量评估,并按风险优先级做了可验证整改。评估结论:综合约 **3.5/5**(改出来的屎山,仍可救)。
**当前真实状态(2026-09-23 界面正常后)**:
- **P0 服务端风险**:已落地(非幂等不自动重发、网卡默认 I210、跨进程 native 互斥/复用)。
- **P2 服务端边界**:`ecat_client.py` / `ecat_proc.py` 仍在;`esp32_server.py` 已变薄。
- **P3 卫生**:git 内 `scripts/` 收敛、报告在 `docs/reports/`、lab dump 清理。
- **电机前端**:**有意回滚后再安全重做**。完整 `main.js` 恢复 STATUS 契约(`offlineOverlay` / `statusLive`)后,仅外提 **`scope.js` / `seq.js` / `ota.js`**;`main.js` **3233** 行。连接/轮询/`renderStatusPanel` **仍在 main.js**。
- **未再实施**(上次拆分改坏界面后的决策):conn-poll 外提、R5 transport 版本/参数下沉、R6 电机链路强制 ecat-bus、R7 toast/quiet 去重。`ecat-bus.js` 仍供 device-link 使用;电机 `transport*.js` 保持拆分前行为。
### 整改对照(与仓库现状对齐)
| ID | 状态 | 落点 |
|----|------|------|
| R1 非幂等不重发 | **done** | `ecat_client.py` `_ecat_target_idempotent` |
| R2 网卡默认 I210 | **done** | README / `ecat_proc` / `ecat-bus.js` |
| R3 跨进程互斥/复用 | **done** | `_EcatProcXLock` + 复用健康监听 |
| R4 拆 main.js | **partial** | `scope`/`seq`/`ota` 已外提;**无** conn-poll;STATUS 渲染仍在 main.js |
| R5 版本/参数下沉 | **not in tree** | 电机 transport 回滚后无 `waitVersion`(device-link 侧未依赖) |
| R6 ECAT bootstrap 单点 | **partial** | `ecat-bus.js` 供 device-link;电机 transport-ecat 仍为拆分前实现 |
| R7 toast/quiet 去重 | **not in tree** | main.js 内仍有 `toast` / `setQuietMode`;`toast-stack.js` 已移除 |
| R8–R11 服务端 | **done** | `ecat_client.py` + `ecat_proc.py` |
| R12–R14 卫生 | **done** | scripts 清单 / docs/reports / lab dump |
| R15 README | **partial** | 结构与正式脚本清单已写;长篇实测未整段迁出 |
| R16 CSS | **partial** | 所有权注释;两套 btn/toast 刻意分域 |
### 不可逆教训(再拆必读)
1. STATUS DOM 契约:HTML 为 `#offlineOverlay` / `#statusLive`;错用不存在的 id 会导致**永远显示离线**(CSS 未坏)。
2. 机械外提禁止同作用域重复绑定(`setQuietMode` / `TICK_WORKER_URL`)。
3. **禁止** PowerShell 默认编码改 UTF-8 源文件。
4. **验收以界面为准**(`scripts/ui_diag_motor.mjs`),后端 e2e 绿不能代替 UI 绿。
5. 现场快照:`_backup/motor-good-7ea80f0/`(gitignore)。
**Verification** —
- `py_compile esp32_server.py ecat_client.py ecat_proc.py` PASS(P0/P2 保留期)
- `test_esf_pack.py` PASS;`test_motor_refresh.mjs` 62/62 PASS(前端重做后)
- `node --check` main/scope/seq/ota PASS
- UI 门禁(Edge→:2625/motor.html):控制台无报错;绿灯 AL OP;N1–N3 有数据;离线遮罩 `display:none`
- 真机 `check_web_send_latency.py`(原 `e2e_web_send_latency.py`;现随守卫加固改名入库,
并新增第 6 段读 `health.guard` 可观测面。下表为改名前两轮的结果):均为 **PASS 26 / FAIL 1**,exit=1。唯一失败项固定为 **`motor/start`(force_reopen)后立刻读 health 显示 `loop=0`**;同轮 `dc=1 dc_us=250 wkc=3`,且 `motor/send`×6 与 GD32 出口 tx/ack 均前进(HTTP p50≈1.9–5.4ms,出口 p50≈2.1–2.3ms,点击→STATUS≈14ms),WiFi 对照可用。判定为 **e2e 对 force_reopen 后 health 的采样竞态/口径问题**,不是发送链路回归;界面侧数据正常。
- 2026-09-21 续:上述 `loop=0` 竞态现已由 `check_recover_after_reset.py` 改成"轮询到
恢复为止(上限 15s)",不再单点采样;同轮补上守卫可见面断言(`alive_ticks` 心跳、
`fires`、`tx_base`)。tx 冻结守卫为何会悄悄失效、以及"必须触发/不许误触发"两极
的测试契约,见 `tools/ecat/MIGRATION.md` 的「加固 2026-09-21」段。
**Journey log** —
- 大拆 P1 曾导致界面全无数据 → 用户要求回滚 + 保留现场 + 重做
- 重做策略:只外提示波器/序列/OTA,**不动** STATUS/连接状态机
- e2e 冷启动 `loop=0` 与「发送已成功」可并存:属 health 采样竞态,不单说明链路死
- 历史坏提交仍在 git 时间线,工作区以回滚后状态为准;未做 history rewrite
- main 相对 `gitee/main` ahead 数十提交,是否 push 由用户决定
## [S1] Problem
用户要求评估 `G:\Robot_v2\DCU\tools\ESP32` 上位机是否已成「屎山」(冗余/复杂/难维护),并按清单整改。中途因大拆分导致电机页界面无数据,需在**不破坏现场**的前提下恢复并只做安全范围内的重构。
## [S2] Design
### 评估结论(保持)
- 领域复杂度真实(CAN FD、双链路、PDO 实时、pywebview 双进程)。
- 主要结构债:上帝文件、重复状态机、服务端越界替 ecat 补进程治理、杂物堆积。
- 综合约 **3.5/5**:可治理,但边界必须跟上迭代。
### 整改后架构契约(当前)
1. **电机页 STATUS**:仅 `toggleClsIfChanged(statusEl("offlineOverlay"|"statusLive"), ...)`,与 `motor.html` id 一致。
2. **电机前端模块**:`main.js` 装配 + `scope.js` / `seq.js` / `ota.js`(factory 注入 ctx);连接与 STATUS 渲染留在 main.js。
3. **服务端**:`esp32_server.py` + `ecat_client.py`(代理/ESF1/幂等策略)+ `ecat_proc.py`(autostart/锁/RT)。
4. **ECAT 默认网卡**:`I210` 为代码/文档一致来源。
5. **现场备份**:`_backup/` 不入库。
### [S3] Out of Scope
- 不再自动进行连接层大拆 / transport 版本下沉(除非另开任务并加 UI 门禁)
- 不 rewrite git 历史
- 不评估固件仓内部实现
- 本回合不 push 远端(除非用户明确要求)
## Tasks
- [x] T1: 全仓评估与 R1–R16 清单 — acceptance: 规格含证据与优先级 (covers: S2)
- [x] T2: P0 服务端风险修复 — acceptance: 幂等策略 + I210 + 跨进程锁 (covers: S2)
- [x] T3: P2/P3 服务端拆分与卫生 — acceptance: ecat_client/proc + scripts/reports (covers: S2)
- [x] T4: 界面故障根因与回滚 — acceptance: overlay 契约恢复,UI 门禁通过 (covers: S2)
- [x] T5: 安全重做 scope/seq/ota — acceptance: 每步 UI 门禁 + node --check (covers: S2)
- [x] T6: 收尾 e2e + 规格与现状对齐 — acceptance: e2e 结果记入 Report;对照表反映 partial/not-in-tree (covers: S2)
+110
View File
@@ -0,0 +1,110 @@
---
feature: pdo-fast-send
status: delivered
updated: 2026-09-23
branch: main
commits: working-tree (uncommitted; dual-repo main authorized)
---
# PDO 快发(网页按钮 → 原生入队二进制直通)
## Report
**What was built** — 桌面 APP(esp32_app)下,电机页 EtherCAT 链路的**全部** motor
发送**强制**走 ESF1 二进制快发:JS `desktopEcatSendRaw` → GUI 进程
`ecat_send_raw` 打包 → native 持久 TCP 口(health.`fast_send_port`)入 `g_cmd_q`。
**已堵死慢发**:Python 桥删除 HTTP `send_raw` / JSON `motor/send` 回落;前端在
`hasDesktopFastSend()` 为真时失败直接 `ok:false`,不再 `api("motor/send")`。
浏览器无壳标签仍走 HTTP `/ecat/api/motor/send`(脚本/诊断同理)。布局:请求
magic `ESF1` + nframes + 72B/帧;响应 `FSOK` 8 字节。
**Verification** —
- `build_native.bat` PASS;health `fast_send_port` 存在,loop=1 wkc=3
- `scripts/test_esf_pack.py` PASS;`py_compile esp32_server.py` PASS
- `node --check` desktop-bridge / transport-ecat / transport / main PASS
- `scripts/check_esf_latency.py`:TCP FSOK p50≈0.046–0.08ms(HTTP 对照仍在 native 路由上)
- 源码检查:`ecat_send_raw_frames` 无 `motor/send`/`http_send_raw` 回落;
`sendViaFastOrJson` 有桥时不调 `api("motor/send")`
- `test_motor_transport.mjs` 68/68 PASS(Node 无 pywebview → 走 HTTP 契约路径)
- `test_motor_refresh.mjs` 62/62 PASS
- 独立评审 CRITICAL/MAJOR(锁/静态缓冲/端口缓存)此前已修复
**Journey log** —
- 用户期望「纳秒进 PDO」不可达;可压的是控制面,入队后仍是 250µs 一拍 PDO
- pywebview JS→Python hop 仍在,TCP 快发已把 native 侧压到 ~50–80µs
- 沙箱拦 `git worktree add` → 用户明确授权双仓 main
- 持久 TCP 必须 send+recv 整段持锁,否则 FSOK 错配
- **2026-09-23:用户要求堵死慢发**——桌面强制 ESF1、桥内删除 HTTP/JSON 回落;
浏览器/脚本仍保留 native HTTP 路由
## [S1] Problem
电机控制台在 EtherCAT 链路上点发送时,信号要走很长控制面才进 `ecat_native`
的 `g_cmd_q`:浏览器 fetch→代理→JSON,桌面 APP 再叠 pywebview 桥。用户要
「一点按钮极短 CPU 周期内进 PDO」。纳秒不可达;native 入队后到总线由 PDO
周期决定(250µs 档),可压缩的是控制面。HTTP 路径还存在 **8–12ms 调度尾部**,
必须防止桌面壳误走慢路径。
## [S2] Design
### 目标行为(2026-09-23 修订:堵死慢发)
1. 桌面 APP + 桥可用:ECAT 全部 motor 发送 **只走** ESF1 TCP;失败 `ok:false`,
**无** HTTP/JSON 回落。
2. Python `ecat_send_raw_frames`:**仅** TCP ESF1(失败可重探端口后再试 TCP 一次)。
3. 浏览器标签(无 pywebview):仍走 `/ecat/api/motor/send`。
4. native `/api/motor/send` 与 `/api/motor/send_raw` **保留**(浏览器/诊断脚本)。
5. UI 不新增专用开关;`sendHint` / `desktopBridgeLabel` 反映通道形态。
6. 不承诺纳秒。
### 工作区(用户授权覆盖)
- ESP32 与 tools/ecat 均在 **main** 上改(2026-09-23 授权;沙箱拦 worktree add)。
- native:`tools/ecat/ecat_native.c` + `build_native.bat`。
### 二进制帧布局(小端)
**请求**:
```
u32 magic = 0x31465345 /* ESF1 */
u8 nframes /* 1..64 */
u8 flags
u16 reserved
repeat nframes: /* 72B each */
u8 ch, u8 dlc, u16 pad, u32 id, u8 data[64]
```
**响应**:`u32 FSOK(0x4B4F5346) | u8 ok | u8 queued | u16 backlog`
### Native
- `127.0.0.1` `bind(0)` 快发口;health 增加 `fast_send_port`。
- 持久连接读 ESF1 → `parse_esf` → 共用 `enqueue_frames` → FSOK。
- HTTP `/api/motor/send_raw` 与 JSON `/api/motor/send` 仍服务浏览器/脚本。
- `send_raw` 解析缓冲为栈上(HTTP 多线程)。
### GUI 桥(esp32_server.py)
- `pack_esf` / `unpack_fsok`;`_GuiApi.ecat_send_raw`。
- **仅**持久 TCP;connect+sendall+recv 整段持锁。
- 失败:关 socket + `g_fast_send_port=0` → 重探后再试 TCP 一次 → 仍败则 ok:false。
- **无** HTTP/JSON 回落代码。
### 前端
- `desktop-bridge.js`:`desktopEcatSendRaw` / `hasDesktopFastSend`。
- `transport-ecat.js`:`sendViaFastOrJson` 有桥时只调快发。
- 缓存版本 `?v=20260923i`。
### Out of Scope
- 纳秒;共享内存;预装载+位触发;浏览器强制禁 HTTP;删除 native JSON 路由;
改 PDO 周期;WiFi 链路。
## Tasks
- [x] T1: native 快发口 + `/api/motor/send_raw` + health 字段 (covers: S2)
- [x] T2: Python 打包 + `ecat_send_raw` TCP-only(无回落) (covers: S2; depends: T1)
- [x] T3: desktop-bridge + transport-ecat 有桥强制快发 (covers: S2; depends: T2)
- [x] T4: 延迟脚本 + 文档 + 堵死慢发修订 (covers: S2; depends: T1)
File diff suppressed because one or more lines are too long
@@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>ecat-rx-stuck.md 修订附录 2026-10-19</title><style>
body{font-family:-apple-system,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;margin:0;background:#f5f6f8;color:#1d2330;line-height:1.6}
.wrap{max-width:1180px;margin:0 auto;padding:28px 24px 60px}
h1{font-size:26px;margin:0 0 6px}h2{font-size:20px;margin:36px 0 12px;padding-bottom:6px;border-bottom:2px solid #d9dee8}
h3{font-size:16px;margin:22px 0 8px}
.meta{color:#5b6476;font-size:13px}
.card{background:#fff;border:1px solid #e1e5ec;border-radius:10px;padding:16px 18px;margin:12px 0}
table{border-collapse:collapse;width:100%;background:#fff;font-size:13px;margin:8px 0}
th,td{border:1px solid #e1e5ec;padding:6px 8px;vertical-align:top;text-align:left}
th{background:#eef1f6;font-weight:600}
code{font-family:Consolas,Menlo,monospace;font-size:12px;background:#f0f2f6;padding:1px 4px;border-radius:3px;word-break:break-all}
.sev{display:inline-block;padding:1px 8px;border-radius:10px;font-size:12px;font-weight:600;color:#fff;white-space:nowrap}
.s0{background:#b3261e}.s1{background:#e8710a}.s2{background:#3367d6}.s3{background:#5f6b7a}
.kpis{display:flex;gap:12px;flex-wrap:wrap}.kpi{flex:1;min-width:130px;background:#fff;border:1px solid #e1e5ec;border-radius:10px;padding:12px 14px}
.kpi b{display:block;font-size:26px}
.issue{background:#fff;border:1px solid #e1e5ec;border-left:5px solid #ccc;border-radius:8px;padding:12px 16px;margin:10px 0}
.issue.s0b{border-left-color:#b3261e}.issue.s1b{border-left-color:#e8710a}.issue.s2b{border-left-color:#3367d6}.issue.s3b{border-left-color:#5f6b7a}
.issue h4{margin:0 0 6px;font-size:15px}.issue dl{margin:0;display:grid;grid-template-columns:72px 1fr;gap:3px 10px;font-size:13px}
.issue dt{color:#5b6476}.issue dd{margin:0}
.st{white-space:nowrap;font-weight:600}.st-ok{color:#1e7e34}.st-part{color:#b26a00}.st-open{color:#b3261e}.st-na{color:#5f6b7a}.st-old{color:#6b4fbb}
pre.arch{background:#1f2430;color:#e6e9ef;padding:14px;border-radius:8px;overflow:auto;font-size:12.5px;line-height:1.45}
a{color:#2457c5}
.note{font-size:12.5px;color:#5b6476}
</style></head><body><div class="wrap"><h1>《ecat-rx-stuck.md》修订附录 2026-10-19</h1><div class="meta"><a href="../ecat-rx-stuck.md">原报告</a> · 核对基线 commit <code>eac3717</code> · 总评审:<a href="../../../review/DCU_DesktopController_专家评审_2026-10-19.html">DCU_DesktopController_专家评审_2026-10-19.html</a></div><div class="card"><b>原报告:</b>EtherCAT 从机帧接收异常(输入全 0 / 发帧后才流动)<br><b>时间线:</b>status: in-progress,2026-09-16(e91d53e)<br><b>原结论概要:</b>根因在从站 SSC SM-Synchron 下应用未被唤醒;主站进 OP 后用 dlc=0 + seq 沿踢帧并放宽活性判据,从站救援路径先 Process 再 InputMapping(1.6.42)。T1~T3 均已勾选。<p class="note">原文件保持不变;本附录逐条记录其结论在当前代码中的状态。</p></div><h2>逐条结论跟踪</h2><table><tr><th>编号</th><th>原结论 / 建议</th><th>当前状态</th><th>证据(文件:行号)</th><th>备注</th></tr><tr><td>RX-1</td><td>主站 OP 后 seq 沿踢帧 + 活性判据加宽</td><td class="st st-na">无法在本仓库核实</td><td><code>—</code></td><td>ecat_native.c 不在本仓</td></tr><tr><td>RX-2</td><td>从站新鲜度救援先 Process 再 InputMapping</td><td class="st st-na">无法在本仓库核实</td><td><code>—</code></td><td>固件仓</td></tr><tr><td>RX-3</td><td>规格状态仍为 in-progress,但 T1~T3 已勾选</td><td class="st st-open">仍存在</td><td><code>docs/compose/spec/ecat-rx-stuck.md:3</code></td><td>建议改为 delivered 或注明剩余事项</td></tr></table><h2>结论</h2><div class="card">共 3 条:已修复 0、部分修复 0、仍存在/存疑 1、已过时 0、无法在本仓库核实 2。</div></div></body></html>
File diff suppressed because one or more lines are too long
@@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>pdo-fast-send.md 修订附录 2026-10-19</title><style>
body{font-family:-apple-system,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;margin:0;background:#f5f6f8;color:#1d2330;line-height:1.6}
.wrap{max-width:1180px;margin:0 auto;padding:28px 24px 60px}
h1{font-size:26px;margin:0 0 6px}h2{font-size:20px;margin:36px 0 12px;padding-bottom:6px;border-bottom:2px solid #d9dee8}
h3{font-size:16px;margin:22px 0 8px}
.meta{color:#5b6476;font-size:13px}
.card{background:#fff;border:1px solid #e1e5ec;border-radius:10px;padding:16px 18px;margin:12px 0}
table{border-collapse:collapse;width:100%;background:#fff;font-size:13px;margin:8px 0}
th,td{border:1px solid #e1e5ec;padding:6px 8px;vertical-align:top;text-align:left}
th{background:#eef1f6;font-weight:600}
code{font-family:Consolas,Menlo,monospace;font-size:12px;background:#f0f2f6;padding:1px 4px;border-radius:3px;word-break:break-all}
.sev{display:inline-block;padding:1px 8px;border-radius:10px;font-size:12px;font-weight:600;color:#fff;white-space:nowrap}
.s0{background:#b3261e}.s1{background:#e8710a}.s2{background:#3367d6}.s3{background:#5f6b7a}
.kpis{display:flex;gap:12px;flex-wrap:wrap}.kpi{flex:1;min-width:130px;background:#fff;border:1px solid #e1e5ec;border-radius:10px;padding:12px 14px}
.kpi b{display:block;font-size:26px}
.issue{background:#fff;border:1px solid #e1e5ec;border-left:5px solid #ccc;border-radius:8px;padding:12px 16px;margin:10px 0}
.issue.s0b{border-left-color:#b3261e}.issue.s1b{border-left-color:#e8710a}.issue.s2b{border-left-color:#3367d6}.issue.s3b{border-left-color:#5f6b7a}
.issue h4{margin:0 0 6px;font-size:15px}.issue dl{margin:0;display:grid;grid-template-columns:72px 1fr;gap:3px 10px;font-size:13px}
.issue dt{color:#5b6476}.issue dd{margin:0}
.st{white-space:nowrap;font-weight:600}.st-ok{color:#1e7e34}.st-part{color:#b26a00}.st-open{color:#b3261e}.st-na{color:#5f6b7a}.st-old{color:#6b4fbb}
pre.arch{background:#1f2430;color:#e6e9ef;padding:14px;border-radius:8px;overflow:auto;font-size:12.5px;line-height:1.45}
a{color:#2457c5}
.note{font-size:12.5px;color:#5b6476}
</style></head><body><div class="wrap"><h1>《pdo-fast-send.md》修订附录 2026-10-19</h1><div class="meta"><a href="../pdo-fast-send.md">原报告</a> · 核对基线 commit <code>eac3717</code> · 总评审:<a href="../../../review/DCU_DesktopController_专家评审_2026-10-19.html">DCU_DesktopController_专家评审_2026-10-19.html</a></div><div class="card"><b>原报告:</b>PDO 快发(网页按钮 → 原生入队二进制直通)<br><b>时间线:</b>status: delivered,2026-09-23(2c6235d)<br><b>原结论概要:</b>桌面 APP 下 ECAT 电机发送强制走 ESF1 TCP 快发,堵死 HTTP/JSON 慢发回落;失败可重探端口后再试 TCP 一次。<p class="note">原文件保持不变;本附录逐条记录其结论在当前代码中的状态。</p></div><h2>逐条结论跟踪</h2><table><tr><th>编号</th><th>原结论 / 建议</th><th>当前状态</th><th>证据(文件:行号)</th><th>备注</th></tr><tr><td>FS-1</td><td>Python 桥 ESF1 仅走 TCP,无 HTTP 回落</td><td class="st st-ok">已修复(已交付)</td><td><code>ecat_client.py:231-287</code></td><td></td></tr><tr><td>FS-2</td><td>前端 sendViaFastOrJson 有桥时只调快发</td><td class="st st-ok">已修复(已交付)</td><td><code>js/motor/transport-ecat.js:282-300</code></td><td></td></tr><tr><td>FS-3</td><td>失败重探端口后再试 TCP 一次</td><td class="st st-open">仍存在风险</td><td><code>ecat_client.py:247-279</code></td><td>sendall 成功后 recv 失败也会重发,同一帧可能入队两次(P-2,致命)</td></tr><tr><td>FS-4</td><td>堵死慢发原则</td><td class="st st-part">部分修复</td><td><code>js/motor/transport-ecat.js:259-271</code></td><td>低频路径(motor/send、motor/start)桥失败仍回落 HTTP(M-8)</td></tr></table><h2>相关新问题(详见总评审)</h2><div class="issue s0b" id="P-2"><h4><span class="sev s0">致命</span> [P-2] ESF1 快发在 sendall 成功后出错仍整包重发</h4><dl><dt>模块</dt><dd>主机服务/安全</dd><dt>位置</dt><dd><code>ecat_client.py:247-279</code></dd><dt>现象</dt><dd>_tcp_once 持锁 sendall(body) 后读 8 字节 FSOK;recv 超时/断连抛异常时 attempt 0 会 continue,attempt 1 再发同一 body。与同文件非幂等不重发原则(_ECAT_IDEMPOTENT_TAILS)矛盾。</dd><dt>风险</dt><dd>同一帧运动指令被 native 入队两次,电机执行两次增量/相对命令。</dd><dt>修改建议</dt><dd>仅当 connect 阶段失败时重试;sendall 之后的任何异常直接返回 ok:false(可附 maybe_sent 标记)。</dd></dl></div><div class="issue s1b" id="M-8"><h4><span class="sev s1">严重</span> [M-8] ECAT 桥失败回落 HTTP,非幂等命令可能执行两次</h4><dl><dt>模块</dt><dd>电机控制/故障保护</dd><dt>位置</dt><dd><code>js/motor/transport-ecat.js:259-271; js/common/desktop-bridge.js:59-74</code></dd><dt>现象</dt><dd>低频路径(motor/send、motor/start 等)在桌面桥失败或超时后无条件回落 HTTP 再发一次;desktopEcatPost/desktopEcatSendRaw 在 JS 侧无超时。</dd><dt>风险</dt><dd>桥已把命令送达 native 但响应超时时,HTTP 再送一次,造成重复运动指令/重复 start;与 pdo-fast-send『堵死慢发』原则冲突。</dd><dt>修改建议</dt><dd>按 ecat_client.py 的幂等白名单处理:非幂等尾部仅在确认未送达(连接失败)时回落;JS 桥调用加 Promise.race 超时。</dd></dl></div><h2>结论</h2><div class="card">共 4 条:已修复 2、部分修复 1、仍存在/存疑 1、已过时 0、无法在本仓库核实 0。</div></div></body></html>
@@ -0,0 +1,25 @@
<!DOCTYPE html>
<html lang="zh-CN"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>tab-keepalive.md 修订附录 2026-10-19</title><style>
body{font-family:-apple-system,"Segoe UI","PingFang SC","Microsoft YaHei",sans-serif;margin:0;background:#f5f6f8;color:#1d2330;line-height:1.6}
.wrap{max-width:1180px;margin:0 auto;padding:28px 24px 60px}
h1{font-size:26px;margin:0 0 6px}h2{font-size:20px;margin:36px 0 12px;padding-bottom:6px;border-bottom:2px solid #d9dee8}
h3{font-size:16px;margin:22px 0 8px}
.meta{color:#5b6476;font-size:13px}
.card{background:#fff;border:1px solid #e1e5ec;border-radius:10px;padding:16px 18px;margin:12px 0}
table{border-collapse:collapse;width:100%;background:#fff;font-size:13px;margin:8px 0}
th,td{border:1px solid #e1e5ec;padding:6px 8px;vertical-align:top;text-align:left}
th{background:#eef1f6;font-weight:600}
code{font-family:Consolas,Menlo,monospace;font-size:12px;background:#f0f2f6;padding:1px 4px;border-radius:3px;word-break:break-all}
.sev{display:inline-block;padding:1px 8px;border-radius:10px;font-size:12px;font-weight:600;color:#fff;white-space:nowrap}
.s0{background:#b3261e}.s1{background:#e8710a}.s2{background:#3367d6}.s3{background:#5f6b7a}
.kpis{display:flex;gap:12px;flex-wrap:wrap}.kpi{flex:1;min-width:130px;background:#fff;border:1px solid #e1e5ec;border-radius:10px;padding:12px 14px}
.kpi b{display:block;font-size:26px}
.issue{background:#fff;border:1px solid #e1e5ec;border-left:5px solid #ccc;border-radius:8px;padding:12px 16px;margin:10px 0}
.issue.s0b{border-left-color:#b3261e}.issue.s1b{border-left-color:#e8710a}.issue.s2b{border-left-color:#3367d6}.issue.s3b{border-left-color:#5f6b7a}
.issue h4{margin:0 0 6px;font-size:15px}.issue dl{margin:0;display:grid;grid-template-columns:72px 1fr;gap:3px 10px;font-size:13px}
.issue dt{color:#5b6476}.issue dd{margin:0}
.st{white-space:nowrap;font-weight:600}.st-ok{color:#1e7e34}.st-part{color:#b26a00}.st-open{color:#b3261e}.st-na{color:#5f6b7a}.st-old{color:#6b4fbb}
pre.arch{background:#1f2430;color:#e6e9ef;padding:14px;border-radius:8px;overflow:auto;font-size:12.5px;line-height:1.45}
a{color:#2457c5}
.note{font-size:12.5px;color:#5b6476}
</style></head><body><div class="wrap"><h1>《tab-keepalive.md》修订附录 2026-10-19</h1><div class="meta"><a href="../tab-keepalive.md">原报告</a> · 核对基线 commit <code>eac3717</code> · 总评审:<a href="../../../review/DCU_DesktopController_专家评审_2026-10-19.html">DCU_DesktopController_专家评审_2026-10-19.html</a></div><div class="card"><b>原报告:</b>Tab Keep-Alive(多 iframe 常驻)<br><b>时间线:</b>status: delivered,2026-09-16(eaafee1 / be26f4d)<br><b>原结论概要:</b>tabs.html 一标签一懒创建 iframe,visibility 切换不销毁文档;注入的 F5/⟳ 只刷新激活帧;明确不做后台降频。<p class="note">原文件保持不变;本附录逐条记录其结论在当前代码中的状态。</p></div><h2>逐条结论跟踪</h2><table><tr><th>编号</th><th>原结论 / 建议</th><th>当前状态</th><th>证据(文件:行号)</th><th>备注</th></tr><tr><td>TK-1</td><td>多 iframe 宿主 + visibility 切换</td><td class="st st-ok">已修复(已交付)</td><td><code>tabs.html:70-81, 100, 113</code></td><td></td></tr><tr><td>TK-2</td><td>F5/⟳ 只 reload .page-frame.active</td><td class="st st-ok">已修复(已交付)</td><td><code>esp32_server.py:126, 150, 166</code></td><td></td></tr><tr><td>TK-3</td><td>不做后台页降频(Out of Scope)</td><td class="st st-open">仍存在风险</td><td><code>tabs.html:76-81; js/common/polling.js:350-352</code></td><td>隐藏帧持续轮询挤占设备 6 个 socket(C-13);电机页发送节拍在后台被节流(M-10)——建议重新评估此决策</td></tr></table><h2>相关新问题(详见总评审)</h2><div class="issue s1b" id="M-10"><h4><span class="sev s1">严重</span> [M-10] 周期发送与序列计时在后台标签被节流,且无可见性处理</h4><dl><dt>模块</dt><dd>电机控制/故障保护</dd><dt>位置</dt><dd><code>js/motor/main.js:2488; js/motor/seq.js:503</code></dd><dt>现象</dt><dd>周期发送用主线程 setInterval,序列保持用 setTimeout;隐藏 iframe/最小化后被浏览器降到 ≥1 s 乃至分钟级。只有状态轮询走 Worker。无 visibilitychange 停止/告警;WiFi 链路关窗时无停机。</dd><dt>风险</dt><dd>后台时控制节拍严重失真,序列时间轴不可预测。</dd><dt>修改建议</dt><dd>发送节拍迁入 tick.worker.js;visibilitychange 时提示或按策略安全停机;pagehide 时对 WiFi 也发停机(sendBeacon)。</dd></dl></div><div class="issue s2b" id="C-13"><h4><span class="sev s2">一般</span> [C-13] 对设备的并发轮询过多</h4><dl><dt>模块</dt><dd>CAN/公共层</dd><dt>位置</dt><dd><code>js/common/device-link.js:30; js/can.js:131, 424; js/can_monitor.js:487, 490; tabs.html:76-81; js/common/polling.js:350-352</code></dd><dt>现象</dt><dd>/system/status 被 Owner(1 Hz)、OTA 页(1 Hz)、can.js(5 s)、can_monitor(5 s) 同时轮询;/can/feedback 被抽屉与独立监控页各 1 Hz;隐藏 iframe 继续轮询,无可见性暂停与离线退避。README 称设备 httpd 仅 6 个 socket 且 lru_purge。</dd><dt>风险</dt><dd>连接被挤占驱逐,业务请求失败。</dd><dt>修改建议</dt><dd>状态统一由 Owner 广播;隐藏页暂停;离线指数退避。</dd></dl></div><h2>结论</h2><div class="card">共 3 条:已修复 2、部分修复 0、仍存在/存疑 1、已过时 0、无法在本仓库核实 0。</div></div></body></html>
+93
View File
@@ -0,0 +1,93 @@
---
feature: tab-keepalive
status: delivered
updated: 2026-09-16
branch: feat/tab-keepalive
commits: 9bb804ec350534e462599d9c2524eacc0a88d207..eaafee131211190928e0c035f7c0fd89ed722fd4
---
# Tab Keep-Alive(多 iframe 常驻)
## Report
**What was built** — `tabs.html` 从「单 iframe 换 src」改为「一标签一懒创建 iframe」:
切换只改 `visibility`/`pointer-events`,不改 `src`、不 `remove`。功能页文档常驻,
WebSocket、轮询、传输状态在切走再切回后仍在。F5/⟳(`esp32_server.py` 注入脚本)只
reload 激活的 `.page-frame`,不再重载外壳(重载外壳会销毁全部保活帧)。外壳判定改为
`#viewHost`。
**Verification** —
- `python -m py_compile esp32_server.py` → PASS
- `tabs.html` 内联脚本 UTF-8 抽出后 `node --check` → PASS
- 独立评审(9bb804e..eaafee1):规格符合 / 正确性 / 代码库一致性 均 PASS,无 critical
**Journey log** —
- 单 iframe `view.src=path` 是连接全灭的根因,不是各页轮询写错
- 保活隐藏用 `visibility` 而非 `display:none`,降低引擎对不可见文档的节流
- 注入 F5 必须认 `.page-frame.active`;外壳 `location.reload()` 会一次清掉所有保活文档
- 页内跳到「另一个已登记标签」时不再自动抢高亮(多帧语义下更安全;旧单 iframe 会)
- 懒创建时用 `activePage` 拦 load 抢高亮,避免 `dcu_tab` 被慢帧覆盖
## [S1] Problem
`tabs.html` 用**单 iframe** 承载功能页,切换标签时执行 `view.src = path`。
这会销毁上一页的整个文档:
- WebSocket(`app-lease`、调试通道)断开
- `setInterval` / `setTimeout` 轮询停止
- 电机页 EtherCAT/WiFi 传输状态、`transport-ecat` 的 `started` 标志丢失
- OTA 页 `ecat_load` 轮询、SPI 状态轮询冷启动
- 用户「调完一切、切走再切回」后连接全灭,表现为界面假死/全 `--`
外壳自身的 `app-lease.js` 仍在,但功能页内的连接与状态无法幸存。
这是结构性缺陷,不是某个页面的 bug。
## [S2] Design
### 外壳:一标签一 iframe,只 show/hide
- `TABS` 每项对应一个懒创建 iframe(`class="page-frame"`,`data-page`)。
- 首次进入某标签才 `src=page`;之后切换只改可见性,**不改 src、不 remove**。
- 宿主容器 `id="viewHost"`(`data-shell`;注入脚本用 `#viewHost` 判定外壳)。
- 激活帧:`visibility:visible; pointer-events:auto`;非激活:`visibility:hidden; pointer-events:none`。
**不用 `display:none`**——部分引擎对 display:none iframe 的定时器/绘制更狠。
- 切换时同步 `localStorage.dcu_tab`(沿用现有键与 `pageToTab` 迁移)。
### 状态保全契约
| 状态 | 保活后 |
|------|--------|
| 功能页内 WS / 轮询 / Module 全局 | 留在各自 iframe 文档内,切走不断 |
| 外壳 `app-lease` | 仍由 `tabs.html` 自持(覆盖整个窗口) |
| `ecat_native` 后台 PDO 循环 | 服务端 + 租约决定,与是否隐藏无关 |
| F5 / ⟳ | **只 reload 激活 iframe**,不重载外壳 |
### 注入刷新脚本(`esp32_server.py` `_RELOAD_SNIPPET`)
- `reloadCur()`:`querySelector('.page-frame.active')`;兼容 `#view`;否则 `location.reload()`。
- 外壳判定:`#viewHost` 存在(兼容旧 `#view`)。
- 功能页内(非 TOP)行为不变。
### 导航与高亮
- 点击标签 → `go(page)`:懒创建 → 激活目标 → 其它帧隐藏。
- iframe `load`:仅当该帧仍是 `activePage` 且 pathname 映射回**本帧登记路径**才写高亮/`dcu_tab`。
页内跳到另一个已登记标签不会自动抢外壳高亮(多帧语义下更安全)。
- 未知页:仍留在当前 iframe 内显示。
### 内存
用户裁定「全部标签常驻」。不引入 LRU 卸载(卸载即回到原病)。
## [S3] Out of Scope
- 不合并成 SPA、不把连接迁到外壳进程
- 不改各页内部轮询/传输实现
- 不改服务端租约协议
- 不做「后台页降频」策略
## Tasks
- [x] T1: `tabs.html` 多 iframe 宿主 + 懒创建 + show/hide 切换 — acceptance: 切走再切回,OTA/电机页的轮询与 WS 仍在,无需重新连接 (covers: S2)
- [x] T2: `esp32_server.py` 注入的 F5/⟳ 只刷新激活帧 — acceptance: 外壳按 F5 仅重载当前标签文档,其它标签 iframe 仍在 DOM 且未 reload (covers: S2)
- [x] T3: 语法/评审验收 — acceptance: py_compile + inline JS node --check PASS;独立评审无 critical (covers: S2)
+289
View File
@@ -0,0 +1,289 @@
<!DOCTYPE html>
<html lang="zh-CN"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>EtherCAT 单拍耗时 345µs→124µs:改动原理与验证</title>
<style>
:root{--fg:#111827;--mut:#6b7280;--bd:#e5e7eb;--bg:#f8fafc;--acc:#1d4ed8;--warn:#b45309;--bad:#b91c1c;--ok:#047857}
*{box-sizing:border-box}
body{margin:0;background:#fff;color:var(--fg);
font:15.5px/1.72 -apple-system,"Segoe UI","Microsoft YaHei",system-ui,sans-serif}
main{max-width:1120px;margin:0 auto;padding:40px 28px 90px}
h1{font-size:29px;line-height:1.3;margin:0 0 6px;letter-spacing:-.2px}
h2{font-size:20px;margin:44px 0 12px;padding-bottom:7px;border-bottom:2px solid var(--bd)}
h3{font-size:16px;margin:26px 0 8px;color:#374151}
p,li{margin:8px 0}
.meta{color:var(--mut);font-size:13.5px;margin-bottom:26px}
.card{background:var(--bg);border:1px solid var(--bd);border-left:4px solid var(--acc);
border-radius:8px;padding:16px 20px;margin:18px 0}
.card.warn{border-left-color:var(--warn);background:#fffbeb}
.card.bad{border-left-color:var(--bad);background:#fef2f2}
.card.ok{border-left-color:var(--ok);background:#ecfdf5}
.card h3{margin:0 0 8px;font-size:15px;color:inherit}
.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(430px,1fr));gap:18px;margin:18px 0}
.fig{border:1px solid var(--bd);border-radius:8px;padding:12px 10px 8px;background:#fff}
.fig > .cap{font-size:12.5px;font-weight:600;color:#374151;margin:0 0 4px 8px}
.fig > .sub{font-size:11.5px;color:var(--mut);margin:0 0 4px 8px}
table{border-collapse:collapse;width:100%;font-size:13px;margin:14px 0}
th,td{border:1px solid var(--bd);padding:6px 9px;text-align:right}
th{background:#f1f5f9;font-weight:600;text-align:center}
td:first-child,th:first-child{text-align:left}
tr.best td{background:#ecfdf5;font-weight:600}
code{background:#f1f5f9;padding:1.5px 5px;border-radius:4px;font-size:12.5px;
font-family:ui-monospace,"Cascadia Mono",Consolas,monospace}
.kpi{display:flex;gap:14px;flex-wrap:wrap;margin:22px 0 8px}
.kpi div{flex:1 1 168px;border:1px solid var(--bd);border-radius:8px;padding:13px 15px;background:#fff}
.kpi b{display:block;font-size:23px;line-height:1.25;color:var(--acc)}
.kpi span{font-size:12px;color:var(--mut)}
ul{padding-left:22px}
.foot{margin-top:44px;padding-top:16px;border-top:1px solid var(--bd);
font-size:12.5px;color:var(--mut)}
.legend{font-size:12px;color:#374151;margin:2px 0 4px 8px}
.legend i{display:inline-block;width:11px;height:11px;border-radius:2px;margin:0 4px -1px 10px}
</style></head><body><main>
<h1>EtherCAT 单拍耗时 345µs→124µs:改动原理与验证</h1>
<div class="meta">调查日期 2026-09-11 · 台子:i7-14700K + Intel I210(100 Mbps 全双工)+ DCU(GD32E508 + LAN9252)+ 3 台电机 · 源报告 <code>tools/ecat/REPORT_ecat_beat_tail_20260911.md</code> · 本页所有数字已对照源码与实机复测核验(<code>layout=dense</code>、rx p50=92.1µs、wkc 3/3、v1.6.22.0)</div>
<div class="kpi"><div><b>345 → 124µs</b><span>9/11 当天 0.5ms 档整拍 p50 实测(cyc_us.work)</span></div><div><b>310 → 92µs</b><span>主站等回包 rx p50 —— 变化都在这一段</span></div><div><b>124.6µs</b><span>9/8 同为 win 布局的实测单拍(CSV)—— 345µs 并非 win 固有成本</span></div><div><b>线速主导</b><span>主站 rx ≈ 帧在线上的往返(~1KB @100Mbit ≈ 80µs 量级);从站写 ESC 是另一段时间</span></div></div>
<div class="card"><h3>结论(2026-09-12 修订)</h3>
<ul>
<li><b>实测事实不变</b>:9/11 当天 0.5ms 档整拍从 345µs 变 124µs、rx 从 ~310µs 变 92.1µs(≈纯线路 81.3µs + ~10µs),与板子重启进 1.6.21/22 密排同时发生;1.6.22 稀疏写开关 A/B/A 无可测影响(92.2 / 92.2 / 93.0µs)。</li>
<li><b>但"345µs 是 win 布局的固有成本、密排省出 220µs"这条归因已被动摇</b>:9/8 的存量扫描在同样 win 布局下实测单拍 <b>124.6µs</b>(0.125ms 档 7780Hz 的硬约束),与 dense 相同——345µs 是 9/10 夜才出现的异常态,不是 win 的常态。详见下方"重大更正"卡。</li>
<li><b>仍然成立的独立事实</b>:刷写 ≠ 生效(镜像 01:44 刷入、约 10 小时后断电重启才生效,三方计数器锚点);台账只能证明"写进去了"——<code>flash_gd32.py</code> 已加生效守卫。</li>
</ul></div>
<div class="card bad"><h3>重大更正(2026-09-12 复核):220µs 的归因被动摇,两个变量被混在一起</h3>
<p>用户质疑"5 个 STATUS 变 3 个,怎么会差 20 倍"——顺着查,发现两件原调查没做的事:</p>
<ul>
<li><b>机制核对(代码层)</b>:两版 buildInput 逐行对比,纯 CPU 差仅 <b>1~3µs</b>(旧:5×48B + 5×44B 无条件拷贝;新:27 槽选最新 + 影子比对 + 3×48B)。对 ESC 的 PRAM 写两版同为整块 600B(从站侧写 ESC 成本,与主站 rx 不是同一段时间),且 A/B/A 证明它对主站往返不可见——<b>"删掉覆盖窗 / 差分填充省出的 CPU"解释不了 229µs</b>。另:差分填充在本台工况下基本不命中(STATUS ~1.25 帧/拍/台,槽 seq 几乎每拍都变,重建路径照样走)。</li>
<li><b>反例数据(9/8 存量扫描)</b>:<code>lab/ecat_rate_sweep.csv</code>——win 布局、0.5ms 档 cyc_p50=<b>124.6µs</b>、达成 100%;1ms=126.9µs;0.125ms=119.5µs 且实测 7780Hz(单拍必须 ≤128µs 才可能)。与 dense 的 124µs 同水平。</li>
</ul>
<p><b>混淆的变量</b>(刷板台账 git 史 + 主站 git 史):9/8 板上镜像 <code>201832b5…</code>(win)→ 9/10 前已刷 <code>4e84a354…</code>(win 期 1.6.15→1.6.20 演进);主站 9/9 03:05 提交 <code>97fab23</code>(三布局自适应、发现窗基址 {0,7,14,20}→{0,5,10,15,20,22}、覆盖窗按 seq 归一)。<b>345µs 出现在这两个改动之后、9/8 之前</b>——原归因把回归记在 1.6.21 密排头上,但密排也可能只是"绕开"了回归(dense 下主站强制 n_bases=1,不轮转窗口)。</p>
<p><b>钉死实验(一次刷板,未执行)</b>:① 刷回 <code>959e2ae</code>(win 固件)+ <code>build_native/ecat_native.prev.exe</code>(9/6 构建,早于 97fab23;它不认识 dense 布局,必须配 win 板)→ 回 ~124µs 即主站回归实锤;② 同 win 板 + 现主站 → 345µs 复现即双向确认;③ 若 prev.exe 也 345µs ⇒ 回归在从站 win 期固件演进里,对 1.6.15→1.6.20 bisect;④ win 模式读 DWT 剖分计数器 0x2000 sub5/6/9/10(PDI_Isr 全程 / 输出映射 / 输入映射 / 其中写侧)——SDO 走独占路由会停环掉 OP,需专门开窗。</p></div>
<h2>一、一拍的生命周期:210µs 差在哪一环</h2>
<p>主站每一拍做的事:<b>组帧 → <code>pcap_sendpacket</code> 发出 → 等帧从线路绕回来(<code>pcap_next_ex</code>)→ 解码</b>。等回包(rx)的时长 = 线路传输时间 + <b>从站把这一拍的输入镜像备好的时间</b>——LAN9252 的 SM2 事件触发 PDI 中断,从站要在 ESC 把输入区搬上线路之前把 600B 输入镜像写好,主站就得等这段活干完。</p>
<div class="grid">
<div class="fig"><div class="cap">① 同一 0.5ms 档、同一静默判据下的一拍构成(µs)</div>
<div class="legend"><i style="background:#94a3b8"></i>主站自身(pre+post 合计 0.3µs)<i style="background:#2563eb"></i>发送(组帧 0.2 + pcap_sendpacket ≈28.4)<i style="background:#fca5a5"></i>线路传输 81.3µs(两布局相同)<i style="background:#dc2626"></i>从站每拍准备(PDI buildInput)</div>
<svg viewBox="0 0 620 212" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="一拍构成对比">
<line x1="150" y1="180" x2="600" y2="180" stroke="#9aa3af" stroke-width="1.2"/>
<line x1="150" y1="28" x2="150" y2="180" stroke="#9aa3af" stroke-width="1.2"/>
<line x1="278" y1="176" x2="278" y2="184" stroke="#9aa3af"/><text x="278" y="197" text-anchor="middle" font-size="10.5" fill="#6b7280">100</text>
<line x1="406" y1="176" x2="406" y2="184" stroke="#9aa3af"/><text x="406" y="197" text-anchor="middle" font-size="10.5" fill="#6b7280">200</text>
<line x1="534" y1="176" x2="534" y2="184" stroke="#9aa3af"/><text x="534" y="197" text-anchor="middle" font-size="10.5" fill="#6b7280">300</text>
<text x="150" y="197" text-anchor="middle" font-size="10.5" fill="#6b7280">0</text>
<text x="596" y="197" text-anchor="middle" font-size="10.5" fill="#6b7280">µs</text>
<text x="142" y="64" text-anchor="end" font-size="12" fill="#374151">1.6.15 滑窗</text>
<text x="170" y="40" text-anchor="middle" font-size="10.5" fill="#1e40af">发送 ≈28.6µs</text>
<rect x="150" y="48" width="2" height="24" fill="#94a3b8"/>
<rect x="152" y="48" width="36.6" height="24" fill="#2563eb"/>
<rect x="188.6" y="48" width="104.1" height="24" fill="#fca5a5"/>
<rect x="292.7" y="48" width="293.1" height="24" fill="#dc2626"/>
<rect x="585.8" y="48" width="2" height="24" fill="#94a3b8"/>
<text x="240" y="64" text-anchor="middle" font-size="10.5" fill="#7f1d1d" font-weight="600">线路 81.3</text>
<text x="439" y="64" text-anchor="middle" font-size="10.5" fill="#fff" font-weight="600">从站每拍准备 ≈229µs</text>
<text x="152" y="86" text-anchor="start" font-size="11" font-weight="600" fill="#b91c1c">rx 往返 ≈310µs = 线路 81.3 + 从站准备 ≈229 · 整拍 p50 345µs · 达成 93.9%</text>
<text x="142" y="134" text-anchor="end" font-size="12" fill="#374151">1.6.21 密排</text>
<text x="308" y="110" text-anchor="start" font-size="11.5" font-weight="600" fill="#047857">整拍 p50 124µs · 达成 99.9%</text>
<rect x="150" y="118" width="2" height="24" fill="#94a3b8"/>
<rect x="152" y="118" width="36.6" height="24" fill="#2563eb"/>
<rect x="188.6" y="118" width="104.1" height="24" fill="#fca5a5"/>
<rect x="292.7" y="118" width="12.8" height="24" fill="#dc2626"/>
<rect x="305.5" y="118" width="2" height="24" fill="#94a3b8"/>
<text x="240" y="134" text-anchor="middle" font-size="10.5" fill="#7f1d1d" font-weight="600">线路 81.3</text>
<text x="152" y="156" text-anchor="start" font-size="11" font-weight="600" fill="#047857">rx 92.1µs = 线路 81.3 + 从站准备 ≈10(差分填充命中,接近纯线时)</text>
<line x1="292.7" y1="30" x2="292.7" y2="172" stroke="#dc2626" stroke-width="1" stroke-dasharray="4,3" opacity="0.55"/>
<text x="298" y="36" font-size="10.5" fill="#b91c1c">虚线处线路段用完(28.6+81.3≈110µs),之后整段在等从站</text>
</svg>
<div class="sub">数字出处:整拍与 rx 取自同机同档静默窗实测(win 345 / 310;dense 124 / 92.2,1ms 档复测 92.1);发送 ≈28.6µs 为 0.5ms 档 A 臂 p50(1ms 档 dbg 拆段 26.6~31.3µs,内含组帧 0.2µs);线路 81.3µs = 1016B×8÷100Mbps;win 从站准备 ≈229 = 310−81.3,与固件自量 buildInput 357µs(更重负载下所测)同量级。三段的逐层组成见下表。</div>
</div>
</div>
<h3>三段逐层拆解(对应图中蓝 / 粉 / 红三个色块)</h3>
<table>
<tr><th>段</th><th>0.5ms 实测 p50</th><th>从外到内的组成</th><th>出处 / 尚未拆开的部分</th></tr>
<tr><td>发送(蓝)</td><td>28.6µs</td><td>组帧 ≈0.2µs(组 LRW + 填输出区)→ <code>pcap_sendpacket</code> ≈28.4µs:NPF 栈把 1016B 交给 I210 驱动并提交发送</td><td><code>/api/motor/dbg</code> 拆段(sd_us 列)。驱动/DMA 内部无仪表;B 臂曾见发送 p99 348.9µs 的偶发阻塞(单窗样本,未立论)</td></tr>
<tr><td>线路(粉)</td><td>81.3µs</td><td>帧 1016B = 过程数据 984B(输出 384 + 输入 600)+ 以太网头 14 + EtherType 2 + 数据报头 10 + WKC 2 + FCS 4;8128bit ÷ 100Mbps;再加前导 8B + 帧间隙 12B ≈ +1.6µs;从站 ESC 硬件转发 µs 级</td><td>由帧构成计算,两布局逐字节同长——结构上不可能解释 220µs 差</td></tr>
<tr><td>从站准备(红)· win</td><td>≈229µs</td><td>1.6.15 每拍在 PDI 里:① 按 win_base 轮转准备 27 槽表 ② 段4 覆盖窗 5×44B 拷贝 ③ MotorState 重读 + 逐槽 48B 拷贝 ④ 整块 600B 写入 ESC</td><td>实测差值 310−81.3。①②③ 纯 CPU 差仅 1~3µs;④ 是从站写 ESC(两布局同在、不影响主站往返)——229µs 载体未闭环;9/8 同布局 124.6µs 表明"345µs=win 固有"不成立</td></tr>
<tr><td>从站准备(红)· dense</td><td>≈10µs</td><td>差分填充命中:节点集合与各 seq 未变 → 整段复用影子,①②③全免;只剩 count 个槽的更新推送 + ESC 取数</td><td>rx 92.1 − 81.3。220µs 的差全部消失在这里</td></tr>
</table>
<p class="meta">口径备注:win 时代插桩拆段亦记录到 <code>pcap_sendpacket</code> 181.1µs、其后续中位 1 次 poll 即得帧(§四表)——该口径下发送调用内部吸收了部分等待,<b>发送/等回包两段的分界随仪表口径浮动</b>;但整拍总量(345)与"扣除线路后的从站侧总延迟"(≈229)不受此影响。另 1ms 档每拍 next_ex 轮询中位 79 次(轮询间隔 ~1.2µs),0.5ms 档为紧轮询。</p>
<p><b>原理(按 9/11 当天的观测口径):</b>SM 长度(600B)与线速两布局完全相同,所以"线路那一截"不变;rx 的差值实测全部落在"等从站"这一段。但这段差值的载体机制存在重大疑点——两版 buildInput 的纯 CPU 差仅 1~3µs,且 9/8 同布局实测 124.6µs 表明 345µs 并非 win 固有成本——见开头"重大更正"卡。图①对比的是 9/11 上午 win 异常态 vs 午后 dense 的实测,不代表两种布局的固有差距。</p>
<h2>二、改动原理 A:从站输入映射 1.6.15 滑窗 → 1.6.21 密排整轮</h2>
<p>两种布局的输入区都是 600B(SM 长度、SII 都不变),只靠输入区第 35 字节的魔数 <code>0xA5</code> 区分(<code>ecat_native.c:74-84</code>,固件 <code>ethercat_can_bridge.h:154</code> 同款注释):</p>
<div class="grid">
<div class="fig"><div class="cap">② 输入 PDO 600B 布局对比(按字节偏移)</div>
<svg viewBox="0 0 620 252" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="输入区布局对比">
<text x="142" y="68" text-anchor="end" font-size="12" fill="#374151">1.6.15 滑窗</text>
<rect x="150" y="52" width="22.4" height="26" fill="#cbd5e1"/>
<rect x="172.4" y="52" width="5.6" height="26" fill="#64748b"/>
<rect x="178" y="52" width="196" height="26" fill="#2563eb"/>
<rect x="374" y="52" width="28" height="26" fill="#7c3aed"/>
<rect x="402" y="52" width="156.8" height="26" fill="#dc2626"/>
<rect x="558.8" y="52" width="11.2" height="26" fill="#16a34a"/>
<text x="276" y="68" text-anchor="middle" font-size="10.5" fill="#fff" font-weight="600">段2 STATUS 5×56B(固定 5 槽,空槽占位)</text>
<text x="480" y="47" text-anchor="middle" font-size="10.5" fill="#b91c1c" font-weight="600">段4 覆盖窗 5×44B(每拍维护)</text>
<text x="480" y="90" text-anchor="middle" font-size="10" fill="#7f1d1d">fillRxWindow / RxWinSlot 整套</text>
<text x="161" y="47" text-anchor="middle" font-size="10" fill="#475569">桥计数</text>
<text x="142" y="178" text-anchor="end" font-size="12" fill="#374151">1.6.21 密排</text>
<rect x="150" y="162" width="22.4" height="26" fill="#cbd5e1"/>
<rect x="172.4" y="162" width="5.6" height="26" fill="#64748b"/>
<rect x="178" y="162" width="117.6" height="26" fill="#2563eb"/>
<rect x="295.6" y="162" width="235.2" height="26" fill="#eff6ff" stroke="#93c5fd" stroke-dasharray="4,3"/>
<rect x="530.8" y="162" width="28" height="26" fill="#7c3aed"/>
<rect x="558.8" y="162" width="11.2" height="26" fill="#16a34a"/>
<text x="236" y="178" text-anchor="middle" font-size="10.5" fill="#fff" font-weight="600">密排槽×3(56B/台)</text>
<text x="413" y="157" text-anchor="middle" font-size="10" fill="#1d4ed8">按 count 扩展,最多 9 台(空节点不占位)</text>
<text x="413" y="200" text-anchor="middle" font-size="10" fill="#1d4ed8">1.6.22 稀疏写:这段不再推入 ESC RAM</text>
<text x="413" y="178" text-anchor="middle" font-size="10" fill="#6b7280">count=3 时空闲</text>
<text x="161" y="157" text-anchor="middle" font-size="10" fill="#475569">桥计数</text>
<line x1="150" y1="216" x2="570" y2="216" stroke="#9aa3af" stroke-width="1.2"/>
<text x="150" y="233" text-anchor="middle" font-size="10" fill="#6b7280">0</text>
<text x="220" y="233" text-anchor="middle" font-size="10" fill="#6b7280">100</text>
<text x="290" y="233" text-anchor="middle" font-size="10" fill="#6b7280">200</text>
<text x="360" y="233" text-anchor="middle" font-size="10" fill="#6b7280">300</text>
<text x="430" y="233" text-anchor="middle" font-size="10" fill="#6b7280">400</text>
<text x="500" y="233" text-anchor="middle" font-size="10" fill="#6b7280">500</text>
<text x="570" y="233" text-anchor="middle" font-size="10" fill="#6b7280">600(字节)</text>
<text x="150" y="248" font-size="10" fill="#6b7280">头区 32..39:win= active/boot/win_base/win_count/gseq@36 · 密排= count/active/boot/magic@35/gseq@36 —— 两种布局 gseq 同在偏移 36</text>
</svg>
<div class="sub">段3 长帧(CONFIG/版本应答):win 在 320..359,密排前移到 544..583;段5 诊断两者都在 584..599。主站 <code>decode_input</code> 每拍判 magic@35 自适应,两端可任意先后升级。</div>
</div>
</div>
<h3>1.6.21 的三个子改动,各砍掉什么活</h3>
<div class="card"><h3>① 密排整轮:固定 5 槽 + win_base 轮转扫 27 槽 → count 个密排槽</h3>
<p>旧方案段 2 固定 5 个 STATUS 槽(空槽 node_id=0 也占位),主站按 <code>win_base</code> 轮转窗口才能盖到 9 节点×3 通道=27 槽的表——接 3 台电机却会看到 5 个状态帧。新方案一拍直接带齐总线上全部电机(本台 3 台=3 槽×56B),槽内按 node_id 升序,主站按 <code>count</code> 迭代。砍掉的是<b>主站轮转窗口带来的多拍刷新分频</b>与从站"为凑满 5 槽而做的填充"。</p></div>
<div class="card"><h3>② 删段 4 覆盖窗口(fillRxWindow / RxWinSlot 整套)</h3>
<p>旧方案每拍要把非 STATUS 帧抄进 5×44B 的历史覆盖窗反复重发——这是纯粹的每拍 CPU 拷贝活。新方案非 STATUS 帧只走段 1/3 的"最近一帧",覆盖窗整套删除(图中 win 红段在密排侧不复存在)。</p></div>
<div class="card"><h3>③ 差分填充:节点集合与各 seq 未变时整段复用影子</h3>
<p>注意一个容易误解的点:电机"不动"≠数据不变——STATUS 帧按电机自己的节奏持续上报(本台 3 台合计 ~3750 帧/s,每台 ~1.25 帧/拍),所以大多数拍里槽 seq 都变了、重建路径照样走(实测槽 seq 每 ~1 拍刷新一次)。差分填充真正的收益是"没变的槽不重读不重拷、重建范围从无条件 5 槽缩到实际变化的槽"——量级是 µs 级的,<b>不能用它解释 rx 310→92</b>(见开头"重大更正"卡)。</p></div>
<h2>三、改动原理 B:1.6.22 稀疏写为何对主站往返"无效"</h2>
<p>1.6.22 的思路:PDI 往 ESC RAM 写输入镜像时按 count 只写「段1+段2 有效区」与「段3+段5」,跳过空槽区,省 <code>HW_EscWriteIsr</code> 的 PRAM 字节。<b>方向是对的,但省错了地方</b>——这里有一个关键的不对称:</p>
<div class="card warn"><h3>不对称原理:差分填充有效、稀疏写无效</h3>
<ul>
<li>主站等的关键路径 = <b>SM 交换</b>(ESC 引擎把 600B 输入区搬上线路)+ 交换前从站把镜像<b>备好</b>。</li>
<li><b>差分填充砍的是"备好镜像"的 CPU 活</b>(MotorState 重读、逐槽拷贝、覆盖窗维护)→ 直接缩短关键路径 ⇒ rx 310→92µs。</li>
<li><b>稀疏写砍的是"推镜像进 ESC RAM"的字节数</b>,但 ESC 引擎上线搬运量由 <b>SM 长度(600B)</b>决定,不看 PDI 实际写了多少 → 主站等待不变。</li>
</ul></div>
<table>
<tr><th>A/B/A 轮次(只改这一个开关)</th><th>板上版本</th><th>0.5ms 达成</th><th>长拍%</th><th>发送 p50/p99 µs</th><th>等回包 rx p50/p99 µs</th><th>load_perm</th></tr>
<tr class="best"><td>A:稀疏写=1(出厂)</td><td>1.6.22.0</td><td>99.90%</td><td>0.10</td><td>28.6 / 45.9</td><td>92.2 / 190.9</td><td>489</td></tr>
<tr><td>B:整包写=0(A/B 基线)</td><td>1.6.22.1</td><td>99.42%</td><td>0.83</td><td>29.8 / 348.9</td><td>92.2 / 210.4</td><td>441</td></tr>
<tr class="best"><td>A 回归</td><td>1.6.22.0</td><td>99.85%</td><td>0.10</td><td>29.4 / 86.9</td><td>93.0 / 101.2</td><td>—</td></tr>
</table>
<p class="meta">判读:rx p50 三轮 92.2/92.2/93.0µs ⇒ 稀疏写对主站往返无可测影响;<code>load_perm</code> 反而在整包写侧更低(441 vs 489)⇒ 它不能当这项的验收口径(真正该看 ESC 对象 0x2000 sub10,需独占路由停循环才能读,未做)。B 臂发送 p99 348.9µs 与 A 臂不同量级,但单窗样本不足以立论。当前 <code>config.h</code> 已回置出厂 A 臂(<code>CONFIG_ECAT_SPARSE_ESC_WRITE=1</code>)。</p>
<h2>四、配套改动:把"看不见的东西"变成仪表</h2>
<p>排查过程暴露了四个观测缺口,对应的改动与各自原理:</p>
<table>
<tr><th>改动</th><th>解决什么问题</th><th>原理</th><th>验证</th></tr>
<tr><td><code>flash_gd32.py</code> 生效守卫</td><td>台账只能证明"写进去了",不能证明"板上在跑"(本次 10 小时空窗的根因)</td><td>刷前记 <code>ota.gd32_app</code> 版本 + <code>gd32_adc.uptime_sec</code>;刷完必须"版本变了<b>或</b> uptime 归零"才写台账,否则非零退出不写</td><td>两轮真实刷写 1.6.22.0→.1→.0 通过</td></tr>
<tr><td><code>ecat_native.c</code> <code>cyc_us.late/lost</code> 接真值</td><td>两字段旧为硬编码 0,超期/跳拍完全不可见</td><td>最近 STATS_N 拍窗口内统计:<code>late</code>=起跑即超期(k&gt;0)的拍数,<code>lost</code>=这些拍跳过的周期数(累计仍在 skip_total)</td><td>重载窗 late=21/lost=28,健康窗 0/0</td></tr>
<tr><td><code>/api/motor/dbg</code> 尾部追加两列</td><td>发送段是黑盒:不知 sendpacket 花多久、轮询了几次</td><td>第 7/8 列 = 本拍 <code>pcap_sendpacket</code> 净耗时与 <code>pcap_next_ex</code> 次数;旧列序不变,按前 6 列下标读的消费方不受影响</td><td>0.5ms 档 181µs 调用、每拍中位 1 次 poll</td></tr>
<tr><td><code>soem-src/oshw/win32/nicdrv.c</code> pcap 调用计数</td><td>主/从栈调用混在一起没法归因</td><td>只在 <code>stacknumber==0</code>(主栈)累加 QPC 计数,冗余栈不计防双计</td><td>A/B 与旧构建行为一致(中性)</td></tr>
<tr><td><code>watch_beat_tail.py</code>(新)</td><td>跨分钟现象无法回溯(布局翻转恰好发生在两次采样之间)</td><td>每 10s 只读一轮 <code>motor/state</code>+<code>rxraw</code>+<code>app/load</code>,落 27 列 CSV(layout/work/send/rx/late/lost/λ/达成)</td><td><code>--once</code> 跑通</td></tr>
<tr><td><code>transport-ecat.js</code> 常量适配</td><td>前端仍按旧 5 槽/6 周期口径显示</td><td>密排下一拍整轮:<code>MOT_SLOTS_PER_PDO</code> 5→9、<code>MOTOR_SCAN_CYCLES</code> 6→1;能否拿齐电机由后端 decode 决定,常量只影响展示口径</td><td>电机页原样复用,无回归</td></tr>
<tr><td><code>lab/spi_test.html</code> 跳拍卡</td><td>旧版需开逐拍环才有超期数据</td><td>直接读 <code>cyc_us.late/lost</code>(最近 400 拍窗),并注明旧后端恒报 0 不可当"无超期"</td><td>真机复跑成功,<code>node --check</code> 通过</td></tr>
</table>
<h2>五、证据:dense 是真的,以及为什么今天才生效</h2>
<div class="grid">
<div class="fig"><div class="cap">③ 判据:槽 seq 距当前 gseq 的滞后分布(111 次连续采样 ×3 槽=333 样本)</div>
<svg viewBox="0 0 560 232" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="槽滞后分布">
<rect x="376" y="20" width="160" height="180" fill="#dc2626" opacity="0.06"/>
<rect x="376" y="20" width="160" height="180" fill="none" stroke="#dc2626" stroke-dasharray="4,3" opacity="0.5"/>
<text x="456" y="52" text-anchor="middle" font-size="10.5" fill="#b91c1c" font-weight="600">滑窗方案预期落点</text>
<text x="456" y="68" text-anchor="middle" font-size="10" fill="#7f1d1d">每槽每 ≥2 拍才轮到一次</text>
<text x="456" y="82" text-anchor="middle" font-size="10" fill="#7f1d1d">滞后应 ≈4~8 帧 —— 实测 0 个样本</text>
<rect x="73" y="24.5" width="34" height="170.5" fill="#2563eb"/>
<rect x="125" y="63.3" width="34" height="131.7" fill="#2563eb"/>
<rect x="177" y="75.7" width="34" height="119.3" fill="#2563eb"/>
<rect x="229" y="102" width="34" height="93" fill="#2563eb"/>
<rect x="281" y="193.5" width="34" height="1.5" fill="#2563eb"/>
<text x="90" y="20" text-anchor="middle" font-size="10" fill="#1e40af">110</text>
<text x="142" y="58" text-anchor="middle" font-size="10" fill="#1e40af">85</text>
<text x="194" y="71" text-anchor="middle" font-size="10" fill="#1e40af">77</text>
<text x="246" y="97" text-anchor="middle" font-size="10" fill="#1e40af">60</text>
<text x="298" y="189" text-anchor="middle" font-size="10" fill="#1e40af">1</text>
<line x1="60" y1="195" x2="536" y2="195" stroke="#9aa3af" stroke-width="1.2"/>
<text x="90" y="211" text-anchor="middle" font-size="10.5" fill="#6b7280">0</text>
<text x="142" y="211" text-anchor="middle" font-size="10.5" fill="#6b7280">1</text>
<text x="194" y="211" text-anchor="middle" font-size="10.5" fill="#6b7280">2</text>
<text x="246" y="211" text-anchor="middle" font-size="10.5" fill="#6b7280">3</text>
<text x="298" y="211" text-anchor="middle" font-size="10.5" fill="#6b7280">4</text>
<text x="456" y="211" text-anchor="middle" font-size="10.5" fill="#6b7280">≥6(帧)</text>
<text x="60" y="16" font-size="10.5" fill="#6b7280">样本数 · λ≈3.75 帧/拍(3750 帧/s @ 1kHz)</text>
<text x="280" y="228" text-anchor="middle" font-size="11" fill="#374151">槽 seq 滞后于当前 global_seq(帧数)</text>
</svg>
<div class="sub">三台电机落在槽 2/5/8:滑窗方案盖住它们需 ≥2 个基址轮转 → 每槽每 ≥2 拍才刷新、滞后应集中在 4~8 帧。实测 1/3 样本滞后为 0、全部 ≤4 帧 ⇒ 只能是"每拍带齐整轮"的密排语义。seq 与 gseq 都是 GD32 自己盖的号,滞后能对上同时证明解码偏移没错。(原报告曾把"gseq≡rx_count"当最强证据,复核发现两种布局 gseq 同在偏移 36,该条不能区分布局,已撤换为本判据。)</div>
</div>
<div class="fig"><div class="cap">④ 时间线:刷写 ≠ 生效</div>
<svg viewBox="0 0 620 150" width="100%" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="时间线">
<line x1="70" y1="70" x2="560" y2="70" stroke="#9aa3af" stroke-width="1.6"/>
<circle cx="100" cy="70" r="5" fill="#2563eb"/>
<circle cx="290" cy="70" r="5" fill="#dc2626"/>
<circle cx="430" cy="70" r="5" fill="#b45309"/>
<circle cx="530" cy="70" r="5" fill="#047857"/>
<text x="100" y="30" text-anchor="middle" font-size="11" fill="#374151">01:44 镜像刷入 1.6.22</text>
<text x="100" y="45" text-anchor="middle" font-size="10" fill="#6b7280">台账 sha=hex 展平 ✓</text>
<text x="290" y="30" text-anchor="middle" font-size="11" fill="#b91c1c">白天:仍在跑老代码</text>
<text x="290" y="45" text-anchor="middle" font-size="10" fill="#6b7280">主站实测 win 布局 · 232B · 整拍 345µs</text>
<text x="430" y="100" text-anchor="middle" font-size="11" fill="#b45309">中午断电重启</text>
<text x="530" y="100" text-anchor="middle" font-size="11" fill="#047857">生效:dense · 124µs</text>
<path d="M100,84 L100,96 L430,96 L430,84" fill="none" stroke="#b45309" stroke-width="1.4"/>
<text x="265" y="115" text-anchor="middle" font-size="11.5" font-weight="600" fill="#b45309">≈10 小时:写入 ≠ 生效</text>
<text x="70" y="140" font-size="10" fill="#6b7280">三方计数器锚点(不依赖系统时钟):主站进程存活 2084s · GD32 桥计数归零 1914s(gseq÷λ)· ESP32 上电 1922s —— 后两者差 8s,GD32 与 ESP32 同次上电;板子比主站晚 ~2.8 分钟重启,且 uptime 单调增(只重启过一次)</text>
</svg>
<div class="sub">主站在 02:3x 直接读到 <code>layout="win" len=232</code> 配 345µs——刷入后 10 小时仍是老代码在跑。台账 <code>_last_gd32_sha.txt</code> 当时就与 Release hex 展平一致,即"写进去了"为真,"在跑了"为假。(A/B/A 之后台账现为 dda92d11…,对应 12:46 刷回 A 臂的产物,本次复核独立展平重算一致。)</div>
</div>
</div>
<h3>同机同日前后对照(观察性证据)</h3>
<table>
<tr><th>从站布局</th><th>rx 往返</th><th>整拍 p50</th><th>达成</th><th>长拍&gt;1.4×</th><th>跳拍 k&gt;0</th><th>gap p99</th></tr>
<tr><td><code>win</code>(1.6.15 滑窗)</td><td>~310µs</td><td>345µs</td><td>93.9%(1879Hz)</td><td>7.9%</td><td>8.6%</td><td>1144µs</td></tr>
<tr class="best"><td><code>dense</code>(1.6.21/22 密排)</td><td>92.2µs</td><td>123µs</td><td>99.9%(1998Hz)</td><td>0.10%</td><td>~0.6%</td><td>=周期本身</td></tr>
</table>
<p class="meta">0.5ms 档余量从 155µs 变为 377µs:旧方案任何调度抖动都会成片跳拍,新方案长拍基本消失。另 1ms 档现网 <code>late/lost</code>(k&gt;0 口径,连 1µs 超期都算)为个位数/400 拍窗,与"长拍&gt;1.4×≈0%"不矛盾——两套阈值灵敏度不同,并列引用时必须标注口径。</p>
<h2>六、被实测排除的原因(别再重测)</h2>
<table>
<tr><th>候选解释</th><th>排除依据</th></tr>
<tr><td>线路变快 / 帧变小</td><td>两种布局 Ibytes 同为 600B(<code>ecat_native.c:74</code> 与固件头文件双侧注释),帧长 ~1016B 不变,线速仍 100Mbps 全双工</td></tr>
<tr><td>收帧超时(EC_TIMEOUTRET)</td><td>预算 2000µs(µs 单位),11,264 拍里仅 1 拍 EC_NOFRAME;win 时代 rx p50 7~9µs 起步,预算从不消耗——收紧它是自证式"变好"</td></tr>
<tr><td>主站构建差异 / 双实例</td><td>插桩版与旧构建 A/B 一致;单进程单端口;旧 exe 留档 <code>build_native/ecat_native.pre-instr.exe</code></td></tr>
<tr><td>观察者负载</td><td>面板全量 10Hz×3、单 dbg 10Hz(≈1.5MB/s JSON)、零请求静默窗,三者结果在噪声内相同</td></tr>
<tr><td>CPU 抢占</td><td>另开 4 / 12 个纯算线程压机,send/rx/work 一动不动</td></tr>
<tr><td>"PRAM 逐 DWORD 轮数 150→45"</td><td>被 A/B/A 否:整包写 600B 的 B 臂同样 92µs——写侧省字节数不进入主站关键路径(§三不对称原理)</td></tr>
</table>
<h2>七、复核更正(本次排查自己错过又改掉的判断)</h2>
<div class="card bad"><h3>证据层的两处撤换</h3>
<ul>
<li><b>"gseq≡rx_count,差恒 0"是同义反复</b>:<code>MOTW_OFF_GLOBAL_SEQ</code> 与 <code>MOTW_OFF_GSEQ_DENSE</code> 都是偏移 36(<code>ecat_native.c:69,82</code>),两种布局读同一字节,分不开 dense 与 win。已撤换为 §五-③ 的槽滞后判据。</li>
<li><b>魔数 0xA5 无法从主机侧直接读</b>:<code>/api/motor/rxraw</code> 返回的是批量接收区缓冲快照,不是 PDO 输入镜像;路由表中没有任何路由吐输入镜像。故"直接验魔数"这条路不存在,dense 判定只能靠上述间接判据 + 60s×30 与 111 次采样稳定性(layout 恒 dense、len 恒 176、count 恒 3、win_bases 恒 None)。</li>
</ul></div>
<div class="card bad"><h3>限制声明的一处更正(本次复核新增)</h3>
<ul>
<li><b>"DCU_APP 不在任何 git 仓库、布局无法 A/B"不成立</b>:<code>DCU_APP</code> 是独立 git 仓库(38 个提交)——<code>959e2ae</code> = 1.6.14~1.6.20 滑窗覆盖窗源码、<code>1742d9a</code> = 1.6.21 密排;命令行构建链(cbuild + AC6)现成。因此"win→dense 换来 220µs"这一环<b>可以做受控实验钉死</b>:checkout <code>959e2ae</code> 重编刷板 → 预期 rx 回到 ~310µs → 再刷回 1.6.22(flash_gd32.py 生效守卫会留台账)。属刷板操作,未执行。</li>
<li><b>遗留:重刷同一版本无法证明生效</b>——<code>gd32_adc.uptime_sec</code> 实测不随 GD32 换镜像归零,守卫的"uptime 归零"分支不可靠,目前只能靠版本号变化。根治要 BOOT 上报复位计数/复位原因,或 OTA 完成后强制 APP 复位回读。</li>
</ul></div>
<div class="card warn"><h3>归因的置信度边界(2026-09-12 修订)</h3>
<ul>
<li>已受控验证:稀疏写开关(A/B/A,单臂 2048 拍静默窗,效应上界 ≲1µs/拍);排除项(§六);"刷写≠生效"时间锚点(§五)。</li>
<li><b>已动摇</b>:win→dense 与 220µs 的因果归因——9/8 同布局实测 124.6µs 的反例 + 两版 buildInput 纯 CPU 差仅 1~3µs。原"三条独立吻合"里"旧值≈357µs"一条存疑:357µs 是 1.6.11 时代(496B 输入 + 环形批量区)的测量,与滑窗 600B 版不同代。</li>
<li>待做:开头"重大更正"卡的刷板 A/B(959e2ae + prev.exe 双轮)是当前唯一能闭合因果的实验。</li>
</ul></div>
<h2>八、遗留与下一步</h2>
<div class="card"><h3>按优先级</h3>
<ul>
<li><b>布局 A/B 钉死归因(方案已更新)</b>:刷回 <code>959e2ae</code>(win 固件)后做两轮——配 <code>ecat_native.prev.exe</code>(旧主站,≤78fe6cf)与现主站各测一轮 0.5ms:124µs / 345µs 的分野即判定回归在主站还是从站 win 期固件;再用 0x2000 sub5/6/9/10 定位。刷板会中断 OP 会话,需专门安排。</li>
<li><b>0.25ms 档第一次有理论余量</b>(124/250=50%),值得按同一静默判据实测——但注意此前结论:从站 SSC 的 SM2 事件路径在快档有自己的墙,别只看主站达成率。</li>
<li><b>重刷同版本的生效证明</b>:BOOT 上报复位计数,或 OTA 后强制 APP 复位回读版本。</li>
<li><code>0x2000 sub10</code>(EscWrite 周期)精确验收需独占路由(停循环、从站掉 OP),要专门开一窗。</li>
<li>ms 级离群未清零:dense 时代仍见 work max 2153µs / rx max 1658µs(p90 仅 ~110µs),量级无害但没定位。</li>
<li>本次全部改动<b>未提交</b>(tools/ecat 3 文件+报告、tools/ESP32 7 文件、DCU_APP 4 文件),旧主站 exe 留档在 <code>build_native/ecat_native.pre-instr.exe</code>。</li>
</ul></div>
<div class="foot">本报告依据 <code>tools/ecat/REPORT_ecat_beat_tail_20260911.md</code>、当日调查会话与复核证据整理;所有代码引证(偏移定义、357µs、路由表、台账 sha)已于 2026-09-11 对照源码与实机独立核验,实时复测 rx p50=92.1µs / layout=dense / wkc 3/3 / v1.6.22.0。相关页面:<a href="ECAT_RATE_TRADEOFF.html">ECAT_RATE_TRADEOFF.html</a>(周期×故障率×CPU 调研)· <code>lab/spi_test.html</code>(SPI/ECAT 测速)。</div>
</main></body></html>
File diff suppressed because one or more lines are too long
+291
View File
@@ -0,0 +1,291 @@
# EtherCAT 周期速度 × 故障率 × CPU 占用 调研
> **⚠ 本 Markdown 版含一条已作废的结论,权威版本是 `ECAT_RATE_TRADEOFF.html`。**
> 作废内容:第一节「`drop` ≡ λ → 丢帧率 100%、与周期无关」。**错因**:`drop`、
> `bridge.rx_count`、`head.global_seq` **各自都以约 4000/s 增长**,两两相除必然得到 100%,
> 那只是同速量之比、不是丢帧。**反证**(后续实测):两台电机 STATUS 以 4001 帧/s 正常刷新,
> 帧确实收到了;而 ECAT 批量窗口去重后的 CONFIG 事件流只有约 92 帧/s,与 `drop` 的 4000/s
> 完全不成比例。`drop` 的口径至今未查明,**不得用它做任何丢帧判断**。
> 仍然成立:速度上限、从站 CPU 负担、拔 CAN 对照、>2kHz 时 STATUS/CONFIG 一同塌陷。
调研日期:2026-09-08 · 台子:本机 I210 + 1 个 GD32E508 从站(双电机在线)
数据与脚本:`scripts/sweep_ecat_rate.py`(扫描)、`scripts/calibrate_ecat_metrics.py`(现场标定)、
`scripts/plot_ecat_sweep.py`(出图)、`scripts/plot_ecat_nocan_vs_can.py`(带/无 CAN 对比);
曲线 `lab/ecat_rate_sweep.png`、`lab/ecat_rate_sweep_nocan_vs.png`;原始数据
`lab/ecat_rate_sweep_withcan.csv`、`lab/ecat_rate_sweep_nocan.csv`、`lab/ecat_calib.json`
(`*.csv`/`*.jsonl` 按 `.gitignore` 约定不入库)。
本轮共两轮扫描:**带 CAN**(第四~五节主结论)与**拔掉 CAN 的补测**(第四 b 节)。
---
## 一、结论
**速度余量约 2×,且瓶颈在从站 GD32,不在主站。** 实测可用到 **0.5ms(2kHz)**;
0.25ms 起电机数据刷新率从 4000 帧/s 掉到 88 帧/s(带 CAN 那轮 8s/档定点实测,当场确认
`online=True`,故为真失效)。上限的**具体位置没测到** —— 只夹在 (0.25ms, 0.5ms] 之间。
**「加快 ECAT 周期能减少 CAN 丢帧」这个前提被本次数据推翻。** 在 4ms→0.5ms 整个区间,
桥接层丢帧 `bridge.drop` 与 CAN 到帧率 λ **完全相等**(丢帧率恒 100.0%),与周期无关。
该字段属 GD32 段1 桥接层的接收计数,其消费者是 GD32 主循环而非 ECAT PDO 填充路径,
所以 ECAT 周期快慢改不了它(字段的精确含义尚有一处未闭合矛盾,见第五节)。
**提速的实际收益与代价**:
| | 4ms | 2ms | 1ms(现行) | **0.5ms** | 0.25ms | 0.125ms |
|---|---|---|---|---|---|---|
| 实测循环率 | 249Hz | 499Hz | 998Hz | **1999Hz** | 3989Hz | 7780Hz |
| 达成率 | 99.7% | 99.7% | 99.8% | **100.0%** | 99.7% | 97.2% |
| GD32 负载 | 20.2% | 28.3% | 44.5% | **76.7%** | 99.7% | 99.3% |
| 主站错拍 skip/s | 0.02 | 0.08 | 1.1 | **11.9** | 98.6 | 5106.6 |
| 电机数据刷新 /s ¹ | 4000† | 4000† | 4000.2 | **4000.2** | 87.9 | 147.5 |
| CAN 到帧 λ /s | 3987 | 3990 | 3992 | **3995** | 159 | 190 |
| 桥接层丢帧 drop /s | 3987 | 3990 | 3992 | **3995** | 159 | 190 |
| 丢帧率 | 100.0% | 100.0% | 100.0% | **99.99%** | 100.0% | 100.0% |
| 主机 native CPU | 100.3% | 100.3% | 100.3% | **100.3%** | 100.3% | 87.5% |
¹ 电机刷新率只在 **1 / 0.5 / 0.25 / 0.125ms** 四档定点实测(8s/档)。标 † 的 4ms、2ms 两列
是按"λ 不随周期变化"推的,**未实测**。
建议:**要吃到这份余量就停在 0.5ms**(功能与 1ms 完全同速、达成率满分、CPU 76.7% 仍有余量,
代价是错拍从 1.1/s 升到 11.9/s)。0.25ms 不是"更快",是**功能损坏**。
**补测(拔掉全部 CAN)后新增两点**:
- CAN 的每拍负担是**常量 ≈9 个百分点**(2/1/0.5ms 三档 Δ=9.3/9.1/8.7pp),不随频率张开
→ 带 CAN 那轮的快档塌陷不是 CAN 挤出来的,上限由纯 ECAT 的过程数据交换决定。
- 纯 ECAT 到 2kHz 只需 68.0%,模型外推上限约 **3.5kHz**(带 CAN 约 2.7kHz)。
但 **0.25ms 那档没测到数**:加压时从站停止应答(`wkc=-1`、`load_perm` 成残值),
所以"3.5kHz"目前是外推而非实测,**不要据此设定期望值**。详见第四 b 节。
---
## 二、工况与数据身份(先声明,因为对账不上)
| 项 | 值 | 能否确认 |
|---|---|---|
| GD32 烧写台账 | `lab/_last_gd32_sha.txt` = `201832b5a8e258d6…`(未提交状态) | 台账本身可读 |
| 在盘固件产物 | 展平后哈希 `63d6ab9b…`(09-08 02:31)、`e6a6e75f…`(09-05) | **均不等于台账** |
| 主站构建 | `ecat_native.c` 改于 02:39、`ecat_native.exe` 改于 02:40,均有未提交改动 | 运行中 PID 29284 起于 02:32 |
| 板载固件版本 | — | **无法确认**(设备不回报版本字) |
三点后果,均已在结论中按保守口径处理:
1. 台账哈希与任何在盘产物都不匹配,且产物时间戳(02:31)**晚于**台账记录 —— 无法证明板上跑的
就是当前源码编出的那一版。**本报告不复用任何历史固件结论**(含"1.6.5 已修 seqlock 撕裂")。
2. 测量期间主站构建又更新过(02:39/02:40),无法证明 02:32 启动的进程内存里就是盘上那份 exe。
3. `pdo_reentry` 字段要固件 1.6.7+ 才有、`rx_torn` 要 1.6.6+。两者本次恒零,**既可能是"没发生",
也可能是"该字段根本没实现、恒读 0"** —— 因版本未知,二者无法区分。
---
## 三、判据口径(含三个当场失效/需修正的测法)
先做 Phase 0 标定(`scripts/calibrate_ecat_metrics.py`)再扫描,因为固件改过很多版,
不能假设任何历史判据仍然有效。标定过程中我自己有三个判据当场失效或被我误判,记录如下以免重犯:
| 测法 | 结果 | 处置 |
|---|---|---|
| 拿「seq 值域 − 观测到的 distinct seq」当丢帧率 | 报出 **99.686%** 丢帧 | **方向与 `drop`≈λ 一致,不是假信号**;但分母是全局 seq 值域(含 STATUS 全流量),口径不干净 → 不单独引用,只作 `drop` 的旁证。曾据 `OVF=0` 判它"被证伪",该否定**收回**(见下节 OVF 说明) |
| 用 HTTP 轮询看 `rx_batch.count` 当"每拍几帧" | 读出 cap=2,与源码 3 帧/拍不符 | 轮询欠采样。每拍口径必须取 native 的按拍环 `/api/can/cadence`(实测 count 众数=2,与轮询一致,但与 1.6.15 的 5 槽上限不是一回事) |
| 用 `rxraw` 的环快照列 `rxb_count_mode_per_cycle` / `lost` / `pending` | 快档读出 5、4、2 | 该环只存最近 512 拍,0.25ms 档 = 128ms、0.125ms 档 = 64ms,环没装满 —— 读到的是**行数**不是帧数。**这几列不入结论** |
最终采用的口径:
- **速度达成**:`cycles`(u32)增量 / 墙钟,**cycles 与时刻同取同舍**(沿用 `tools/ecat/rate_probe.py` 的纪律,其注释记过一次因分子分母不同源把 99.2% 误报成 96% 的实踩)。
- **CAN 到帧 λ**:`head.global_seq`(u32,固件 `ECAT_MOTW_OFF_GLOBAL_SEQ`「状态表全局刷新序号」)。
与电机槽 `seq` 同速(实测均 4000/s)—— 两条独立字段互证,λ 口径可信。
- **桥接层丢帧**:`bridge.drop`,固件出处 `ECAT_CAN_RX_OFF_DROP = 24`,注释明确为
「**投递时上一帧还没被取走**」的丢帧数(u16)—— 即窗口被覆盖而未送达主站的帧,
语义正对本题。自增另有 `canfd.h:53 rx_drop_count`(CAN RX 软件队列满,`canfd.cpp:1615`
`queue.count >= kRxQueueDepth`,队列深 `kRxQueueDepth=16`);两者都指向"消费不及"。
u16 在 4000/s 下 16 秒绕一圈 → 10Hz 逐次累加 + `(b-a) mod 65536`,单次增量 >32768 记为
绕圈作废。**本次作废点数 = 0**,即采样率足够。
- **不能当"无丢帧"证据的字段**:`rx_batch.lost` / cadence 的 `lost`/`pending`。它们对应固件
`OVF`,而 `fillRxWindow` 里 `in[...] = s_rxWinOvf; s_rxWinOvf = 0;` —— **每拍清零取增量**,
实测恒 0 只说明单拍增量小于分辨率,不代表没有丢帧。曾据它判 `drop` 为死字段,此推断**收回**。
- **功能存活(真故障轴)**:电机槽 `seq`(u32)前进速率。这是唯一直接回答"用户还能不能用"的指标。
- **CPU(从站)**:`load_perm`(千分比,1s 滚动窗,`ECAT_DIAG_OFF_LOAD`)。**它不是整机 CPU 占用率**
—— 固件 `sys/cpu_load.h:9-27` 定义收窄为「中断 + 非延迟处理段」占墙钟之比,并**刻意排除**
SSC MainLoop 轮询与 `AppSpiCommandBus.poll()`;且当前是 free-run(`native/health.dc=0`),
注释写明"free-run 调试模式下 PDO 应用跑在主循环轮询路径,**不计入**"。
因此:① 不能把"load→100%"读成"CPU 耗尽";② 但它仍随循环率强增(20.2%→76.7%),
这份增量只能来自**被计入的部分**(PDI/EXTI 等挂钩 ISR 每拍触发一次),
所以作为"每拍中断负担"的代理量仍然成立 —— 第五节据此建模,并把结论限定在这个口径内。
- **CPU(主机)**:`psutil` 按 PID 差分,同时报单核% 与整机%(`scripts/cpu_sample.ps1` 只报前者,
28 核机器上会把 3.6% 误读成 100%)。
- **撕裂/重入**:`rx_torn` / `pdo_reentry` 为 u16 **饱和**计数(到 65535 停),按
`delta_saturate` 处理,封顶即报"下界"而非精确值。
---
## 四、曲线与解读
![ECAT 速度-故障率-CPU 实测曲线](lab/ecat_rate_sweep.png)
**① 主站循环率** 在 4ms→0.25ms 全程贴着 1:1(≥99.7%),0.125ms 才降到 97.2%。
主站单拍耗时 `cyc_us.total.p50` 各档恒约 120–134µs、与设定周期无关(自旋钉核所致),
所以**主站侧根本不是限制项**。附带修正一条历史记录:`rate_probe.py` 写着"真机一送一回
≈165µs 物理墙",本次实测 0.125ms 档跑到 7780Hz,那道墙在当前 5 槽布局下已经不是限制。
**② CPU 占用** 是唯一陡峭的分界线。GD32 负载 20.2 → 28.3 → 44.5 → **76.7 → 99.7**%,
在 0.5ms 与 0.25ms 之间越顶。主机 native 全程 100.3%(自旋线程恒占一个逻辑核,
`/api/motor/spin` 已是空操作,CPU 降不下来)—— 有意思的是 0.125ms 档反而**降到 87.5%**,
因为从站已经不回数据、总线事务提前完成:**主机 CPU 掉下来的那一刻,恰恰是从站死掉的那一刻**。
这条曲线说明"提速免费"的判断只在一半意义上成立:不花主机成本,但花从站成本,而从站才是预算。
**③ 故障率** 是本次的核心图。λ 与 `drop` 两条线**重合到肉眼分不开**,横跨 4ms→0.5ms 全程,
然后一起从 4000/s 断崖式塌到 ~150/s。读法:
- 重合 = 到多少丢多少,**丢帧率 100% 且与周期无关** → 提速不改善它;
- 一起塌 = 0.25ms 起连 CAN 收帧本身都停了 → 提速会**打断**它。
电机数据刷新率(红)与 λ 同步塌陷,确认这不是测量假象而是功能失效。
**④ 主站错拍 `skip_total`** 是次级上限:0.02 → 11.9(0.5ms)→ 98.6(0.25ms)→ 5107/s(0.125ms)。
它在 GD32 越顶之前就已经指数上升,若只看主站视角,0.5ms 的 11.9 次/s 是可接受代价,
但 0.25ms 的 98.6 次/s 已说明主站也在勉强。
---
## 四b、补测:拔掉全部 CAN 设备后的纯 EtherCAT 速度
**动机**:带 CAN 的那轮里,快档同时出现「λ 塌陷」与「电机掉线」两种征象,二者在 λ/seq 上
长得一模一样,无法区分是**每拍过程数据交换太重**还是**CAN 流量挤掉了主循环**。
拔 CAN 是解这个混淆的判别性实验。
**结果**(同脚本同参数,`lab/ecat_rate_sweep_nocan.csv`,对比图 `lab/ecat_rate_sweep_nocan_vs.png`):
| 周期 | 循环率 | 带 CAN | 无 CAN | Δ |
|---|---|---|---|---|
| 2ms | 500Hz | 28.3% | **19.0%** | 9.3pp |
| 1ms | 1000Hz | 44.5% | **35.4%** | 9.1pp |
| 0.5ms | 2000Hz | 76.7% | **68.0%** | 8.7pp |
| 0.25ms | 4000Hz | 99.7% | **不可引用** | — |
**三条读数**:
1. **CAN 的负担是常量、不是变量**:三档 Δ 稳定在 8.7–9.3pp。所以 CAN 既不是上限的成因,
也不随频率张开 —— 带 CAN 那轮的快档塌陷**不是 CAN 挤出来的**。
2. **纯 ECAT 到 2kHz 毫无问题**(68.0%,达成率 100.0%,错拍 17.1 次/s)。
3. **上限仍没测到**:加压到 0.25ms 时**从站停止用有效数据应答** ——
`wkc=-1`(cadence 环里 512/512 拍全为 65535)、`bridge.valid=false`、`head.active=0`、
`app/load` 的 `age_ms=214338`,而主站 `cycles` 仍以 1004Hz 前进、`op=1`、`al_status=0`。
即总线在空转、从站不应答。**0.25ms 档的 99.2% 是从站停报后的 load_perm 残值,
本报告不引用。**
**用无 CAN 数据重拟合第五节的两项模型**:F=(0.680−0.190)/(2.0−0.25)≈**272µs**、
R≈4.0%,硬上限周期 F/(1−R)≈**284µs(3.5kHz)**。
对比带 CAN 的 F≈323µs → 2.7kHz。**拔 CAN 大约把上限从 2.7kHz 抬到 3.5kHz,
但 0.25ms=4kHz 按此模型仍不可行** —— 与实测"0.25ms 出事"方向一致,只是这次
出事的表征是从站不应答而非负载封顶,机理未定。
**扫描工具的两个缺陷由这轮暴露并已修**:
- 原脚本会把从站停报后的 `load_perm` 残值当实测写入结论 → 已加 `age_ms<1000` 闸,
并新增 `gd32_load_samples` / `gd32_load_stale` 两列,使"这个数有没有被测到"可见。
- 原脚本未记电机在线标志 → 已加 `motors_online` / `online_frac` / `motor_refresh_per_s`。
**台子现状**:从站仍停在 `wkc=-1`,恢复到 1ms 也不能自愈(native 的楔死看门狗未触发,`wr=0`)。
继续测快档前需要先断电重启板子。
---
## 五、机理:为什么是 2 倍而不是更多
从站每拍要写满输入 PDO。1.6.15 布局下输入 600B,`fillRxWindow` 在
PDI_Isr/Sync0_Isr(preempt=0)里跑,**每拍都要跑一次**,与 CAN 是否有新帧无关。
先否定一个想当然的模型:把「负载 × 周期」当作每拍固定开销,反推得到
808 / 566 / 445 / 384µs(4ms→0.5ms),**单调下降**——说明它量的是每拍总工作量,
里面还含一项与周期成正比的后台摊薄,不是常数。改成两项模型:
```
load(T) = F / T + R F = 每拍固定开销,R = 与周期无关的本底
```
用两端点(4ms/20.2% 与 0.5ms/76.7%)辨识:
```
F = (0.767 − 0.202) / (1/0.5 − 1/4) = 0.565 / 1.750 ≈ 323µs
R = 0.202 − 323/4000 ≈ 11.7%
```
用**未参与拟合**的中间两点检验:
| T | 模型 F/T+R | 实测 | 残差 |
|---|---|---|---|
| 2ms | 17.8% | 28.3% | +10.5 |
| 1ms | 44.0% | 44.5% | +0.5 |
| 0.25ms | **140.9%** | 99.7%(封顶) | 算术上 >100% → 不可行 |
| 0.125ms | **269.9%** | 99.3%(封顶) | 同上 |
模型在 1ms 处几乎精确、2ms 处偏低(低速档还有别的开销项),但关键预测成立:
**存在硬上限周期 F/(1−R) ≈ 367µs ≈ 2.7kHz**,实测分界正好落在它两侧
(0.5ms=2kHz 可跑、0.25ms=4kHz 不可跑)。这条是可证伪的:若下一次扫描在 0.4ms
也能满速运行且负载 <100%,本模型即被推翻。
用第四 b 节的**无 CAN** 数据独立重拟合,得 F≈272µs、R≈4.0%、上限 ≈284µs(3.5kHz)——
同一模型形式、两组独立数据、F 相差 16%(正是 CAN 那份负担),说明 F/T+R 这个结构站得住。
但**两组都没能直接测到上限本身**:带 CAN 时在 0.25–0.5ms 之间靠"功能塌陷"间接定位,
无 CAN 时 0.25ms 直接从站停报。所以上限目前是被**夹在** (0.25ms, 0.5ms] 区间内,
不是被测出来的。
结论:从站侧的极限周期由**每拍固定开销 F**决定,要突破只能减小每拍要搬的字节数
(当前 600B / 5 槽 / 56B 电机槽),**不是调主站周期能解决的**。主机侧完全有余量
(native 恒约 100% 单核,与周期无关),所以预算全在从站。
丢帧是另一条独立链路,与 ECAT 周期无关:`drop` 字段位于**段1(CAN RX 桥,偏移 24)**,
属 GD32 桥接层自己的接收/投递计数,其消费者是主循环(`sys/task_runner.cpp:248`)而非
ECAT PDO 填充路径 —— 所以 ECAT 周期快慢改不了它,实测四个档位恒等于 λ 也印证了这点。
但这里有一处**尚未闭合的矛盾**,不能糊过去:固件 `config.h:125` 的 1.6.15 设计说明写着
「STATUS 自 1.6.9 起不入窗,滤掉后入窗流量降到 CONFIG 量级」「5 槽=5000 帧/s 对 4000 帧/s
留 25% 余量」—— 按此设计意图,丢帧应当接近 0。实测却是 `drop` 以 3992/s 持续增长、
且恰好等于 λ(含 STATUS 的全流量)。两种解释都还活着:
(a) 该计数统计的是入窗**之前**的 CAN RX 队列/桥接层丢弃,STATUS 在入队环节就被丢,与窗口
容量无关(则 `drop`≈λ 完全合理,设计意图也没被违背);
(b) 字段在当前固件里已改义或读错偏移,`drop`≈λ 是巧合。
本次数据不足以区分二者,**列为待验证第 1 项**。无论哪种,都不影响主结论:
`drop` 与 ECAT 周期无关 → 提速不改善它。
---
## 六、局限
1. 单台单次,未做重复性与温漂验证。
2. λ≈3993/s 是本次台子的 CAN 工况。λ 变则分界变,本报告给的是**相对关系**,
0.5ms 这个具体数字不可直接搬到别的 CAN 流量下。
3. 板载固件版本无法确认(见第二节),历史固件结论一律未采信;`rx_torn` / `pdo_reentry`
恒零属**无检出功效**,只能表述为"本次测不到",**不得**读成"无撕裂/已修好"。
4. `cyc_us.late` / `cyc_us.lost` 恒为 0(`ecat_native.c` 里是 Python 时代遗留占位),未接线,未使用。
5. 扫描用 HTTP 以 10Hz 打 native,对被测对象有扰动;但主机 CPU 已恒 100% 单核,
且 native 另有 HTTP 线程,扰动量级判断为小 —— **未单独做无负载对照实验**。
6. `rxb_count_mode_per_cycle` / `rxb_lost_max` / `rxb_pending_max` 三列因环缓冲快照问题不可引用
(见第三节),CSV 中保留原值但结论未使用。
6b. **快档塌陷这条结论的证据来源要分清**:电机刷新率与 λ 的塌陷,取自扫描后另做的 8s/档
定点实测,那四次都当场确认 `online=True` 且 seq 在动,所以是**真功能失效**,不是电机
掉线造成的假象。但扫描 CSV 本身**没记在线标志**,事后无法复核。补该列前不要引用
CSV 的快档数字。
(2026-09-08 复测时电机已处于 `online=False`、`global_seq` 冻结状态 —— 总线本身正常,
cycles 仍以 1000Hz 前进、CAN 侧仍以 ~3700 帧/s 收帧,停的是 STATUS 上报。
该状态下的 0.4/0.35ms 快档读数一律不采信,也**未**写入本报告。)
7. 每拍固定开销 F≈323µs 是从 4 个负载点**反推**的,不是直接计时测得;两项模型在
2ms 档残差 +10.5 个百分点,说明低速档还有未建模的开销项。F 只能当量级用。
## 七、待验证(下一步该做的判据)
1. **`drop` 语义二选一**(第五节的 (a)/(b)):从 ESP32/WiFi 侧读固件那份 u32
`rx_drop_count`(`app_spi_cmd_can_feedback.cpp:99`,CAN 通道队列满丢弃的权威口径),
与 ECAT 侧 u16 `bridge.drop` 对速率。若两侧同为 ~4000/s → (a) 成立,丢帧发生在入窗之前,
与 ECAT 无关;若 WiFi 侧接近 0 而 ECAT 侧 4000/s → (b) 成立,字段读错偏移,
本报告的故障率轴需要整体重做。**这是唯一还悬着的承重结论。**
2. **从站侧健康度目前不可读**:`OVF` 在 `fillRxWindow` 里每拍清零,只能取到单拍增量,
而单拍增量落在 u8 分辨率以下即恒 0 —— 等于从站没有可用的累计丢帧仪表。
要判窗口是否真的健康,需要固件改成累计值或主站侧累加(属改动,需另行批准)。
3. **每拍固定开销 F 直接计时**:给 `fillRxWindow` 加计时(属改动,需另行批准)。
目前 F≈323µs 是从 4 个负载点反推的;直接量到 F 就能把「上限 2.7kHz」从拟合
变成算式,也才能判断减字节能否真的换到速度。
4. **模型已被证伪(保守方向)**:F/T+R 预测 0.4ms 约 92% 可行,但 0.4ms/0.35ms 的复测因
电机掉线作废,**上限究竟在 0.4 还是 0.5ms 仍未定**。恢复电机后需重跑这两档;
在此之前,建议值只写"0.5ms 已验证可跑",不要外推到 0.4ms。
5. **重复性**:同一档跑 3 次看 λ / GD32 负载 / 电机刷新率 / **online 标志** 的散布,
确认 0.5ms 与 0.25ms 的分界不是单次偶然,也不是电机掉线的副产物。
6. **0.5ms 档实跑功能验证**:在 0.5ms 下开电机页跑一段完整命令序列,确认前端
`drainRx()` 与波形在 2kHz 下不积压(本调研只测了计数器,没测界面实际可用性)。
+269
View File
@@ -0,0 +1,269 @@
<!DOCTYPE html>
<html lang="zh-CN"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>GD32 刷写流程对比:ESP32 中继(老)× EtherCAT 直灌(新)</title>
<style>
:root{--fg:#111827;--mut:#6b7280;--bd:#e5e7eb;--bg:#f8fafc;--acc:#1d4ed8;--warn:#b45309;--bad:#b91c1c;--ok:#15803d}
*{box-sizing:border-box}
body{margin:0;background:#fff;color:var(--fg);font:15.5px/1.72 -apple-system,"Segoe UI","Microsoft YaHei",system-ui,sans-serif}
main{max-width:1180px;margin:0 auto;padding:34px 28px 90px}
h1{font-size:28px;line-height:1.3;margin:0 0 6px;letter-spacing:-.2px}
h2{font-size:19px;margin:34px 0 11px;padding-bottom:7px;border-bottom:2px solid var(--bd)}
h3{font-size:15.5px;margin:24px 0 7px;color:#374151}
.meta{color:var(--mut);font-size:13.5px;margin-bottom:22px}
.card{background:var(--bg);border:1px solid var(--bd);border-left:4px solid var(--acc);border-radius:8px;padding:14px 19px;margin:16px 0}
.card.warn{border-left-color:var(--warn);background:#fffbeb}
.card.bad{border-left-color:var(--bad);background:#fef2f2}
.card.good{border-left-color:var(--ok);background:#f0fdf4}
.card h3{margin:0 0 7px;font-size:15px;color:inherit}
table{border-collapse:collapse;width:100%;font-size:13px;margin:13px 0}
th,td{border:1px solid var(--bd);padding:6px 9px;text-align:left;vertical-align:top}
th{background:#f1f5f9;font-weight:600;text-align:center}
td.n,th.n{text-align:right;font-variant-numeric:tabular-nums}
td.c{text-align:center}
tr.hi td{background:#ecfdf5}
tr.lo td{background:#fef2f2}
code{background:#f1f5f9;padding:1.5px 5px;border-radius:4px;font-size:12.5px;font-family:ui-monospace,"Cascadia Mono",Consolas,monospace}
.ev{color:var(--mut);font-size:11.5px;font-family:ui-monospace,Consolas,monospace}
ul{padding-left:21px}
.kpi{display:flex;gap:13px;flex-wrap:wrap;margin:20px 0 6px}
.kpi div{flex:1 1 172px;border:1px solid var(--bd);border-radius:8px;padding:12px 14px;background:#fff}
.kpi b{display:block;font-size:22px;line-height:1.25;color:var(--acc)}
.kpi span{font-size:12px;color:var(--mut)}
.kpi div.warn b{color:var(--warn)}
.tabs{position:sticky;top:0;z-index:9;background:#fff;padding:14px 0 0;margin:22px 0 4px;border-bottom:2px solid var(--bd);display:flex;gap:8px;flex-wrap:wrap}
.tab{appearance:none;border:1px solid var(--bd);border-bottom:none;background:#f1f5f9;color:#374151;padding:9px 17px;border-radius:8px 8px 0 0;font-size:14px;cursor:pointer;font-family:inherit;position:relative;top:2px}
.tab:hover{background:#e8eef6}
.tab.on{background:#fff;color:var(--acc);font-weight:600;box-shadow:0 2px 0 #fff}
.tab .cnt{font-size:11px;color:var(--mut);font-weight:400}
.page{display:none}.page.on{display:block}
.chain{display:flex;align-items:stretch;flex-wrap:wrap;margin:16px 0 6px}
.node{flex:1 1 128px;min-width:124px;border:1px solid var(--bd);border-radius:8px;background:#fff;padding:9px 11px;font-size:12.5px;line-height:1.5}
.node b{display:block;font-size:13px;margin-bottom:2px}
.node .t{color:var(--mut);font-size:11.5px}
.node.hi{border-color:var(--acc);background:#eff6ff}
.node.drop{border-color:#fca5a5;background:#fef2f2}
.arrow{flex:0 0 24px;display:flex;align-items:center;justify-content:center;color:#9aa3af;font-size:17px}
.byte{display:flex;gap:2px;flex-wrap:wrap;margin:9px 0}
.byte div{border:1px solid var(--bd);border-radius:4px;padding:5px 7px;font-size:11.5px;font-family:ui-monospace,Consolas,monospace;background:#fff;text-align:center;line-height:1.45}
.byte div small{display:block;color:var(--mut);font-size:10.5px;font-family:inherit}
.byte div.key{background:#eff6ff;border-color:#bfdbfe}
.byte div.meta2{background:#fffbeb;border-color:#fde68a}
.legend{font-size:12.5px;color:var(--mut);margin:6px 0 0}
.foot{margin-top:44px;padding-top:15px;border-top:1px solid var(--bd);font-size:12.5px;color:var(--mut)}
</style></head><body><main>
<h1>GD32 刷写流程对比:ESP32 中继(老)× EtherCAT 直灌(新)</h1>
<div class="meta">2026-09-13 · BOOT <code>v1.1.0.7</code>(<code>ER_ROM0 64,552 / 73,728 B</code>)· 主站 <code>ecat_native.exe</code> 22:18 构建 · 每条数字带 <span class="ev">文件:行号</span> · 镜像基准 = <code>DCU_APP</code> Debug <code>.hex</code> 展平后 <b>124,992 B</b>(本机实测)</div>
<div class="kpi">
<div><b>2 604 → 527</b><span>同张镜像的链路往返次数(结构硬数字)</span></div>
<div><b>48 → 241 B</b><span>每帧净落 flash 的字节数</span></div>
<div><b>252 KB → 无</b><span>中间存储:ESP32 缓存分区整个消失</span></div>
<div class="warn"><b>0 次</b><span>新流程的上板实测次数 —— 一条都没跑过</span></div>
</div>
<div class="card"><h3>一句话结论</h3>
新流程<b>不是"更快的刷法",是"少一层先决条件的刷法"</b>。省下的 5 倍往返是副产品,真正的收益是:出厂首刷不再要求板上先有一颗刷好并配好网的 ESP32。两条路并存互为备份,而且刻意复用了同一个 flash 写入者、同一个提交顺序、同一条掉电不变式 —— 新流程没有引入第二套"刷坏了怎么回来"的语义。</div>
<nav class="tabs">
<button class="tab on" data-p="cmp">对比</button>
<button class="tab" data-p="old">老流程 <span class="cnt">WiFi → ESP32 → SPI</span></button>
<button class="tab" data-p="new">新流程 <span class="cnt">EtherCAT CoE SDO</span></button>
</nav>
<section class="page on" id="p-cmp">
<h2>逐维度对照</h2>
<table>
<tr><th style="width:126px">维度</th><th>老:WiFi → ESP32 中继 → SPI</th><th>新:PC 主站 → EtherCAT → SDO</th></tr>
<tr class="hi"><td><b>先决条件</b></td><td>板上必须已有 ① 刷好固件的 ESP32 ② 配好网的 ESP32 ③ 上位机知道设备 IP。<span class="ev">esp32_server.py:830</span></td><td>总线上有电、从站在 PREOP 即可。<b>ESP32 可以完全没刷过。</b></td></tr>
<tr><td><b>参与部件</b></td><td>浏览器 → 本机 2625(只发静态页)→ ESP32:80 → GD32。<span class="ev">esp32_server.py:1-7</span></td><td>浏览器 → 同一个 <code>ecat_native.exe</code>(页面也它发的)→ Npcap/I210 → GD32。<b>ESP32 不在链路里。</b></td></tr>
<tr><td><b>镜像中间存储</b></td><td>ESP32 flash 的 <code>gd32_ota</code> 分区 <b>252 KB</b>@0x11000,两段式:先整份存下算 SHA,<code>/ota/relay</code> 才起中继。<span class="ev">partitions.csv:7-8;http_handlers.cpp:515;ota_relay.cpp:1128</span></td><td>PC 内存 216 KB 静态缓冲 <code>BOOT_DL_IMG_MAX 0x36000</code>。不落任何中间设备。</td></tr>
<tr><td><b>浏览器→服务端请求</b></td><td>1 次整文件 POST(<code>/ota/upload</code>)+ 1 次 <code>/ota/relay</code> + 轮询。<b>无断点续传</b>:每次从偏移 0 重写。<span class="ev">upload-esp32.js:238-247;http_handlers.cpp:440</span></td><td>6 次 push(每次 24,576 B → hex 49,152 字符,卡在 native 的 65,535 B body 上限内)+ 1 次 run。<span class="ev">已冒烟:received 24576→49152→73728 精确累加</span></td></tr>
<tr class="hi"><td><b>每帧净落 flash</b></td><td>线上一帧 = v2 头 16 B + DATA 64 B + CRC 4 B;64 B 里只有 <b>48 B</b> 是固件。<span class="ev">config.h:140-146;ota_relay.cpp:1135-1151</span></td><td>4 KB 块 = <b>17 个邮箱帧</b>(1 initiate 带 240 B + 16 段×247 B)⇒ 每帧 <b>241 B</b>,是老的 5.0 倍。<span class="ev">sdoserv.c:210,744</span></td></tr>
<tr class="hi"><td><b>链路往返次数</b></td><td>124,992 / 48 = <b>2 604 包</b>,每包一次「发 → 等 ACK(50 ms 上限,最多重发 5 次)」。<span class="ev">ota_relay.cpp:1181,1185</span></td><td>124,992 / 4096 = <b>31 次 SDO 传输</b> = 527 帧,外加总长 / begin / commit 三个控制 SDO。</td></tr>
<tr><td><b>鉴权</b></td><td>AES-128 三步双向握手,会话密钥作为前缀喂进每包 CRC32(防重放/篡改)。<span class="ev">OTA.cpp:976-1246,1414-1442</span></td><td><b>出厂形态零鉴权。</b>唯一裁决点 <code>ecatArmed()</code> = 配置页令牌 <b>或</b> A 区不可启动;commit 成功即收回令牌,用完即弃。<span class="ev">OTA.cpp:792-812,905-915</span></td></tr>
<tr><td><b>主站复判身份?</b></td><td>不存在该问题 —— ESP32 是唯一持有 AES 密钥的对端。</td><td><b>不复判。</b>读 <code>0x1008</code> 只当日志读数。APP 的对象字典里没有 <code>0x2500/0x2501</code>(实测只有 0x1600/0x1A00/0x1C12/0x1C13/0x2000/0x6000/0x7000),写上去只会 SDO abort,擦不到一个字节。</td></tr>
<tr class="hi"><td><b>掉电不变式</b></td><td colspan="2"><b>完全相同,且是刻意复用的。</b>擦除前先把 A 区三面标志(CHECK/AVAL/EN)全标 <code>NOT_AVAL</code> ⇒ 任何时刻掉电都表现为「AVAL 未置 = 不可启动 = 留守 BOOT 可重灌」。<span class="ev">OTA.cpp:635-637;config.h:37-43;main.cpp:229-402</span></td></tr>
<tr><td><b>提交顺序</b></td><td>向量表校验 → <code>calculateAppCrc</code>+<code>writeAppCrc</code> → CHECK → AVAL → EN;跳转延后到 <code>process()</code> 排空 <code>pending_app_jump</code>。<span class="ev">OTA.cpp:372-410</span></td><td><b>逐字对齐同一条</b>,只在 CRC 之后多插一步收回令牌。CRC 必须先于 AVAL —— <code>verifyAppCrc()</code> 对擦除态 0xFFFFFFFF 返回 true,真门控是三面标志。<span class="ev">OTA.cpp:900-915</span></td></tr>
<tr><td><b>对在跑业务的影响</b></td><td>SPI 中继是独立链路,不占 EtherCAT 环;但会话期间 BOOT 会丢弃 CAN OTA 帧。<span class="ev">OTA.cpp:303-309;Can.cpp:174-179</span></td><td><code>run</code> 全程持 <code>g_bus_lock</code> + <code>loop_stop()</code> ⇒ PDO 循环冻结数秒。但目标本来就得停在 BOOT(进不了 SAFEOP/OP),实际没有循环可冻。</td></tr>
<tr><td><b>可观测性</b></td><td>进度靠轮询 <code>/system/status</code> 的 <code>relay_seq/relay_total_packets</code>;每 32 包一次健康检查。<span class="ev">upload-gd32.js:58,194;app_config.h:86</span></td><td>从站 <code>0x2500:03</code> 实时报已落盘字节、<code>:04</code> 报 arm/会话位;主站 <code>probe</code> 一次读回,进度条两侧各一个独立读数。</td></tr>
<tr><td><b>刷 BOOT 自己</b></td><td colspan="2"><b>两条都不行。</b>BOOT 跑在 <code>0x08000000</code>,改不了自己正在执行的 flash ⇒ 永远只能 SWD/JTAG。新流程没改变这一点。</td></tr>
<tr class="lo"><td><b>刷"正在跑 APP 的机器"</b></td><td>可以 —— 这正是老流程的主用途(APP 收 OTA 请求、复位进 BOOT 再灌)。<span class="ev">main.cpp:249-272</span></td><td><b>还不行。</b>A 区可启动 ⇒ 令牌是硬门,而置令牌的入口没接(<code>ecatArmSet</code> 目前只有 commit 里那个"收回"调用者)。要么 BOOT 加密会话加命令码(要花余下的 9,176 B),要么 APP 侧复位进 BOOT 前自己写 word8。</td></tr>
<tr><td><b>BOOT flash 代价</b></td><td>基线 36,340 B</td><td>+28,212 B ⇒ 64,552 B,<b>余 9,176 B(用 87.6%)</b>;RAM 32,392 B(堆为分段下载放大到 16 KB,栈仍 1 KB)。<span class="ev">GD32E50X_BOOT.axf.map</span></td></tr>
<tr><td><b>成熟度</b></td><td>产线在用;有 CLI 与刷写台账 —— 只有 <code>ESP32/scripts/flash_gd32.py</code> 写 <code>lab/_last_gd32_sha.txt</code>,且要拿到"板上版本变了 / uptime 归零"的证据才写。<span class="ev">scripts/flash_gd32.py:36,132-148</span></td><td><b>代码接通、零上板。</b>主站软件路径已冒烟(路由分发 / 缓冲累加 / 边界值全过),但 ESC 起板、<code>0x1008</code> 可读、SDO 字节序三条只有源码级推导。</td></tr>
</table>
<div class="card warn"><h3>关于"快了多少" —— 不能说</h3>
老流程 ESP32 侧注释有实测:<b>正常 ~2 ms/包</b>、"777 包正常仅 ~1.6 s"。<span class="ev">ota_relay.cpp:356-368</span> 按这个常数换算,124,992 B = 2 604 包 ≈ <b>5.2 s</b> 纯传输。<br>
新流程<b>没有任何一次真机计时</b>,这里填不出可比的数字 —— 5 倍往返差是代码结构决定的硬事实,端到端耗时不是。第一次上板要记的就是三件事:begin 实际阻塞多久、单块实际耗时、整轮总耗时。</div>
<div class="card good"><h3>为什么值得两条都留</h3>
<ul>
<li><b>覆盖场景不重叠</b>:出厂首刷只有新流程能做(那时还没有 ESP32 固件);刷在跑的机器目前只有老流程能做(有 AES 通道)。</li>
<li><b>故障域独立</b>:WiFi 配不上 / ESP32 没刷 / 网卡拉不起 / 从站没进 PREOP —— 任意一边坏,另一边还是条路。</li>
<li><b>共用同一个落盘点</b>:两条最终都调 <code>eraseApplicationArea / writeApplicationData / validateApplication / OTA_SET</code>;BOOT 里始终只有一个 flash writer,<code>ecatBegin</code> 在 SPI/CAN 会话活跃时直接拒绝并发。</li>
</ul></div>
<h2>新流程的未证清单</h2>
<table>
<tr><th style="width:40px">#</th><th>待证</th><th>判据(一条就够)</th></tr>
<tr><td class="c">1</td><td>BOOT 侧 ESC 起不起得来(EXMC/HBI/RST#/IRQ 这套移植层从未在 BOOT 跑过)</td><td>串口出 <code>[BOOT][ESC] PDI ok DL=… 邮箱可用</code>;否则 <code>FAIL PDI 未就绪 DL=… EEP=…</code></td></tr>
<tr><td class="c">2</td><td>主站认不认出它在 BOOT</td><td><code>probe</code> 回 <code>name="GD32E508 DCU BOOT"</code> 且 <code>armed=1</code></td></tr>
<tr><td class="c">3</td><td>SDO 分段载荷字节序(源码推得原序,未实测)</td><td>用现成 <code>/api/sdo</code> 往 <code>0x2501</code> 写 <code>00 01 02 … 0F</code>,看落 flash 后回读是否逐字节同序;不对就只在 <code>ObjFoeDlWrite</code> 一处加转换</td></tr>
<tr><td class="c">4</td><td>begin 的整区预擦到底阻塞多久、8 s 超时够不够</td><td>串口时间戳与主站 <code>ec_SDOwrite</code> 往返计时对表</td></tr>
<tr><td class="c">5</td><td>刷完 APP 真能起来</td><td>重新连接后 <code>probe</code> 的设备名变回 <code>"GD32E508 DCU"</code> —— 从另一次启动读的,不自证</td></tr>
</table>
<div class="card bad"><h3>顺带查到的一个既有缺陷(老流程同样有)</h3>
<code>writeApplicationData()</code> 的循环条件是 <code>i + 4 &lt;= size</code> ⇒ <b>不足 4 字节的尾巴被静默丢弃</b>,注释里写的"用 0xFF 补齐一个 word"代码并没有做。<span class="ev">OTA.cpp:762-782</span><br>
老流程每包固定 48 B(4 对齐)所以碰不到,只有<b>非 4 对齐的镜像总长</b>会在最后一包丢 1~3 字节;新流程在主站侧补 0xFF 到 4 对齐并按补齐长度声明总量,绕开了这个坑,但<b>没有去改已在产线跑的 SPI 路径</b>。要修该单独一次改动、单独一次回归。</div>
</section>
<section class="page" id="p-old">
<h2>老流程:浏览器 → ESP32 → SPI → GD32 BOOT</h2>
<div class="chain">
<div class="node"><b>浏览器</b><span class="t">算 SHA-256<br>整文件一次 POST</span></div><div class="arrow">→</div>
<div class="node"><b>本机 2625</b><span class="t">只发静态页<br>不碰硬件</span></div><div class="arrow">→</div>
<div class="node"><b>ESP32 :80</b><span class="t">/ota/upload<br>每 4 KB 写分区</span></div><div class="arrow">→</div>
<div class="node drop"><b>ESP32 flash</b><span class="t">gd32_ota 252 KB<br>NVS 存元数据</span></div><div class="arrow">→</div>
<div class="node"><b>/ota/relay</b><span class="t">AES 三步握手<br>逐包读回转发</span></div><div class="arrow">→</div>
<div class="node hi"><b>GD32 BOOT</b><span class="t">SPI ISR 入队<br>主循环写 flash</span></div>
</div>
<p class="legend">红框 = 新流程里整个消失的一跳。</p>
<h3>关键常量</h3>
<table>
<tr><th>项</th><th class="n">值</th><th>出处</th></tr>
<tr><td>每帧总长 / 其中固件数据</td><td class="n">64 B / <b>48 B</b></td><td class="ev">config.h:140-146</td></tr>
<tr><td>SPI v2 帧开销</td><td class="n">20 B(16 头 + 4 CRC)</td><td class="ev">spi_link_v2.h:34-36</td></tr>
<tr><td>SPI 时钟 / 注释实测每帧</td><td class="n">13.333 MHz / ≈96 µs</td><td class="ev">sdkconfig.defaults:20;spi_master.cpp:80</td></tr>
<tr><td>ISR→主循环 DATA 槽 / flash 写缓冲槽</td><td class="n">4 / <b>8</b></td><td class="ev">config.h:178,180</td></tr>
<tr><td>ESP32 上传块 / 固件上限</td><td class="n">4 096 B / 216 KB</td><td class="ev">app_config.h:74,76</td></tr>
<tr><td>单包等 ACK / 最多重发 / 跨包停刷</td><td class="n">50 ms / 5 次 / 10 s</td><td class="ev">ota_relay.cpp:1181,1185,53</td></tr>
<tr><td>转发预算</td><td class="n">25 ms/包,总 10~120 s</td><td class="ev">ota_relay.cpp:356-368</td></tr>
<tr><td>BOOT 会话超时 / APP 延后启动窗口</td><td class="n">50 000(≈1 s) / 10 000 ms</td><td class="ev">config.h:168,169</td></tr>
<tr><td>握手各步超时(ESP32 侧)</td><td class="n">800 / 5000 / 3000 / 3000 ms</td><td class="ev">ota_relay.cpp:942,1036,1062,1090</td></tr>
</table>
<h3>一帧 DATA 的字节排布</h3>
<div class="byte">
<div class="key">0–3<small>会话号 LE</small></div>
<div class="key">4–7<small>包号 LE</small></div>
<div class="meta2">8–55<small>48 B AES-128 密文</small></div>
<div class="key">56–59<small>结束标志 IFEND</small></div>
<div class="key">60–63<small>CRC32(密钥参与)</small></div>
</div>
<p class="legend">外面再套 SPI v2 头:SOF0/SOF1/VER/FLAGS、SESSION@4、TX_SEQ@8、ACK_SEQ@9、CREDIT@10、TYPE@11、LEN@12,尾部 4 B MPEG-2 CRC32。<span class="ev">spi_link_v2.h:34-51;OTA.cpp:1002-1018;ota_relay.cpp:1135-1151</span></p>
<h3>握手三步(双向认证)</h3>
<table>
<tr><th style="width:38px">步</th><th>上位机 → BOOT</th><th>BOOT → 上位机</th><th>出处</th></tr>
<tr><td class="c">1</td><td>魔术字 <code>00 AC</code></td><td>回显 ACK,转 <code>WAITING_RANDOM</code></td><td class="ev">OTA.cpp:976-988</td></tr>
<tr><td class="c">2</td><td>16 B <code>nonce_server</code></td><td>36 B = UID(12)+nonce[8..15](8)+<code>AES(K_master,K_session)</code>(16),转 <code>WAITING_CONFIRM</code></td><td class="ev">OTA.cpp:1195-1219</td></tr>
<tr><td class="c">3</td><td><code>AES(K_session, nonce XOR 0x55)</code> 16 B</td><td><code>0xBA 0xC0</code>,转 <code>CHECKING_FIRMWARE</code></td><td class="ev">OTA.cpp:1224-1246</td></tr>
</table>
<p>状态机全序 <code>IDLE → WAITING_RANDOM → WAITING_CONFIRM → CHECKING_FIRMWARE → RECEIVING_FIRMWARE → FINISHED</code><span class="ev">OTA.h:223-230</span>。每包 CRC32(IEEE) 先把 16 B 会话密钥喂进去、再喂 48 B 明文,所以改一比特或重放旧包都对不上。<span class="ev">OTA.cpp:1414-1442</span> 主密钥两端硬编码同值。<span class="ev">config.h:158-161;app_config.h:150-153</span></p>
<h3>从收到字节到落 flash</h3>
<ul>
<li>EXTI ISR 只把帧入队(4 槽)→ <code>SpiOta::poll()</code> 在主循环解密校验并回 ACK → 再 drain 8 槽环形缓冲逐字 <code>fmc_word_program</code>。<span class="ev">SpiOta.cpp:99-114;OTA.cpp:725-757</span></li>
<li>背压靠 credit:<code>g_localCredit = 8 − flash_buf_cnt − pending</code>。<b>没有固定速率上限,也没有每包往返耗时统计。</b><span class="ev">OTA.cpp:1082-1085</span></li>
<li>去重:<code>包号 &lt; next_expected_packet</code> 判重,只回 ACK 不二次写 flash;重复 SPI 帧走 DUP-SEQ 回放缓存的最终 ACK。<span class="ev">OTA.cpp:1021-1031;SpiOta.cpp:409-427</span></li>
<li>擦除只擦本轮需要的页:<code>ceil(expected_size/2048)+1</code> 页,另单独保证 CRC 页被擦。<span class="ev">OTA.cpp:635-712</span></li>
<li>ACK 帧:<code>0xBA 0xC0</code> + 4 B 期望下一包号;末包特殊 seq <code>0xF0F0F0F0</code>;错误帧多 1 B 错误码。<span class="ev">OTA.cpp:251-260,1103,1285-1295</span></li>
<li>前端防呆:选文件即读前 8 字节判包身份(MSP∈SRAM、复位向量∈A 区;落 BOOT 区即拒),HEX 自动展平成 BIN(间隙填 0xFF)。<span class="ev">file-handle.js:458-516,563-574</span></li>
</ul>
<h3>失败与恢复分流</h3>
<p><code>main.cpp run()</code> 读三面标志分流:二者皆 AVAL 且 CRC 过 → 直接跳;CHECK AVAL / EN NOT_AVAL → 开 10 s 等 OTA 窗口;A 区坏但 B 区有效 → <code>move_app_data()</code> 整体搬回;都不行 → 长期留守等重下。FWDGT 复位会把 EN 标 NOT_AVAL,阻止反复跳进卡死的固件。<span class="ev">main.cpp:204-210,229-402</span></p>
<div class="card warn"><h3>代码里没有的东西(别当成有据)</h3>
<ul>
<li>SPI 中继链路的<b>实测</b>端到端耗时 / KB 每秒 —— 只有预算常数和注释里的 ~2 ms/包。</li>
<li>WiFi/HTTP 上传的断点续传:NVS 元数据预留了 <code>stored_size/last_seq</code>,HTTP 路径<b>未实装</b>。<span class="ev">gd32_flash_store.h:23;ota_coordinator.cpp:246</span></li>
<li>BOOT 侧握手各步的独立毫秒超时:统一走 <code>OTA_TIMEOUT</code> 计数。<span class="ev">OTA.cpp:431</span></li>
</ul></div>
<div class="card"><h3>同一后端的三条入口</h3>
浏览器、<code>tools/flash_gd32.py</code>(裸 BIN、不写台账)、<code>tools/ESP32/scripts/flash_gd32.py</code>(带 <code>{"total_size":N}</code> 头、写台账)三条都打 ESP32 固件 80 端口的同一组 <code>/ota/upload + /ota/relay + /system/status</code>,<b>没有独立通道</b>;设备侧对带不带 JSON 头都兼容。<span class="ev">http_handlers.cpp:403-407;tools/flash_gd32.py:88;scripts/flash_gd32.py:98</span></div>
</section>
<section class="page" id="p-new">
<h2>新流程:PC 主站 → EtherCAT 邮箱 → BOOT 写 flash</h2>
<div class="chain">
<div class="node"><b>浏览器</b><span class="t">ecat.html「BOOT 刷写」<br>页内自带 HEX→BIN</span></div><div class="arrow">→</div>
<div class="node"><b>ecat_native :8090</b><span class="t">/api/ota/ecat<br>镜像攒在 PC 内存</span></div><div class="arrow">→</div>
<div class="node"><b>I210 / Npcap</b><span class="t">loop_stop 独占<br>CoE SDO 分段下载</span></div><div class="arrow">→</div>
<div class="node"><b>LAN9252</b><span class="t">SM0 邮箱 256 B<br>PRAM FIFO 逐 DWORD</span></div><div class="arrow">→</div>
<div class="node hi"><b>GD32 BOOT</b><span class="t">SSC 攒满 4 KB 才回调<br>OTAHandler 写 flash</span></div>
</div>
<p class="legend">只有 5 跳,且 ESP32 完全不在里面。页面本身也是 <code>ecat_native</code> 发的,所以没有跨域、没有第二个服务。</p>
<h3>操作时序(六个 op,只有后两个碰总线)</h3>
<table>
<tr><th style="width:100px">op</th><th>做什么</th><th class="n">碰总线?</th><th>要点</th></tr>
<tr><td><code>open</code></td><td>声明镜像长度,清 PC 侧缓冲</td><td class="c">否</td><td>超 221,184 B 直接拒。<b>不抢 <code>g_bus_lock</code>、不 <code>loop_stop</code></b>,所以上传过程不会把正在跑的电机循环冻掉</td></tr>
<tr><td><code>push</code></td><td>追加一段 hex 到镜像尾部</td><td class="c">否</td><td>24,576 B/次 = hex 49,152 字符,卡在 <code>handle_conn</code> 的 65,535 B body 上限内</td></tr>
<tr><td><code>status</code> / <code>abort</code></td><td>查已收字节 / 丢缓冲</td><td class="c">否</td><td><code>abort</code> 只清 PC 侧,总线一个字节都没动</td></tr>
<tr><td><code>probe</code></td><td>读 <code>0x1008</code> 设备名 + <code>0x2500:03/:04</code></td><td class="c">是</td><td><b>纯诊断读数,不是门槛</b></td></tr>
<tr><td><code>run</code></td><td>真正的灌写:总长 → begin → 31 块 → commit</td><td class="c">是</td><td>全程持 <code>g_bus_lock</code> + <code>loop_stop()</code>;只有 commit 成功才不把 PDO 循环还回来(从站正在硬复位)</td></tr>
</table>
<h3>BOOT 侧只挂两个对象</h3>
<table>
<tr><th style="width:92px">索引</th><th class="n">类型</th><th>条目</th><th>语义</th></tr>
<tr><td><code>0x2500</code></td><td class="n">REC ×4<br>UINT32</td><td>:0 条目数 / :1 命令 / :2 总长 / :3 已收 / :4 状态</td>
<td>:1 写 <code>1</code>=begin、<code>2</code>=commit、<code>3</code>=abort;:4 bit0=允许刷写、bit1=会话活跃、高 16 位=错误码。<b>:3/:4 只读</b>,读的时候现取 <code>ota.ecatReceived()</code></td></tr>
<tr><td><code>0x2501</code></td><td class="n">VAR<br>OCTETSTRING<br>4 096 B</td><td>只有 <code>:00</code></td>
<td>数据通道。建成 VAR 而不是 ARR:VAR 的 <code>maxSubindex</code> 恒为 0,所以<b>只能写 <code>:00</code>,且 complete access 一律 UNSUPPORTED_ACCESS</b> ⇒ SOEM 侧必须传 <code>CA=FALSE</code>。<span class="ev">objdef.c:1278,1295,1352</span></td></tr>
</table>
<p>BitSize 是 UINT16 ⇒ 单条目天花板 8191 B,块长取 4096 就是这个原因。SSC 的分段下载<b>攒满整块才调一次 <code>OBJ_Write</code></b>(最后一帧才分发,且分发前不做通用长度校验),所以从站侧不需要 begin/data/commit 拼包状态机。<span class="ev">sdoserv.c:292-295;objdef.c:1352;sdoserv.c:714</span></p>
<h3>一个 4 KB 块在总线上的样子</h3>
<div class="byte">
<div class="meta2">initiate<small>带 240 B</small></div>
<div class="key">段 1<small>247 B</small></div>
<div class="key">段 2<small>247 B</small></div>
<div class="key">…<small>×14</small></div>
<div class="key">段 16<small>151 B</small></div>
</div>
<p class="legend">共 <b>17 帧搬 4096 B</b>。段长上限两端各算一次取小者:SOEM <code>maxdata = mbx_l − 0x10 = 240</code>(首帧带数),SSC 段 <code>256 − 6 − 3 = 247</code>。全图 124,992 B ⇒ 31 块 = 527 帧。<span class="ev">ethercatcoe.c:346,450;sdoserv.c:210</span></p>
<h3>放行判据只有一个执行点</h3>
<div class="card good">
<p><code>ecatArmed() = 配置页 word8 == 0xACA70001 || !(CHECK==AVAL &amp;&amp; EN==AVAL)</code></p>
<p>后半句和 <code>main.cpp run()</code> 决定"跳 APP"用的是同两个 flash 字,所以四种 BOOT 留守情形(空板首刷 / 下载中断电 / A 区 CRC 失败 / FWDGT 复位)天然放行,不需要任何先决条件;反过来 APP 正常在跑时两字皆 <code>OTA_AVAL</code>,令牌就是硬门 —— 而令牌写不进(SDO 摸不到配置页),所以刷不动一台正常机器。</p>
<p><code>ecatCommit()</code> 成功路径里先 <code>ecatArmSet(false)</code> 收回令牌再置三面标志:用完即弃。此刻 CHECK/EN 还是 <code>eraseApplicationArea</code> 标的 NOT_AVAL,所以收回这一拍救砖分支照样为真,不影响本次提交。<span class="ev">OTA.cpp:792-812,905-915</span></p>
</div>
<h3>刻意复用老流程的部分(一条都没新写)</h3>
<ul>
<li>擦:<code>eraseApplicationArea()</code> —— 同样先标三面 NOT_AVAL,同样按 <code>ceil(size/2048)+1</code> 页。</li>
<li>写:<code>writeApplicationData()</code> —— 唯一区别是主站先把镜像补 0xFF 到 4 对齐(<code>ecatFeed</code> 另有游标 4 对齐前置校验)。</li>
<li>校验:<code>validateApplication()</code> —— MSP∈SRAM、复位向量∈A 区,与前端包身份判定同判据。</li>
<li>提交:CRC → CHECK → AVAL → EN 同序;跳转都靠 <code>process()</code> 排空 <code>pending_app_jump</code>,<b>不在收包/邮箱上下文里跳</b>(否则会把 SM0 半包留在总线上)。</li>
<li>并发:<code>ecatBegin</code> 在 <code>isActive()</code>(SPI/CAN 会话活跃)时直接拒绝 ⇒ BOOT 里始终只有一个 flash writer。</li>
<li>退出:<code>jumpToApplication()</code> 里补 <code>bootEcatDeinit()</code> 拆 EXTI1/NVIC、LAN9252 IRQ 引脚、EXMC、DWT,否则 APP 会吃到 BOOT 留的 pending 中断并在被占用的总线上重跑 init。</li>
</ul>
<h3>新增的移植层花了多少</h3>
<table>
<tr><th>项</th><th class="n">值</th><th>备注</th></tr>
<tr><td>无 EtherCAT 基线</td><td class="n">36,340 B</td><td class="ev">Phase 0 对照组:关掉唯一调用者后 --gc-sections 剥回此值</td></tr>
<tr><td>邮箱-only SSC(空移植层桩件)</td><td class="n">57,816 B</td><td>净增 21,472 B —— 原方案声称的"12 KB"是假的</td></tr>
<tr><td><b>真移植层 + 会话(定版)</b></td><td class="n"><b>64,552 B</b></td><td>再 +6,736 B(EXMC/HBI/CSR 双读/PRAM/RST#/IRQ + 0x2500/0x2501 + arm 门控);<b>余 9,176 B</b></td></tr>
<tr><td>RAM</td><td class="n">32,392 / 131,072 B</td><td>堆 0xC00→<b>0x4000</b>(每笔分段下载 <code>ALLOCMEM(块长)</code> 走 malloc);栈仍 1 KB ⇒ SDO 回调里不许开块级大数组</td></tr>
</table>
</section>
<div class="foot">生成:Qoder 会话 · 证据基线 <code>GD32E50X_BOOT</code> v1.1.0.7 / <code>tools/ecat</code> ecat_native 22:18 / <code>ESP32_COMM</code> 现网分支 · 本轮改动均未提交 git</div>
</main>
<script>
document.querySelectorAll(".tab").forEach(function(b){b.onclick=function(){
document.querySelectorAll(".tab").forEach(function(x){x.classList.remove("on")});
document.querySelectorAll(".page").forEach(function(x){x.classList.remove("on")});
b.classList.add("on");
document.getElementById("p-"+b.dataset.p).classList.add("on");
scrollTo(0,0);
};});
</script>
</body></html>
@@ -0,0 +1,107 @@
# EtherCAT 跳拍优化实验报告
日期:2026-09-17
台子:i7-14700K + I210「以太网 2」100Mbps + GD32E508 从站(3 电机在线)
主判据:**不跳拍率**(`late_per_s` / `skip_per_s`,来自 `/api/motor/dbg` 拍环 `k>0`)
原始数据:`lab/skip_meter/*.json`
## 一、结论(按性价比)
| 措施 | 1ms late/s | 判定 |
|---|---|---|
| 基线(进程 Normal,原绑定) | ~10–25(漂) | 起点 |
| 进程 RealTime | 均值更好(~8–12) | **有效,保留** |
| 关 I210 上 VMware Bridge 等协议 | 有助(单独测过 4.1/s 一枪;混方差) | **有效,保留** |
| 关中断裁决 ITR=0 | **70/s** | **有害,已还原** |
| 关 EEE | **98/s** | **有害,已还原** |
| ITR=50(非法)/固定低 ITR | 更差或无效 | **已还原** |
| 关 IPv4/IPv6(本卡仅链路本地) | 无可测收益 | **已重新打开** |
| **周期 2ms** | **0.06/s ≈ 不跳** | **主判据下的最优运行点** |
| 周期 4ms | 0 | 同上,更宽裕 |
**主判据一句话**:
- 必须 **1ms**:当前约 **10–17 late/s**(work p50≈366µs,余量只有 ~630µs,偶发 1ms 尖峰必跳)。
- 允许 **2ms**:约 **0.06 late/s(16s 仅 1 拍)**,达成 500.00Hz,**不跳拍率优先时选这个**。
## 二、机理回顾(本轮再确认)
跳拍 = `pdo_worker` 自旋到绝对截止点时 `next_t <= now`,补 `k` 个周期。
本轮形态:
1. **send 长尾**(`pcap_sendpacket` ≈1ms,rxc=1)— Npcap/驱动/DPC,不是从站。
2. **rx 长尾**(等回包 ≈1ms,rxc≈200+)— 主机接收路径,不是总线被占。
从站 `load_perm≈336‰`、WKC 3/3、layout=dense,**没有被别的 EtherCAT 用户抢走**。
注意:本轮中后期 `rx p50` 从早晨的 ~90µs 变成 ~330µs(进程重启/绑定调整后未回到 90)。
这压缩了 1ms 档余量,是 late 从 ~10 抬到 ~15–25 的主要原因之一;机理未完全闭合(怀疑桌面负载/DPC 形态漂移,非网卡高级属性)。
## 三、已落地的改动(可回滚)
### 3.1 保留
1. **`ecat_native` 启动尝试 RealTime**(`tools/ecat/ecat_native.c`)
- 先提 `SE_INC_BASE_PRIORITY_NAME` 再 `SetPriorityClass(RT)`。
- **实测自设仍会被钳成 High**(令牌无该特权时 API 仍可能返回成功/或被系统钳位)。
- 因此 **`esp32_server.py` 在 Popen 后从外部 `SetPriorityClass(RT)`**(能粘住,已验证)。
- 备份:`tools/ecat/build_native/ecat_native.pre-rt.exe`。
2. **I210 绑定裁剪**(仅「以太网 2」)
- 已禁用:`vmware_bridge`, `ms_pacer`, `ms_lldp`, `ms_rspndr`, `ms_lltdio`, `ms_server`, `ms_msclient`
- 仍启用:`INSECURE_NPCAP`(必须)、`ms_tcpip`/`ms_tcpip6`(已恢复)
- 快照:`lab/skip_meter/nic_bind_*.json`
3. **I210 高级属性**:已还原为实验前原值(ITR=适应性,EEE 按系统,FlowControl Rx+Tx 等)。
### 3.2 运行态
- 周期:**已恢复 1ms**(与实验前一致,避免破坏你当前用法)
- WKC 3/3、OP、loop=1、`pcore_mask=0x50`
- 进程优先级:当前为 **RealTime**(外部设置)
## 四、回滚
```powershell
# 1) 恢复 I210 协议绑定
python scripts/skip_bind_experiment.py --restore
python scripts/skip_bind_noip.py --restore # 若曾关过 IP,脚本会 Enable
# 2) 恢复 I210 高级属性(用任一 nic_before_*.json)
python scripts/skip_nic_experiment.py --restore --snap lab/skip_meter/nic_before_1789580690.json
# 3) 还原旧主站二进制
# 先停 ecat_native,再:
Copy-Item tools\ecat\build_native\ecat_native.pre-rt.exe tools\ecat\build_native\ecat_native.exe -Force
# 然后由 esp32_server 自动重拉,或手动跑 ecat_web.bat
# 4) 进程优先级改回 High(不必还原绑定时用)
Get-Process ecat_native | ForEach-Object { $_.PriorityClass = 'High' }
```
网卡高级属性原始快照:`lab/skip_meter/nic_before_1789580690.json`(含 ITR=65535 等)。
## 五、标定/实验脚本
| 脚本 | 用途 |
|---|---|
| `scripts/skip_rate_meter.py` | 标准跳拍率标定(主判据) |
| `scripts/skip_host_snapshot.py` | 主机/I210/进程现场快照 |
| `scripts/skip_prio_ab.py` | High vs RealTime A/B |
| `scripts/skip_quiet_periods.py` | 1/2/4ms 静默对比 |
| `scripts/skip_nic_experiment.py` | 网卡高级属性实验 + `--restore` |
| `scripts/skip_bind_experiment.py` | 绑定裁剪 + `--restore` |
| `scripts/skip_restart_native.py` | 进程热重启对照 |
## 六、建议
1. **不跳拍优先 → 生产用 2ms**(本轮实测 16s 仅 1 拍 late)。若必须 1ms,接受 ~1%/拍 late,并保证 RealTime + 无 VMware 桥。
2. **重启 `esp32_server` 后**,新代码会在拉起 `ecat_native` 时自动提 RealTime;当前若只重启 native 不重启 server,需再手动设一次。
3. 勿再关 ITR/EEE 做“低延迟”优化——本台子上是反向的。
4. `rx p50` 90→330µs 未闭合,下一轮可专查桌面 DPC(关 360/同步盘/任务管理器常刷)对照。
## 七、我在这轮里错过又改掉的判断
1. 「关中断裁决能降 send 长尾」→ 实测 70/s,立刻还原。
2. 「关 EEE 能降功耗相关抖动」→ 实测 98/s,立刻还原。
3. 「进程自设 RealTime 会粘住」→ 日志显示成功但 `PriorityClass=High`,改为启动方外部设置。
4. 首版标定器拿 unix 时间去比 `now_s()` 拍环 → 恒 0 late,已修。
+359
View File
@@ -0,0 +1,359 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8"/>
<title>对接电机 2/3 积分差归因报告</title>
<style>
:root {
--bg:#0f1419; --card:#161c24; --ink:#e8eef7; --mut:#8a9bb0;
--ok:#3d8f5b; --warn:#d4a017; --bad:#e25555; --line:#2a3340;
}
* { box-sizing:border-box; }
body {
margin:0; background:var(--bg); color:var(--ink);
font-family:"Segoe UI","Microsoft YaHei",system-ui,sans-serif;
line-height:1.55; padding:24px;
}
h1 { font-size:22px; font-weight:600; margin:0 0 6px; }
h2 { font-size:16px; font-weight:600; margin:0 0 12px; }
.sub { color:var(--mut); font-size:13px; margin-bottom:18px; }
.card {
background:var(--card); border:1px solid var(--line); border-radius:12px;
padding:18px 20px; margin-bottom:16px;
}
.lead { margin:0 0 12px; color:var(--ink); }
code {
background:#0c1014; padding:1px 6px; border-radius:4px;
font-family:Consolas,monospace; font-size:13px; color:#c5d4e8;
}
ul.verdict { list-style:none; padding:0; margin:0 0 14px; }
ul.verdict li {
margin:0 0 10px; padding:10px 12px; background:#121820;
border-left:3px solid var(--line); border-radius:0 8px 8px 0;
}
.tag {
display:inline-block; font-size:12px; font-weight:600; padding:2px 8px;
border-radius:999px; margin-right:8px; vertical-align:middle;
}
.tag.ok { background:rgba(61,143,91,.2); color:#6dca8f; }
.tag.warn { background:rgba(212,160,23,.2); color:#f0c35a; }
.callout {
background:#121a14; border:1px solid #2a4a34; border-radius:8px;
padding:12px 14px; margin-top:8px;
}
.callout.small { font-size:13px; background:#1a1520; border-color:#3a2a4a; }
table {
width:100%; border-collapse:collapse; font-size:12.5px; margin-top:8px;
}
th, td {
border-bottom:1px solid var(--line); padding:6px 8px; text-align:left;
}
th { color:var(--mut); font-weight:500; }
td.ok { color:#6dca8f; }
td.warn { color:#f0c35a; }
td.bad { color:#ff8a8a; }
.annot-grid {
display:grid; grid-template-columns:1fr 1fr; gap:12px;
}
@media (max-width:800px) { .annot-grid { grid-template-columns:1fr; } }
.annot {
background:#121820; border-radius:8px; padding:12px; border-left:3px solid var(--line);
}
.annot.ok { border-left-color:var(--ok); }
.annot.warn { border-left-color:var(--warn); }
.annot.bad { border-left-color:var(--bad); }
.ann-title { font-weight:600; margin-bottom:6px; font-size:13px; }
.annot p { margin:0; font-size:13px; color:#c5d0dc; }
.arrow-legend { margin-top:12px; font-size:13px; color:var(--mut); }
.arrow-legend div { margin:4px 0; }
.arrow { display:inline-block; width:18px; height:3px; margin-right:8px; vertical-align:middle; }
.ok-arrow { background:var(--ok); }
.warn-arrow { background:var(--warn); }
.bad-arrow { background:var(--bad); }
.metrics {
display:grid; grid-template-columns:repeat(4,1fr); gap:10px; margin-top:10px;
}
@media (max-width:800px) { .metrics { grid-template-columns:1fr 1fr; } }
.metric {
background:#121820; border:1px solid var(--line); border-radius:8px; padding:10px 12px;
}
.metric .k { color:var(--mut); font-size:12px; }
.metric .v { font-size:20px; font-weight:600; margin-top:2px; }
.metric .v.ok { color:#6dca8f; }
.metric .v.warn { color:#f0c35a; }
</style>
</head>
<body>
<h1>对接电机 2 / 3:积分差是谁造成的?</h1>
<div class="sub">
数据:ECAT 无源采集 <code>lab/ecat_passive_latest.csv</code> · 周期 0.25ms ·
理论:刚性镜像对接时 <code>v2+v3≡0</code>,<code>∫(v2+v3)dt≡0</code>,<code>a2+a3=常数</code><br/>
方法:对齐 cycles 后做梯形积分;先加后积与先积后加一致。
</div>
<div class="card">
<h2>关键数字</h2>
<div class="metrics">
<div class="metric"><div class="k">巡航 |v| N2 / N3</div>
<div class="v ok">199.38 / 199.38</div></div>
<div class="metric"><div class="k">巡航段 ∫(v2+v3) 平均</div>
<div class="v ok">0.11°</div></div>
<div class="metric"><div class="k">停车段 ∫(v2+v3) |均值|</div>
<div class="v warn">6.43°</div></div>
<div class="metric"><div class="k">干净起动 Δt 中位/均值</div>
<div class="v ok">0.00 / 0.38 ms</div></div>
</div>
</div>
<section class="card">
<h2>通俗结论:到底是谁慢?</h2>
<p class="lead">先说人话:<strong>两台电机并不是“有一台全程都慢”</strong>。
你看到的积分差,主要不是巡航时谁转得慢,而是<strong>快停下 / 刚要走的那几十一到几百毫秒里,两台对不齐</strong>,
把理论应为 0 的 <code>∫(v2+v3)dt</code> 顶出 5~20° 左右的偏差。</p>
<ul class="verdict">
<li><span class="tag ok">启动</span>
干净起动时(|Δt|≤50ms 的行程),N3−N2 约为
<strong>0.375 ms</strong> 量级 →
<strong>几乎是同时启动</strong>,ECAT 同帧下发没有问题。
有 2 个行程测出上百毫秒的“假早启动”,那是<strong>上一段还没停稳、N3 仍在动</strong>,
不是这一段命令发晚了。</li>
<li><span class="tag ok">巡航</span>
两边平均 |v|:N2 <strong>199.38</strong>°/s,N3 <strong>199.38</strong>°/s,
差值约 <strong>0.000</strong>°/s → <strong>稳态速度几乎一样,谈不上谁慢</strong>。
巡航段 ∫(v2+v3) 平均只有约 <strong>0.11°</strong>,贴合理论 0。</li>
<li><span class="tag warn">停车 / 换向</span>
行程末端 ∫(v2+v3) 平均约 <strong>-1.09°</strong>,绝对值平均约 <strong>6.43°</strong>,
明显大于巡航。停止窗口里:
出现「只有 N2 还在走」的行程约 <strong>4</strong> 次(积分贡献合计 7.23°),
「只有 N3 还在走」约 <strong>2</strong> 次(合计 2.13°)。
<strong>两边都有刹车尾巴,不是单边永久变慢</strong>;有的行程 N2 拖尾更明显,有的行程 N3 更明显。</li>
</ul>
<div class="callout">
<strong>一句话:</strong>积分差的大头在 <strong>停车阶段</strong>。
对接约束要求 <code>v2 = −v3</code>;只要刹停瞬间一台还带着速度、另一台已经接近 0,
<code>v2+v3</code> 就会冒尖峰,积分就会被拉离理论 0。这属于<strong>动态不同步</strong>,
不等于某台电机“标定转速低、一直慢”。
</div>
<div class="arrow-legend">
<div><span class="arrow ok-arrow"></span> 正常:同时启动 / 巡航一致 / 中段积分≈0</div>
<div><span class="arrow warn-arrow"></span> 注意:停车沿残速、行程衔接重叠、积分被顶开</div>
<div><span class="arrow bad-arrow"></span> 偏大:该行程 ∫(v2+v3) 或编码器 Δ(a2+a3) 明显偏离 0</div>
</div>
</section>
<section class="card">
<h2>曲线(带标注)</h2>
<svg viewBox="0 0 960 420" width="100%" xmlns="http://www.w3.org/2000/svg">
<rect width="960" height="420" fill="#0f1419"/>
<line x1="54.0" x2="54.0" y1="40" y2="390" stroke="#2a3340" stroke-width="1"/><text x="57.0" y="54" fill="#5c6b7e" font-size="9" font-family="Segoe UI,sans-serif">#0</text><line x1="323.2" x2="323.2" y1="40" y2="390" stroke="#2a3340" stroke-width="1"/><text x="326.2" y="54" fill="#5c6b7e" font-size="9" font-family="Segoe UI,sans-serif">#1</text><line x1="411.7" x2="411.7" y1="40" y2="390" stroke="#2a3340" stroke-width="1"/><text x="414.7" y="54" fill="#5c6b7e" font-size="9" font-family="Segoe UI,sans-serif">#2</text><line x1="500.3" x2="500.3" y1="40" y2="390" stroke="#2a3340" stroke-width="1"/><text x="503.3" y="54" fill="#5c6b7e" font-size="9" font-family="Segoe UI,sans-serif">#3</text><line x1="588.7" x2="588.7" y1="40" y2="390" stroke="#2a3340" stroke-width="1"/><text x="591.7" y="54" fill="#5c6b7e" font-size="9" font-family="Segoe UI,sans-serif">#4</text><line x1="677.3" x2="677.3" y1="40" y2="390" stroke="#2a3340" stroke-width="1"/><text x="680.3" y="54" fill="#5c6b7e" font-size="9" font-family="Segoe UI,sans-serif">#5</text><line x1="765.7" x2="765.7" y1="40" y2="390" stroke="#2a3340" stroke-width="1"/><text x="768.7" y="54" fill="#5c6b7e" font-size="9" font-family="Segoe UI,sans-serif">#6</text><line x1="854.8" x2="854.8" y1="40" y2="390" stroke="#2a3340" stroke-width="1"/><text x="857.8" y="54" fill="#5c6b7e" font-size="9" font-family="Segoe UI,sans-serif">#7</text>
<polyline fill="none" stroke="#4da3ff" stroke-width="1.3" points="56.0,97.8 58.3,97.9 60.4,97.9 62.6,97.8 64.7,97.8 66.8,97.9 69.0,97.8 71.1,97.9 73.0,97.8 75.3,97.8 77.3,97.8 79.3,97.9 81.0,97.8 83.2,97.8 85.4,97.8 87.3,97.8 89.4,97.8 91.2,97.9 93.3,97.8 95.5,97.9 97.5,97.7 99.7,97.9 102.1,97.9 104.1,97.8 106.2,97.8 108.2,97.9 110.3,97.8 112.5,97.8 114.9,97.7 117.1,98.0 119.2,97.9 121.4,97.9 123.8,97.8 126.1,97.9 128.2,97.9 130.7,97.8 133.0,97.8 135.5,97.8 137.6,97.8 140.0,97.8 142.0,97.8 144.4,97.8 146.7,97.8 148.9,97.8 151.2,97.8 153.7,97.9 155.8,97.8 157.9,97.8 160.0,97.8 162.1,97.9 164.1,97.9 166.4,97.8 168.5,97.9 170.8,97.8 173.1,97.8 175.1,97.8 177.2,97.9 179.4,97.9 181.5,97.8 183.7,97.9 185.8,97.8 188.0,97.8 189.8,97.8 191.9,97.8 194.0,97.8 196.1,97.9 198.2,97.9 200.4,97.9 202.5,97.8 204.7,97.8 206.8,97.8 209.0,97.8 211.0,97.9 213.0,97.8 215.0,97.8 217.2,97.8 219.2,97.7 221.2,97.8 223.3,97.9 225.3,97.7 227.4,97.9 229.4,97.9 231.9,97.9 234.3,97.7 236.3,97.9 238.4,97.9 240.5,97.8 242.5,97.9 244.6,97.9 246.6,97.8 248.5,134.8 250.8,140.0 253.0,140.3 255.2,140.6 257.2,141.0 259.4,140.9 261.5,141.1 263.6,141.1 265.7,140.8 267.8,140.7 270.1,140.8 272.1,141.0 274.1,139.7 276.6,140.6 278.8,140.4 281.1,140.4 283.1,140.4 285.2,140.5 287.2,140.9 289.2,140.5 291.2,139.6 293.3,139.0 295.5,139.2 297.5,139.6 299.7,139.3 301.9,139.6 303.8,140.2 306.0,139.9 308.1,139.2 310.4,139.1 312.5,139.2 314.7,139.6 316.8,139.4 318.6,140.3 320.9,139.7 323.2,102.4 325.1,97.6 327.2,97.8 329.3,97.8 331.2,97.8 333.3,98.0 335.2,97.7 337.4,59.0 339.3,55.7 341.6,55.2 343.7,55.2 345.9,55.6 348.1,55.4 350.3,55.7 352.5,55.6 354.8,55.4 356.9,55.5 359.0,55.3 361.1,55.2 363.5,55.1 365.6,55.0 367.8,55.0 369.9,55.5 372.1,55.7 374.4,55.6 376.5,55.4 378.6,55.6 381.0,55.7 383.4,55.3 385.6,55.2 388.0,55.3 390.8,55.5 393.1,55.6 395.3,55.5 397.4,55.5 399.8,55.3 402.2,55.4 404.2,56.1 406.3,55.2 408.6,56.0 410.8,60.8 412.9,97.7 415.5,97.8 417.7,97.8 419.9,97.9 422.0,97.9 424.3,97.8 426.2,136.0 428.4,139.5 430.7,139.2 432.9,139.0 434.7,140.4 436.9,140.4 438.8,140.6 440.9,140.7 442.9,140.7 445.4,140.4 447.5,140.3 449.8,140.2 452.1,139.6 454.5,140.6 456.7,140.5 458.9,140.8 460.9,140.7 462.9,139.4 465.2,140.7 467.5,140.8 469.6,140.6 471.7,140.8 473.7,140.5 475.8,139.8 477.8,138.7 479.9,139.5 481.7,140.4 483.6,140.5 485.8,140.7 487.9,140.3 490.3,140.1 492.5,140.5 494.6,140.4 497.0,139.7 499.5,124.7 501.8,97.8 504.0,97.9 506.5,97.8 508.8,97.8 511.3,97.8 513.5,97.9 515.6,56.5 517.6,55.4 519.9,55.2 522.1,55.4 524.2,55.7 526.2,55.6 528.2,55.4 530.4,55.5 532.5,55.2 534.7,55.2 537.0,55.7 539.1,55.3 541.2,55.3 543.4,55.2 545.5,55.6 547.7,55.6 549.7,55.4 551.6,55.3 553.7,55.5 555.9,55.4 557.8,55.6 560.0,55.2 562.5,55.3 564.5,55.4 566.5,55.3 568.5,55.4 570.6,55.8 573.2,55.4 575.3,55.2 577.3,55.4 579.5,55.6 581.6,55.6 583.8,55.5 586.3,55.4 588.3,88.5 590.4,97.8 592.3,97.9 594.6,97.9 597.1,97.8 599.0,97.9 601.2,97.9 603.6,137.6 605.8,140.3 608.1,140.8 610.3,140.5 612.7,140.5 615.0,139.6 617.3,138.9 619.6,139.4 621.7,139.7 623.8,139.3 625.9,138.9 628.1,139.1 630.3,139.1 632.2,139.7 634.4,139.2 636.4,139.8 638.6,139.6 640.8,139.3 643.0,139.7 645.4,140.5 647.5,140.7 649.7,140.6 651.9,140.5 654.3,140.5 656.2,140.7 658.2,140.8 660.7,140.4 662.7,140.7 664.9,140.5 667.0,140.5 669.2,140.5 671.5,140.4 673.8,140.2 675.8,140.7 678.2,98.1 680.9,97.8 683.2,97.8 685.4,97.7 687.6,97.8 689.8,97.9 692.0,58.0 694.3,55.3 696.6,55.3 698.9,55.1 701.2,55.2 703.5,55.2 705.9,56.3 708.5,55.3 710.8,55.6 713.2,55.8 715.5,55.5 717.8,55.4 720.5,55.3 722.8,55.6 725.0,55.9 727.2,56.1 729.9,55.2 731.9,55.6 734.1,55.8 736.4,55.5 739.1,55.6 741.3,55.4 743.3,55.3 745.5,55.4 747.8,55.2 749.8,55.2 752.1,55.2 754.4,55.4 756.7,55.3 759.1,55.3 761.2,55.0 763.4,55.2 765.7,93.3 767.9,97.9 770.3,97.8 772.4,97.8 774.8,97.9 777.0,97.7 779.3,112.1 781.3,138.6 783.5,140.0 785.7,140.0 787.8,140.3 790.3,138.5 792.9,140.3 795.5,139.8 797.9,139.4 800.1,140.6 802.3,140.9 804.9,140.4 807.4,139.5 809.5,140.0 812.0,141.0 814.1,140.6 816.3,140.9 818.3,139.7 820.6,140.7 823.0,140.5 825.3,140.4 827.6,140.3 830.0,139.3 832.6,140.5 834.9,140.4 837.0,140.5 839.3,140.4 841.5,140.5 843.9,139.9 846.4,140.5 848.7,140.4 851.4,139.7 853.9,125.1 856.2,97.8 858.8,97.8 861.2,97.9 863.6,97.8 865.9,97.8 868.4,58.3 870.8,55.5 873.1,55.1 875.4,55.4 877.9,55.2 880.2,55.5 882.3,55.3 884.6,55.3 887.1,55.0 889.6,55.5 891.9,55.6 894.1,55.2 896.6,55.5 898.9,55.0 901.2,56.1 903.6,55.3 905.6,55.8 908.1,55.2 910.3,55.5 912.7,55.4 914.9,55.7 917.3,55.3 919.4,55.4 921.5,55.4 923.8,55.4 926.0,55.5 928.2,55.2 930.5,55.0 933.3,55.4 935.5,55.5 937.7,55.6 940.0,55.6"/>
<polyline fill="none" stroke="#f0a030" stroke-width="1.3" points="56.0,97.8 58.3,97.8 60.4,97.8 62.6,97.8 64.7,97.8 66.8,97.7 69.0,97.8 71.1,97.8 73.0,97.8 75.3,97.8 77.3,97.8 79.3,97.9 81.0,97.9 83.2,97.8 85.4,97.7 87.3,97.9 89.4,97.8 91.2,97.8 93.3,97.8 95.5,97.9 97.5,97.8 99.7,97.8 102.1,97.9 104.1,97.8 106.2,97.8 108.2,97.8 110.3,97.8 112.5,97.8 114.9,97.8 117.1,97.8 119.2,97.9 121.4,97.8 123.8,97.8 126.1,97.9 128.2,97.8 130.7,97.8 133.0,97.8 135.5,97.8 137.6,97.9 140.0,97.9 142.0,97.8 144.4,97.9 146.7,97.8 148.9,97.9 151.2,97.8 153.7,97.8 155.8,97.8 157.9,97.8 160.0,97.8 162.1,97.8 164.1,97.9 166.4,97.8 168.5,97.8 170.8,97.8 173.1,97.8 175.1,97.8 177.2,97.8 179.4,97.9 181.5,97.8 183.7,97.8 185.8,97.8 188.0,97.8 189.8,97.9 191.9,97.8 194.0,97.8 196.1,97.9 198.2,97.8 200.4,97.8 202.5,97.8 204.7,97.9 206.8,97.8 209.0,97.8 211.0,97.8 213.0,97.8 215.0,97.8 217.2,97.9 219.2,97.9 221.2,97.8 223.3,97.8 225.3,97.8 227.4,97.9 229.4,97.9 231.9,97.8 234.3,97.8 236.3,97.8 238.4,97.8 240.5,97.9 242.5,97.8 244.6,97.8 246.6,97.9 248.5,61.2 250.8,56.2 253.0,55.1 255.2,54.9 257.2,55.6 259.4,55.6 261.5,55.8 263.6,55.9 265.7,55.6 267.8,55.5 270.1,55.6 272.1,55.4 274.1,55.0 276.6,55.4 278.8,55.4 281.1,55.4 283.1,55.2 285.2,55.2 287.2,55.5 289.2,55.8 291.2,55.9 293.3,55.4 295.5,55.3 297.5,55.3 299.7,55.2 301.9,55.3 303.8,55.4 306.0,55.5 308.1,55.7 310.4,55.5 312.5,55.4 314.7,55.5 316.8,55.6 318.6,55.5 320.9,55.3 323.2,55.6 325.1,61.9 327.2,97.6 329.3,97.9 331.2,97.8 333.3,97.9 335.2,97.8 337.4,136.4 339.3,139.5 341.6,140.2 343.7,140.2 345.9,140.2 348.1,140.1 350.3,140.2 352.5,140.1 354.8,140.2 356.9,140.2 359.0,140.2 361.1,140.3 363.5,140.3 365.6,140.3 367.8,140.4 369.9,140.1 372.1,140.3 374.4,140.2 376.5,139.9 378.6,140.2 381.0,140.3 383.4,140.5 385.6,140.5 388.0,140.2 390.8,139.9 393.1,140.2 395.3,140.2 397.4,140.2 399.8,140.3 402.2,140.4 404.2,140.1 406.3,140.2 408.6,140.3 410.8,140.3 412.9,99.4 415.5,97.8 417.7,97.8 419.9,97.8 422.0,97.8 424.3,97.8 426.2,59.3 428.4,56.4 430.7,55.6 432.9,55.5 434.7,55.8 436.9,55.6 438.8,55.8 440.9,55.7 442.9,55.3 445.4,55.7 447.5,55.7 449.8,55.6 452.1,55.4 454.5,55.2 456.7,55.1 458.9,55.1 460.9,55.3 462.9,55.3 465.2,55.3 467.5,55.2 469.6,55.7 471.7,55.3 473.7,55.2 475.8,55.3 477.8,55.2 479.9,54.9 481.7,55.4 483.6,55.8 485.8,55.8 487.9,55.7 490.3,55.5 492.5,55.4 494.6,55.6 497.0,55.3 499.5,84.1 501.8,98.0 504.0,98.0 506.5,97.9 508.8,97.9 511.3,97.9 513.5,97.9 515.6,138.9 517.6,139.6 519.9,140.2 522.1,140.2 524.2,140.2 526.2,140.0 528.2,140.1 530.4,140.2 532.5,140.0 534.7,140.2 537.0,140.1 539.1,139.9 541.2,140.0 543.4,140.2 545.5,140.0 547.7,140.6 549.7,140.0 551.6,140.1 553.7,140.3 555.9,140.0 557.8,140.1 560.0,140.0 562.5,139.9 564.5,140.2 566.5,140.2 568.5,140.3 570.6,140.3 573.2,140.1 575.3,140.2 577.3,140.2 579.5,140.2 581.6,140.4 583.8,140.5 586.3,140.3 588.3,100.7 590.4,97.9 592.3,97.7 594.6,97.8 597.1,97.9 599.0,97.8 601.2,97.8 603.6,57.7 605.8,55.5 608.1,55.6 610.3,55.7 612.7,55.6 615.0,55.5 617.3,55.1 619.6,55.4 621.7,55.2 623.8,55.2 625.9,55.4 628.1,55.3 630.3,55.3 632.2,55.2 634.4,55.2 636.4,55.2 638.6,55.1 640.8,55.2 643.0,55.4 645.4,55.6 647.5,55.9 649.7,55.6 651.9,55.4 654.3,55.5 656.2,55.5 658.2,55.5 660.7,55.6 662.7,55.4 664.9,55.5 667.0,55.5 669.2,55.1 671.5,55.3 673.8,55.2 675.8,72.1 678.2,98.0 680.9,97.9 683.2,97.8 685.4,97.8 687.6,97.9 689.8,97.8 692.0,138.1 694.3,139.8 696.6,140.2 698.9,140.3 701.2,140.0 703.5,140.5 705.9,140.3 708.5,140.0 710.8,140.0 713.2,140.2 715.5,140.0 717.8,140.0 720.5,140.2 722.8,140.3 725.0,140.3 727.2,140.3 729.9,139.9 731.9,140.0 734.1,139.9 736.4,140.2 739.1,140.1 741.3,140.0 743.3,140.2 745.5,140.1 747.8,140.0 749.8,139.9 752.1,139.9 754.4,140.3 756.7,140.2 759.1,140.1 761.2,140.3 763.4,140.6 765.7,99.0 767.9,97.8 770.3,97.8 772.4,97.9 774.8,97.8 777.0,97.8 779.3,83.1 781.3,57.2 783.5,55.9 785.7,55.6 787.8,55.5 790.3,55.0 792.9,55.6 795.5,55.0 797.9,55.3 800.1,55.6 802.3,55.7 804.9,55.5 807.4,55.3 809.5,55.2 812.0,55.7 814.1,55.7 816.3,55.8 818.3,55.6 820.6,55.4 823.0,55.5 825.3,55.2 827.6,55.6 830.0,55.6 832.6,55.2 834.9,55.4 837.0,55.4 839.3,55.2 841.5,55.6 843.9,55.5 846.4,55.0 848.7,55.2 851.4,55.6 853.9,94.6 856.2,98.0 858.8,97.9 861.2,97.8 863.6,97.9 865.9,97.9 868.4,136.5 870.8,139.9 873.1,140.1 875.4,140.1 877.9,140.1 880.2,139.8 882.3,140.1 884.6,140.0 887.1,140.4 889.6,140.2 891.9,140.2 894.1,140.3 896.6,140.1 898.9,140.0 901.2,140.2 903.6,140.2 905.6,140.0 908.1,140.2 910.3,140.4 912.7,140.4 914.9,140.4 917.3,140.1 919.4,140.0 921.5,140.0 923.8,140.0 926.0,140.0 928.2,140.1 930.5,140.3 933.3,140.3 935.5,140.1 937.7,140.1 940.0,140.5"/>
<text x="56" y="44" fill="#8a9bb0" font-size="11" font-family="Segoe UI,Microsoft YaHei,sans-serif">速度 deg/s — 蓝 N2 / 橙 N3</text>
<line x1="56" x2="940" y1="97.8" y2="97.8" stroke="#2a3340" stroke-dasharray="4 3"/>
<polyline fill="none" stroke="#ff5c5c" stroke-width="1.3" points="56.0,229.9 58.3,229.9 60.4,229.9 62.6,229.9 64.7,229.8 66.8,229.8 69.0,229.8 71.1,229.9 73.0,229.8 75.3,229.8 77.3,229.8 79.3,229.9 81.0,229.9 83.2,229.8 85.4,229.8 87.3,229.9 89.4,229.8 91.2,229.8 93.3,229.8 95.5,230.0 97.5,229.8 99.7,229.9 102.1,229.9 104.1,229.9 106.2,229.8 108.2,229.9 110.3,229.8 112.5,229.8 114.9,229.8 117.1,230.0 119.2,229.9 121.4,229.9 123.8,229.9 126.1,229.9 128.2,229.9 130.7,229.8 133.0,229.8 135.5,229.9 137.6,229.9 140.0,229.9 142.0,229.9 144.4,229.9 146.7,229.9 148.9,229.9 151.2,229.8 153.7,230.0 155.8,229.8 157.9,229.8 160.0,229.9 162.1,229.9 164.1,229.9 166.4,229.8 168.5,229.9 170.8,229.9 173.1,229.8 175.1,229.8 177.2,229.9 179.4,230.0 181.5,229.8 183.7,229.9 185.8,229.8 188.0,229.8 189.8,229.9 191.9,229.9 194.0,229.8 196.1,229.9 198.2,229.9 200.4,229.9 202.5,229.8 204.7,229.9 206.8,229.8 209.0,229.8 211.0,229.9 213.0,229.9 215.0,229.8 217.2,229.9 219.2,229.8 221.2,229.8 223.3,229.9 225.3,229.8 227.4,230.0 229.4,229.9 231.9,229.9 234.3,229.8 236.3,229.9 238.4,229.9 240.5,229.9 242.5,229.9 244.6,229.9 246.6,229.9 248.5,230.1 250.8,230.3 253.0,229.7 255.2,229.7 257.2,230.6 259.4,230.5 261.5,230.8 263.6,230.9 265.7,230.5 267.8,230.3 270.1,230.5 272.1,230.5 274.1,229.1 276.6,230.2 278.8,230.0 281.1,230.0 283.1,229.8 285.2,229.8 287.2,230.5 289.2,230.4 291.2,229.7 293.3,228.9 295.5,229.0 297.5,229.3 299.7,228.9 301.9,229.3 303.8,229.8 306.0,229.7 308.1,229.2 310.4,229.0 312.5,229.0 314.7,229.4 316.8,229.3 318.6,229.9 320.9,229.4 323.2,199.7 325.1,200.9 327.2,229.6 329.3,229.9 331.2,229.9 333.3,230.0 335.2,229.7 337.4,229.6 339.3,229.5 341.6,229.7 343.7,229.7 345.9,230.0 348.1,229.7 350.3,230.0 352.5,229.9 354.8,229.9 356.9,229.9 359.0,229.7 361.1,229.8 363.5,229.7 365.6,229.5 367.8,229.6 369.9,229.9 372.1,230.2 374.4,230.0 376.5,229.6 378.6,230.0 381.0,230.1 383.4,230.0 385.6,229.9 388.0,229.7 390.8,229.7 393.1,230.0 395.3,229.9 397.4,229.9 399.8,229.8 402.2,230.0 404.2,230.3 406.3,229.6 408.6,230.3 410.8,234.3 412.9,231.0 415.5,229.9 417.7,229.9 419.9,230.0 422.0,229.9 424.3,229.9 426.2,229.6 428.4,230.1 430.7,229.2 432.9,229.0 434.7,230.2 436.9,230.2 438.8,230.5 440.9,230.5 442.9,230.2 445.4,230.3 447.5,230.1 449.8,230.0 452.1,229.3 454.5,230.0 456.7,229.8 458.9,230.1 460.9,230.2 462.9,229.1 465.2,230.2 467.5,230.2 469.6,230.3 471.7,230.2 473.7,229.9 475.8,229.4 477.8,228.4 479.9,228.9 481.7,230.0 483.6,230.4 485.8,230.5 487.9,230.1 490.3,229.8 492.5,230.1 494.6,230.1 497.0,229.3 499.5,240.3 501.8,229.9 504.0,230.1 506.5,230.0 508.8,229.9 511.3,229.9 513.5,229.9 515.6,229.7 517.6,229.3 519.9,229.6 522.1,229.9 524.2,230.1 526.2,229.8 528.2,229.7 530.4,229.9 532.5,229.5 534.7,229.7 537.0,230.0 539.1,229.5 541.2,229.6 543.4,229.6 545.5,229.7 547.7,230.2 549.7,229.7 551.6,229.6 553.7,230.0 555.9,229.7 557.8,229.9 560.0,229.5 562.5,229.5 564.5,229.8 566.5,229.7 568.5,229.9 570.6,230.3 573.2,229.8 575.3,229.7 577.3,229.9 579.5,230.0 581.6,230.1 583.8,230.1 586.3,229.9 588.3,224.7 590.4,229.9 592.3,229.8 594.6,229.9 597.1,229.9 599.0,229.9 601.2,229.9 603.6,229.6 605.8,230.0 608.1,230.5 610.3,230.3 612.7,230.3 615.0,229.4 617.3,228.5 619.6,229.2 621.7,229.2 623.8,228.9 625.9,228.7 628.1,228.9 630.3,228.8 632.2,229.2 634.4,228.8 636.4,229.3 638.6,229.2 640.8,229.0 643.0,229.4 645.4,230.2 647.5,230.6 649.7,230.2 651.9,230.1 654.3,230.1 656.2,230.3 658.2,230.4 660.7,230.1 662.7,230.2 664.9,230.1 667.0,230.2 669.2,229.8 671.5,229.9 673.8,229.7 675.8,243.6 678.2,230.2 680.9,229.9 683.2,229.8 685.4,229.8 687.6,229.9 689.8,229.9 692.0,230.2 694.3,229.4 696.6,229.7 698.9,229.7 701.2,229.5 703.5,229.9 705.9,230.6 708.5,229.6 710.8,229.8 713.2,230.1 715.5,229.7 717.8,229.6 720.5,229.7 722.8,230.1 725.0,230.3 727.2,230.5 729.9,229.5 731.9,229.8 734.1,229.9 736.4,229.9 739.1,230.0 741.3,229.7 743.3,229.8 745.5,229.7 747.8,229.5 749.8,229.4 752.1,229.4 754.4,229.9 756.7,229.7 759.1,229.7 761.2,229.5 763.4,230.0 765.7,227.2 767.9,229.9 770.3,229.8 772.4,229.9 774.8,229.8 777.0,229.8 779.3,229.5 781.3,230.0 783.5,230.1 785.7,229.8 787.8,229.9 790.3,228.2 792.9,230.1 795.5,229.2 797.9,229.1 800.1,230.3 802.3,230.6 804.9,230.1 807.4,229.1 809.5,229.4 812.0,230.6 814.1,230.4 816.3,230.7 818.3,229.6 820.6,230.3 823.0,230.1 825.3,229.7 827.6,230.0 830.0,229.2 832.6,229.9 834.9,230.0 837.0,230.0 839.3,229.8 841.5,230.2 843.9,229.7 846.4,229.7 848.7,229.8 851.4,229.5 853.9,249.1 856.2,229.9 858.8,229.9 861.2,229.9 863.6,229.9 865.9,229.9 868.4,229.2 870.8,229.6 873.1,229.5 875.4,229.8 877.9,229.6 880.2,229.6 882.3,229.6 884.6,229.6 887.1,229.7 889.6,229.9 891.9,229.9 894.1,229.7 896.6,229.9 898.9,229.4 901.2,230.4 903.6,229.8 905.6,230.0 908.1,229.7 910.3,230.1 912.7,230.0 914.9,230.2 917.3,229.7 919.4,229.6 921.5,229.7 923.8,229.6 926.0,229.7 928.2,229.5 930.5,229.6 933.3,229.9 935.5,229.8 937.7,229.9 940.0,230.2"/>
<text x="56" y="184" fill="#8a9bb0" font-size="11" font-family="Segoe UI,Microsoft YaHei,sans-serif">合速度 v2+v3(理论 0)— 停车尖峰在这里</text>
<line x1="56" x2="940" y1="229.9" y2="229.9" stroke="#3a4656" stroke-dasharray="4 3"/>
<polyline fill="none" stroke="#b48cff" stroke-width="1.3" points="56.0,373.0 58.3,373.0 60.4,373.0 62.6,373.0 64.7,373.0 66.8,373.0 69.0,373.0 71.1,373.0 73.0,373.0 75.3,373.0 77.3,373.0 79.3,373.0 81.0,373.0 83.2,373.0 85.4,373.0 87.3,373.0 89.4,373.0 91.2,373.0 93.3,373.0 95.5,373.0 97.5,373.0 99.7,373.0 102.1,373.0 104.1,373.0 106.2,373.0 108.2,373.0 110.3,373.0 112.5,373.0 114.9,373.0 117.1,373.0 119.2,373.0 121.4,373.0 123.8,373.0 126.1,373.0 128.2,373.0 130.7,373.0 133.0,373.0 135.5,373.1 137.6,373.1 140.0,373.0 142.0,373.0 144.4,373.0 146.7,373.0 148.9,373.0 151.2,373.1 153.7,373.1 155.8,373.1 157.9,373.1 160.0,373.1 162.1,373.1 164.1,373.1 166.4,373.1 168.5,373.1 170.8,373.1 173.1,373.1 175.1,373.1 177.2,373.0 179.4,373.0 181.5,373.0 183.7,373.1 185.8,373.1 188.0,373.1 189.8,373.1 191.9,373.1 194.0,373.1 196.1,373.1 198.2,373.1 200.4,373.1 202.5,373.1 204.7,373.1 206.8,373.1 209.0,373.1 211.0,373.1 213.0,373.1 215.0,373.1 217.2,373.1 219.2,373.1 221.2,373.1 223.3,373.0 225.3,373.0 227.4,373.0 229.4,373.0 231.9,373.0 234.3,373.0 236.3,373.0 238.4,373.0 240.5,373.0 242.5,373.1 244.6,373.1 246.6,373.1 248.5,372.3 250.8,371.9 253.0,371.9 255.2,372.0 257.2,372.0 259.4,372.1 261.5,372.0 263.6,372.1 265.7,372.1 267.8,372.1 270.1,372.2 272.1,372.3 274.1,372.4 276.6,372.5 278.8,372.5 281.1,372.6 283.1,372.6 285.2,372.6 287.2,372.6 289.2,372.6 291.2,372.6 293.3,372.6 295.5,372.7 297.5,372.8 299.7,372.8 301.9,372.9 303.8,373.1 306.0,373.1 308.1,373.1 310.4,373.1 312.5,373.2 314.7,373.2 316.8,373.2 318.6,373.3 320.9,373.3 323.2,360.9 325.1,316.7 327.2,307.2 329.3,307.2 331.2,307.2 333.3,307.2 335.2,307.2 337.4,307.2 339.3,307.3 341.6,307.3 343.7,307.2 345.9,307.2 348.1,307.1 350.3,307.1 352.5,307.0 354.8,307.0 356.9,307.0 359.0,307.0 361.1,307.0 363.5,307.0 365.6,306.9 367.8,306.9 369.9,306.9 372.1,306.9 374.4,306.9 376.5,306.9 378.6,306.9 381.0,306.9 383.4,306.8 385.6,306.8 388.0,306.8 390.8,306.8 393.1,306.8 395.3,306.8 397.4,306.8 399.8,306.8 402.2,306.7 404.2,306.7 406.3,306.7 408.6,306.7 410.8,306.8 412.9,325.2 415.5,325.6 417.7,325.5 419.9,325.5 422.0,325.5 424.3,325.5 426.2,324.6 428.4,324.3 430.7,324.2 432.9,324.1 434.7,324.2 436.9,324.2 438.8,324.2 440.9,324.2 442.9,324.2 445.4,324.2 447.5,324.2 449.8,324.2 452.1,324.2 454.5,324.1 456.7,324.2 458.9,324.2 460.9,324.2 462.9,324.2 465.2,324.2 467.5,324.2 469.6,324.2 471.7,324.2 473.7,324.2 475.8,324.1 477.8,324.2 479.9,324.2 481.7,324.3 483.6,324.1 485.8,324.1 487.9,324.2 490.3,324.3 492.5,324.3 494.6,324.3 497.0,324.3 499.5,327.7 501.8,331.0 504.0,331.0 506.5,331.0 508.8,331.0 511.3,331.0 513.5,331.0 515.6,330.8 517.6,330.9 519.9,330.8 522.1,330.8 524.2,330.9 526.2,330.9 528.2,330.9 530.4,330.8 532.5,330.8 534.7,330.8 537.0,330.8 539.1,330.8 541.2,330.8 543.4,330.8 545.5,330.8 547.7,330.8 549.7,330.8 551.6,330.8 553.7,330.9 555.9,330.8 557.8,330.8 560.0,330.8 562.5,330.8 564.5,330.8 566.5,330.9 568.5,330.9 570.6,330.9 573.2,330.8 575.3,330.9 577.3,330.8 579.5,330.9 581.6,330.9 583.8,330.8 586.3,330.9 588.3,320.6 590.4,319.1 592.3,319.1 594.6,319.1 597.1,319.1 599.0,319.1 601.2,319.1 603.6,317.9 605.8,317.7 608.1,317.6 610.3,317.7 612.7,317.8 615.0,317.8 617.3,317.7 619.6,317.6 621.7,317.6 623.8,317.7 625.9,317.7 628.1,317.7 630.3,317.6 632.2,317.7 634.4,317.7 636.4,317.8 638.6,317.8 640.8,317.8 643.0,317.8 645.4,317.7 647.5,317.7 649.7,317.7 651.9,317.6 654.3,317.7 656.2,317.7 658.2,317.7 660.7,317.7 662.7,317.7 664.9,317.7 667.0,317.7 669.2,317.7 671.5,317.7 673.8,317.7 675.8,318.9 678.2,332.3 680.9,332.3 683.2,332.3 685.4,332.3 687.6,332.3 689.8,332.3 692.0,331.9 694.3,331.8 696.6,331.7 698.9,331.7 701.2,331.7 703.5,331.7 705.9,331.7 708.5,331.7 710.8,331.7 713.2,331.7 715.5,331.8 717.8,331.8 720.5,331.8 722.8,331.8 725.0,331.8 727.2,331.8 729.9,331.8 731.9,331.8 734.1,331.8 736.4,331.8 739.1,331.9 741.3,331.9 743.3,331.9 745.5,331.9 747.8,331.9 749.8,331.8 752.1,331.9 754.4,331.9 756.7,331.8 759.1,331.8 761.2,331.8 763.4,331.8 765.7,320.6 767.9,319.7 770.3,319.7 772.4,319.8 774.8,319.8 777.0,319.8 779.3,319.7 781.3,318.9 783.5,318.7 785.7,318.7 787.8,318.6 790.3,318.5 792.9,318.6 795.5,318.6 797.9,318.5 800.1,318.5 802.3,318.5 804.9,318.6 807.4,318.5 809.5,318.5 812.0,318.5 814.1,318.5 816.3,318.5 818.3,318.4 820.6,318.5 823.0,318.5 825.3,318.5 827.6,318.4 830.0,318.4 832.6,318.4 834.9,318.4 837.0,318.4 839.3,318.3 841.5,318.3 843.9,318.4 846.4,318.3 848.7,318.4 851.4,318.4 853.9,334.2 856.2,340.8 858.8,340.7 861.2,340.7 863.6,340.7 865.9,340.7 868.4,340.4 870.8,340.3 873.1,340.3 875.4,340.3 877.9,340.4 880.2,340.5 882.3,340.4 884.6,340.4 887.1,340.4 889.6,340.4 891.9,340.4 894.1,340.4 896.6,340.4 898.9,340.4 901.2,340.3 903.6,340.4 905.6,340.4 908.1,340.3 910.3,340.3 912.7,340.3 914.9,340.4 917.3,340.4 919.4,340.4 921.5,340.5 923.8,340.5 926.0,340.5 928.2,340.5 930.5,340.5 933.3,340.7 935.5,340.8 937.7,340.8 940.0,340.8"/>
<text x="56" y="294" fill="#8a9bb0" font-size="11" font-family="Segoe UI,Microsoft YaHei,sans-serif">积分 ∫(v2+v3)dt(理论 0)— 每段末的偏离就是耦合误差</text>
<line x1="56" x2="940" y1="373.0" y2="373.0" stroke="#3a4656" stroke-dasharray="4 3"/>
<text x="56" y="408" fill="#6b7a8d" font-size="11" font-family="Segoe UI,sans-serif">t_rel 0.1..60.1s · ECAT 0.25ms · 竖线为行程起点</text>
<!-- 标注箭头 -->
<g font-family="Segoe UI,Microsoft YaHei,sans-serif" font-size="11">
<text x="750" y="70" fill="#3d8f5b">↑ 巡航:两线贴合,都不慢</text>
<text x="730" y="210" fill="#d4a017">↓ 停车沿:v_sum 尖峰 → 积分爬升</text>
</g>
</svg>
</section>
<section class="card">
<h2>图上怎么看(对照上方曲线)</h2>
<div class="annot-grid">
<div class="annot ok">
<div class="ann-title">↑ 巡航段(两条速度曲线几乎贴合)</div>
<p>N2 蓝线、N3 橙线在 |v|≈200°/s 附近重合 → <strong>都不慢</strong>。
下方红线 <code>v2+v3</code> 贴 0,积分曲线几乎水平 → 对接约束在稳态是成立的。</p>
</div>
<div class="annot warn">
<div class="ann-title">↓ 停车沿 / 换向点(红线尖峰)</div>
<p>接近终点时,一台速度先掉下来、另一台还在走 → <code>v2+v3</code> 出现 ±100~200°/s 尖峰。
积分曲线在这里<strong>爬上或爬下</strong>,行程结束就停在理论 0 以外几度到二十度。
<strong>这里是积分差的主要来源。</strong></p>
</div>
<div class="annot warn">
<div class="ann-title">↓ 行程刚衔接时(起动窗)</div>
<p>若上一段没完全停稳就反向,会看到“N3 早就动了”的假象(表格里 onΔ 为 −800ms 量级)。
这<strong>不是启动指令不同步</strong>,而是<strong>速度还在反向爬坡 / 残速</strong>,
也会把起动窗积分抬高一点。</p>
</div>
<div class="annot bad">
<div class="ann-title">↓ 紫色角度和 a2+a3</div>
<p>理论应为常数(理想为 0)。行程终点若偏到 ±10~25°,与该行程 ∫(v2+v3) 同量级 →
说明偏差是<strong>真实位移耦合误差</strong>,不只是速度噪声。</p>
</div>
</div>
<div class="callout small">
<strong>标定建议(若要压积分差):</strong>
① 先统一两台的减速段(同样斜率刹停),而不是去改巡航速度;
② 检查 N2/N3 负载与制动是否对称;
③ 控制器上对对接轴做 <code>v2+v3 → 0</code> 的纠偏,比单轴调 PID 更对症;
④ 序列 hold 不要短于“两台都确认停稳”的时间,避免重叠行程把积分搅浑。
</div>
</section>
<section class="card">
<h2>各行程明细(积分从行程起点重算)</h2>
<table>
<thead>
<tr>
<th>#</th><th>方向</th><th>起动Δ(N3-N2)</th><th>巡航|v| N2/N3</th>
<th>∫起动</th><th>∫巡航</th><th>∫停车</th>
<th>停时仅N2在动</th><th>停时仅N3在动</th><th>停车归因</th><th>Δ(a2+a3)</th>
</tr>
</thead>
<tbody>
<tr>
<td>#0</td><td>down</td>
<td class="ok">+0.00 ms</td>
<td class="ok">199.30 / 199.23</td>
<td>+0.00</td>
<td class="ok">-0.28</td>
<td class="warn">+7.34</td>
<td>+0.00</td>
<td>+1.29</td>
<td class="warn">N3 残速</td>
<td>-21.32°</td>
</tr>
<tr>
<td>#1</td><td>up</td>
<td class="bad">-827.50 ms</td>
<td class="ok">199.42 / 199.42</td>
<td>+28.52</td>
<td class="ok">+0.11</td>
<td class="warn">-7.58</td>
<td>+0.00</td>
<td>-0.84</td>
<td class="warn">N3 残速</td>
<td>+25.81°</td>
</tr>
<tr>
<td>#2</td><td>down</td>
<td class="bad">-840.50 ms</td>
<td class="ok">199.28 / 199.30</td>
<td>-2.08</td>
<td class="ok">+0.09</td>
<td class="warn">-3.39</td>
<td>+0.00</td>
<td>+0.00</td>
<td class="warn">两台接近同步刹停</td>
<td>-1.12°</td>
</tr>
<tr>
<td>#3</td><td>up</td>
<td class="ok">+2.25 ms</td>
<td class="ok">199.47 / 199.43</td>
<td>+0.12</td>
<td class="ok">+0.26</td>
<td class="warn">+5.99</td>
<td>+0.58</td>
<td>+0.00</td>
<td class="warn">N2 残速</td>
<td>-0.14°</td>
</tr>
<tr>
<td>#4</td><td>down</td>
<td class="ok">-1.50 ms</td>
<td class="ok">199.34 / 199.40</td>
<td>+0.74</td>
<td class="ok">+0.22</td>
<td class="warn">-7.46</td>
<td>-1.24</td>
<td>+0.00</td>
<td class="warn">N2 残速</td>
<td>+0.19°</td>
</tr>
<tr>
<td>#5</td><td>up</td>
<td class="ok">+1.50 ms</td>
<td class="ok">199.47 / 199.43</td>
<td>+0.26</td>
<td class="ok">+0.20</td>
<td class="warn">+6.13</td>
<td>+0.92</td>
<td>+0.00</td>
<td class="warn">N2 残速</td>
<td>-0.19°</td>
</tr>
<tr>
<td>#6</td><td>down</td>
<td class="ok">+0.00 ms</td>
<td class="ok">199.25 / 199.35</td>
<td>+0.64</td>
<td class="ok">+0.35</td>
<td class="warn">-11.65</td>
<td>-4.49</td>
<td>+0.00</td>
<td class="warn">N2 残速</td>
<td>+0.21°</td>
</tr>
<tr>
<td>#7</td><td>up</td>
<td class="ok">+0.00 ms</td>
<td class="ok">199.51 / 199.49</td>
<td>+0.15</td>
<td class="ok">-0.04</td>
<td class="ok">+1.92</td>
<td>+0.00</td>
<td>+0.00</td>
<td class="ok">两台接近同步刹停</td>
<td>-13.38°</td>
</tr>
</tbody>
</table>
<p class="sub" style="margin-top:10px">
单位:角度 deg(积分)· 速度 deg/s · 时间 ms。
「停时仅N2/N3在动」为该窗口内单边残速对 ∫(v2+v3) 的贡献。
</p>
</section>
<section class="card">
<h2>原始结论脚本输出(备查)</h2>
<pre style="white-space:pre-wrap;font-size:12px;color:#a8b6c8;margin:0">{
"main_source_of_integral_error": "起动段",
"phase_Isum_mean": {
"on": 3.5436874809462364,
"cruise": 0.11441910352287721,
"stop": 1.0892628954283632
},
"who_lags_at_stop": "N2",
"stop_lag_ms_n3_minus_n2_mean": 0,
"cruise_speed": "巡航速度接近",
"cruise_bias_deg_s": 0.0004292367234076469,
"plain": [
"启动:两台几乎同时动(差约 0.x ms),启动不是积分变大的主因。",
"巡航:两台平均速度几乎一样,积分在中段也很小(贴合理论 0)。",
"停车:积分差主要在这里积出来;N3 相对 N2 平均晚停约 0 ms。",
"停止窗里「只剩一台在动」时:主要是 N2 还在走,把 ∫(v2+v3) 推离 0。",
"所以:不是谁「全程都慢」,而是 N3 停车更拖、在停机瞬间继续走,积分被顶起来。",
"N2 也有停车滞后,但幅度和样本都更小;巡航略快一点点时对积分贡献有限。"
]
}</pre>
</section>
</body>
</html>

Some files were not shown because too many files have changed in this diff Show More