/** * 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):读到 invalid/过期时,代替用户走完 * 服务→网卡→OP→PDO 循环 四级自愈(与 motor/transport-ecat.js 同一套 API), * 不必再单独打开电机页选 EtherCAT。避让: * - ECAT 刷写中(window.bootEcatBusy)或其它 OTA 在跑时**绝不动总线** * —— open 会 loop_stop、motor/start 会抢 OP,会把正在灌写的会话掐死; * - 冷却 8s,避免无适配器/无从站时每秒刷 open; * - 首次成功只打一条日志,之后静默。 * 首页需持有 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 { ECAT_DEFAULT_ADAPTER } 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 + ';当前不可用:外壳/本页会自动 open+motor/start;' + '若持续为 --,检查网卡/总线供电,或手动打开电机控制页选 EtherCAT。' + '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(); }); } function adapterSubstr() { try { var s = localStorage.getItem('dcu_ecat_adapter'); if (s && s.trim()) return s.trim(); } catch (_) {} return ECAT_DEFAULT_ADAPTER; } /** 刷写/上传进行中:绝不动总线(open=loop_stop,start=抢 OP)。 */ function busBusy() { return !!window.bootEcatBusy || !!window.isUploading || !!(window.bleOta && window.bleOta.isUploading && window.bleOta.isUploading()); } /** * 确保网卡已开 + OP + 后台 PDO 循环在跑。 * 与 motor/transport-ecat.js 的 ensureOpen/ensureStarted 同一套 API, * 只做布尔成败,不引入 transient/慢请求表(本页没有那套基础设施)。 */ async function ensurePdoCycle() { var st = null; try { st = await ecatPost('status', {}, 6000); } catch (_) { /* 后端未起:下面 open 会触发代理 _ecat_autostart */ } if (!st || st.ok !== true) { var o = await ecatPost('open', { substr: adapterSubstr() }, 25000); if (!o || o.ok !== true) return false; } /* 周期/自旋与电机页共用 localStorage,用户调过的档位不被本页覆盖。 */ var body = {}; try { var v = parseFloat(localStorage.getItem('dcu_ecat_period_ms')); if (v > 0) body.period_ms = v; var sp = localStorage.getItem('dcu_ecat_spin'); if (sp === '0' || sp === '1') body.spin = (sp === '1'); } catch (_) {} /* motor/start 内部未进 OP 时自己走 PREOP→SAFEOP→OP,最坏约 24s */ var sr = await ecatPost('motor/start', body, 30000); return !!(sr && sr.ok === true); } /** 读数无效时尝试自动拉起。冷却 + 刷写避让。返回是否本次调用成功启动。 */ async function tryAutoStart() { if (busBusy()) return false; var now = Date.now(); if (now - startAttemptAt < START_COOLDOWN_MS) return false; startAttemptAt = now; try { var ok = await ensurePdoCycle(); if (ok) { if (!startLogged) { startLogged = true; if (window.addLog) { window.addLog('I', 'ECAT', '已自动拉起 EtherCAT PDO 循环(GD32 负载将在首个 1s 窗口后显示)'); } } return true; } } catch (_) { /* 代理/后端离线、open/start 失败:走退避 */ } return false; } 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 started = false; try { started = await tryAutoStart(); } catch (_) {} if (started) { /* 刚 start 成功时 seq 仍可能是 0(首个 1s 窗口未滚完), * 温和计数:保持 1s 节拍等窗口,不要立刻 5s/30s 退避。 */ failCount = Math.min(failCount, 1); } 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); }