Files
DCU_DesktopController/js/ota/boot_ecat.js
T
cbb eac3717f9a feat: 添加局域网设备发现功能和链路胶囊优化
- 实现局域网设备发现面板,支持探测ESP32中继板并自动切换端点
- 添加POST /api/lan/probe接口用于设备探测,返回三态判决结果
- 实现快扫和全段扫描两种模式,支持种子地址优先探测
- 添加端点唯一写手机制,防止多处同时修改设备IP
- 优化链路胶囊UI显示WiFi/EtherCAT/BLE连接状态和设备名称
- 修复EtherCAT链路OTA刷写期间PDO循环暂停和AL状态处理问题
- 调整PDO周期设置范围为0.125-4ms,支持亚毫秒显示为微秒
- 添加ESP32 WiFi使能开关控件样式和数据来源角标显示
2026-10-13 00:48:34 +08:00

870 lines
42 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, 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 选择器(与共享模式同一形状):它留下句柄并持久化,本卡片刷前才能
* "热重载"按句柄重读磁盘;传统 <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));
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<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();
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);
}
}