feat(ecat): 启动后把 ecat_native 提到 RealTime;跳拍优化实验脚本与报告

- esp32_server: Popen 后外部 SetPriorityClass(RT/High),native 自设会被钳成 High
- 入库 skip_* 标定/实验脚本与 REPORT_ecat_skip_opt_20260917
- 前端 WIP:layout/app/polling/boot_ecat/index
- 不含 lab/skip_meter 原始采样与 preview 截图
This commit is contained in:
cbb
2026-09-17 02:32:20 +08:00
parent b3a3a365bb
commit e91d53e0fc
22 changed files with 1956 additions and 61 deletions
+1 -1
View File
@@ -247,7 +247,7 @@ document.addEventListener('click', function (ev) {
case 'ble-pick-file': var fi = document.getElementById('firmwareFile'); if (fi) fi.click(); break;
case 'ble-clear-file': if (window.bleOta) window.bleOta.clearFile(); break;
case 'start-upload': startUpload(arg); break;
case 'boot-ecat-probe': bootEcatProbe(true); break;
case 'boot-ecat-probe': if (!window.bootEcatBusy) bootEcatProbe(true); break;
case 'boot-ecat-pick': bootEcatPick(); break;
case 'boot-ecat-run': bootEcatFlash(); break;
case 'boot-ecat-abort': bootEcatAbort(); break;
+14 -3
View File
@@ -58,6 +58,7 @@ var statusPollInFlight = false; // 是否正在请求 /system/status,防止
var logsInFlight = false; // 是否正在请求 /logs,防止重叠抢 socket
var pageVisible = !document.hidden; // 页面可见性:切到后台/最小化/切标签会变 false
var uploadPollChip = null; // 当前暂停轮询的上传目标,用于区分 WiFi/日志策略
var networkDiagDone = false; // 本轮离线期间是否已做过网关诊断(防每秒刷屏)
/**
* 调整 /system/status 目标间隔。GD32 中继期用 400ms 缩小「转发 N/M」相对设备日志的滞后;
@@ -101,6 +102,7 @@ function scheduleLogsPoll(delayMs) {
export function startPolling() {
window.consecutivePollFailures = 0;
networkDiagDone = false;
statusPollIntervalMs = 1000;
if (statusTimerId) clearTimeout(statusTimerId);
if (logsTimerId) clearTimeout(logsTimerId);
@@ -112,6 +114,7 @@ export function pausePollingForUpload() {
window.pollingPausedForUpload = true;
uploadPollChip = window.activeUploadChip || window.flashTarget || null;
window.consecutivePollFailures = 0;
networkDiagDone = false;
// ★ 上传大固件期间必须停掉 status/logs 轮询。
// httpd max_open_sockets=4 + lru_purge:轮询会再占 socket,LRU 可能踢掉
// 正在写 Flash 的 /ota/upload 连接 → 浏览器 upload 进度已 100%(发完),
@@ -135,6 +138,7 @@ export function resumePollingAfterUpload() {
window.pollingPausedForUpload = false;
uploadPollChip = null;
window.consecutivePollFailures = 0;
networkDiagDone = false;
statusPollIntervalMs = 1000;
if (logsTimerId) clearTimeout(logsTimerId);
// 上传结束立即刷新一次状态;若当前已在请求中,则让它完成后按新间隔继续
@@ -201,6 +205,7 @@ export async function pollStatus() {
// 恢复:之前有失败计数,说明设备曾离线,现在恢复在线
if (window.consecutivePollFailures > 0) {
window.consecutivePollFailures = 0;
networkDiagDone = false;
addLog('I', '系统', '设备已恢复在线,轮询恢复正常');
showToast('设备已恢复在线', 'success');
// 仅清理终态残留。禁止对活跃会话 otaSessionReset:
@@ -278,13 +283,17 @@ export async function pollStatus() {
var otaOngoing = window.pollingPausedForUpload ||
sessionActive || sessionFailed ||
window.isEsp32Rebooting || !!window.currentOtaTarget || lastDeviceBusy;
if (pageVisible && window.consecutivePollFailures >= 5 && !otaOngoing) {
/* EtherCAT 刷写/链路时不跑 WiFi 网关诊断:设备可能根本不在 WiFi 上,
* 反复弹「网关可达;设备不响应」只会干扰。仍走下面的「未连接」UI。 */
var ecatActive = window.connectionMode === 'ecat' || !!window.bootEcatBusy;
if (pageVisible && window.consecutivePollFailures >= 5 && !otaOngoing &&
!ecatActive && !networkDiagDone) {
networkDiagDone = true;
addLog('W', '诊断', '设备连续 5 次无响应,正在诊断网络...');
var diag = await diagnoseNetwork();
addLog('W', '诊断', diag);
addLog('W', '诊断', '诊断结果: ' + diag);
updateConnectionUi(false, '诊断中');
window.applyDisconnectedRuntimeState();
addLog('W', '诊断', '诊断结果: ' + diag);
}
// ★ 状态机优先:会话已 FAILED → 保持失败 UI,仅标连接胶囊为"设备忙/未连接"
if (sessionFailed) {
@@ -353,6 +362,7 @@ document.addEventListener('visibilitychange', function () {
pageVisible = !document.hidden;
if (!pageVisible) return;
window.consecutivePollFailures = 0;
networkDiagDone = false;
// 切回前台立即刷新状态;若当前已在请求中,取消等待中的下一次让它尽快重排
if (statusPollInFlight) {
if (statusTimerId) clearTimeout(statusTimerId);
@@ -419,6 +429,7 @@ setTimeout(function () {
if (ipInput) {
ipInput.addEventListener('change', function () {
window.consecutivePollFailures = 0;
networkDiagDone = false;
addLog('I', '系统', 'IP 已修改,重新启动轮询...');
if (!statusPollInFlight) {
if (statusTimerId) clearTimeout(statusTimerId);
+87 -29
View File
@@ -124,22 +124,42 @@ function showLog(res) {
/**
* @description 按"有没有已选 GD32 镜像 + 有没有任务在跑"重算按钮可用性。
* 幂等,供 file-handle / console-mode / upload 各处调用;不缓存判据、不复制 arm 判断。
* 交接按钮文案由最后一次 probe/reboot 结果驱动(window.bootEcatIsBoot)。
*/
export function refreshBootEcatUi() {
var probeBtn = el('bootEcatProbeBtn');
var runBtn = el('bootEcatRunBtn');
var abortBtn = el('bootEcatAbortBtn');
var rebootBtn = el('bootEcatRebootBtn');
/* 别的通道在刷就互斥:三条传输共用同一块 flash,没有并发可言 */
var otherBusy = !!window.isUploading ||
!!(window.bleOta && window.bleOta.isUploading && window.bleOta.isUploading());
var hasImg = !!window.gd32File;
if (probeBtn) probeBtn.disabled = window.bootEcatBusy;
if (rebootBtn) rebootBtn.disabled = window.bootEcatBusy || otherBusy;
if (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 与镜像校验。 */
export function bootEcatPick() {
var input = el('fileInputGd32');
@@ -156,26 +176,23 @@ export async function bootEcatProbe(verbose) {
try {
r = await api({ op: 'probe' });
} catch (e) {
setTxt('bootEcatWho', '后端无响应');
setEcatConn('err', '后端无响应');
setRoleBadge('unknown', null);
if (verbose) window.addLog('E', 'ECAT刷写', '探测请求异常:' + e);
return null;
}
var who = r.boot ? '引导程序' : (r.name ? '应用' : '读不到');
var ver = r.sw || '—';
setTxt('bootEcatWho', who);
setRoleBadge(r.boot ? 'boot' : (r.name ? 'app' : 'unknown'), r.sw || null);
setTxt('bootEcatName', r.name || '—');
setTxt('bootEcatSw', ver);
setTxt('bootEcatArm', r.armed ? '允许' : '不允许');
setTxt('bootEcatRecv', bytes(r.slave_recv || 0));
if (r.boot) setEcatConn('ok', '引导程序 ' + ver + ' · 可以刷写');
else if (r.name) setEcatConn('warn', '应用 ' + ver + ' · 刷写会自动交接回 BOOT');
else setEcatConn('err', '邮箱无回应');
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 ? '是引导程序,可以刷写'
: (r.name ? '是应用,刷写链里第 3 步会写它的 0x2502 让它回 BOOT(1.6.34 以前的 APP 没这个对象,只能走 SPI 0xB0 / CAN 0x320)'
(r.boot ? '是引导程序,点主按钮可跳回 APP(0x2503)'
: (r.name ? '是应用,点主按钮写 0x2502 进 BOOT'
: '邮箱没回应,多半是老 BOOT 或栈没起来')));
}
return r;
@@ -243,42 +260,71 @@ async function waitBootOnBus(maxTries, gapMs) {
}
/**
* @description 单独一次「让应用回引导程序」:写 APP 的 CoE 0x2502:00 = 1。
* 刷写链里本来就带这一步,这个按钮是给"只想把板子停在 BOOT 上等人查"的场合 ——
* 交接一个字节 flash 都不写,代价只是从站重启一次、停在 BOOT 的 10s 窗口里等。
* 跑着老 APP(<1.6.34)的机器字典里没有 0x2502,主站会把从站的 SDO abort 码原样带回来,
* 日志里直接就能看出该退回 SPI 0xB0 / CAN 0x320。
* @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');
if (btn) btn.textContent = '交接中…';
setEcatMsg('正在让应用回引导程序…');
var isBoot = window.bootEcatIsBoot === true;
if (btn) btn.textContent = isBoot ? '跳转中…' : '交接中…';
setEcatMsg(isBoot ? '正在让引导程序跳回应用…' : '正在让应用回引导程序…');
try {
if (!await ensureBusOpen()) {
setEcatStatus('err', '总线没打开');
setEcatMsg('主站没能打开总线(见日志)');
return;
}
var r = await api({ op: 'reboot' });
/* 先用本次动作的方向;若 probe 结果与缓存不符,会话内纠正 */
var op = window.bootEcatIsBoot === true ? 'jump_app' : 'reboot';
var r = await api({ op: op });
showLog(r);
if (!r || r.ok !== true) {
setEcatStatus('err', '交接被拒');
setEcatMsg('交接没被受理(见日志里从站的 abort 码)');
setEcatStatus('err', op === 'jump_app' ? '跳回被拒' : '交接被拒');
setEcatMsg(op === 'jump_app'
? '跳回没被受理(BOOT 需 ≥1.1.0.20;见日志 abort 码)'
: '交接没被受理(见日志里从站的 abort 码)');
await bootEcatProbe(false);
return;
}
if (r.already) {
setEcatStatus('ok', '对面本来就是引导程序');
setEcatMsg('从站已在 BOOT,无需交接,可直接刷写');
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 {
@@ -293,15 +339,27 @@ export async function bootEcatReboot() {
}
} catch (e) {
setEcatStatus('err', '请求异常');
setEcatMsg('交接请求异常');
window.addLog('E', 'ECAT刷写', '交接请求异常:' + e);
setEcatMsg('交接/跳回请求异常');
window.addLog('E', 'ECAT刷写', '交接/跳回请求异常:' + e);
} finally {
window.bootEcatBusy = false;
if (btn) btn.textContent = '让应用回引导程序';
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;