Files
DCU_DesktopController/js/ota/ecat_load.js
T
cbb 514070b36d fix(ui): 「谁可以动总线」收到一处判定;healthCheck 与 OTA 页不再自己 loop_stop
同一症状(点一次发送→很久才动/不动)至少有五条路径能到达,上一轮只加固了
native 检测层那一条的一半。本 commit 关掉两条由我们自己的前端反复按 loop_stop
的成因——不需要硬件故障就能把健康总线掐停:motor/start 内部先 loop_stop,停手
>1000ms 从站过程数据看门狗就踢出 OP,反复这样会把 SSC 邮箱楔到只能断电。

1) healthCheck 违反自己的契约。文件注释写着「只读…绝不 motor/start、不动总线」,
   代码却是内联的 dc !== 1 || dcs !== 250 → motor/start。这把「回包没有 dc 字段」
   当成「DC 配错」,于是一篇错误回包/一份旧 exe 就打出一记 loop_stop,而这条路径
   只在 probe() 超时后走(那一刻已经有一记 loop_stop 在飞)。
   git 史:断言 383535b 引入即红 → 2304ed6 改走 ecat-bus.repairEcatDc(含「缺字段
   不动总线」守卫)转绿 → 7ea80f0 前端回滚删掉那次调用,又红。
2) OTA 负载页以「读数不新鲜」为理由无条件 motor/start。固件 <1.5.61(段5 恒 0)、
   GD32 没跑 APP、首个 1s 窗口没滚完都会读数不新鲜而循环完全健康——一个跟电机无关
   的页面于是周期性掐停总线。现在先证明再动手;native 完全没起仍算「该拉起」,
   刷写体验不变。

做法:判定与预算只放 ecat-bus.js——
  proveEcatLoop(h)  'up'|'down'|'unknown':三态是重点,证明不了就不许自动动手
  proveEcatDc(h)    同上,把「缺字段=证明不了」收编成唯一实现
  ensureEcatLoopRepair()  in-flight 锁 + 最小间隔 20s(时间戳先写后发,落
  localStorage 才跨标签页/跨 F5)+ 连续 3 次封顶;reason 随 body 走,供代理侧统一上限
repairEcatDc 新增「native 正在全量重连时不动总线」;isEcatBusHealthy 与 proveEcatLoop
共用同一处判定(两个「看起来在跑」迟早漂移)。transport-ecat 的 readHealth 附带原始
回包 raw,证明函数与界面读同一份,不再抄一遍字段名。

测试:test_ecat_autostart.mjs 26→57 项。stub 现在能喂任意 health 形状并按 reason
计数,新增 [2c] 反空断言(DC 确实配错时必须恰又一次且带 reason)、[2d] 三态矩阵、
[2e] OTA 决策、[2f] 预算(间隔/封顶/复位后放行)。另加 scripts/check_fe_guard_mutation.py:
把实现逐条退回旧形状看断言是否真变红(全绿=那条断言是自证的)——M1..M6 实测全部变红,
还原后基线仍 57/0。配套门槛见 tools/ecat/check_guard_regression.py 的 S6/S7。

未做(另计):loop=0 的三重闩锁(transport 的 started 位、transient 正则、device-link
的 e.loop=loadValid||op 假位——native /api/status 的 slave.state 是缓存,循环停了仍报 8)
与 UI 对 loop/guard 的可见性;以及 loop_stop 不清 g_cmd_q 造成的「重启那刻陈旧命令一起灌出去」。
2026-09-22 00:30:33 +08:00

211 lines
9.3 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.
/**
* ecat_load.js - GD32(DCU_APP) CPU 负载率显示(EtherCAT 通道,仅 OTA 中继页使用)
*
* 数据来源:DCU_APP 1.5.61+ 把「中断 + 非延迟处理段」的占用率(1.5.63 收窄定义)写进
* EtherCAT 输入 PDO 段 5 诊断区(1s 滚动窗口,0.1% 分辨率),主站
* tools/ecat/ecat_native.exe 解码后由 /api/app/load 提供纯缓存读。
* 本模块经本服务器的同源代理 /ecat/api/* 调它(代理只收 POST,见 esp32_server
* _ecat_proxy),显示在「运行指标」卡的 #mGd32Load。
*
* 与 /system/status 轮询完全独立:ESP32 离线不影响本显示,反之亦然;
* 失败只静默退避,绝不计入 ESP32 的连接失败计数。
*
* **自动拉起**(2026-09-21 起改为"先证明再动"):只有证明"后台 PDO 循环确实停了"
* 才代替用户走完
* 服务→网卡→OP→PDO 循环 四级自愈(与 motor/transport-ecat.js 同一套 API),
* 不必再单独打开电机页选 EtherCAT。为什么必须改:'读数不新鲜' 与 '循环停了' 不是一回事
* —— 固件 <1.5.61(段 5 恒 0)、app 没跑、首个 1s 窗口没滚完,都会读数不新鲜而循环
* 完全健康;旧实现按读数不新鲜就 motor/start,而 motor/start 先 loop_stop,于是这个
* 跟电机无关的页面会周期性掐停健康总线(停手 >1s 看门狗踢出 OP,反复会把邮箱楔到只能断电)。
* 避让:
* - ECAT 刷写中(window.bootEcatBusy)或其它 OTA 在跑时**绝不动总线**
* —— open 会 loop_stop、motor/start 会抢 OP,会把正在灌写的会话掐死;
* - 冷却 8s(本页)+ 自动重启全局预算:最小间隔 20s、连续 3 次没救活就封顶(ecat-bus);
* - 首次成功只打一条日志,之后静默。
* 首页需持有 app 租约(index.html 挂 app-lease.js),否则服务端在
* 租约归零宽限期后会把刚拉起的循环又停掉。
*
* 失效语义(都显示 '--'):
* - valid:false —— ECAT 后端没在跑 / 总线从未打开过 / 固件 < 1.5.61(段 5 恒 0)
* / 循环刚拉起、首个 1s 窗口还没滚完(seq 仍 0);
* - age_ms > 3000 —— PDO 循环已停(没有页面持有 app 租约时 ecat_native 会
* 自动停循环退回 INIT),负载值不再滚动,视为过期;
* - 代理 503 —— ecat_native 没在跑。退避轮询(1s→5s→30s),避免不用 EtherCAT
* 的用户按 1Hz 反复触发 esp32_server 的自动拉起逻辑。
*
* 请求统一走 ConnectionHub(device-link.ecatPost),与 Owner 健康轮询同源。
*/
import { deviceLink } from '../common/device-link.js';
import {
proveEcatLoop, ensureEcatLoopRepair, noteEcatLoopUp,
} from '../common/ecat-bus.js';
var POLL_MS = 1000; /* 正常轮询节拍(负载窗口 1s 一滚,再快没意义) */
var STALE_MS = 3000; /* 超过 3 个窗口没刷新即视为过期 */
var START_COOLDOWN_MS = 8000; /* 自动 open/start 冷却,防无设备时刷屏 */
var failCount = 0; /* 连续失败/过期次数,驱动退避 */
var timerId = null;
var startAttemptAt = 0; /* 上次尝试自动拉起的时刻 */
var startLogged = false; /* 成功拉起只记一条日志 */
function el() { return document.getElementById('mGd32Load'); }
/* 写前比较:1Hz 轮询里大部分字段不变,无条件写会白白触发样式失效(同 stages.js) */
function setText(node, txt) {
if (node && node.textContent !== txt) node.textContent = txt;
}
function setCls(node, cls) {
if (node && node.className !== cls) node.className = cls;
}
function setTitle(node, title) {
if (node && node.title !== title) node.title = title;
}
var TIP_BASE = 'DCU_APP CPU 负载率:中断+非延迟处理(任务派发/OTA中继)占墙钟比,不含常开轮询与空转;EtherCAT 输入 PDO 段5,1s 窗口';
var TIP_OFF = TIP_BASE + ';当前不可用:只有证明 PDO 循环确实停了,本页才会自动 open+motor/start;'
+ '循环在跑而这里仍是 --,通常是固件 <1.5.61(段5 恒 0)、GD32 没跑 APP,'
+ '或刚拉起还没滚完首个 1s 窗口 —— 这些都不会去重启总线。'
+ 'ECAT 刷写期间会暂停自动拉起。';
/** @description 把 /api/app/load 的 JSON 渲染到 #mGd32Load。返回值是否有效(驱动退避)。 */
function render(d) {
var node = el();
if (!node) return false;
var fresh = !!d && d.valid === true
&& Number.isFinite(d.age_ms) && d.age_ms >= 0 && d.age_ms <= STALE_MS;
if (!fresh) {
setText(node, '--');
setCls(node, 'v');
setTitle(node, TIP_OFF);
return false;
}
var perm = Number(d.load_perm);
if (!Number.isFinite(perm)) {
setText(node, '--');
setCls(node, 'v');
setTitle(node, TIP_OFF);
return false;
}
var pct = perm / 10;
setText(node, pct.toFixed(1) + '%');
/* ≥85% 变琥珀提示余量吃紧;其余用正常强调色 */
setCls(node, pct >= 85 ? 'v warn' : 'v ok');
setTitle(node, TIP_BASE + ';窗口序号 ' + d.seq + ',' + d.age_ms + 'ms 前更新');
return true;
}
function ecatPost(path, body, timeoutMs) {
return fetch('/ecat/api/' + path.replace(/^\/+/, ''), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body || {}),
signal: AbortSignal.timeout(timeoutMs || 8000),
cache: 'no-store',
}).then(function (r) { return r.json(); });
}
/** 刷写/上传进行中:绝不动总线(open=loop_stop,start=抢 OP)。 */
function busBusy() {
return !!window.bootEcatBusy || !!window.isUploading
|| !!(window.bleOta && window.bleOta.isUploading && window.bleOta.isUploading());
}
/**
* 纯判据:给定 native/health 的回包,本页该不该动总线。单独导出是为了能被离线判死
* (测试不必装 DOM)。判据本身仍只有 proveEcatLoop 一处实现。
* @returns {"repair"|"idle-up"|"unproven"}
*/
export function decidePdoCycle(h) {
/* treatOfflineAsDown:连不上/没开网卡时不存在"被我们停掉的健康循环",拉起是安全的
* —— 这正是本页存在的意义(不必先开电机页也能刷写)。只有本页传这个旗标。 */
var pr = proveEcatLoop(h, { treatOfflineAsDown: true });
if (pr === "down") return "repair";
if (pr === "up") return "idle-up";
return "unproven";
}
/**
* 需要时才把总线拉起来:先证明循环停了,再走 网卡 open → motor/start。
* 动作本身交给 ecat-bus.ensureEcatLoopRepair —— 它与电机页、Owner 共用同一份预算
* (in-flight 锁 / 最小间隔 20s / 连续 3 次封顶),否则三个页面各自节流就等于三倍频率。
* @returns {Promise<string>} 'idle-up' | 'started' | 'unproven' | 'refused:<why>'
*/
async function ensurePdoCycle() {
var h = null;
try {
h = await ecatPost('native/health', {}, 3000);
} catch (_) {
h = null; /* 代理 503 / native 未起:proveEcatLoop 会按 treatOfflineAsDown 归类 */
}
if (decidePdoCycle(h) !== 'repair') {
if (proveEcatLoop(h) === 'up') noteEcatLoopUp();
return decidePdoCycle(h);
}
var r = await ensureEcatLoopRepair(ecatPost, 'auto_loop_repair', {
treatOfflineAsDown: true,
/* 周期/自旋与电机页共用 localStorage,用户调过的档位不被本页覆盖。 */
fromLocalStorage: true,
});
return r.acted ? 'started' : 'refused:' + r.why;
}
/** 读数无效时按需拉起。冷却 + 刷写避让。返回本次的判定(字符串协议见 ensurePdoCycle)。 */
async function tryAutoStart() {
if (busBusy()) return 'busy';
var now = Date.now();
if (now - startAttemptAt < START_COOLDOWN_MS) return 'cooldown';
startAttemptAt = now;
try {
var act = await ensurePdoCycle();
if (act === 'started' && !startLogged) {
startLogged = true;
if (window.addLog) {
window.addLog('I', 'ECAT',
'已自动拉起 EtherCAT PDO 循环(GD32 负载将在首个 1s 窗口后显示)');
}
}
return act;
} catch (_) {
/* 代理/后端离线、open/start 失败:走退避 */
return 'failed';
}
}
async function tick() {
var node = el();
if (!node) return; /* 页面上没有该指标就不再调度 */
var ok = false;
try {
ok = render(await deviceLink.ecatPost('app/load', {}, 3000));
} catch (_) { /* 代理/后端离线,走退避 */ }
if (ok) {
failCount = 0;
} else {
var act = 'skipped';
try { act = await tryAutoStart(); } catch (_) {}
if (act === 'started') {
/* 刚 start 成功时 seq 仍可能是 0(首个 1s 窗口未滚完),
* 温和计数:保持 1s 节拍等窗口,不要立刻 5s/30s 退避。 */
failCount = Math.min(failCount, 1);
} else if (act === 'idle-up') {
/* 循环确实在跑,只是读数不新鲜(固件 <1.5.61 段5 恒 0 / app 没跑 / 窗口没滚完)。
* 这不是"该重启",继续敲门的唯一后果是白读几轮 —— 退到 30s 慢档。 */
failCount = Math.max(failCount, 7);
} else {
failCount++;
}
}
var wait = POLL_MS;
if (failCount > 6) wait = 30000;
else if (failCount > 2) wait = 5000;
timerId = setTimeout(tick, wait);
}
/** @description 启动轮询。幂等;页面上没有 #mGd32Load 时不启动。 */
export function startEcatLoadPoller() {
if (timerId !== null || !el()) return;
timerId = setTimeout(tick, POLL_MS);
}