feat: 添加局域网设备发现功能和链路胶囊优化

- 实现局域网设备发现面板,支持探测ESP32中继板并自动切换端点
- 添加POST /api/lan/probe接口用于设备探测,返回三态判决结果
- 实现快扫和全段扫描两种模式,支持种子地址优先探测
- 添加端点唯一写手机制,防止多处同时修改设备IP
- 优化链路胶囊UI显示WiFi/EtherCAT/BLE连接状态和设备名称
- 修复EtherCAT链路OTA刷写期间PDO循环暂停和AL状态处理问题
- 调整PDO周期设置范围为0.125-4ms,支持亚毫秒显示为微秒
- 添加ESP32 WiFi使能开关控件样式和数据来源角标显示
This commit is contained in:
cbb
2026-10-13 00:48:34 +08:00
parent 514070b36d
commit eac3717f9a
41 changed files with 4229 additions and 934 deletions
+177 -35
View File
@@ -33,7 +33,7 @@
* native 的两级邮箱自愈,最坏 7s+。**R1 后 _ecat_proxy 不对非幂等路径自动重发**
* (假超时/服务刚拉起时返回 503,需用户/脚本显式重试);run 超时给 300s、其余 60s。
*/
import { ECAT_DEFAULT_ADAPTER } from "../common/ecat-bus.js";
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 上限内 */
@@ -49,6 +49,25 @@ 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]];
@@ -98,6 +117,12 @@ async function post(path, body, timeoutMs) {
* /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) {
@@ -112,9 +137,23 @@ async function ensureBusOpen(force) {
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);
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 那条同一个日志池)。 */
@@ -200,14 +239,15 @@ export async function bootEcatProbe(verbose) {
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;
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'
: '邮箱没回应,多半是老 BOOT 或栈没起来')));
: '读不到 0x1008(reason=' + (r.reason || '旧 native 无此字段') +
(r.detail ? ',' + r.detail : '') + ')')));
}
return r;
}
@@ -261,16 +301,53 @@ function setEcatMsg(msg) {
/**
* @description 交接之后等 BOOT 真的回到总线,把身份读回来。
* 这里刻意**不**再打一次 /api/open:probe 内部那两级自愈(单播活性判据 → 必要时全量
* 重枚举)就是为"从站刚硬复位、ESC 里 STADR/SM/邮箱配置全被清成 0"准备的,实测只多付
* ~180ms;而在这里多花的每一毫秒都是从 BOOT 那 10s 窗口里扣的。
* 这里刻意**不**再无条件打一次 /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;
@@ -284,7 +361,7 @@ async function waitBootOnBus(maxTries, gapMs) {
*/
export async function bootEcatReboot() {
if (window.bootEcatBusy) return;
window.bootEcatBusy = true;
setEcatFlag('bootEcatBusy', true);
refreshBootEcatUi();
var btn = el('bootEcatRebootBtn');
var isBoot = window.bootEcatIsBoot === true;
@@ -310,11 +387,11 @@ export async function bootEcatReboot() {
}
if (r.already) {
if (op === 'jump_app') {
window.bootEcatIsBoot = false;
setEcatFlag('bootEcatIsBoot', false);
setEcatStatus('ok', '对面本来就是应用');
setEcatMsg('从站已在 APP,无需跳回');
} else {
window.bootEcatIsBoot = true;
setEcatFlag('bootEcatIsBoot', true);
setEcatStatus('ok', '对面本来就是引导程序');
setEcatMsg('从站已在 BOOT,无需交接,可直接刷写');
}
@@ -324,11 +401,15 @@ export async function bootEcatReboot() {
}
if (op === 'jump_app') {
setEcatMsg('跳回已受理,等 APP 上总线…');
var a = await waitAppOnBus(10, 800);
var a = await waitAppOnBus(12, 300);
if (a && a.name && !a.boot) {
window.bootEcatIsBoot = false;
setEcatStatus('ok', '已回到应用');
setEcatMsg('APP 已在总线应答(0x100A = ' + (a.sw || '—') + ')');
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 不过');
@@ -341,7 +422,7 @@ export async function bootEcatReboot() {
setEcatMsg('交接已受理,等 BOOT 上总线…');
var b = await waitBootOnBus(10, 800);
if (b && b.boot) {
window.bootEcatIsBoot = true;
setEcatFlag('bootEcatIsBoot', true);
setEcatStatus('ok', '已进引导程序');
setEcatMsg('BOOT 已在总线应答(0x100A = ' + (b.sw || '—') + '),窗口 10s:可以直接刷写');
} else {
@@ -359,24 +440,53 @@ export async function bootEcatReboot() {
setEcatMsg('交接/跳回请求异常');
window.addLog('E', 'ECAT刷写', '交接/跳回请求异常:' + e);
} finally {
window.bootEcatBusy = false;
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); });
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;
@@ -480,7 +590,7 @@ export async function bootEcatFlash() {
}
clearArm();
window.bootEcatBusy = true;
setEcatFlag('bootEcatBusy', true);
/* 置跨页"刷写中"标记(读侧见 common/utils.js flashBusyNow):交接与 BOOT↔APP
* 期间 GD32 刚复位,ESP32 的 CAN 反馈查询(0xC1)必被链路层拒,问了也是白问、
* 还会在日志里刷屏 —— CAN/电机页的 1Hz 轮询据此避让。
@@ -667,9 +777,16 @@ export async function bootEcatFlash() {
var up = !!(after && after.name && !after.boot);
if (up) {
setEcatStage(6, 'done', '已复位');
setEcatStage(7, 'done', '完成');
setEcatStatus('ok', '刷写完成');
setEcatPct(100, '新 APP 已在总线上应答 · 0x100A = ' + (after.sw || '—'));
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) {
@@ -679,17 +796,42 @@ export async function bootEcatFlash() {
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');
/* 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', '未完成');
@@ -710,7 +852,7 @@ export async function bootEcatFlash() {
setEcatPct(0, '刷写请求异常:' + (e && e.name === 'TimeoutError' ? '超时' : e));
window.addLog('E', 'ECAT刷写', '刷写请求异常:' + e);
} finally {
window.bootEcatBusy = false;
setEcatFlag('bootEcatBusy', false);
/* 刷写收尾:撤掉跨页"刷写中"标记(正常与异常路径都走这里) */
try { localStorage.removeItem('dcu_flash_busy_until'); } catch (_) {}
if (runBtn) {