/**
* 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);
}
}