Files
DCU_DesktopController/js/ota/ecat_load.js
T
cbb eac3717f9a feat: 添加局域网设备发现功能和链路胶囊优化
- 实现局域网设备发现面板,支持探测ESP32中继板并自动切换端点
- 添加POST /api/lan/probe接口用于设备探测,返回三态判决结果
- 实现快扫和全段扫描两种模式,支持种子地址优先探测
- 添加端点唯一写手机制,防止多处同时修改设备IP
- 优化链路胶囊UI显示WiFi/EtherCAT/BLE连接状态和设备名称
- 修复EtherCAT链路OTA刷写期间PDO循环暂停和AL状态处理问题
- 调整PDO周期设置范围为0.125-4ms,支持亚毫秒显示为微秒
- 添加ESP32 WiFi使能开关控件样式和数据来源角标显示
2026-10-13 00:48:34 +08:00

216 lines
9.5 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 { setCellSource } from '../common/utils.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);
setCellSource('mGd32Load', 'off');
return false;
}
var perm = Number(d.load_perm);
if (!Number.isFinite(perm)) {
setText(node, '--');
setCls(node, 'v');
setTitle(node, TIP_OFF);
setCellSource('mGd32Load', '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 前更新');
/* /api/app/load 走 EtherCAT → 标 ECAT 来源 */
setCellSource('mGd32Load', 'ecat');
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);
}