Files
DCU_DesktopController/js/ota/boot_ecat.js
T

728 lines
34 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 解析器 ——
* 解析器抄两份一定会漂,而灌错包的后果是"刷成功但永远跳不起来"。
* ⚠ 但刷前必须按 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 } 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'; }
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 = ECAT_DEFAULT_ADAPTER;
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 与镜像校验。
* 优先 FSAPI 选择器(与共享模式同一形状):它留下句柄并持久化,本卡片刷前才能
* "热重载"按句柄重读磁盘;传统 <input> 选完即无句柄,重编后会退化成中止+提示。 */
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));
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 + '%';
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"准备的,实测只多付
* ~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 刷前从磁盘重读固件(与 WiFi/BLE/电机 OTA 同一条规矩)。
* 本地重编会覆盖同路径的 .hex,而 window.gd32File 只是"选文件那一刻"的 File 对象,
* 之后磁盘变了它不会跟着变 —— 直接拿它刷,灌进去的是上次那包,表现为
* 「控制台全绿、板子版本/Build 不变」(2026-09-18 现场)。
* @return {Promise<Uint8Array|null>} 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();
window.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, 'done', '完成');
setEcatStatus('ok', '刷写完成');
setEcatPct(100, '新 APP 已在总线上应答 · 0x100A = ' + (after.sw || '—'));
/* 刷前刷后版本一致 = 灌进去的多半还是原来那包(页面缓存旧文件/选错包),
* 流程全绿说明不了"换了版本" —— 补一条可观察告警(只提示,不拦:同版本重刷合法)。 */
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 区写进去了;邮箱读不到 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;
/* 刷写收尾:撤掉跨页"刷写中"标记(正常与异常路径都走这里) */
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);
}
}