diff --git a/js/ota/boot_ecat.js b/js/ota/boot_ecat.js new file mode 100644 index 0000000..60ad04f --- /dev/null +++ b/js/ota/boot_ecat.js @@ -0,0 +1,568 @@ +/** + * 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 解析器 —— + * 解析器抄两份一定会漂,而灌错包的后果是"刷成功但永远跳不起来"。 + * + * 为什么点「直灌」时才 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+。_ecat_proxy 超时后会拉起后端并**把同一个 POST + * 重发一次**,带副作用的请求假超时 = 让后端把命令再写一遍,所以这条路由下 run 给 + * 300s、其余给 60s,见 esp32_server.py 里 ECAT 代理那一段。 + */ + +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'; } + +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,循环起不来 —— 结构上就让位了,不需要额外互斥标志。 + */ +async function ensureBusOpen(force) { + if (!force) { + try { + var st = await post('status', {}, 6000); + if (st && st.ok === true) return true; + } catch (_) { /* 后端还没起来:下面 open 请求会触发代理自动拉起 */ } + } + var substr = 'I210'; + try { + var s = localStorage.getItem('dcu_ecat_adapter'); + if (s && s.trim()) substr = s.trim(); + } catch (_) {} + var r = await post('open', { substr: substr }, 25000); + showLog(r); + return !!(r && r.ok === true); +} + +/** @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 判断。 + */ +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 (runBtn) runBtn.disabled = window.bootEcatBusy || otherBusy || !hasImg; + if (abortBtn) abortBtn.disabled = window.bootEcatBusy; +} + +/** @description 复用 GD32 那条路的文件控件:同一条 processFile 做 .hex→bin 与镜像校验。 */ +export function bootEcatPick() { + 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) { + setTxt('bootEcatWho', '后端无响应'); + setEcatConn('err', '后端无响应'); + if (verbose) window.addLog('E', 'ECAT刷写', '探测请求异常:' + e); + return null; + } + var who = r.boot ? '引导程序' : (r.name ? '应用' : '读不到'); + var ver = r.sw || '—'; + setTxt('bootEcatWho', who); + 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', '邮箱无回应'); + 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)' + : '邮箱没回应,多半是老 BOOT 或栈没起来'))); + } + 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 + '%'; + 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"准备的,实测只多付 + * ~180ms;而在这里多花的每一毫秒都是从 BOOT 那 10s 窗口里扣的。 + * @return {object|null} 最后一次 probe 读数;拿到 boot:true 就提前返回 + */ +async function waitBootOnBus(maxTries, gapMs) { + var last = null; + for (var k = 0; k < maxTries; k++) { + last = await bootEcatProbe(false); + if (last && last.boot) return last; + await new Promise(function (res) { setTimeout(res, gapMs); }); + } + return last; +} + +/** + * @description 单独一次「让应用回引导程序」:写 APP 的 CoE 0x2502:00 = 1。 + * 刷写链里本来就带这一步,这个按钮是给"只想把板子停在 BOOT 上等人查"的场合 —— + * 交接一个字节 flash 都不写,代价只是从站重启一次、停在 BOOT 的 10s 窗口里等。 + * 跑着老 APP(<1.6.34)的机器字典里没有 0x2502,主站会把从站的 SDO abort 码原样带回来, + * 日志里直接就能看出该退回 SPI 0xB0 / CAN 0x320。 + */ +export async function bootEcatReboot() { + if (window.bootEcatBusy) return; + window.bootEcatBusy = true; + refreshBootEcatUi(); + var btn = el('bootEcatRebootBtn'); + if (btn) btn.textContent = '交接中…'; + setEcatMsg('正在让应用回引导程序…'); + try { + if (!await ensureBusOpen()) { + setEcatStatus('err', '总线没打开'); + setEcatMsg('主站没能打开总线(见日志)'); + return; + } + var r = await api({ op: 'reboot' }); + showLog(r); + if (!r || r.ok !== true) { + setEcatStatus('err', '交接被拒'); + setEcatMsg('交接没被受理(见日志里从站的 abort 码)'); + await bootEcatProbe(false); + return; + } + if (r.already) { + setEcatStatus('ok', '对面本来就是引导程序'); + setEcatMsg('从站已在 BOOT,无需交接,可直接刷写'); + await bootEcatProbe(true); + return; + } + setEcatMsg('交接已受理,等 BOOT 上总线…'); + var b = await waitBootOnBus(10, 800); + if (b && b.boot) { + 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 { + window.bootEcatBusy = false; + if (btn) btn.textContent = '让应用回引导程序'; + refreshBootEcatUi(); + } +} + +/** @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 七步一次点完:读镜像 → 开总线并把镜像收进主站 → 交接进 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(); + + window.bootEcatBusy = true; + 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 { + var u8 = new Uint8Array(await file.arrayBuffer()); + 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'); + 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, 'done', '完成'); + setEcatStatus('ok', '刷写完成'); + setEcatPct(100, '新 APP 已在总线上应答 · 0x100A = ' + (after.sw || '—')); + } else if (okRun) { + /* commit 成功只证明 A 区写进去了;邮箱读不到 APP 名 = EtherCAT 没恢复, + * 对用户就是「不能用」。这里禁止画成半成功:进度钉在提交完成那一档, + * 阶段 6/7 全部警告态,文案直接说清副作用。 */ + setEcatStage(6, 'warn', '已复位?'); + setEcatStage(7, 'err', 'EtherCAT 未恢复'); + setEcatStatus('err', '刷写已提交,但 EtherCAT 不可用'); + setEcatPct(100, '镜像已写入,但总线邮箱读不到 APP —— EtherCAT 当前不可用'); + window.addLog('E', 'ECAT刷写', + 'commit OK,但 0x1008 读不到 APP 名 = EtherCAT 未恢复(HBI/邮箱路径死)。' + + '板上若仍是 1.6.39,warm jump 后 IDX 残值会让旧探针假阴性 —— 需刷 1.6.40;' + + '若已是 1.6.40 仍这样,看串口 [ECAT][ESC] init 是 FAIL BYTE_TEST 还是 core not ready'); + } 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 { + window.bootEcatBusy = false; + 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); + } +}