- 实现局域网设备发现面板,支持探测ESP32中继板并自动切换端点 - 添加POST /api/lan/probe接口用于设备探测,返回三态判决结果 - 实现快扫和全段扫描两种模式,支持种子地址优先探测 - 添加端点唯一写手机制,防止多处同时修改设备IP - 优化链路胶囊UI显示WiFi/EtherCAT/BLE连接状态和设备名称 - 修复EtherCAT链路OTA刷写期间PDO循环暂停和AL状态处理问题 - 调整PDO周期设置范围为0.125-4ms,支持亚毫秒显示为微秒 - 添加ESP32 WiFi使能开关控件样式和数据来源角标显示
870 lines
42 KiB
JavaScript
870 lines
42 KiB
JavaScript
/**
|
||
* 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);
|
||
}
|
||
}
|