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。
This commit is contained in:
@@ -0,0 +1,751 @@
|
||||
/**
|
||||
* 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,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user