diff --git a/REPORT_ecat_skip_opt_20260917.md b/REPORT_ecat_skip_opt_20260917.md new file mode 100644 index 0000000..1c57219 --- /dev/null +++ b/REPORT_ecat_skip_opt_20260917.md @@ -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,已修。 diff --git a/css/layout.css b/css/layout.css index f7953e4..70fffa2 100644 --- a/css/layout.css +++ b/css/layout.css @@ -373,6 +373,128 @@ select option { background: var(--bg-panel); color: var(--text); } 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 青绿) */ #uploadGd32Btn { background: linear-gradient(135deg, var(--gd32) 0%, #f59e0b 100%); diff --git a/docs/compose/spec/ecat-rx-stuck.md b/docs/compose/spec/ecat-rx-stuck.md new file mode 100644 index 0000000..74dcc75 --- /dev/null +++ b/docs/compose/spec/ecat-rx-stuck.md @@ -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) diff --git a/esp32_server.py b/esp32_server.py index 17314a6..5a8e950 100644 --- a/esp32_server.py +++ b/esp32_server.py @@ -178,6 +178,35 @@ g_ecat_log_fp = None # native stdout 落盘句柄(见 _ecat_autostart 内注 g_ecat_lock = threading.Lock() # 串行化自动启动, 避免并发请求拉起多个进程 +def _set_ecat_realtime(pid: int) -> bool: + """把 ecat_native 提到 RealTime 进程优先级。 + + native 自己 SetPriorityClass(RT) 会被系统静默钳成 High(令牌无 + SeIncreaseBasePriorityPrivilege)。由本服务(启动方)从外部设置才能粘住。 + pdo 线程已是 TIME_CRITICAL:进程类 High→线程 15,RealTime→线程 31, + 后者才扛得住桌面 DPC/调度抢占。实测 1ms 档 late ~12/s → 个位数。 + 失败返回 False,不影响启动。 + """ + try: + import ctypes + import ctypes.wintypes as wt + PROCESS_SET_INFORMATION = 0x0200 + REALTIME_PRIORITY_CLASS = 0x00000100 + HIGH_PRIORITY_CLASS = 0x00000080 + k32 = ctypes.WinDLL("kernel32", use_last_error=True) + h = k32.OpenProcess(PROCESS_SET_INFORMATION, False, int(pid)) + if not h: + return False + try: + if k32.SetPriorityClass(h, REALTIME_PRIORITY_CLASS): + return True + return bool(k32.SetPriorityClass(h, HIGH_PRIORITY_CLASS)) + finally: + k32.CloseHandle(h) + except Exception: + return False + + class _GuiApi: """暴露给窗口内 JS (window.pywebview.api) 的 GUI 控制接口。""" @@ -342,6 +371,10 @@ def _ecat_autostart(): creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), ) logs.append(f"已自动启动 EtherCAT 原生守护进程(pid {g_ecat_child.pid})") + if _set_ecat_realtime(g_ecat_child.pid): + logs.append(f"EtherCAT 进程优先级已提到 RealTime(pid {g_ecat_child.pid})") + else: + logs.append("EtherCAT 进程 RealTime 提权失败(保持默认/High, 可手动设)") except OSError as e: return None, logs + [f"启动 EtherCAT 服务失败: {type(e).__name__}: {e}"] diff --git a/js/app.js b/js/app.js index 13d34c4..6201554 100644 --- a/js/app.js +++ b/js/app.js @@ -247,7 +247,7 @@ document.addEventListener('click', function (ev) { case 'ble-pick-file': var fi = document.getElementById('firmwareFile'); if (fi) fi.click(); break; case 'ble-clear-file': if (window.bleOta) window.bleOta.clearFile(); break; case 'start-upload': startUpload(arg); break; - case 'boot-ecat-probe': bootEcatProbe(true); break; + case 'boot-ecat-probe': if (!window.bootEcatBusy) bootEcatProbe(true); break; case 'boot-ecat-pick': bootEcatPick(); break; case 'boot-ecat-run': bootEcatFlash(); break; case 'boot-ecat-abort': bootEcatAbort(); break; diff --git a/js/common/polling.js b/js/common/polling.js index 5430863..ec986d2 100644 --- a/js/common/polling.js +++ b/js/common/polling.js @@ -58,6 +58,7 @@ var statusPollInFlight = false; // 是否正在请求 /system/status,防止 var logsInFlight = false; // 是否正在请求 /logs,防止重叠抢 socket var pageVisible = !document.hidden; // 页面可见性:切到后台/最小化/切标签会变 false var uploadPollChip = null; // 当前暂停轮询的上传目标,用于区分 WiFi/日志策略 +var networkDiagDone = false; // 本轮离线期间是否已做过网关诊断(防每秒刷屏) /** * 调整 /system/status 目标间隔。GD32 中继期用 400ms 缩小「转发 N/M」相对设备日志的滞后; @@ -101,6 +102,7 @@ function scheduleLogsPoll(delayMs) { export function startPolling() { window.consecutivePollFailures = 0; + networkDiagDone = false; statusPollIntervalMs = 1000; if (statusTimerId) clearTimeout(statusTimerId); if (logsTimerId) clearTimeout(logsTimerId); @@ -112,6 +114,7 @@ export function pausePollingForUpload() { window.pollingPausedForUpload = true; uploadPollChip = window.activeUploadChip || window.flashTarget || null; window.consecutivePollFailures = 0; + networkDiagDone = false; // ★ 上传大固件期间必须停掉 status/logs 轮询。 // httpd max_open_sockets=4 + lru_purge:轮询会再占 socket,LRU 可能踢掉 // 正在写 Flash 的 /ota/upload 连接 → 浏览器 upload 进度已 100%(发完), @@ -135,6 +138,7 @@ export function resumePollingAfterUpload() { window.pollingPausedForUpload = false; uploadPollChip = null; window.consecutivePollFailures = 0; + networkDiagDone = false; statusPollIntervalMs = 1000; if (logsTimerId) clearTimeout(logsTimerId); // 上传结束立即刷新一次状态;若当前已在请求中,则让它完成后按新间隔继续 @@ -201,6 +205,7 @@ export async function pollStatus() { // 恢复:之前有失败计数,说明设备曾离线,现在恢复在线 if (window.consecutivePollFailures > 0) { window.consecutivePollFailures = 0; + networkDiagDone = false; addLog('I', '系统', '设备已恢复在线,轮询恢复正常'); showToast('设备已恢复在线', 'success'); // 仅清理终态残留。禁止对活跃会话 otaSessionReset: @@ -278,13 +283,17 @@ export async function pollStatus() { var otaOngoing = window.pollingPausedForUpload || sessionActive || sessionFailed || window.isEsp32Rebooting || !!window.currentOtaTarget || lastDeviceBusy; - if (pageVisible && window.consecutivePollFailures >= 5 && !otaOngoing) { + /* EtherCAT 刷写/链路时不跑 WiFi 网关诊断:设备可能根本不在 WiFi 上, + * 反复弹「网关可达;设备不响应」只会干扰。仍走下面的「未连接」UI。 */ + var ecatActive = window.connectionMode === 'ecat' || !!window.bootEcatBusy; + if (pageVisible && window.consecutivePollFailures >= 5 && !otaOngoing && + !ecatActive && !networkDiagDone) { + networkDiagDone = true; addLog('W', '诊断', '设备连续 5 次无响应,正在诊断网络...'); var diag = await diagnoseNetwork(); - addLog('W', '诊断', diag); + addLog('W', '诊断', '诊断结果: ' + diag); updateConnectionUi(false, '诊断中'); window.applyDisconnectedRuntimeState(); - addLog('W', '诊断', '诊断结果: ' + diag); } // ★ 状态机优先:会话已 FAILED → 保持失败 UI,仅标连接胶囊为"设备忙/未连接" if (sessionFailed) { @@ -353,6 +362,7 @@ document.addEventListener('visibilitychange', function () { pageVisible = !document.hidden; if (!pageVisible) return; window.consecutivePollFailures = 0; + networkDiagDone = false; // 切回前台立即刷新状态;若当前已在请求中,取消等待中的下一次让它尽快重排 if (statusPollInFlight) { if (statusTimerId) clearTimeout(statusTimerId); @@ -419,6 +429,7 @@ setTimeout(function () { if (ipInput) { ipInput.addEventListener('change', function () { window.consecutivePollFailures = 0; + networkDiagDone = false; addLog('I', '系统', 'IP 已修改,重新启动轮询...'); if (!statusPollInFlight) { if (statusTimerId) clearTimeout(statusTimerId); diff --git a/js/ota/boot_ecat.js b/js/ota/boot_ecat.js index 60ad04f..9c68449 100644 --- a/js/ota/boot_ecat.js +++ b/js/ota/boot_ecat.js @@ -124,22 +124,42 @@ function showLog(res) { /** * @description 按"有没有已选 GD32 镜像 + 有没有任务在跑"重算按钮可用性。 * 幂等,供 file-handle / console-mode / upload 各处调用;不缓存判据、不复制 arm 判断。 + * 交接按钮文案由最后一次 probe/reboot 结果驱动(window.bootEcatIsBoot)。 */ export function refreshBootEcatUi() { - var probeBtn = el('bootEcatProbeBtn'); var runBtn = el('bootEcatRunBtn'); var abortBtn = el('bootEcatAbortBtn'); var rebootBtn = el('bootEcatRebootBtn'); - /* 别的通道在刷就互斥:三条传输共用同一块 flash,没有并发可言 */ var otherBusy = !!window.isUploading || !!(window.bleOta && window.bleOta.isUploading && window.bleOta.isUploading()); var hasImg = !!window.gd32File; - if (probeBtn) probeBtn.disabled = window.bootEcatBusy; - if (rebootBtn) rebootBtn.disabled = window.bootEcatBusy || otherBusy; + if (rebootBtn) { + rebootBtn.disabled = window.bootEcatBusy || otherBusy; + if (!window.bootEcatBusy) { + var isBoot = window.bootEcatIsBoot === true; + rebootBtn.textContent = isBoot ? '跳回应用' : '让应用回引导程序'; + rebootBtn.title = isBoot + ? '写 BOOT 的 CoE 0x2503:00=1,跳回 APP(BOOT ≥1.1.0.20)' + : '写 APP 的 CoE 0x2502:00=1,让它复位进引导程序'; + } + } if (runBtn) runBtn.disabled = window.bootEcatBusy || otherBusy || !hasImg; if (abortBtn) abortBtn.disabled = window.bootEcatBusy; } +function setRoleBadge(role, sw) { + var badge = el('bootEcatRoleBadge'); + var txt = el('bootEcatRoleTxt'); + var swEl = el('bootEcatRoleSw'); + if (!badge) return; + var r = role === 'boot' ? 'boot' : role === 'app' ? 'app' : 'unknown'; + badge.setAttribute('data-role', r); + if (txt) { + txt.textContent = r === 'boot' ? 'BOOT' : r === 'app' ? 'APP' : '未探测'; + } + if (swEl) swEl.textContent = sw || (r === 'unknown' ? '—' : '版本未知'); +} + /** @description 复用 GD32 那条路的文件控件:同一条 processFile 做 .hex→bin 与镜像校验。 */ export function bootEcatPick() { var input = el('fileInputGd32'); @@ -156,26 +176,23 @@ export async function bootEcatProbe(verbose) { try { r = await api({ op: 'probe' }); } catch (e) { - setTxt('bootEcatWho', '后端无响应'); - setEcatConn('err', '后端无响应'); + setRoleBadge('unknown', null); if (verbose) window.addLog('E', 'ECAT刷写', '探测请求异常:' + e); return null; } - var who = r.boot ? '引导程序' : (r.name ? '应用' : '读不到'); var ver = r.sw || '—'; - setTxt('bootEcatWho', who); + setRoleBadge(r.boot ? 'boot' : (r.name ? 'app' : 'unknown'), r.sw || null); setTxt('bootEcatName', r.name || '—'); setTxt('bootEcatSw', ver); setTxt('bootEcatArm', r.armed ? '允许' : '不允许'); - setTxt('bootEcatRecv', bytes(r.slave_recv || 0)); - if (r.boot) setEcatConn('ok', '引导程序 ' + ver + ' · 可以刷写'); - else if (r.name) setEcatConn('warn', '应用 ' + ver + ' · 刷写会自动交接回 BOOT'); - else setEcatConn('err', '邮箱无回应'); + setTxt('bootEcatRecv', (r.srv_recv != null ? bytes(r.srv_recv) : '—') + ' / ' + bytes(r.slave_recv || 0)); + window.bootEcatIsBoot = !!r.boot; + refreshBootEcatUi(); if (verbose) { window.addLog('I', 'ECAT刷写', '0x1008 = "' + (r.name || '—') + '" 0x100A = "' + (r.sw || '') + '" → ' + - (r.boot ? '是引导程序,可以刷写' - : (r.name ? '是应用,刷写链里第 3 步会写它的 0x2502 让它回 BOOT(1.6.34 以前的 APP 没这个对象,只能走 SPI 0xB0 / CAN 0x320)' + (r.boot ? '是引导程序,点主按钮可跳回 APP(0x2503)' + : (r.name ? '是应用,点主按钮写 0x2502 进 BOOT' : '邮箱没回应,多半是老 BOOT 或栈没起来'))); } return r; @@ -243,42 +260,71 @@ async function waitBootOnBus(maxTries, gapMs) { } /** - * @description 单独一次「让应用回引导程序」:写 APP 的 CoE 0x2502:00 = 1。 - * 刷写链里本来就带这一步,这个按钮是给"只想把板子停在 BOOT 上等人查"的场合 —— - * 交接一个字节 flash 都不写,代价只是从站重启一次、停在 BOOT 的 10s 窗口里等。 - * 跑着老 APP(<1.6.34)的机器字典里没有 0x2502,主站会把从站的 SDO abort 码原样带回来, - * 日志里直接就能看出该退回 SPI 0xB0 / CAN 0x320。 + * @description 交接按钮总入口:按对端身份自动选方向。 + * - 已在 BOOT → op=jump_app(写 0x2503) + * - 在 APP / 未知 → op=reboot(写 0x2502) + * 刷写链里本来就带 reboot 这一步;本按钮给"只想手动来回切换"的场合。 */ export async function bootEcatReboot() { if (window.bootEcatBusy) return; window.bootEcatBusy = true; refreshBootEcatUi(); var btn = el('bootEcatRebootBtn'); - if (btn) btn.textContent = '交接中…'; - setEcatMsg('正在让应用回引导程序…'); + var isBoot = window.bootEcatIsBoot === true; + if (btn) btn.textContent = isBoot ? '跳转中…' : '交接中…'; + setEcatMsg(isBoot ? '正在让引导程序跳回应用…' : '正在让应用回引导程序…'); try { if (!await ensureBusOpen()) { setEcatStatus('err', '总线没打开'); setEcatMsg('主站没能打开总线(见日志)'); return; } - var r = await api({ op: 'reboot' }); + /* 先用本次动作的方向;若 probe 结果与缓存不符,会话内纠正 */ + var op = window.bootEcatIsBoot === true ? 'jump_app' : 'reboot'; + var r = await api({ op: op }); showLog(r); if (!r || r.ok !== true) { - setEcatStatus('err', '交接被拒'); - setEcatMsg('交接没被受理(见日志里从站的 abort 码)'); + setEcatStatus('err', op === 'jump_app' ? '跳回被拒' : '交接被拒'); + setEcatMsg(op === 'jump_app' + ? '跳回没被受理(BOOT 需 ≥1.1.0.20;见日志 abort 码)' + : '交接没被受理(见日志里从站的 abort 码)'); await bootEcatProbe(false); return; } if (r.already) { - setEcatStatus('ok', '对面本来就是引导程序'); - setEcatMsg('从站已在 BOOT,无需交接,可直接刷写'); + if (op === 'jump_app') { + window.bootEcatIsBoot = false; + setEcatStatus('ok', '对面本来就是应用'); + setEcatMsg('从站已在 APP,无需跳回'); + } else { + window.bootEcatIsBoot = true; + setEcatStatus('ok', '对面本来就是引导程序'); + setEcatMsg('从站已在 BOOT,无需交接,可直接刷写'); + } + refreshBootEcatUi(); await bootEcatProbe(true); return; } + if (op === 'jump_app') { + setEcatMsg('跳回已受理,等 APP 上总线…'); + var a = await waitAppOnBus(10, 800); + if (a && a.name && !a.boot) { + window.bootEcatIsBoot = false; + setEcatStatus('ok', '已回到应用'); + setEcatMsg('APP 已在总线应答(0x100A = ' + (a.sw || '—') + ')'); + } else { + setEcatStatus('warn', '跳回后没等到 APP'); + setEcatMsg('写了 0x2503 但没等到应用 —— 看串口,或 A 区 CRC 不过'); + window.addLog('E', 'ECAT刷写', + '跳回请求已受理却等不到 APP 应答 —— 确认 BOOT ≥1.1.0.20 且 A 区镜像完整'); + } + refreshBootEcatUi(); + return; + } setEcatMsg('交接已受理,等 BOOT 上总线…'); var b = await waitBootOnBus(10, 800); if (b && b.boot) { + window.bootEcatIsBoot = true; setEcatStatus('ok', '已进引导程序'); setEcatMsg('BOOT 已在总线应答(0x100A = ' + (b.sw || '—') + '),窗口 10s:可以直接刷写'); } else { @@ -293,15 +339,27 @@ export async function bootEcatReboot() { } } catch (e) { setEcatStatus('err', '请求异常'); - setEcatMsg('交接请求异常'); - window.addLog('E', 'ECAT刷写', '交接请求异常:' + e); + setEcatMsg('交接/跳回请求异常'); + window.addLog('E', 'ECAT刷写', '交接/跳回请求异常:' + e); } finally { window.bootEcatBusy = false; - if (btn) btn.textContent = '让应用回引导程序'; refreshBootEcatUi(); } } +/** + * @description 跳回之后等 APP 真的回到总线(与 waitBootOnBus 对称)。 + */ +async function waitAppOnBus(maxTries, gapMs) { + var last = null; + for (var k = 0; k < maxTries; k++) { + last = await bootEcatProbe(false); + if (last && last.name && !last.boot) return last; + await new Promise(function (res) { setTimeout(res, gapMs); }); + } + return last; +} + /** @description 放弃:丢弃服务端缓冲。总线此刻本来就没动过(推只到主站内存)。 */ export async function bootEcatAbort() { if (window.bootEcatBusy) return; diff --git a/pages/index.html b/pages/index.html index 690f3d9..3fb3170 100644 --- a/pages/index.html +++ b/pages/index.html @@ -13,7 +13,7 @@ - + @@ -145,34 +145,27 @@
点击按钮连接 / 断开蓝牙设备
- + @@ -569,7 +562,7 @@ 必须持有租约,否则服务端在租约归零宽限期后会把循环又停掉。 与 tabs.html / motor.html 并存时连接数>1,互不影响。 --> - +