Files
DCU_DesktopController/js/motor/conn-poll.js
T

752 lines
29 KiB
JavaScript
Raw Normal View History

/**
* 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,
};
}