From 7ea80f01efd61274db90b2eb900839ee5b35843c Mon Sep 17 00:00:00 2001 From: cbb <2625274797@qq.com> Date: Mon, 21 Sep 2026 03:04:09 +0800 Subject: [PATCH] =?UTF-8?q?fix(ui):=20=E7=94=B5=E6=9C=BA=E5=89=8D=E7=AB=AF?= =?UTF-8?q?=E5=9B=9E=E6=BB=9A=E5=88=B0=20P1=20=E6=8B=86=E5=88=86=E5=89=8D?= =?UTF-8?q?=EF=BC=8C=E6=81=A2=E5=A4=8D=20STATUS=20=E7=A6=BB=E7=BA=BF?= =?UTF-8?q?=E5=B1=82=E5=A5=91=E7=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因:拆分后 renderStatusPanel 操作 #statusOffline/#statusPanel, HTML 实际 id 是 offlineOverlay/statusLive,遮罩永不摘掉。 CSS/HTML 结构本就未重写;本提交恢复完整 main.js 与 dual-link 传输层。 --- js/motor/conn-poll.js | 751 ------- js/motor/main.js | 3811 +++++++++++++++++++++++++++++++----- js/motor/ota.js | 669 ------- js/motor/scope.js | 320 --- js/motor/seq.js | 538 ----- js/motor/transport-ecat.js | 268 +-- js/motor/transport-wifi.js | 138 -- js/motor/transport.js | 7 +- pages/motor.html | 4 +- 9 files changed, 3378 insertions(+), 3128 deletions(-) delete mode 100644 js/motor/conn-poll.js delete mode 100644 js/motor/ota.js delete mode 100644 js/motor/scope.js delete mode 100644 js/motor/seq.js diff --git a/js/motor/conn-poll.js b/js/motor/conn-poll.js deleted file mode 100644 index 5d0b29e..0000000 --- a/js/motor/conn-poll.js +++ /dev/null @@ -1,751 +0,0 @@ -/** - * 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, - }; -} diff --git a/js/motor/main.js b/js/motor/main.js index 20cc233..ed70332 100644 --- a/js/motor/main.js +++ b/js/motor/main.js @@ -1,16 +1,7 @@ /** - * motor/main.js - 电机控制台页面逻辑(ES Module 装配入口) - * - * P1 拆分(R4–R7):本文件只做装配与页面共用业务(解码/发送流控/UI), - * 大块职责外提: - * ./scope.js 示波器 - * ./ota.js 电机 OTA + IDB - * ./seq.js 命令序列 - * ./conn-poll.js 探测/失败计数/轮询调度 - * ./transport*.js 链路 I/O + waitVersion / queryNodeIdParam(R5) - * ../common/ecat-bus.js ECAT bootstrap 单点(R6) - * ../common/toast-stack.js 堆叠 toast + setQuietMode(R7) - * 逻辑行为相对拆分前保持不变;不改 CAN 协议解码与流控语义。 + * motor/main.js - 电机控制台页面逻辑(ES Module 入口) + * 由 motor.html 内联 +