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