/** * boot_ecat.js - 经 EtherCAT 邮箱直灌 GD32 APP(BOOT 侧收包),与「GD32 OTA」并行、互为备份 * * 这条链路完全不经过 ESP32/WiFi/BLE:浏览器 → 本页同源代理 /ecat/api/ota/ecat * → esp32_server._ecat_proxy → ecat_native /api/ota/ecat → CoE SDO 分段下载 → BOOT 写 A 区。 * 立项理由:出厂首刷时板上根本没有 APP,SPI/WiFi 那条要靠 ESP32 中继才存在;空板 * (A 区不可启动)时 BOOT 自己放行,不需要任何令牌。 * * 文件复用上面「GD32 固件」那张卡已选的 window.gd32File: * file-handle.js 的 processFile 对 .hex 已做过 convertHexFileToBin(=parseIntelHex, * 0x04/0x02 改基址、段间空洞填 0xFF、逐行校验和),并用 checkGd32ImageFile 拦掉 * 「引导程序包 / 向量表非法」。所以这里只读扁平字节,不再抄一份 HEX 解析器 —— * 解析器抄两份一定会漂,而灌错包的后果是"刷成功但永远跳不起来"。 * ⚠ 但刷前必须按 FSAPI 句柄重读磁盘(readFreshImage):本地重编会覆盖同路径的 .hex, * 页面里的 window.gd32File 还是"选文件那一刻"的旧内容,不复读就会把旧包灌进 A 区, * 表现为「控制台全绿、板子版本/Build 不变」。与 upload-esp32/BLE/电机 OTA 同一条规矩。 * * 为什么点「直灌」时才 open+push,而不是选完文件就推: * native 的镜像缓冲跨请求存活(op=abort 才丢)。选文件时推 = 服务端可能留着上一轮 * 镜像,改完文件不重推就会把旧包刷进去。改成"按下才推",缓冲与界面上的文件天然同步。 * * 步骤顺序照电机 OTA 那条:**先把镜像收进主站,最后一步才把板子踢进 BOOT**。 * open/push 只动主站内存,板子此刻还在跑 APP —— 文件选错、推送中断、镜像非法, * 代价都是"什么都没发生"。交接(写 APP 的 CoE 0x2502:00=1)排在灌写前一瞬, * 因为 BOOT 的 10s 窗口是从复位那一刻就开始烧的。 * * 判据只放一处:对面是谁(BOOT 还是 APP、有没有 arm)由 BOOT 里的 ecatArmed() 裁决, * 主站只把 0x1008 的读数写进日志、不当门槛(对着 APP 写 0x2500 只会 SDO abort, * 对着老 BOOT 只会超时,两种都写不进一个字节)。所以"探测"是纯诊断,不解锁任何按钮。 * 交接后那一格「确认引导程序」同属读数:它提前告诉你写没写对人,但不接管放行。 * * 代理超时:run 一次要走 519 个邮箱帧 + 整区预擦,实测会到几十秒;reboot/probe 走 * native 的两级邮箱自愈,最坏 7s+。**R1 后 _ecat_proxy 不对非幂等路径自动重发** * (假超时/服务刚拉起时返回 503,需用户/脚本显式重试);run 超时给 300s、其余 60s。 */ import { ECAT_DEFAULT_ADAPTER, runEcatBootstrap } from "../common/ecat-bus.js"; var BOOT_API = '/ecat/api/ota/ecat'; var PUSH_BYTES = 24576; /* 转 HEX 后 49152 字符,留在 native 65535 的 body 上限内 */ var HEX2 = []; for (var i = 0; i < 256; i++) HEX2.push(i.toString(16).padStart(2, '0')); window.bootEcatBusy = false; var armedOnce = false; /* 二次确认的中间态:会抹掉 A 区且中途无回退 */ var armTimer = null; function el(id) { return document.getElementById(id); } function setTxt(id, t) { var n = el(id); if (n && n.textContent !== t) n.textContent = t; } function bytes(n) { return (n >>> 0).toLocaleString() + ' B'; } /** * @description 写 bootEcatBusy / bootEcatIsBoot 的唯一出口,只为留一条带时刻的读数。 * 为什么收在一处:这两个标志就是外壳 Owner 要不要重拉总线的门 * (js/common/device-link.js 里 `if (window.bootEcatBusy || ...) return;` 与 * `if (window.bootEcatIsBoot === true) return;`)。2026-10-12「交接后 EtherCAT 连不上、 * 要重启才行」要看的就是「这扇门被谁在什么时候按住、按了多久」——散在八处各打一条 * 日志必然漂,所以判据只留一个执行点。 * 只在值真的翻转时打印:反复赋同一个值不是事件,刷屏反而盖掉真读数。 */ function setEcatFlag(name, val) { var next = !!val; if (window[name] === next) return; window[name] = next; if (window.addLog) { window.addLog('I', 'ECAT刷写', '[门] ' + name + '=' + (next ? 'true' : 'false') + '(t=+' + Math.round(performance.now()) + 'ms)'); } } function hexOf(u8, start, end) { var s = ''; for (var k = start; k < end; k++) s += HEX2[u8[k]]; return s; } /* op→前端超时(ms)。代理侧:run=300s / 其余 ota=60s;前端要更短,避免 * 后端假死后按钮永远「刷写中…」——超时后 finally 一定会清 busy。 */ var API_TMO = { open: 30000, push: 30000, abort: 10000, status: 10000, probe: 20000, reboot: 25000, run: 180000 }; async function api(body) { var op = (body && body.op) || ''; var tmo = API_TMO[op] || 20000; var r = await fetch(BOOT_API, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}), signal: AbortSignal.timeout(tmo) }); return await r.json(); } /** @brief 打这条路由之外的 /ecat/api/* (status/open 等主站通用口)。 */ async function post(path, body, timeoutMs) { var r = await fetch('/ecat/api/' + path, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body || {}), signal: AbortSignal.timeout(timeoutMs || 8000) }); return await r.json(); } /** * @description 让主站把总线打开(枚举到 PREOP 就停),已经是开的就什么都不做。 * 为什么刷写卡片要自己管这一条:交接链必须一次点完 —— BOOT 的 10s 窗口从复位那刻 * 就开始烧,中间不能停下来让人去电机页点连接。 * 网卡子串与电机页共用同一个 localStorage 键 dcu_ecat_adapter(换过网卡的场合不重存一份)。 * /api/open 内部会先 loop_stop 再 ec_init + 重枚举,所以它一定掐掉正在跑 PDO 的电机循环; * 但刷写期间这不构成互相打架:BOOT 钉在 PREOP,电机页那套 2s 自愈探测器打 /motor/start * 也进不了 OP,循环起不来 —— 结构上就让位了,不需要额外互斥标志。 * @note 必须带重试(最多 3 次,间隔 2s):commit 后新 APP 起板看到 warm 残值会自己补 * 一发 LAN9252 RST# 对齐 POR(esc_lan9252.cpp:441-446),芯片+PHY 整颗复位 → I210 链路 * 重协商 1~2s,第一拍 open 大概率落在抖动窗口里。而 /api/open 是非幂等路径,代理到点 * 只回 503「请重试一次」、绝不自动重发(ecat_client.py R1/P0)—— 这句"请重试"只能在 * 这里执行;不重试的话后面 6 轮 probe 全打在没重建的会话上,读数必然为空,却会被 * 当成"从站 EtherCAT 没恢复"报给用户(2026-10-09 21:49 就是这么误判的)。 */ async function ensureBusOpen(force) { if (!force) { try { var st = await post('status', {}, 6000); if (st && st.ok === true) return true; } catch (_) { /* 后端还没起来:下面 open 请求会触发代理自动拉起 */ } } var substr = ECAT_DEFAULT_ADAPTER; try { var s = localStorage.getItem('dcu_ecat_adapter'); if (s && s.trim()) substr = s.trim(); } catch (_) {} for (var i = 0; i < 3; i++) { if (i > 0) { window.addLog('I', 'ECAT刷写', '重开总线 第 ' + (i + 1) + '/3 次(等链路重协商)…'); await new Promise(function (res) { setTimeout(res, 2000); }); } var r = null; try { /* 35s:必须比代理的 30s 长,否则浏览器先断 —— 那样连后端那句结构化的 * 「请重试一次」都拿不到,只剩一条没信息量的超时。 */ r = await post('open', { substr: substr }, 35000); } catch (e) { r = { ok: false, log: ['重开请求异常: ' + e] }; } showLog(r); if (r && r.ok === true) return true; } return false; } /** @description 把 native 回的多行 log 逐条落到控制台日志(与 WiFi/BLE 那条同一个日志池)。 */ function showLog(res) { (res.log || []).forEach(function (l) { var bad = /失败|FAIL|错误|不符|异常|无效|超|拒绝/.test(l); window.addLog(bad ? 'E' : 'I', 'ECAT刷写', l); }); } /** * @description 按"有没有已选 GD32 镜像 + 有没有任务在跑"重算按钮可用性。 * 幂等,供 file-handle / console-mode / upload 各处调用;不缓存判据、不复制 arm 判断。 * 交接按钮文案由最后一次 probe/reboot 结果驱动(window.bootEcatIsBoot)。 */ export function refreshBootEcatUi() { var runBtn = el('bootEcatRunBtn'); var abortBtn = el('bootEcatAbortBtn'); var rebootBtn = el('bootEcatRebootBtn'); var otherBusy = !!window.isUploading || !!(window.bleOta && window.bleOta.isUploading && window.bleOta.isUploading()); var hasImg = !!window.gd32File; 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 与镜像校验。 * 优先 FSAPI 选择器(与共享模式同一形状):它留下句柄并持久化,本卡片刷前才能 * "热重载"按句柄重读磁盘;传统 选完即无句柄,重编后会退化成中止+提示。 */ export function bootEcatPick() { if (window.showOpenFilePicker && typeof window.pickFileViaFSAPI === 'function') { Promise.resolve(window.pickFileViaFSAPI('gd32')).catch(function (e) { if (e && e.name !== 'AbortError') { window.addLog('W', 'ECAT刷写', 'FSAPI 选择失败: ' + (e.message || e) + ',可改用上传区重新选择'); } }); return; } var input = el('fileInputGd32'); if (!input) { window.showToast('找不到 GD32 文件选择控件', 'error'); return; } input.click(); } /** @description 探测对面是谁。纯诊断读数,不参与放行判断。 */ export async function bootEcatProbe(verbose) { var r; try { r = await api({ op: 'probe' }); } catch (e) { setRoleBadge('unknown', null); if (verbose) window.addLog('E', 'ECAT刷写', '探测请求异常:' + e); return null; } var ver = r.sw || '—'; setRoleBadge(r.boot ? 'boot' : (r.name ? 'app' : 'unknown'), r.sw || null); setTxt('bootEcatName', r.name || '—'); setTxt('bootEcatSw', ver); setTxt('bootEcatArm', r.armed ? '允许' : '不允许'); setTxt('bootEcatRecv', (r.srv_recv != null ? bytes(r.srv_recv) : '—') + ' / ' + bytes(r.slave_recv || 0)); setEcatFlag('bootEcatIsBoot', !!r.boot); refreshBootEcatUi(); if (verbose) { window.addLog('I', 'ECAT刷写', '0x1008 = "' + (r.name || '—') + '" 0x100A = "' + (r.sw || '') + '" → ' + (r.boot ? '是引导程序,点主按钮可跳回 APP(0x2503)' : (r.name ? '是应用,点主按钮写 0x2502 进 BOOT' : '读不到 0x1008(reason=' + (r.reason || '旧 native 无此字段') + (r.detail ? ',' + r.detail : '') + ')'))); } return r; } function setEcatConn(state, text) { var box = el('bootEcatConn'); var txt = el('bootEcatConnTxt'); if (box) box.className = 'conn-indicator ' + state; if (txt) txt.textContent = text; } function setEcatStage(n, state, detail) { var node = el('ec' + n); var det = el('ecd' + n); if (!node) return; node.className = 'stage-node' + (state ? ' ' + state : ''); if (det) det.textContent = detail || ''; } /** @description 清阶段条。导出是给 console-mode 用的:节点数在 6→7 改过一次, * 本地再抄一份循环必然漂。 */ export function resetEcatStages() { for (var i = 1; i <= 7; i++) setEcatStage(i, '', ''); } window.resetEcatStages = resetEcatStages; function setEcatStatus(kind, text) { var bar = el('stageStatusBar'); var txt = el('stageStatusText'); if (bar) bar.className = 'stage-status-bar' + (kind ? ' ' + kind : ''); if (txt) txt.textContent = text; } function setEcatPct(p, msg) { var n = Math.max(0, Math.min(100, Math.round(Number(p) || 0))); var fill = el('progressFill'); var pct = el('progressPct'); var txt = el('progressTxt'); if (fill) { fill.style.width = n + '%'; fill.classList.toggle('live', n > 0 && n < 100); } if (pct) pct.textContent = n + '%'; if (txt && msg != null && msg !== '') txt.textContent = msg; } function setEcatMsg(msg) { var txt = el('progressTxt'); if (txt) txt.textContent = msg; } /** * @description 交接之后等 BOOT 真的回到总线,把身份读回来。 * 这里刻意**不**再无条件打一次 /api/open:probe 内部那两级自愈(单播活性判据 → 必要时 * 全量重枚举)就是为"从站刚硬复位、ESC 里 STADR/SM/邮箱配置全被清成 0"准备的; * 而在这里多花的每一毫秒都是从 BOOT 那 10s 窗口里扣的。 * @note 旧注释写的"probe 实测只多付 ~180ms"只在**从站健康**时成立(2026-10-13 正极 * 实测 15-69ms)。从站推不进 PREOP 时它最坏要 28.5s,比前端 20s 超时还长 —— 那才是 * 页面卡死、要重启主站的成因,native 侧已按墙钟预算封顶(最坏 ≈10.6s)。 * @return {object|null} 最后一次 probe 读数;拿到 boot:true 就提前返回 */ async function waitBootOnBus(maxTries, gapMs) { var last = null; var t0 = performance.now(); for (var k = 0; k < maxTries; k++) { var rp = performance.now(); last = await bootEcatProbe(false); /* 每轮都留一行,别只留最后一句总结:native 的 reason 分型(no_slave / no_preop / * reopen_fail / sdo_timeout / app)是"链路没恢复"和"probe 没重试"唯一的区分读数, * 以前 verbose=false 把它整轮丢弃,2026-10-12 那轮就是因此定不下断点。 * 耗时也一起打:2026-10-13 实测「从站能枚举到、但 AL 推不进 PREOP」时单轮 probe * 要 28.5s(三级 dl_pull_preop × 4 次 2s statecheck),比前端 API_TMO.probe=20s * 还长 —— 浏览器先 abort、native 仍在锁内跑完它,于是等锁实测到 54s。native 现已 * 按「必须短于 20s」配了墙钟预算(最坏 ≈10.6s)。没有这一列就看不出窗口被谁吃掉。 */ if (window.addLog) { var s = (last && last.stage_ms) || {}; window.addLog('I', 'ECAT刷写', '[盯BOOT] #' + (k + 1) + '/' + maxTries + ' t=+' + Math.round(performance.now() - t0) + 'ms' + ' 本轮=' + Math.round(performance.now() - rp) + 'ms' + ' boot=' + (last && last.boot ? 'Y' : 'N') + ' reason=' + ((last && last.reason) || '无响应') + ' name="' + ((last && last.name) || '') + '"' + (s.total_ms != null ? ' 分段[preop=' + s.preop_ms + '/第' + s.preop_try + ' enum=' + s.enum_ms + '/第' + s.enum_try + ' reopen=' + s.reopen_ms + ' sdo=' + s.sdo_name_ms + ']' : '')); } if (last && last.boot) return last; /* 主站会话被它自己的全量重连兜底关掉了(ec_close 后 do_open 失败 → g_bus_open 留在 * 0),之后每条请求都只会回 bus_closed,角色格永远停在「未探测」—— 2026-10-13 实测 * t=+15s 就是这一格。这里立刻按上电那条路重开一次(复用 ensureBusOpen,不另写一套 * 恢复逻辑),否则剩下几轮全打在死会话上,白烧 BOOT 的窗口。 */ if (last && last.bus_open === 0) { window.addLog('W', 'ECAT刷写', '[盯BOOT] 主站会话已被兜底重连关掉(bus_open=0),按上电路径重开总线…'); await ensureBusOpen(true); } await new Promise(function (res) { setTimeout(res, gapMs); }); } return last; } /** * @description 交接按钮总入口:按对端身份自动选方向。 * - 已在 BOOT → op=jump_app(写 0x2503) * - 在 APP / 未知 → op=reboot(写 0x2502) * 刷写链里本来就带 reboot 这一步;本按钮给"只想手动来回切换"的场合。 */ export async function bootEcatReboot() { if (window.bootEcatBusy) return; setEcatFlag('bootEcatBusy', true); refreshBootEcatUi(); var btn = el('bootEcatRebootBtn'); var isBoot = window.bootEcatIsBoot === true; if (btn) btn.textContent = isBoot ? '跳转中…' : '交接中…'; setEcatMsg(isBoot ? '正在让引导程序跳回应用…' : '正在让应用回引导程序…'); try { if (!await ensureBusOpen()) { setEcatStatus('err', '总线没打开'); setEcatMsg('主站没能打开总线(见日志)'); return; } /* 先用本次动作的方向;若 probe 结果与缓存不符,会话内纠正 */ var op = window.bootEcatIsBoot === true ? 'jump_app' : 'reboot'; var r = await api({ op: op }); showLog(r); if (!r || r.ok !== true) { setEcatStatus('err', op === 'jump_app' ? '跳回被拒' : '交接被拒'); setEcatMsg(op === 'jump_app' ? '跳回没被受理(BOOT 需 ≥1.1.0.20;见日志 abort 码)' : '交接没被受理(见日志里从站的 abort 码)'); await bootEcatProbe(false); return; } if (r.already) { if (op === 'jump_app') { setEcatFlag('bootEcatIsBoot', false); setEcatStatus('ok', '对面本来就是应用'); setEcatMsg('从站已在 APP,无需跳回'); } else { setEcatFlag('bootEcatIsBoot', true); setEcatStatus('ok', '对面本来就是引导程序'); setEcatMsg('从站已在 BOOT,无需交接,可直接刷写'); } refreshBootEcatUi(); await bootEcatProbe(true); return; } if (op === 'jump_app') { setEcatMsg('跳回已受理,等 APP 上总线…'); var a = await waitAppOnBus(12, 300); if (a && a.name && !a.boot) { setEcatFlag('bootEcatIsBoot', false); setEcatMsg('APP 已在总线应答(0x100A = ' + (a.sw || '—') + '),恢复连接…'); var restored = await restoreEcatLikePowerOn(); setEcatStatus(restored ? 'ok' : 'warn', restored ? '已回到应用' : 'APP 已起,连接未完全恢复'); setEcatMsg(restored ? 'APP 已回到总线,连接已按上电流程恢复(0x100A = ' + (a.sw || '—') + ')' : 'APP 已应答但 open/motor/start 未全部成功 —— 见日志,可再点『探测』或手动连接'); } 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) { setEcatFlag('bootEcatIsBoot', true); setEcatStatus('ok', '已进引导程序'); setEcatMsg('BOOT 已在总线应答(0x100A = ' + (b.sw || '—') + '),窗口 10s:可以直接刷写'); } else { setEcatStatus('warn', '交接后没等到 BOOT'); setEcatMsg('写了 0x2502 但 10s 内没等到引导程序 —— 看 BOOT 串口,或板子还停在 APP'); var why = (b && b.name) ? '0x1008 仍是「应用」:' + b.name + '(板子没进 BOOT,或进了又跳回)' : '邮箱无回应:从站未被拉回 PREOP(链路或 BOOT 邮箱未通,查 BOOT 串口 PDI ok 之后的输出)'; window.addLog('E', 'ECAT刷写', '交接请求已受理却等不到 BOOT 应答 —— ' + why + ';另确认 BOOT 版本是否 ≥1.1.0.12(旧版窗口一到就跳回 APP)'); } } catch (e) { setEcatStatus('err', '请求异常'); setEcatMsg('交接/跳回请求异常'); window.addLog('E', 'ECAT刷写', '交接/跳回请求异常:' + e); } finally { setEcatFlag('bootEcatBusy', false); refreshBootEcatUi(); } } /** * @description 跳回之后等 APP 真的回到总线(与 waitBootOnBus 对称)。 * 首轮前先短等 300ms(覆盖 BOOT 80ms 静默 + APP init),gap 收紧到 300ms: * 旧 800ms 会让「其实已经好了」多愣一拍。 */ async function waitAppOnBus(maxTries, gapMs) { await new Promise(function (res) { setTimeout(res, 300); }); 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 || 300); }); } return last; } /** * @description APP 回总线后,按「刚上电」同一条链拉起连接: * health → open 网卡 → motor/start(PREOP→SAFEOP→OP + PDO 循环)。 * 与 device-link Owner 的 bootstrapEcatBus / runEcatBootstrap 同一实现, * 不在本文件另写一套恢复逻辑。 */ async function restoreEcatLikePowerOn() { setEcatMsg('按上电流程恢复连接(open + motor/start)…'); try { var r = await runEcatBootstrap({ ecatPost: post, fromLocalStorage: true, reason: 'user_probe', }); if (r && r.ok) { window.addLog('I', 'ECAT刷写', r.reused ? 'PDO 循环本已在跑,复用(与上电幂等语义一致)' : '已按上电流程恢复:open + motor/start(OP + PDO)'); return true; } } catch (e) { window.addLog('W', 'ECAT刷写', '按上电流程恢复连接失败:' + (e && e.message ? e.message : e)); } return false; } /** @description 放弃:丢弃服务端缓冲。总线此刻本来就没动过(推只到主站内存)。 */ export async function bootEcatAbort() { if (window.bootEcatBusy) return; try { await api({ op: 'abort' }); } catch (_) { /* 后端不在就只清界面 */ } clearArm(); setEcatMsg('已放弃:服务端缓冲丢弃,总线没动过'); setEcatPct(0); setEcatStatus('err', '已放弃'); window.addLog('I', 'ECAT刷写', '已放弃本轮:服务端缓冲丢弃,总线没动过'); refreshBootEcatUi(); } function clearArm() { armedOnce = false; if (armTimer !== null) { clearTimeout(armTimer); armTimer = null; } var b = el('bootEcatRunBtn'); if (b) { b.textContent = 'EtherCAT 刷写 APP'; b.className = 'btn btn-block'; } } /** * @description 刷前从磁盘重读固件(与 WiFi/BLE/电机 OTA 同一条规矩)。 * 本地重编会覆盖同路径的 .hex,而 window.gd32File 只是"选文件那一刻"的 File 对象, * 之后磁盘变了它不会跟着变 —— 直接拿它刷,灌进去的是上次那包,表现为 * 「控制台全绿、板子版本/Build 不变」(2026-09-18 现场)。 * @return {Promise} null = 拿不到可信镜像,调用方必须中止 * (此刻总线未动、A 区未擦,中止零代价)。 */ async function readFreshImage() { var handle = window.gd32FileHandle; if (!handle) { /* 拖拽/传统文件框选的:没有句柄就无从确认磁盘上现在是什么,而本路径 * begin 会先整区擦 A 区 —— 比 WiFi 那条(警告后继续)严格一档,直接收手。 */ window.addLog('E', 'ECAT刷写', '无法确认镜像是否为最新:当前文件未经文件系统句柄选择(拖拽/传统文件框)。' + '请点上面「GD32 固件」那张卡的上传区重新选择后再刷 —— ' + '本路径会先擦 A 区,拿旧包灌写等于白刷'); window.showToast('请重新选择 GD32 固件后再刷', 'error'); return null; } var fresh; try { fresh = await handle.getFile(); } catch (e) { window.addLog('E', 'ECAT刷写', '重新读取固件失败:' + (e && e.message ? e.message : e) + '(磁盘上那个产物被移动/删除?)—— 已收手,一个字节都没写'); window.showToast('固件重新读取失败,请重新选择', 'error'); return null; } try { /* 统一走 processFile:.hex 按最新源文件重转 BIN,.bin 也过同一道身份校验 * (checkGd32ImageFile 挡「引导程序包 / 向量表非法」),结果写回 window.gd32File。 * 与 upload-esp32 的热重载分支同款。 */ if (typeof window.processFile !== 'function') { throw new Error('processFile 不可用'); } await window.processFile(fresh, 'gd32', { silent: true, skipHandlePersist: true }); } catch (e) { window.addLog('E', 'ECAT刷写', '重读的镜像解析失败:' + (e && e.message ? e.message : e)); window.showToast('固件解析失败,请重新选择', 'error'); return null; } var file = window.gd32File; if (!file) { window.addLog('E', 'ECAT刷写', '重读的镜像未通过 GD32 身份校验(引导程序包/向量表非法)—— 已收手,一个字节都没写'); return null; } var mtime = file.lastModified ? new Date(file.lastModified).toLocaleTimeString('zh-CN', { hour12: false }) : '未知时间'; window.addLog('I', 'ECAT刷写', '镜像热重载: ' + file.name + '(' + bytes(file.size) + ',磁盘 mtime ' + mtime + ')'); return new Uint8Array(await file.arrayBuffer()); } /** * @description 七步一次点完:读镜像 → 开总线并把镜像收进主站 → 交接进 BOOT * → 回读身份 → 邮箱灌写(run) → 从站复位 → 回读确认。 * 顺序要点两条: * 1. 交接排在灌写前一瞬,且**开总线在交接之前** —— /api/open 那 1~2s 花在板子还跑 * APP 的时候,就不占用 BOOT 那 10s 窗口;run 成功后 BOOT 自己复位跳进新 APP, * 主站侧会话随之作废,所以不在此处重连。 * 2. 「回读身份」是读数不是门:对面是谁由 BOOT 里的 ecatArmed() 裁决,写错对象 * 只会拿到 SDO abort,一个字节也写不进去。 */ export async function bootEcatFlash() { if (window.bootEcatBusy) return; var file = window.gd32File; if (!file) { window.showToast('先在上面选 GD32 固件', 'error'); return; } if (!armedOnce) { armedOnce = true; var b = el('bootEcatRunBtn'); if (b) { b.textContent = '确认:擦除 A 区并刷写'; b.className = 'btn btn-danger btn-block'; } armTimer = setTimeout(clearArm, 6000); window.showToast('再点一次确认。这一按会抹掉 A 区,中途没有回退路径', 'error'); return; } clearArm(); setEcatFlag('bootEcatBusy', true); /* 置跨页"刷写中"标记(读侧见 common/utils.js flashBusyNow):交接与 BOOT↔APP * 期间 GD32 刚复位,ESP32 的 CAN 反馈查询(0xC1)必被链路层拒,问了也是白问、 * 还会在日志里刷屏 —— CAN/电机页的 1Hz 轮询据此避让。 * 带到期时间(10min,覆盖最坏灌写 + probe 自愈),页面异常也不会把标记挂死。 */ try { localStorage.setItem('dcu_flash_busy_until', String(Date.now() + 10 * 60 * 1000)); } catch (_) {} refreshBootEcatUi(); var runBtn = el('bootEcatRunBtn'); if (runBtn) { runBtn.disabled = true; runBtn.textContent = '刷写中…'; } if (typeof window.switchStageFlow === 'function') window.switchStageFlow('ecat'); if (typeof window.setActivePhase === 'function') window.setActivePhase('ecatOta'); resetEcatStages(); setEcatPct(0, '读取镜像…'); setEcatStage(1, 'live', '读取中'); setEcatStatus('active', '读取镜像…'); try { /* 步骤 1:按句柄从磁盘重读镜像 —— 不能信页面缓存(见 readFreshImage 注) */ var u8 = await readFreshImage(); if (!u8 || u8.length === 0) { setEcatStage(1, 'err', '未取得镜像'); setEcatStatus('err', '镜像没就绪'); setEcatPct(0, '没拿到可信镜像 —— 一个字节都没写(见日志)'); return; } file = window.gd32File; /* readFreshImage 已把重读结果写回共享槽 */ setEcatStage(1, 'done', bytes(u8.length)); setEcatStage(2, 'live', '开总线'); setEcatStatus('active', '打开主站总线…'); setEcatPct(5, '打开主站总线(枚举到 PREOP)…'); if (!await ensureBusOpen()) { setEcatStage(2, 'err', '未连接'); setEcatStatus('err', '总线没打开'); setEcatPct(0, '主站没能打开总线 —— 一个字节都没写'); return; } setEcatStage(2, 'live', 'open'); /* 刷前身份读数(读数,不是门):记下 0x1008/0x100A,末尾与刷后比对 —— * 「刷完版本没变」要能被控制台自证,而不是只看流程全绿。 * 此拍在交接之前,BOOT 那 10s 窗口还没开始计时。 */ var pre = await bootEcatProbe(false); setEcatStatus('active', '推送到主站…'); setEcatPct(8, '打开主站缓冲…'); var o = await api({ op: 'open', size: u8.length }); showLog(o); if (!o.ok) { setEcatStage(2, 'err', '拒绝'); setEcatStatus('err', '主站未接收'); setEcatPct(0, '服务端没接这个镜像(见日志)'); return; } /* 推主站段 8%→30%;灌写段见下(独立 0→100%,不再嵌进整体百分比)。 */ for (var off = 0; off < u8.length; off += PUSH_BYTES) { var end = Math.min(off + PUSH_BYTES, u8.length); var p = await api({ op: 'push', hex: hexOf(u8, off, end) }); if (!p.ok) { showLog(p); setEcatStage(2, 'err', bytes(off)); setEcatStatus('err', '推送中断'); setEcatPct(Math.round(8 + 22 * off / u8.length), '推送中断在 ' + bytes(off) + '(见日志)'); return; } setTxt('bootEcatSrv', bytes(p.received)); var pushPct = Math.round(100 * p.received / u8.length); setEcatPct(8 + Math.round(22 * p.received / u8.length), '推到主站 ' + pushPct + '%'); setEcatStage(2, 'live', pushPct + '%'); } setEcatStage(2, 'done', bytes(u8.length)); /* ── 步骤 3:交接。镜像已在主站手里,此刻才把板子从 APP 踢进 BOOT ── */ setEcatStage(3, 'live', '写 0x2502'); setEcatStatus('active', '让应用交回引导程序…'); setEcatPct(30, '让应用交回引导程序(写 CoE 0x2502:00=1)…'); var hb = await api({ op: 'reboot' }); showLog(hb); if (!hb || hb.ok !== true) { setEcatStage(3, 'err', '被拒'); setEcatStage(4, 'err', '未交接'); setEcatStatus('err', '交接被拒,没刷'); setEcatPct(0, '交接没被受理 —— A 区没动,直接重灌即可'); await bootEcatProbe(false); return; } setEcatStage(3, 'done', hb.already ? '本来就在 BOOT' : '已受理'); /* ── 步骤 4:回读身份。BOOT 刚硬复位、ESC 里地址全被清,靠 probe 的两级自愈拉回来。 * 只给 4 次:落空一次不是免费的 —— probe 自己那级重枚举可能要几秒,而这些秒 * 全是从 BOOT 那 10s 窗口里扣的。落空也不拦,交给步骤 5(判据在 BOOT 里)。 ── */ setEcatStage(4, 'live', '等 BOOT 上总线'); setEcatStatus('active', '等引导程序上总线…'); setEcatPct(35, '等 BOOT 上总线(BOOT 的 10s 窗口正在走)…'); var id = await waitBootOnBus(6, 500); setEcatStage(4, (id && id.boot) ? 'done' : 'live', id && id.boot ? '引导程序 ' + (id.sw || '—') : (id && id.name ? '仍是应用' : '邮箱无回应')); setEcatStage(5, 'live', '0%'); setEcatStatus('active', '邮箱灌写中…'); /* 真实灌写进度:run 期间轮询 /ota/ecat status(不抢总线锁), * 读 native 的 sent/send_total。时间软进度只在 status 还没吐出 total 时兜底。 */ window.addLog('I', 'ECAT刷写', file.name + ' · ' + bytes(u8.length) + ' 已推到主站,开始 SDO 灌写'); var runDone = false; var runSoftPct = 0; var runSent = 0; var runTotal = 0; var runPoll = setInterval(function () { if (runDone) { clearInterval(runPoll); return; } post('ota/ecat', { op: 'status' }, 3000).then(function (st) { if (runDone || !st || !st.ok) return; runSent = Number(st.sent) || 0; runTotal = Number(st.send_total) || 0; if (runTotal > 0) { var pct = Math.min(99, Math.floor(100 * runSent / runTotal)); setEcatPct(pct, 'SDO 灌写中… ' + pct + '%(' + bytes(runSent) + ' / ' + bytes(runTotal) + ')'); setEcatStage(5, 'live', pct + '%'); } else { /* begin 预擦:total 尚未写入。给活性但别钉死在 15%—— * 真 total 一旦出现立刻切到真实进度。 */ if (runSoftPct < 12) { runSoftPct = Math.min(12, runSoftPct + 2); setEcatPct(runSoftPct, 'SDO 灌写中… 预擦/会话建立…'); setEcatStage(5, 'live', runSoftPct + '%'); } } }).catch(function () { /* status 超时忽略 */ }); }, 400); var r = null; try { r = await api({ op: 'run' }); } catch (e) { window.addLog('W', 'ECAT刷写', 'run 的响应没取回(' + e + ')—— 主站不受这条连接影响,改按从站读数复核'); setEcatPct(runSent > 0 && runTotal > 0 ? Math.floor(100 * runSent / runTotal) : runSoftPct, '灌写响应丢失(已灌 ' + bytes(runSent) + '),等从站回来自证…'); } finally { runDone = true; if (runPoll) clearInterval(runPoll); } if (r) showLog(r); var okRun = r ? !!r.ok : null; if (okRun) { setEcatStage(5, 'done', '100%'); setEcatPct(100, 'SDO 灌写完成,已提交 —— BOOT 正在复位进新 APP'); } else if (okRun === false) { setEcatStage(5, 'err', '失败'); setEcatPct(0, '刷写未完成(见日志),A 区可能已擦除,可直接重灌'); } else { setEcatStage(5, 'live', runTotal > 0 ? Math.floor(100 * runSent / runTotal) + '%?' : runSoftPct + '%?'); setEcatPct(runSent > 0 && runTotal > 0 ? Math.floor(100 * runSent / runTotal) : runSoftPct, '提交回音没取到,按从站读数判定'); } setEcatStatus('active', '等 BOOT 复位进新 APP…'); setEcatStage(6, 'live', '复位中'); setEcatStage(7, 'live', '探测中'); /* commit 后从站硬复位 ESC:native 明确要求重新 /api/open 再 probe。 * 以前这里直接 probe —— 邮箱路径死着,请求会挂到超时甚至永远,按钮停在 * 「刷写中…」。现在:先重开总线,再带超时轮询;任一步失败也必须收尾 UI。 */ var after = null; try { await new Promise(function (res) { setTimeout(res, 800); }); setEcatPct(100, '从站复位中,重开主站总线…'); var reopened = false; try { reopened = await ensureBusOpen(true); } catch (_) { reopened = false; } if (!reopened) { window.addLog('W', 'ECAT刷写', 'commit 后重开总线失败,仍尝试 probe'); } for (var t = 0; t < 6; t++) { try { after = await bootEcatProbe(false); } catch (pe) { window.addLog('W', 'ECAT刷写', '确认 probe 异常(' + pe + '),重试'); after = null; } if (after && after.name && !after.boot) break; setEcatPct(100, '等新 APP 上总线… ' + (t + 1) + '/6'); await new Promise(function (res) { setTimeout(res, 1000); }); } } catch (e) { window.addLog('W', 'ECAT刷写', 'commit 后确认流程异常:' + e); } /* 对面报"应用"才是真起来了;还在引导程序 = 写进去了但没跑起来。 * 注意别用 after.ok 当判据 —— 那个字段只在"是 BOOT"时为真。 */ var up = !!(after && after.name && !after.boot); if (up) { setEcatStage(6, 'done', '已复位'); setEcatStage(7, 'live', '恢复连接'); setEcatStatus('active', '按上电流程恢复连接…'); setEcatPct(100, '新 APP 已应答,恢复 OP/PDO…'); var restoredAfterFlash = await restoreEcatLikePowerOn(); setEcatStage(7, restoredAfterFlash ? 'done' : 'warn', restoredAfterFlash ? '完成' : '部分恢复'); setEcatStatus(restoredAfterFlash ? 'ok' : 'warn', restoredAfterFlash ? '刷写完成' : '刷写完成,连接未完全恢复'); setEcatPct(100, restoredAfterFlash ? ('新 APP 已在总线 · 0x100A = ' + (after.sw || '—') + ' · 连接已按上电流程恢复') : ('新 APP 已应答 0x100A = ' + (after.sw || '—') + ',但 open/motor/start 未全部成功')); /* 刷前刷后版本一致 = 灌进去的多半还是原来那包(页面缓存旧文件/选错包), * 流程全绿说明不了"换了版本" —— 补一条可观察告警(只提示,不拦:同版本重刷合法)。 */ if (pre && pre.sw && after.sw && after.sw === pre.sw) { window.addLog('W', 'ECAT刷写', '0x100A 刷前刷后都是 ' + after.sw + ' —— 若这次是换版本/改代码,' + '确认刷的是最新镜像(本页刷前已按句柄重读磁盘;仍相同则应查构建产物是否真被重编)'); window.showToast('刷前刷后版本号相同,请确认刷的是最新固件', 'warning'); } } else if (okRun) { /* commit 成功只证明 A 区写进去了;这一支里三种病必须分开说,且只有 * reason=sdo_timeout(AL 已到位、0x1008 不答)才允许指向从站: * - 有 name 且 boot=true → 新 APP 没跑起来,还在引导程序(A 区/向量表的事) * - bus_closed/no_slave/no_preop/reopen_fail → 主站压根没重连上,读数不自证 * - sdo_timeout → 邮箱路径真没应答,才轮到查固件 * 以前这里不分 reason 一律写"HBI/邮箱路径死",2026-10-09 21:49 那次就是 * 主站 6s 假超时后 6 轮 probe 全打在没重建的会话上,被误判成固件没恢复。 */ var rsn = (after && after.reason) || 'bus_closed'; var isBootNow = !!(after && after.name && after.boot); var slaveBlamed = (rsn === 'sdo_timeout'); setEcatStage(6, isBootNow ? 'err' : 'warn', isBootNow ? '仍在引导程序' : '已复位'); setEcatStage(7, 'err', slaveBlamed ? '邮箱无回应' : (isBootNow ? '没跳进 APP' : '主站未重连')); setEcatStatus('err', slaveBlamed ? '刷写已提交,但从站邮箱无回应' : (isBootNow ? '刷写已提交,但从站仍在引导程序' : '刷写已提交,但主站没能重连上总线')); setEcatPct(100, slaveBlamed ? '镜像已写入,AL 到位但 0x1008 不答 —— 从站邮箱当前不可用' : (isBootNow ? '镜像已写入,对面仍是 BOOT —— 新 APP 没接棒' : '镜像已写入,主站这侧会话没重建(不是从站的读数)')); if (isBootNow) { window.addLog('E', 'ECAT刷写', 'commit OK,但 0x1008 仍是 "' + after.name + '"(引导程序)—— ' + '新 APP 没跳起来:先点「探测」看 BOOT 侧 stay_in_ota/校验位,再看串口 [CRC]/[J5] 有没有走到跳转'); } else if (slaveBlamed) { window.addLog('E', 'ECAT刷写', 'commit OK,AL 已推进到 PREOP 但 0x1008 读不到(reason=sdo_timeout)= 从站邮箱无回应。' + '串口看 [ECAT][ESC] init 是 FAIL BYTE_TEST 还是 core not ready' + (after.detail ? ';native 侧原文:' + after.detail : '')); } else { window.addLog('E', 'ECAT刷写', 'commit OK,但主站没重连上总线(reason=' + rsn + (after && after.detail ? ',' + after.detail : '') + ')—— 这个读数只说明主站这侧没人,**不能**据此说从站 EtherCAT 死了。' + '等链路重协商(新 APP 起板会自己补一发 LAN9252 RST#)后点「探测」复核;' + '同时看 ESP32 侧 SPI 的 INIT_STATUS state/mod/fault_codes 定从站好坏'); } } else { setEcatStage(6, 'err', '未确认'); setEcatStage(7, 'err', '未完成'); setEcatStatus('err', okRun === false ? '刷写未完成' : '结果未确认'); setEcatPct(0, okRun === false ? '刷写在半路就被拒了(见日志),A 区没动' : 'run 无回音且从站仍在引导程序 —— 先点『探测』看落盘量'); if (okRun === false) { window.addLog('E', 'ECAT刷写', '刷写未完成:主站日志里失败那行带了从站的 SDO abort 码,按码定位,别当传输问题重灌'); } else { window.addLog('E', 'ECAT刷写', '刷写结果未确认(run 无回音 + 从站仍在引导程序)——先点『探测』看 0x2500:03 的落盘量,再决定重灌'); } } } catch (e) { setEcatStatus('err', '请求异常'); setEcatPct(0, '刷写请求异常:' + (e && e.name === 'TimeoutError' ? '超时' : e)); window.addLog('E', 'ECAT刷写', '刷写请求异常:' + e); } finally { setEcatFlag('bootEcatBusy', false); /* 刷写收尾:撤掉跨页"刷写中"标记(正常与异常路径都走这里) */ try { localStorage.removeItem('dcu_flash_busy_until'); } catch (_) {} if (runBtn) { runBtn.textContent = 'EtherCAT 刷写 APP'; runBtn.className = 'btn btn-block'; } refreshBootEcatUi(); if (typeof window.updateUploadButtonLabel === 'function') window.updateUploadButtonLabel(); /* ECAT 直灌不经 ESP32 OTA 状态机;SPI 已重同步到新版本,但仪表盘 * 「GD32 APP」卡画的是上一轮 /system/status 的 ota.gd32_app。 * 这里立刻补一轮,避免卡片继续显示刷写前的旧版本。 */ if (typeof window.scheduleStatusPoll === 'function') window.scheduleStatusPoll(0); } }