2026-09-05 19:58:36 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 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-22 00:30:33 +08:00
|
|
|
|
* **自动拉起**(2026-09-21 起改为"先证明再动"):只有证明"后台 PDO 循环确实停了"
|
|
|
|
|
|
* 才代替用户走完
|
2026-09-16 12:31:39 +08:00
|
|
|
|
* 服务→网卡→OP→PDO 循环 四级自愈(与 motor/transport-ecat.js 同一套 API),
|
2026-09-22 00:30:33 +08:00
|
|
|
|
* 不必再单独打开电机页选 EtherCAT。为什么必须改:'读数不新鲜' 与 '循环停了' 不是一回事
|
|
|
|
|
|
* —— 固件 <1.5.61(段 5 恒 0)、app 没跑、首个 1s 窗口没滚完,都会读数不新鲜而循环
|
|
|
|
|
|
* 完全健康;旧实现按读数不新鲜就 motor/start,而 motor/start 先 loop_stop,于是这个
|
|
|
|
|
|
* 跟电机无关的页面会周期性掐停健康总线(停手 >1s 看门狗踢出 OP,反复会把邮箱楔到只能断电)。
|
|
|
|
|
|
* 避让:
|
2026-09-16 12:31:39 +08:00
|
|
|
|
* - ECAT 刷写中(window.bootEcatBusy)或其它 OTA 在跑时**绝不动总线**
|
|
|
|
|
|
* —— open 会 loop_stop、motor/start 会抢 OP,会把正在灌写的会话掐死;
|
2026-09-22 00:30:33 +08:00
|
|
|
|
* - 冷却 8s(本页)+ 自动重启全局预算:最小间隔 20s、连续 3 次没救活就封顶(ecat-bus);
|
2026-09-16 12:31:39 +08:00
|
|
|
|
* - 首次成功只打一条日志,之后静默。
|
|
|
|
|
|
* 首页需持有 app 租约(index.html 挂 app-lease.js),否则服务端在
|
|
|
|
|
|
* 租约归零宽限期后会把刚拉起的循环又停掉。
|
|
|
|
|
|
*
|
2026-09-05 19:58:36 +08:00
|
|
|
|
* 失效语义(都显示 '--'):
|
2026-09-16 12:31:39 +08:00
|
|
|
|
* - valid:false —— ECAT 后端没在跑 / 总线从未打开过 / 固件 < 1.5.61(段 5 恒 0)
|
|
|
|
|
|
* / 循环刚拉起、首个 1s 窗口还没滚完(seq 仍 0);
|
2026-09-05 19:58:36 +08:00
|
|
|
|
* - age_ms > 3000 —— PDO 循环已停(没有页面持有 app 租约时 ecat_native 会
|
|
|
|
|
|
* 自动停循环退回 INIT),负载值不再滚动,视为过期;
|
|
|
|
|
|
* - 代理 503 —— ecat_native 没在跑。退避轮询(1s→5s→30s),避免不用 EtherCAT
|
|
|
|
|
|
* 的用户按 1Hz 反复触发 esp32_server 的自动拉起逻辑。
|
2026-09-16 12:12:24 +08:00
|
|
|
|
*
|
|
|
|
|
|
* 请求统一走 ConnectionHub(device-link.ecatPost),与 Owner 健康轮询同源。
|
2026-09-05 19:58:36 +08:00
|
|
|
|
*/
|
2026-09-16 12:12:24 +08:00
|
|
|
|
import { deviceLink } from '../common/device-link.js';
|
2026-09-22 00:30:33 +08:00
|
|
|
|
import {
|
|
|
|
|
|
proveEcatLoop, ensureEcatLoopRepair, noteEcatLoopUp,
|
|
|
|
|
|
} from '../common/ecat-bus.js';
|
2026-09-21 02:28:26 +08:00
|
|
|
|
|
2026-09-05 19:58:36 +08:00
|
|
|
|
|
|
|
|
|
|
var POLL_MS = 1000; /* 正常轮询节拍(负载窗口 1s 一滚,再快没意义) */
|
|
|
|
|
|
var STALE_MS = 3000; /* 超过 3 个窗口没刷新即视为过期 */
|
2026-09-16 12:31:39 +08:00
|
|
|
|
var START_COOLDOWN_MS = 8000; /* 自动 open/start 冷却,防无设备时刷屏 */
|
2026-09-05 19:58:36 +08:00
|
|
|
|
|
|
|
|
|
|
var failCount = 0; /* 连续失败/过期次数,驱动退避 */
|
|
|
|
|
|
var timerId = null;
|
2026-09-16 12:31:39 +08:00
|
|
|
|
var startAttemptAt = 0; /* 上次尝试自动拉起的时刻 */
|
|
|
|
|
|
var startLogged = false; /* 成功拉起只记一条日志 */
|
2026-09-05 19:58:36 +08:00
|
|
|
|
|
|
|
|
|
|
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 窗口';
|
2026-09-22 00:30:33 +08:00
|
|
|
|
var TIP_OFF = TIP_BASE + ';当前不可用:只有证明 PDO 循环确实停了,本页才会自动 open+motor/start;'
|
|
|
|
|
|
+ '循环在跑而这里仍是 --,通常是固件 <1.5.61(段5 恒 0)、GD32 没跑 APP,'
|
|
|
|
|
|
+ '或刚拉起还没滚完首个 1s 窗口 —— 这些都不会去重启总线。'
|
2026-09-16 12:31:39 +08:00
|
|
|
|
+ 'ECAT 刷写期间会暂停自动拉起。';
|
2026-09-05 19:58:36 +08:00
|
|
|
|
|
|
|
|
|
|
/** @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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-16 12:31:39 +08:00
|
|
|
|
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());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-09-22 00:30:33 +08:00
|
|
|
|
* 纯判据:给定 native/health 的回包,本页该不该动总线。单独导出是为了能被离线判死
|
|
|
|
|
|
* (测试不必装 DOM)。判据本身仍只有 proveEcatLoop 一处实现。
|
|
|
|
|
|
* @returns {"repair"|"idle-up"|"unproven"}
|
2026-09-16 12:31:39 +08:00
|
|
|
|
*/
|
2026-09-22 00:30:33 +08:00
|
|
|
|
export function decidePdoCycle(h) {
|
|
|
|
|
|
/* treatOfflineAsDown:连不上/没开网卡时不存在"被我们停掉的健康循环",拉起是安全的
|
|
|
|
|
|
* —— 这正是本页存在的意义(不必先开电机页也能刷写)。只有本页传这个旗标。 */
|
|
|
|
|
|
var pr = proveEcatLoop(h, { treatOfflineAsDown: true });
|
|
|
|
|
|
if (pr === "down") return "repair";
|
|
|
|
|
|
if (pr === "up") return "idle-up";
|
|
|
|
|
|
return "unproven";
|
2026-09-16 12:31:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-22 00:30:33 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 需要时才把总线拉起来:先证明循环停了,再走 网卡 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)。 */
|
2026-09-16 12:31:39 +08:00
|
|
|
|
async function tryAutoStart() {
|
2026-09-22 00:30:33 +08:00
|
|
|
|
if (busBusy()) return 'busy';
|
2026-09-16 12:31:39 +08:00
|
|
|
|
var now = Date.now();
|
2026-09-22 00:30:33 +08:00
|
|
|
|
if (now - startAttemptAt < START_COOLDOWN_MS) return 'cooldown';
|
2026-09-16 12:31:39 +08:00
|
|
|
|
startAttemptAt = now;
|
|
|
|
|
|
try {
|
2026-09-22 00:30:33 +08:00
|
|
|
|
var act = await ensurePdoCycle();
|
|
|
|
|
|
if (act === 'started' && !startLogged) {
|
|
|
|
|
|
startLogged = true;
|
|
|
|
|
|
if (window.addLog) {
|
|
|
|
|
|
window.addLog('I', 'ECAT',
|
|
|
|
|
|
'已自动拉起 EtherCAT PDO 循环(GD32 负载将在首个 1s 窗口后显示)');
|
2026-09-16 12:31:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-09-22 00:30:33 +08:00
|
|
|
|
return act;
|
|
|
|
|
|
} catch (_) {
|
|
|
|
|
|
/* 代理/后端离线、open/start 失败:走退避 */
|
|
|
|
|
|
return 'failed';
|
|
|
|
|
|
}
|
2026-09-16 12:31:39 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-05 19:58:36 +08:00
|
|
|
|
async function tick() {
|
|
|
|
|
|
var node = el();
|
|
|
|
|
|
if (!node) return; /* 页面上没有该指标就不再调度 */
|
|
|
|
|
|
var ok = false;
|
|
|
|
|
|
try {
|
2026-09-16 12:12:24 +08:00
|
|
|
|
ok = render(await deviceLink.ecatPost('app/load', {}, 3000));
|
2026-09-05 19:58:36 +08:00
|
|
|
|
} catch (_) { /* 代理/后端离线,走退避 */ }
|
2026-09-16 12:31:39 +08:00
|
|
|
|
if (ok) {
|
2026-09-05 19:58:36 +08:00
|
|
|
|
failCount = 0;
|
2026-09-16 12:31:39 +08:00
|
|
|
|
} else {
|
2026-09-22 00:30:33 +08:00
|
|
|
|
var act = 'skipped';
|
|
|
|
|
|
try { act = await tryAutoStart(); } catch (_) {}
|
|
|
|
|
|
if (act === 'started') {
|
2026-09-16 12:31:39 +08:00
|
|
|
|
/* 刚 start 成功时 seq 仍可能是 0(首个 1s 窗口未滚完),
|
|
|
|
|
|
* 温和计数:保持 1s 节拍等窗口,不要立刻 5s/30s 退避。 */
|
|
|
|
|
|
failCount = Math.min(failCount, 1);
|
2026-09-22 00:30:33 +08:00
|
|
|
|
} else if (act === 'idle-up') {
|
|
|
|
|
|
/* 循环确实在跑,只是读数不新鲜(固件 <1.5.61 段5 恒 0 / app 没跑 / 窗口没滚完)。
|
|
|
|
|
|
* 这不是"该重启",继续敲门的唯一后果是白读几轮 —— 退到 30s 慢档。 */
|
|
|
|
|
|
failCount = Math.max(failCount, 7);
|
2026-09-16 12:31:39 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
failCount++;
|
|
|
|
|
|
}
|
2026-09-05 19:58:36 +08:00
|
|
|
|
}
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|