Files
DCU_DesktopController/js/ota/boot_ecat.js
T
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

627 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 判断。
* 交接按钮文案由最后一次 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 与镜像校验。 */
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) {
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));
window.bootEcatIsBoot = !!r.boot;
refreshBootEcatUi();
if (verbose) {
window.addLog('I', 'ECAT刷写',
'0x1008 = "' + (r.name || '—') + '" 0x100A = "' + (r.sw || '') + '" → ' +
(r.boot ? '是引导程序,点主按钮可跳回 APP(0x2503)'
: (r.name ? '是应用,点主按钮写 0x2502 进 BOOT'
: '邮箱没回应,多半是老 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 交接按钮总入口:按对端身份自动选方向。
* - 已在 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');
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') {
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 {
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;
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;
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);
}
}