chore: 入库 boot_ecat.js(app.js 已 import)
This commit is contained in:
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user