Files
DCU_DesktopController/js/motor/conn-poll.js
T
cbb 261caf50b7 refactor(motor): P1 拆分 main.js + 收敛连接/总线状态机
R4 外提 scope/ota/seq/conn-poll;R5 waitVersion/queryNodeIdParam 下沉
transport;R6 ecat-bus 单点 bootstrap;R7 toast-stack + utils 去重。
main.js 4647→~1894 行。?v= 已 bump 20260924a。
2026-09-21 02:26:39 +08:00

752 lines
29 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.
/**
* conn-poll.js — 电机页连接稳健性:硬失败计数 / 断线探测 / 三链轮询调度
* 从 main.js 外提(探测 / 失败计数 / pollLoop Worker 节拍),逻辑行为不变。
* createConnPoll(ctx) 注入 state/toast/deviceLink/onMotorState 等。
* late.stopSeq / late.stopRepeat / late.sendMotorPeriod 由 main 装配后回填。
*/
"use strict";
export function createConnPoll(ctx) {
const {
state, $, toast, deviceLink, flashBusyNow, isUnthrottled,
getSharedDeviceIp, setSharedDeviceIp,
onMotorState, onFeedback, updateTickStuckUi,
setQuietMode, late,
} = ctx;
const FAIL_THRESHOLD = 5;
const FAIL_THRESHOLD_ECAT = 12;
function failThresholdFor(tpId) {
return tpId === "ecat" ? FAIL_THRESHOLD_ECAT : FAIL_THRESHOLD;
}
const CONN_TOAST_GAP_MS = 6000;
const RECOVER_TOAST_MIN_OFFLINE_MS = 8000;
const TICK_INTERVAL_MS = 250;
const TICK_STUCK_WARN = 2;
const PROBE_INTERVAL_MS = 2000;
let tickNextAt = 0;
let tickWorker = null;
let tickUseFallback = false;
const TICK_WORKER_URL = new URL("./tick.worker.js?v=20260802g", import.meta.url);
const stopRepeat = (...a) => late.stopRepeat(...a);
const stopSeq = (...a) => late.stopSeq(...a);
const sendMotorPeriod = (...a) => late.sendMotorPeriod(...a);
function connToast(msg, type) {
const now = Date.now();
if (now - state.lastConnToastAt < CONN_TOAST_GAP_MS) return;
state.lastConnToastAt = now;
toast(msg, type);
}
/** 500/503 busy、超时:设备忙,不是链路掉线 */
function isTransientError(e) {
if (!e) return false;
if (e.transient === true) return true;
const st = Number(e.status);
if (st === 500 || st === 503 || st === 429) return true;
const name = e.name || "";
if (name === "TimeoutError" || name === "AbortError") return true;
const msg = String(e.message || e);
if (/timed?\s*out|aborted|AbortError|signal is aborted/i.test(msg)) return true;
if (/^HTTP\s+(500|503|429)\b/.test(msg)) return true;
/* 网络毛刺:高刷 HTTP 下偶发 Failed to fetch / 连接被重置很常见,
* 算硬失败会在几拍内把整页打成「设备未连接」。真拔线会持续超时/拒连,
* 由 failCount 在更高阈值上兜住(见 noteHardFailure 的网络类加权)。 */
if (/failed to fetch|networkerror|load failed|err_connection|econnreset|econnrefused|socket hang up|net::/i.test(msg)) {
e.transient = true;
return true;
}
return false;
}
/**
* 统一 GET JSON(设备直连)。失败抛 Error,附带 status / transient。
*
* 常规数据链(状态/反馈/发送/版本)已全部走 state.tp 传输层,
* 这里保留给 OTA 那条 WiFi 专属链路复用同一套错误语义。
* @returns {Promise<object>}
*/
async function fetchDeviceJson(path, timeoutMs) {
let resp;
const t0 = performance.now();
try {
resp = await fetch(String(state.ip && state.ip.length ? ('http://' + state.ip) : 'http://') + path, {
signal: AbortSignal.timeout(timeoutMs || 3000),
cache: "no-store",
});
} catch (e) {
const err = e instanceof Error ? e : new Error(String(e));
err.transient = isTransientError(err);
recordSlowReq(path, performance.now() - t0, err.message);
throw err;
}
recordSlowReq(path, performance.now() - t0);
if (!resp.ok) {
const err = new Error("HTTP " + resp.status);
err.status = resp.status;
err.transient = (resp.status === 500 || resp.status === 503 || resp.status === 429);
throw err;
}
return resp.json();
}
/* 慢请求统计:>200ms 记入 phaseDebug.slowStats[path] = {count, maxMs, lastMs, lastAt}。
* 用于诊断"卡在哪个 endpoint"——不是看单次 lastSlowReq,而是看各 path 的卡顿分布。
* 控制台 phaseDebug.slowStats 直接看哪个 path 累计最多。 */
function recordSlowReq(path, durMs, errMsg) {
if (durMs <= 200) return;
const s = state.phaseDebug.slowStats;
const key = path.split("?")[0]; /* /motor/state?bin=1 → /motor/state */
if (!s[key]) s[key] = { count: 0, maxMs: 0, lastMs: 0, lastAt: 0, lastErr: null };
s[key].count++;
s[key].maxMs = Math.max(s[key].maxMs, Math.round(durMs));
s[key].lastMs = Math.round(durMs);
s[key].lastAt = Date.now();
if (errMsg) s[key].lastErr = errMsg;
state.phaseDebug.lastSlowReq = {
path, dur: Math.round(durMs), at: Date.now(), err: errMsg
};
}
/* 连接判定:轮询/探测硬失败达阈值才离线(瞬时错误不计)。 */
function noteHardSuccess() { state.failCount = 0; }
function noteHardFailure(e, source) {
if (e && e.transient) return; /* 500/503/超时/网络毛刺等瞬时错误不计入 */
state.failCount++;
/* ECAT:status 1Hz 健康读失败不该和 motor 热路径同等累计 ——
* 从站 AL 抖一下就断电机页会误停周期发送/命令序列。 */
if (state.tpId === "ecat" && source === "status" && state.connected) {
if (state.failCount < failThresholdFor("ecat") * 2) return;
}
const thr = failThresholdFor(state.tpId);
if (state.failCount >= thr && state.connected) {
try {
state.phaseDebug.lastHardFail = {
source, msg: e && e.message, at: Date.now(), failCount: state.failCount, thr,
};
} catch (_) {}
enterDisconnected(e && e.message, source);
}
}
function enterDisconnected(msg, source) {
if (state.probing) return;
const wasConnected = state.connected;
state.connected = false;
state.connecting = false;
state.probing = true;
state.failCount = 0;
state.offlineSince = Date.now();
state.bootActive = false;
state.slaveAl = null;
state.slaveOp = false;
stopRepeat(true);
stopSeq("连接中断,命令序列已停止", true);
stopDataPolling();
updateConnUi();
$("#netBanner").classList.add("show");
$("#netBannerText").textContent =
"连接中断" + (source ? " (" + source + ")" : "") +
" — 使用主页共享 IP (" + state.ip + ") 自动重连中…";
if (wasConnected) {
connToast("网络异常:" + (msg || "请求失败") + " — 已停止周期发送,自动重连中", "err");
}
startProbe();
}
function startProbe() {
stopTimer("probe");
state.probing = true;
if (!state.offlineSince) state.offlineSince = Date.now();
scheduleProbe(0);
}
function stopProbe() {
stopTimer("probe");
state.probing = false;
}
function scheduleProbe(delayMs) {
stopTimer("probe");
state.poll.probe = setTimeout(function () {
state.poll.probe = null;
probeOnce();
}, delayMs == null ? PROBE_INTERVAL_MS : delayMs);
}
/**
* 把冗长的链路错误压成一句人能看的话。
*
* 为什么需要:EtherCAT 后端会把整串诊断塞进 log[],SSC 邮箱楔死那条会带
* **64 个一模一样的 MailboxError** 加一段 SM 寄存器现场,原样显示等于没显示。
* 这里做两件事:把重复项折叠成"×N",并对已知故障给出**该怎么办**
* —— 楔死是软件恢复不了的(ecat_native.c 里同样注明要断电复位 MCU)。
*/
function summarizeLinkError(msg) {
let s = String(msg || "未知错误");
/* 折叠重复的 MailboxError(…),只留计数 */
const mb = s.match(/MailboxError\([^)]*\)/g);
if (mb && mb.length > 2) {
s = s.replace(/\[?MailboxError\([^)]*\)(,\s*MailboxError\([^)]*\))*\]?/,
`MailboxError ×${mb.length}`);
}
/* 已知故障 → 给可执行的处置建议 */
if (/邮箱地址错位楔死|ConfigMapError/.test(s)) {
return "SSC 邮箱楔死(config_map 失败)—— 需复位 DCU 板 MCU 才能恢复(软件无法自愈)";
}
if (/没有找到描述包含/.test(s)) {
return "找不到匹配的网卡 —— 请在工具栏「链路」旁的输入框改网卡匹配子串";
}
if (/没有发现从站/.test(s)) {
return "总线上没有从站 —— 检查 DCU 板供电与网线是否接在该网口";
}
/* 环境类错误原样给,它本身就是指引。token 对齐 native 代理 autostart 的实际文案:
* "找不到...守护进程" / "请先运行 build_native.bat" / "Npcap 未安装或 8090..8095 全被占"。 */
if (/Npcap|build_native|守护进程|全被占/.test(s)) return s;
/* 兜底:截断,完整内容留在 title 里 */
return s.length > 140 ? s.slice(0, 140) + "…" : s;
}
/** 探测成功收尾:置在线、清横幅、开轮询。代际不符时丢弃(防晚到结果覆盖)。 */
function finishProbeOk(gen, data) {
if (gen !== state.probeGen) return;
const offlineMs = state.offlineSince ? (Date.now() - state.offlineSince) : 0;
stopProbe();
state.connected = true;
state.connecting = false;
state.offlineSince = 0;
state.failCount = 0;
state.probeErr = null;
state.probeErrFull = null;
$("#netBanner").classList.remove("show");
applySystemStatus(data);
updateConnUi();
/* 短离线恢复不弹 toast,避免毛刺「闪一下」 */
if (offlineMs >= RECOVER_TOAST_MIN_OFFLINE_MS) {
connToast("连接已恢复 — " + transportTargetLabel(), "ok");
}
startPolling();
}
async function probeOnce() {
if (!state.probing) return;
const gen = ++state.probeGen;
state.ip = getSharedDeviceIp();
const label = $("#ipLabel");
if (label) label.textContent = transportTargetLabel();
try {
const data = await state.tp.probe();
if (gen !== state.probeGen || !state.probing) return; /* 已有更新的探测在跑 */
finishProbeOk(gen, data);
} catch (e) {
if (gen !== state.probeGen) return; /* 晚到的失败:别把后来的成功打成横幅 */
/* 把失败原因显示出来。
* 原来这里完全静默:WiFi 下还算合理(设备没开机是常态,横幅已说在连哪个 IP),
* 但 EtherCAT 自动接入有四级步骤,任一级失败的原因都**可操作**
* (邮箱楔死要复位 MCU / 网卡子串不对 / 板子没供电 / exe 没编译或 Npcap 未装),
* 静默会让页面永远停在"探测中…",用户完全无从下手 —— 实测就踩到了这点。 */
const raw = (e && e.message) ? String(e.message) : "未知错误";
/* EtherCAT:motor/start 超时 ≠ 总线没起来。
* 外壳 Owner / 上一轮 start 可能已把 PDO 循环跑着,probe 里那记
* motor/start 因 loop_stop+重进 OP 拖过 30s 才报 signal timed out。
* 此时用只读健康位复核:在跑就当连上,别把「实际已连」标成失败。 */
if (state.tpId === "ecat" && state.tp.healthCheck &&
/timed?\s*out|signal|超时/i.test(raw)) {
try {
const data = await state.tp.healthCheck();
if (gen === state.probeGen && state.probing) {
finishProbeOk(gen, data);
return;
}
return; /* 代际已变:结果交给新一轮 */
} catch (_) { /* 真没起来 → 走下面的失败展示 */ }
}
if (gen !== state.probeGen || !state.probing) return;
state.probeErrFull = raw;
state.probeErr = summarizeLinkError(raw);
const bt = $("#netBannerText");
if (bt) {
bt.textContent = (state.tpId === "ecat" ? "EtherCAT 接入失败: " : "连接失败: ")
+ state.probeErr + "(每 2 秒自动重试)";
bt.title = raw; /* 完整诊断留在悬浮提示里 */
}
$("#netBanner").classList.add("show");
updateConnUi(); /* 同步 #devStatus,别让它一直写"探测中…" */
if (state.probing) scheduleProbe(PROBE_INTERVAL_MS);
}
}
/** 连接目标的显示文本:WiFi 是设备 IP,EtherCAT 是本地服务 + 从站。 */
function transportTargetLabel() {
return (state.tpId === "ecat") ? "EtherCAT 从站" : ("IP " + state.ip);
}
/** 当前通道上在线的电机数(1–9)。 */
function onlineMotorCount() {
return (state.motors || []).filter(function (m) {
return m && m.online && m.node >= 1 && m.node <= 9;
}).length;
}
const AL_NAMES = { 0: "NONE", 1: "INIT", 2: "PREOP", 3: "BOOT", 4: "SAFEOP", 8: "OP" };
/**
* 目标是否在线 —— 工具栏指示灯的唯一判据。
*
* 灯的语义保持不变:就是「是否在线」。
* - WiFi:ESP32 HTTP 可达即在线(标签是设备 IP)。
* - EtherCAT:标签是「EtherCAT 从站」,在线 = 从站 AL 处于 OP。
* 卡在 PREOP / INIT / SAFEOP 时,即使主站服务+网卡+PDO 循环在跑、
* 读得到 AL、界面还有残留槽位,灯也不绿。
*/
function isTargetOnline() {
if (!state.connected) return false;
if (state.tpId !== "ecat") return true;
/* AL 已读到:只有 OP 算在线。PREOP 等一律不绿。 */
if (state.slaveAl != null) return state.slaveAl === 8;
/* AL 还没读到:仅当 native health 明确报 op 时才绿 */
return state.slaveOp === true;
}
/** 状态栏追加的在线状态提示(灯黄时把原因写出来)。 */
function onlineStatusParts(data) {
const parts = [];
if (state.tpId === "ecat") {
const al = state.slaveAl;
if (al != null && al !== 8) {
parts.push("从站 " + (AL_NAMES[al] || ("0x" + al.toString(16))) + " · 未在线");
}
}
if (state.connected && !isTargetOnline()) {
if (!parts.length) parts.push("未在线");
}
return parts;
}
/**
* 自动连接当前链路(本页无连接按钮)。
* WiFi 用主页共享 IP,EtherCAT 打本地 EtherCAT 服务。
* 失败不阻断页面,进入探测直到链路可用。
*/
function connectShared() {
state.ip = getSharedDeviceIp();
state.connecting = true;
state.probing = false;
state.failCount = 0;
state.probeErr = null; /* 新一轮连接:清掉上次的失败原因 */
state.probeErrFull = null;
state.offlineSince = Date.now();
state.bootActive = false;
state.slaveAl = null;
state.slaveOp = false;
/* 新一轮探测:递增代际,作废在途的旧 probeOnce(其晚到失败不得再写横幅) */
state.probeGen += 1;
state.ch = parseInt($("#chSelect").value, 10) || 0;
updateConnUi();
$("#netBanner").classList.add("show");
$("#netBannerText").textContent = (state.tpId === "ecat")
? "正在自动接入 EtherCAT … 按需拉起本地服务 → 打开网卡 → 进 OP → 启动 PDO 循环"
: ("自动连接 " + state.ip + " … 本页与 OTA 主页共用 IP");
startProbe();
}
/** 传输回报的状态 → #devStatus。字段差异由各传输的 describeStatus 消化。 */
function applySystemStatus(data) {
const ip = state.tp.reportedIp(data);
if (ip) setSharedDeviceIp(ip);
/* 在线判定用的从站状态:AL 优先,native health 的 op 作补充 */
if (data && data.slave && data.slave.state != null) {
state.slaveAl = Number(data.slave.state) & 0x0F;
state.slaveOp = state.slaveAl === 8;
} else if (data && data.__op != null) {
state.slaveOp = !!data.__op;
}
const parts = (state.tp.describeStatus(data) || []).concat(onlineStatusParts(data));
const el = $("#devStatus");
if (el) el.textContent = parts.length ? parts.join(" · ") : "在线";
updateConnUi();
}
/* 无设备静默态:常驻动画在 160Hz 屏上每帧重算+重绘,实测未接设备时
renderer+GPU 各占约一核的七成。规则见 css/responsive.css 的
html[data-dcu-calm="1"] 段;本页不 import connection.js,故就地写同一标记。 */
function setQuietMode(quiet) {
const v = quiet ? "1" : "0";
if (document.documentElement.dataset.dcuCalm !== v) {
document.documentElement.dataset.dcuCalm = v;
}
}
function updateConnUi() {
const led = $("#connLed");
const ipLabel = $("#ipLabel");
setQuietMode(!state.connected);
if (ipLabel) ipLabel.textContent = transportTargetLabel();
/* 指示灯 = 是否在线(语义不变)。
* 绿 = 在线(isTargetOnline)
* 黄 = 主站/链路可达,但目标未在线(从站没进 OP / 收不到帧)
* 黄闪 = 探测中
* 红 = 未连接
* 以前 connected 就涂绿:EtherCAT 只要服务+网卡+PDO 循环在就算在线,
* 即使从站卡 PREOP、收不到任何 STATUS 也绿着 —— 灯起不到指示作用。 */
if (state.connecting || state.probing) {
if (led) {
led.className = "led led-warn led-pulse";
led.title = "正在探测链路…";
}
if (!state.connected) {
const ds = $("#devStatus");
/* 有失败原因就显示原因摘要,别一直"探测中…"(那等于什么都没说)。
* 摘要截断:SSC 邮箱楔死那类错误会带 64 个 MailboxError + SM 现场,
* 全塞进工具栏会把布局挤爆,完整内容留在横幅和 title 里。 */
if (ds) {
if (state.probeErr) {
const brief = state.probeErr.length > 44
? state.probeErr.slice(0, 44) + "…" : state.probeErr;
ds.textContent = brief;
ds.title = state.probeErrFull || state.probeErr;
} else {
ds.textContent = "探测中…";
ds.title = "";
}
}
}
} else if (state.connected) {
if (led) {
if (isTargetOnline()) {
led.className = "led led-on";
led.title = state.tpId === "ecat"
? "EtherCAT 从站在线(AL OP)"
: ("设备在线 · " + (state.ip || ""));
} else {
led.className = "led led-warn";
const alTxt = state.slaveAl != null
? ("AL " + (AL_NAMES[state.slaveAl] || state.slaveAl)) : "AL 未知";
led.title = state.tpId === "ecat"
? ("EtherCAT 从站未在线(" + alTxt + ")— 卡在 PREOP/非 OP 时不亮绿灯;查供电/网线/从站状态")
: "链路可达但设备未就绪";
}
}
} else {
if (led) {
led.className = "led led-err";
led.title = "未连接";
}
const ds = $("#devStatus");
if (ds) ds.textContent = "未连接";
}
const sendHint = $("#sendHint");
if (sendHint) sendHint.textContent = state.tp.sendHint(state.ch);
const sbCh = $("#sbCh");
if (sbCh) sbCh.textContent = "CH" + state.ch;
}
/* ═══════════════════════ 轮询调度(单飞递归,永不并发) ═══════════════════════ */
function stopTimer(key) {
if (state.poll[key]) {
clearTimeout(state.poll[key]);
clearInterval(state.poll[key]);
state.poll[key] = null;
}
}
/**
* 双链路轮询:motor 和 feedback 各自独立定时器,互不阻塞。
*
* 历史问题:原来 motor → feedback 串行,任一挂起(ESP32 端 mutex 阻塞 /
* WiFi 抖动)会拖累另一个,表现为数据卡顿。
* 现在:motor 和 feedback 各走独立固定节拍定时器,
* - motor 挂起 3s 超时期间,feedback 仍正常刷
* - feedback 挂起不影响 motor
* 两者共用 ESP32 端 httpJsonWorkMutex,ESP32 串行处理即可。
*/
function stopDataPolling() {
state.pollGen += 1;
stopTimer("tick");
stopTimer("feedback");
stopTimer("status");
state.tickInFlight = false;
state.feedbackInFlight = false;
state.tickStuck = 0; /* 停止轮询 → 清除卡顿标记 */
/* 清理挂起的版本等待:主 tick 已停,resolve(null) 让调用方走超时分支,避免 Promise 悬挂 */
if (state.versionWait) {
const w = state.versionWait;
state.versionWait = null;
w.resolve(null);
}
updateTickStuckUi();
/* 停止 Worker 节拍(后台节流兜底) */
if (tickWorker) {
tickWorker.postMessage({ type: "stop" });
try { tickWorker.terminate(); } catch (e) { /* ignore */ }
tickWorker = null;
tickUseFallback = false;
}
}
/**
* 启动数据链路:HTTP 三链(motor tick / feedback 1Hz / status 1Hz)立即开车。
*/
function startPolling() {
stopDataPolling(); /* 递增 pollGen,清残留定时器/在途链 */
state.failCount = 0;
state.tickN = 0;
state.statusTickN = 0;
state.recvTs = []; /* 清除旧的到达时间窗口,Hz 从新数据重新累积 */
tickNextAt = 0; /* 节拍基准重置:首个 tick 立即执行后按新间隔对齐 */
const gen = state.pollGen;
schedulePollLoop(gen, 0);
scheduleFeedbackPoll(gen, 0);
scheduleStatusPoll(gen, 1000);
/* 连上后把持久化的刷新速度同步给 GD32,使 0xC5 推送周期与本地轮询一致(无需用户重新拨动) */
sendMotorPeriod(state.refreshIntervalMs || 250);
}
function scheduleStatusPoll(gen, delayMs) {
if (gen !== state.pollGen) return;
stopTimer("status");
state.poll.status = setTimeout(function () {
state.poll.status = null;
if (gen !== state.pollGen || !state.connected || state.probing) return;
/* lite/full 交替:4 次 lite 后 1 次 full,循环。
* lite ~150B 不调重活;full ~3KB 含 OTA/heap/SPI,5s 一次。 */
const wantFull = (state.statusTickN % 5) === 0;
state.statusTickN = (state.statusTickN || 0) + 1;
fetchSystemStatus(gen, wantFull).finally(() => {
if (gen === state.pollGen && state.connected && !state.probing) {
scheduleStatusPoll(gen, 1000);
}
});
}, delayMs == null ? 1000 : delayMs);
}
/* feedback 独立定时器:固定 1000ms(与 GD32 0xC1 CAN 反馈 1Hz 推送对齐)。
* feedback 是 CAN 错误计数/总线状态快照,GD32 1Hz 推送,前端 1Hz 拉即可,
* 加快只会拿到陈旧数据还跟 motor 抢 ESP32 端 mutex。
* 与 motor 解耦后,motor 挂起不拖 feedback,feedback 挂起不拖 motor。 */
function scheduleFeedbackPoll(gen, delayMs) {
if (gen !== state.pollGen) return;
stopTimer("feedback");
state.poll.feedback = setTimeout(function () {
state.poll.feedback = null;
if (gen !== state.pollGen || !state.connected || state.probing) return;
if (state.feedbackInFlight) {
scheduleFeedbackPoll(gen, 1000);
return;
}
state.feedbackInFlight = true;
fetchFeedback(gen).finally(() => {
state.feedbackInFlight = false;
if (gen === state.pollGen && state.connected && !state.probing) {
scheduleFeedbackPoll(gen, 1000);
}
});
}, delayMs == null ? 1000 : delayMs);
}
/* ═══════ Web Worker 节拍(后台标签页不节流) ═══════
* 浏览器对后台(隐藏)标签页的 setTimeout/setInterval 强制 ≥1s 节流,
* 使刷新速度在切后台后降到 ~1Hz。Dedicated Worker 的定时器不受此限制,
* 故把 motor 轮询的节拍源放到 tick.worker.js:Worker 按设定间隔定期发
* 'tick' 消息,主线程收到后才真正发起一次 fetchMotorState。
* 前台行为与原来一致;后台也能守住设定的刷新速度。 */
/* 用 import.meta.url 解析: 本模块在 js/motor/ 下, 与引用它的页面在哪层目录无关
* (页面已移入 pages/, 文档相对路径会解析成 /pages/js/... 而 404)。 */
const TICK_WORKER_URL = new URL("./tick.worker.js?v=20260802g", import.meta.url);
let tickWorker = null; /* Web Worker 节拍源(null = 未创建/失败) */
let tickUseFallback = false; /* Worker 创建/加载失败 → 回退到 setTimeout 兜底 */
/**
* tick 统一入口:Worker 每 tick、或 setTimeout 兜底触达时调用。
* 负责单飞互斥 + 卡顿标记(是否跳过由 tickInFlight 决定)。
*/
function tickHandler() {
if (!state.connected || state.probing) return;
if (state.tickInFlight) {
/* 上一拍请求仍未完成(已超出一拍时长)→ 本拍跳过。
* 「不限速」档(如 ECAT 的 6ms 下限档)下这是**预期行为**:设定间隔贴着
* 单次往返耗时(均值 ≈1.8ms,但尾部会越过 6ms),节拍源撞上在途请求是常态。
* 此时不计卡顿,否则通道下拉框会一直亮黄并报"连续 N 拍未按时完成",
* 而用户要的就是"能多快就多快"。见 refresh-rate.js 的 UNTHROTTLED_MAX_MS。 */
if (!isUnthrottled(state.refreshIntervalMs)) {
state.tickStuck = (state.tickStuck || 0) + 1;
updateTickStuckUi();
}
return;
}
pollLoop(state.pollGen);
}
/**
* 确保 Worker 就绪并按 interval 校时。Worker 已存在时只更新间隔。
* Worker 加载失败(如 404 或不被服务端支持)→ 回退到 setTimeout 兜底。
*/
function ensureTickWorker(interval) {
if (tickUseFallback) return;
if (tickWorker) {
tickWorker.postMessage({ type: "update", interval });
return;
}
try {
tickWorker = new Worker(TICK_WORKER_URL);
} catch (err) {
tickWorker = null;
tickUseFallback = true;
startTickFallback(state.pollGen);
return;
}
tickWorker.onmessage = (e) => { if (e.data === "tick") tickHandler(); };
tickWorker.onerror = () => {
try { tickWorker.terminate(); } catch (e) { /* ignore */ }
tickWorker = null;
tickUseFallback = true;
startTickFallback(state.pollGen);
};
tickWorker.postMessage({ type: "start", interval });
}
/* Worker 不可用时的兜底:留用原 setTimeout 固定节拍(前台正常,后台受节流)。 */
function startTickFallback(gen) {
if (gen !== state.pollGen) return;
stopTimer("tick");
tickNextAt = 0;
scheduleTickFallbackFrame(gen, 0);
}
function scheduleTickFallbackFrame(gen, delayMs) {
if (gen !== state.pollGen) return;
stopTimer("tick");
const iv = state.refreshIntervalMs || TICK_INTERVAL_MS;
let delay = delayMs != null ? delayMs : iv;
if (delayMs == null && tickNextAt > 0) {
/* 固定节拍对齐:按绝对时间调度,请求耗时不再拖慢周期 */
const remain = tickNextAt - Date.now();
if (remain <= 0) delay = (tickNextAt + iv < Date.now()) ? iv : 10;
else delay = remain;
}
state.poll.tick = setTimeout(function () {
state.poll.tick = null;
if (gen !== state.pollGen) return;
tickHandler();
tickNextAt = (tickNextAt > 0 ? tickNextAt : Date.now()) + (state.refreshIntervalMs || TICK_INTERVAL_MS);
scheduleTickFallbackFrame(gen, null);
}, delay);
}
/**
* motor 轮询主链:只跑 fetchMotorState,feedback/status 已剥离独立定时器。
* 节拍由 Web Worker(或兜底 setTimeout)驱动;pollLoop 自身不重排,
* 单飞互斥和卡顿标记在 tickHandler 侧完成。失败/断线由上层停止调度。
*/
async function pollLoop(gen) {
if (gen !== state.pollGen) return;
state.tickInFlight = true;
try {
await fetchMotorState(gen);
if (gen !== state.pollGen) return;
state.tickN += 1;
} finally {
state.tickInFlight = false;
if (state.tickStuck) { state.tickStuck = 0; updateTickStuckUi(); } /* 请求结束 → 卡顿解除 */
}
/* R5:版本等待已下沉 transport.waitVersion(各自独立轮询/采样),
* 主 tick 不再挂 state.versionWait 钩子。 */
}
/**
* 调度 motor 节拍。delayMs === 0 表示立即触发首拍并启动 Worker;
* 之后每拍由 Worker 自然驱动,不再从这里重排。
*/
function schedulePollLoop(gen, delayMs) {
if (gen !== state.pollGen) return;
if (tickUseFallback) {
if (delayMs === 0) startTickFallback(gen);
return;
}
ensureTickWorker(state.refreshIntervalMs || TICK_INTERVAL_MS);
if (delayMs === 0) setTimeout(tickHandler, 0); /* 立即首拍,后续靠 Worker tick */
}
async function fetchSystemStatus(gen, wantFull) {
if (gen !== state.pollGen || !state.connected || state.probing) return;
try {
/* WiFi:wantFull=false 走 ?lite=1(~150B),不调 ESP32 端
* heap_caps_get_info / 任务栈遍历 / OTA snapshot,避免 httpd 单线程被
* 重 handler 阻塞几十~上百 ms;wantFull=true 5s 一次,含 OTA/heap/SPI。
* EtherCAT:读从站身份/AL 状态,无 lite/full 之分。 */
const data = await state.tp.fetchStatus(wantFull);
if (gen !== state.pollGen) return;
applySystemStatus(data);
noteHardSuccess();
} catch (e) {
if (gen !== state.pollGen) return;
noteHardFailure(e, "status");
}
}
async function fetchMotorState(gen) {
if (gen != null && gen !== state.pollGen) return false;
if (!state.connected || state.probing) return false;
try {
/* WiFi 走二进制 /motor/state?bin=1(~408B vs JSON ~1200B,省 66%);
* EtherCAT 读后台 PDO 循环的快照。两者都归一成同一份 motors[] 契约,
* 所以 onMotorState 及其下游全部不用区分链路。 */
const data = await state.tp.fetchMotorState();
if (gen != null && gen !== state.pollGen) return false;
onMotorState(data);
noteHardSuccess();
return true;
} catch (e) {
if (gen != null && gen !== state.pollGen) return false;
noteHardFailure(e, "motor");
return false;
}
}
async function fetchFeedback(gen) {
if (gen != null && gen !== state.pollGen) return false;
if (!state.connected || state.probing) return false;
/* 刷写/OTA 窗口内跳过:GD32 正在复位或停在 BOOT,CAN 反馈注定拿不到
* (WiFi 链上每次 0xC1 都被 ESP32 链路层拒并打日志)。跨 iframe 标记
* 见 common/utils.js flashBusyNow,随刷写收尾自撤,下轮自动恢复。 */
if (flashBusyNow()) return false;
try {
/* WiFi:/can/feedback?lite=1 —— 跳过 entries 数组(~3KB → ~200B),
* 本页只用 ch/stats 字段,不需要 CAN 帧快照(那是监控页用的)。
* EtherCAT:从最近一次过程数据快照的 bridge 段取,不额外打一次后端。 */
const data = await state.tp.fetchFeedback();
if (gen != null && gen !== state.pollGen) return false;
onFeedback(data);
noteHardSuccess();
return true;
} catch (e) {
if (gen != null && gen !== state.pollGen) return false;
noteHardFailure(e, "feedback");
return false;
}
}
return {
failThresholdFor,
isTransientError,
recordSlowReq,
connToast,
noteHardSuccess,
noteHardFailure,
enterDisconnected,
startProbe,
stopProbe,
probeOnce,
connectShared,
applySystemStatus,
updateConnUi,
isTargetOnline,
transportTargetLabel,
stopTimer,
stopDataPolling,
startPolling,
schedulePollLoop,
pollLoop,
setTickNextAt(v) { tickNextAt = v; },
getTickWorker: () => tickWorker,
getTickUseFallback: () => tickUseFallback,
};
}